From 15165bb42914ddbfb329d553f74f71c7ef2d9e19 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:18:13 +1100 Subject: [PATCH] New settings screen and key remapping for menu screens --- examples/companion_radio/MyMesh.h | 28 +- examples/companion_radio/main.cpp | 139 +- .../companion_radio/ui-new/ChannelScreen.h | 14 +- .../companion_radio/ui-new/Settingsscreen.h | 849 +++++++++++ examples/companion_radio/ui-new/UITask.cpp | 22 + examples/companion_radio/ui-new/UITask.h | 5 + examples/companion_radio/ui-orig/UITask.cpp | 1307 +++++++++++++---- 7 files changed, 2002 insertions(+), 362 deletions(-) create mode 100644 examples/companion_radio/ui-new/Settingsscreen.h diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index d3089de..6665ca6 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -12,7 +12,7 @@ #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "Meck v0.8.2" +#define FIRMWARE_VERSION "Meck v0.8.3" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -180,6 +180,18 @@ public: backupSettingsToSD(); #endif } + void saveChannels() { + _store->saveChannels(this); + #if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD) + backupSettingsToSD(); + #endif + } + void saveContacts() { + _store->saveContacts(this); + #if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD) + backupSettingsToSD(); + #endif + } private: void writeOKFrame(); @@ -199,20 +211,6 @@ private: void checkCLIRescueCmd(); void checkSerialInterface(); - // helpers, short-cuts - void saveChannels() { - _store->saveChannels(this); - #if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD) - backupSettingsToSD(); - #endif - } - void saveContacts() { - _store->saveContacts(this); - #if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD) - backupSettingsToSD(); - #endif - } - DataStore* _store; NodePrefs _prefs; uint32_t pending_login; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 7fa679c..df78591 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -11,6 +11,7 @@ #include "TextReaderScreen.h" #include "ContactsScreen.h" #include "ChannelScreen.h" + #include "SettingsScreen.h" extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire); @@ -447,6 +448,12 @@ void setup() { the_mesh.startInterface(serial_interface); MESH_DEBUG_PRINTLN("setup() - the_mesh.startInterface() done"); + // T-Deck Pro: default BLE to OFF on boot (user can toggle with Bluetooth page) + #if defined(LilyGo_TDeck_Pro) + serial_interface.disable(); + MESH_DEBUG_PRINTLN("setup() - BLE disabled by default (toggle via home screen)"); + #endif + #else #error "need to define filesystem" #endif @@ -503,6 +510,23 @@ void setup() { } #endif + // --------------------------------------------------------------------------- + // First-boot onboarding detection + // Check if node name is still the default hex prefix (first 4 bytes of pub key) + // If so, launch onboarding wizard to set name and radio preset + // --------------------------------------------------------------------------- + #if defined(LilyGo_TDeck_Pro) + { + char defaultName[10]; + mesh::Utils::toHex(defaultName, the_mesh.self_id.pub_key, 4); + NodePrefs* prefs = the_mesh.getNodePrefs(); + if (strcmp(prefs->node_name, defaultName) == 0) { + MESH_DEBUG_PRINTLN("setup() - Default node name detected, launching onboarding"); + ui_task.gotoOnboarding(); + } + } + #endif + // Enable GPS by default on T-Deck Pro #if HAS_GPS // Set GPS enabled in both sensor manager and node prefs @@ -745,20 +769,28 @@ void handleKeyboardInput() { return; } - // C key: allow entering compose mode from reader - if (key == 'c' || key == 'C') { - composeDM = false; - composeDMContactIdx = -1; - composeMode = true; - composeBuffer[0] = '\0'; - composePos = 0; - Serial.printf("Entering compose mode from reader, channel %d\n", composeChannelIdx); - drawComposeScreen(); - lastComposeRefresh = millis(); + // All other keys pass through to the reader screen + ui_task.injectKey(key); + return; + } + + // *** SETTINGS MODE *** + if (ui_task.isOnSettingsScreen()) { + SettingsScreen* settings = (SettingsScreen*)ui_task.getSettingsScreen(); + + // Q key: exit settings (when not editing) + if (!settings->isEditing() && (key == 'q' || key == 'Q')) { + if (settings->hasRadioChanges()) { + // Let settings show "apply changes?" confirm dialog + ui_task.injectKey(key); + } else { + Serial.println("Exiting settings"); + ui_task.gotoHomeScreen(); + } return; } - - // All other keys pass through to the reader screen + + // All other keys → settings screen via injectKey (no forceRefresh) ui_task.injectKey(key); return; } @@ -767,38 +799,11 @@ void handleKeyboardInput() { switch (key) { case 'c': case 'C': - // Enter compose mode - DM if on contacts screen, channel otherwise - if (ui_task.isOnContactsScreen()) { - ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen(); - int idx = cs->getSelectedContactIdx(); - uint8_t ctype = cs->getSelectedContactType(); - if (idx >= 0 && ctype == ADV_TYPE_CHAT) { - composeDM = true; - composeDMContactIdx = idx; - cs->getSelectedContactName(composeDMName, sizeof(composeDMName)); - composeMode = true; - composeBuffer[0] = '\0'; - composePos = 0; - Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx); - drawComposeScreen(); - lastComposeRefresh = millis(); - } - } else { - composeDM = false; - composeDMContactIdx = -1; - composeMode = true; - composeBuffer[0] = '\0'; - composePos = 0; - // If on channel screen, sync compose channel with viewed channel - if (ui_task.isOnChannelScreen()) { - composeChannelIdx = ui_task.getChannelScreenViewIdx(); - } - Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx); - drawComposeScreen(); - lastComposeRefresh = millis(); - } + // Open contacts list + Serial.println("Opening contacts"); + ui_task.gotoContactsScreen(); break; - + case 'm': case 'M': // Go to channel message screen @@ -806,18 +811,22 @@ void handleKeyboardInput() { ui_task.gotoChannelScreen(); break; - case 'r': - case 'R': - // Open text reader + case 'e': + case 'E': + // Open text reader (ebooks) Serial.println("Opening text reader"); ui_task.gotoTextReader(); break; - case 'n': - case 'N': - // Open contacts list - Serial.println("Opening contacts"); - ui_task.gotoContactsScreen(); + case 's': + case 'S': + // Open settings (from home), or navigate down on channel/contacts + if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) { + ui_task.injectKey('s'); // Pass directly for channel/contacts scrolling + } else { + Serial.println("Opening settings"); + ui_task.gotoSettingsScreen(); + } break; case 'w': @@ -831,17 +840,6 @@ void handleKeyboardInput() { } break; - case 's': - case 'S': - // Navigate down/next (scroll on channel screen) - if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) { - ui_task.injectKey('s'); // Pass directly for channel/contacts switching - } else { - Serial.println("Nav: Next"); - ui_task.injectKey(0xF1); // KEY_NEXT - } - break; - case 'a': case 'A': // Navigate left or switch channel (on channel screen) @@ -865,7 +863,7 @@ void handleKeyboardInput() { break; case '\r': - // Select/Enter - if on contacts screen, enter DM compose for chat contacts + // Enter = compose (only from channel or contacts screen) if (ui_task.isOnContactsScreen()) { ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen(); int idx = cs->getSelectedContactIdx(); @@ -881,12 +879,21 @@ void handleKeyboardInput() { drawComposeScreen(); lastComposeRefresh = millis(); } else if (idx >= 0) { - // Non-chat contact selected (repeater, room, etc.) - future use Serial.printf("Selected non-chat contact type=%d idx=%d\n", ctype, idx); } + } else if (ui_task.isOnChannelScreen()) { + composeDM = false; + composeDMContactIdx = -1; + composeChannelIdx = ui_task.getChannelScreenViewIdx(); + composeMode = true; + composeBuffer[0] = '\0'; + composePos = 0; + Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx); + drawComposeScreen(); + lastComposeRefresh = millis(); } else { - Serial.println("Nav: Enter/Select"); - ui_task.injectKey(13); // KEY_ENTER + // Other screens: pass Enter as generic select + ui_task.injectKey(13); } break; diff --git a/examples/companion_radio/ui-new/ChannelScreen.h b/examples/companion_radio/ui-new/ChannelScreen.h index d79bf07..09cc7fe 100644 --- a/examples/companion_radio/ui-new/ChannelScreen.h +++ b/examples/companion_radio/ui-new/ChannelScreen.h @@ -74,7 +74,7 @@ private: public: ChannelScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0), - _msgsPerPage(3), _viewChannelIdx(0), _sdReady(false) { + _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; @@ -327,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]; @@ -456,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; } @@ -471,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); diff --git a/examples/companion_radio/ui-new/Settingsscreen.h b/examples/companion_radio/ui-new/Settingsscreen.h new file mode 100644 index 0000000..dfc364c --- /dev/null +++ b/examples/companion_radio/ui-new/Settingsscreen.h @@ -0,0 +1,849 @@ +#pragma once + +#include +#include +#include +#include +#include "../NodePrefs.h" + +// Forward declarations +class UITask; +class MyMesh; +extern MyMesh the_mesh; + +// --------------------------------------------------------------------------- +// Radio presets +// --------------------------------------------------------------------------- +struct RadioPreset { + const char* name; + float freq; + float bw; + uint8_t sf; + uint8_t cr; + uint8_t tx_power; +}; + +static const RadioPreset RADIO_PRESETS[] = { + { "MeshCore Default", 915.0f, 250.0f, 10, 5, 20 }, + { "Long Range", 915.0f, 125.0f, 12, 8, 20 }, + { "Fast/Short", 915.0f, 500.0f, 7, 5, 20 }, + { "EU Default", 869.4f, 250.0f, 10, 5, 14 }, +}; +#define NUM_RADIO_PRESETS (sizeof(RADIO_PRESETS) / sizeof(RADIO_PRESETS[0])) + +// --------------------------------------------------------------------------- +// Settings row types +// --------------------------------------------------------------------------- +enum SettingsRowType : uint8_t { + ROW_NAME, // Device name (text editor) + ROW_RADIO_PRESET, // Radio preset picker + ROW_FREQ, // Frequency (float) + ROW_BW, // Bandwidth (float) + ROW_SF, // Spreading factor (5-12) + ROW_CR, // Coding rate (5-8) + ROW_TX_POWER, // TX power (1-20 dBm) + ROW_UTC_OFFSET, // UTC offset (-12 to +14) + ROW_CH_HEADER, // "--- Channels ---" separator + ROW_CHANNEL, // A channel entry (dynamic, index stored separately) + ROW_ADD_CHANNEL, // "+ Add Hashtag Channel" + ROW_INFO_HEADER, // "--- Info ---" separator + ROW_PUB_KEY, // Public key display + ROW_FIRMWARE, // Firmware version +}; + +// --------------------------------------------------------------------------- +// Editing modes +// --------------------------------------------------------------------------- +enum EditMode : uint8_t { + EDIT_NONE, // Just browsing + EDIT_TEXT, // Typing into a text buffer (name, channel name) + EDIT_PICKER, // A/D cycles options (radio preset) + EDIT_NUMBER, // W/S adjusts value (freq, BW, SF, CR, TX, UTC) + EDIT_CONFIRM, // Confirmation dialog (delete channel, apply radio) +}; + +// Max rows in the settings list +#define SETTINGS_MAX_ROWS 40 +#define SETTINGS_TEXT_BUF 33 // 32 chars + null + +class SettingsScreen : public UIScreen { +private: + UITask* _task; + mesh::RTCClock* _rtc; + NodePrefs* _prefs; + + // Row table — rebuilt whenever channels change + struct Row { + SettingsRowType type; + uint8_t param; // channel index for ROW_CHANNEL, preset index for ROW_RADIO_PRESET + }; + Row _rows[SETTINGS_MAX_ROWS]; + int _numRows; + + // Cursor & scroll + int _cursor; // selected row + int _scrollTop; // first visible row + + // Editing state + EditMode _editMode; + char _editBuf[SETTINGS_TEXT_BUF]; + int _editPos; + int _editPickerIdx; // for preset picker + float _editFloat; // for freq/BW editing + int _editInt; // for SF/CR/TX/UTC editing + int _confirmAction; // 0=none, 1=delete channel, 2=apply radio + + // Onboarding mode + bool _onboarding; + + // Dirty flag for radio params — prompt to apply + bool _radioChanged; + + // --------------------------------------------------------------------------- + // Row table management + // --------------------------------------------------------------------------- + + void rebuildRows() { + _numRows = 0; + + addRow(ROW_NAME); + addRow(ROW_RADIO_PRESET); + addRow(ROW_FREQ); + addRow(ROW_BW); + addRow(ROW_SF); + addRow(ROW_CR); + addRow(ROW_TX_POWER); + addRow(ROW_UTC_OFFSET); + addRow(ROW_CH_HEADER); + + // Enumerate current channels + for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') { + addRow(ROW_CHANNEL, i); + } else { + break; // channels are contiguous + } + } + + addRow(ROW_ADD_CHANNEL); + addRow(ROW_INFO_HEADER); + addRow(ROW_PUB_KEY); + addRow(ROW_FIRMWARE); + + // Clamp cursor + if (_cursor >= _numRows) _cursor = _numRows - 1; + if (_cursor < 0) _cursor = 0; + skipNonSelectable(1); + } + + void addRow(SettingsRowType type, uint8_t param = 0) { + if (_numRows < SETTINGS_MAX_ROWS) { + _rows[_numRows].type = type; + _rows[_numRows].param = param; + _numRows++; + } + } + + bool isSelectable(int idx) const { + if (idx < 0 || idx >= _numRows) return false; + SettingsRowType t = _rows[idx].type; + return t != ROW_CH_HEADER && t != ROW_INFO_HEADER; + } + + void skipNonSelectable(int dir) { + while (_cursor >= 0 && _cursor < _numRows && !isSelectable(_cursor)) { + _cursor += dir; + } + if (_cursor < 0) _cursor = 0; + if (_cursor >= _numRows) _cursor = _numRows - 1; + } + + // --------------------------------------------------------------------------- + // Radio preset detection + // --------------------------------------------------------------------------- + + int detectCurrentPreset() const { + for (int i = 0; i < (int)NUM_RADIO_PRESETS; i++) { + const RadioPreset& p = RADIO_PRESETS[i]; + if (fabsf(_prefs->freq - p.freq) < 0.01f && + fabsf(_prefs->bw - p.bw) < 0.01f && + _prefs->sf == p.sf && + _prefs->cr == p.cr && + _prefs->tx_power_dbm == p.tx_power) { + return i; + } + } + return -1; // Custom + } + + // --------------------------------------------------------------------------- + // Hashtag channel creation + // --------------------------------------------------------------------------- + + void createHashtagChannel(const char* name) { + // Build channel name with # prefix if not already present + char chanName[32]; + if (name[0] == '#') { + strncpy(chanName, name, sizeof(chanName)); + } else { + chanName[0] = '#'; + strncpy(&chanName[1], name, sizeof(chanName) - 1); + } + chanName[31] = '\0'; + + // Generate 128-bit PSK from SHA-256 of channel name + ChannelDetails newCh; + memset(&newCh, 0, sizeof(newCh)); + strncpy(newCh.name, chanName, sizeof(newCh.name)); + newCh.name[31] = '\0'; + + // SHA-256 the channel name → first 16 bytes become the secret + uint8_t hash[32]; + mesh::Utils::sha256(hash, 32, (const uint8_t*)chanName, strlen(chanName)); + memcpy(newCh.channel.secret, hash, 16); + // Upper 16 bytes left as zero → setChannel uses 128-bit mode + + // Find next empty slot + for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails existing; + if (!the_mesh.getChannel(i, existing) || existing.name[0] == '\0') { + if (the_mesh.setChannel(i, newCh)) { + the_mesh.saveChannels(); + Serial.printf("Settings: Created hashtag channel '%s' at idx %d\n", chanName, i); + } + break; + } + } + } + + void deleteChannel(uint8_t idx) { + // Clear the channel by writing an empty ChannelDetails + // Then compact: shift all channels above it down by one + ChannelDetails empty; + memset(&empty, 0, sizeof(empty)); + + // Find total channel count + int total = 0; + for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') { + total = i + 1; + } else { + break; + } + } + + // Shift channels down + for (int i = idx; i < total - 1; i++) { + ChannelDetails next; + if (the_mesh.getChannel(i + 1, next)) { + the_mesh.setChannel(i, next); + } + } + // Clear the last slot + the_mesh.setChannel(total - 1, empty); + the_mesh.saveChannels(); + Serial.printf("Settings: Deleted channel at idx %d, compacted %d channels\n", idx, total); + } + + // --------------------------------------------------------------------------- + // Apply radio parameters live + // --------------------------------------------------------------------------- + + void applyRadioParams() { + radio_set_params(_prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr); + radio_set_tx_power(_prefs->tx_power_dbm); + the_mesh.savePrefs(); + _radioChanged = false; + Serial.printf("Settings: Radio params applied - %.3f/%g/%d/%d TX:%d\n", + _prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr, _prefs->tx_power_dbm); + } + +public: + SettingsScreen(UITask* task, mesh::RTCClock* rtc, NodePrefs* prefs) + : _task(task), _rtc(rtc), _prefs(prefs), + _numRows(0), _cursor(0), _scrollTop(0), + _editMode(EDIT_NONE), _editPos(0), _editPickerIdx(0), + _editFloat(0), _editInt(0), _confirmAction(0), + _onboarding(false), _radioChanged(false) { + memset(_editBuf, 0, sizeof(_editBuf)); + } + + void enter() { + _editMode = EDIT_NONE; + _cursor = 0; + _scrollTop = 0; + _radioChanged = false; + rebuildRows(); + } + + void enterOnboarding() { + enter(); + _onboarding = true; + // Start editing the device name immediately + _cursor = 0; // ROW_NAME + startEditText(_prefs->node_name); + } + + bool isOnboarding() const { return _onboarding; } + bool isEditing() const { return _editMode != EDIT_NONE; } + bool hasRadioChanges() const { return _radioChanged; } + + // --------------------------------------------------------------------------- + // Edit mode starters + // --------------------------------------------------------------------------- + + void startEditText(const char* initial) { + _editMode = EDIT_TEXT; + strncpy(_editBuf, initial, SETTINGS_TEXT_BUF - 1); + _editBuf[SETTINGS_TEXT_BUF - 1] = '\0'; + _editPos = strlen(_editBuf); + } + + void startEditPicker(int initialIdx) { + _editMode = EDIT_PICKER; + _editPickerIdx = initialIdx; + } + + void startEditFloat(float initial) { + _editMode = EDIT_NUMBER; + _editFloat = initial; + } + + void startEditInt(int initial) { + _editMode = EDIT_NUMBER; + _editInt = initial; + } + + // --------------------------------------------------------------------------- + // Rendering + // --------------------------------------------------------------------------- + + int render(DisplayDriver& display) override { + char tmp[64]; + + // === Header === + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + if (_onboarding) { + display.print("Welcome! Setup"); + } else { + display.print("Settings"); + } + + // Right side: row indicator + snprintf(tmp, sizeof(tmp), "%d/%d", _cursor + 1, _numRows); + display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0); + display.print(tmp); + + display.drawRect(0, 11, display.width(), 1); + + // === Body === + display.setTextSize(0); // tiny font + int lineHeight = 9; + int headerH = 14; + int footerH = 14; + int maxY = display.height() - footerH; + + // Center scroll window around cursor + int maxVisible = (maxY - headerH) / lineHeight; + if (maxVisible < 3) maxVisible = 3; + _scrollTop = max(0, min(_cursor - maxVisible / 2, _numRows - maxVisible)); + int endIdx = min(_numRows, _scrollTop + maxVisible); + + int y = headerH; + + for (int i = _scrollTop; i < endIdx && y + lineHeight <= maxY; i++) { + bool selected = (i == _cursor); + bool editing = selected && (_editMode != EDIT_NONE); + + // Selection highlight + if (selected) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y + 5, display.width(), lineHeight); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + display.setCursor(0, y); + + switch (_rows[i].type) { + case ROW_NAME: + if (editing && _editMode == EDIT_TEXT) { + snprintf(tmp, sizeof(tmp), "Name: %s_", _editBuf); + } else { + snprintf(tmp, sizeof(tmp), "Name: %s", _prefs->node_name); + } + display.print(tmp); + break; + + case ROW_RADIO_PRESET: { + int preset = detectCurrentPreset(); + if (editing && _editMode == EDIT_PICKER) { + if (_editPickerIdx >= 0 && _editPickerIdx < (int)NUM_RADIO_PRESETS) { + snprintf(tmp, sizeof(tmp), "< %s >", RADIO_PRESETS[_editPickerIdx].name); + } else { + strcpy(tmp, "< Custom >"); + } + } else { + if (preset >= 0) { + snprintf(tmp, sizeof(tmp), "Preset: %s", RADIO_PRESETS[preset].name); + } else { + strcpy(tmp, "Preset: Custom"); + } + } + display.print(tmp); + break; + } + + case ROW_FREQ: + if (editing && _editMode == EDIT_NUMBER) { + snprintf(tmp, sizeof(tmp), "Freq: %.3f ", _editFloat); + } else { + snprintf(tmp, sizeof(tmp), "Freq: %.3f MHz", _prefs->freq); + } + display.print(tmp); + break; + + case ROW_BW: + if (editing && _editMode == EDIT_NUMBER) { + snprintf(tmp, sizeof(tmp), "BW: %.1f ", _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 ", _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 ", _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 ", _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 ", _editInt); + } else { + snprintf(tmp, sizeof(tmp), "UTC Offset: %+d", _prefs->utc_offset_hours); + } + display.print(tmp); + break; + + case ROW_CH_HEADER: + display.setColor(DisplayDriver::YELLOW); + display.print("--- Channels ---"); + break; + + case ROW_CHANNEL: { + uint8_t chIdx = _rows[i].param; + ChannelDetails ch; + if (the_mesh.getChannel(chIdx, ch)) { + if (chIdx == 0) { + // Public channel - not deletable + snprintf(tmp, sizeof(tmp), " %s", ch.name); + } else { + snprintf(tmp, sizeof(tmp), " %s", ch.name); + if (selected) { + // Show delete hint on right + const char* hint = "Del:X"; + int hintW = display.getTextWidth(hint); + display.setCursor(display.width() - hintW - 2, y); + display.print(hint); + display.setCursor(0, y); + } + } + } else { + snprintf(tmp, sizeof(tmp), " (empty)"); + } + display.print(tmp); + break; + } + + case ROW_ADD_CHANNEL: + if (editing && _editMode == EDIT_TEXT) { + snprintf(tmp, sizeof(tmp), "# %s_", _editBuf); + } else { + display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN); + strcpy(tmp, "+ Add Hashtag Channel"); + } + display.print(tmp); + break; + + case ROW_INFO_HEADER: + display.setColor(DisplayDriver::YELLOW); + display.print("--- Device Info ---"); + break; + + case ROW_PUB_KEY: { + // Show first 8 bytes of pub key as hex (16 chars) + char hexBuf[17]; + mesh::Utils::toHex(hexBuf, the_mesh.self_id.pub_key, 8); + snprintf(tmp, sizeof(tmp), "ID: %s", hexBuf); + display.print(tmp); + break; + } + + case ROW_FIRMWARE: + snprintf(tmp, sizeof(tmp), "FW: %s", FIRMWARE_VERSION); + display.print(tmp); + break; + } + + y += lineHeight; + } + + display.setTextSize(1); + + // === Confirmation overlay === + if (_editMode == EDIT_CONFIRM) { + int bx = 4, by = 30, bw = display.width() - 8, bh = 36; + display.setColor(DisplayDriver::DARK); + display.fillRect(bx, by, bw, bh); + display.setColor(DisplayDriver::LIGHT); + display.drawRect(bx, by, bw, bh); + + display.setTextSize(0); + if (_confirmAction == 1) { + uint8_t chIdx = _rows[_cursor].param; + ChannelDetails ch; + the_mesh.getChannel(chIdx, ch); + snprintf(tmp, sizeof(tmp), "Delete %s?", ch.name); + display.drawTextCentered(display.width() / 2, by + 4, tmp); + } else if (_confirmAction == 2) { + display.drawTextCentered(display.width() / 2, by + 4, "Apply radio changes?"); + } + display.drawTextCentered(display.width() / 2, by + bh - 14, "Enter:Yes Q:No"); + display.setTextSize(1); + } + + // === Footer === + int footerY = display.height() - 12; + display.drawRect(0, footerY - 2, display.width(), 1); + display.setColor(DisplayDriver::YELLOW); + display.setCursor(0, footerY); + + if (_editMode == EDIT_TEXT) { + display.print("Type, Enter:Ok Q:Cancel"); + } else if (_editMode == EDIT_PICKER) { + display.print("A/D:Choose Enter:Ok"); + } else if (_editMode == EDIT_NUMBER) { + display.print("W/S:Adj Enter:Ok Q:Cancel"); + } else if (_editMode == EDIT_CONFIRM) { + // Footer already covered by overlay + } else { + display.print("Q:Bck"); + const char* r = "W/S:Up/Dwn Entr:Chng"; + display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY); + display.print(r); + } + + return _editMode != EDIT_NONE ? 700 : 1000; + } + + // --------------------------------------------------------------------------- + // Input handling + // --------------------------------------------------------------------------- + + // Handle a keyboard character. Returns true if the screen consumed the input. + bool handleKeyInput(char c) { + // --- Confirmation dialog --- + if (_editMode == EDIT_CONFIRM) { + if (c == '\r' || c == 13) { + if (_confirmAction == 1) { + // Delete channel + uint8_t chIdx = _rows[_cursor].param; + deleteChannel(chIdx); + rebuildRows(); + } else if (_confirmAction == 2) { + applyRadioParams(); + } + _editMode = EDIT_NONE; + _confirmAction = 0; + return true; + } + if (c == 'q' || c == 'Q') { + _editMode = EDIT_NONE; + _confirmAction = 0; + return true; + } + return true; // consume all keys in confirm mode + } + + // --- Text editing mode --- + if (_editMode == EDIT_TEXT) { + if (c == '\r' || c == 13) { + // Confirm text edit + SettingsRowType type = _rows[_cursor].type; + if (type == ROW_NAME) { + if (_editPos > 0) { + strncpy(_prefs->node_name, _editBuf, sizeof(_prefs->node_name)); + _prefs->node_name[31] = '\0'; + the_mesh.savePrefs(); + Serial.printf("Settings: Name set to '%s'\n", _prefs->node_name); + } + _editMode = EDIT_NONE; + if (_onboarding) { + // Move to radio preset selection + _cursor = 1; // ROW_RADIO_PRESET + startEditPicker(max(0, detectCurrentPreset())); + } + } else if (type == ROW_ADD_CHANNEL) { + if (_editPos > 0) { + createHashtagChannel(_editBuf); + rebuildRows(); + } + _editMode = EDIT_NONE; + } + return true; + } + if (c == 'q' || c == 'Q' || c == 27) { + _editMode = EDIT_NONE; + return true; + } + if (c == '\b') { + if (_editPos > 0) { + _editPos--; + _editBuf[_editPos] = '\0'; + } + return true; + } + // Printable character + if (c >= 32 && c < 127 && _editPos < SETTINGS_TEXT_BUF - 1) { + _editBuf[_editPos++] = c; + _editBuf[_editPos] = '\0'; + return true; + } + return true; // consume all keys in text edit + } + + // --- Picker mode (radio preset) --- + if (_editMode == EDIT_PICKER) { + if (c == 'a' || c == 'A') { + _editPickerIdx--; + if (_editPickerIdx < 0) _editPickerIdx = (int)NUM_RADIO_PRESETS - 1; + return true; + } + if (c == 'd' || c == 'D') { + _editPickerIdx++; + if (_editPickerIdx >= (int)NUM_RADIO_PRESETS) _editPickerIdx = 0; + return true; + } + if (c == '\r' || c == 13) { + // Apply preset + if (_editPickerIdx >= 0 && _editPickerIdx < (int)NUM_RADIO_PRESETS) { + const RadioPreset& p = RADIO_PRESETS[_editPickerIdx]; + _prefs->freq = p.freq; + _prefs->bw = p.bw; + _prefs->sf = p.sf; + _prefs->cr = p.cr; + _prefs->tx_power_dbm = p.tx_power; + _radioChanged = true; + } + _editMode = EDIT_NONE; + if (_onboarding) { + // Apply and finish onboarding + applyRadioParams(); + _onboarding = false; + } + return true; + } + if (c == 'q' || c == 'Q') { + _editMode = EDIT_NONE; + return true; + } + return true; + } + + // --- Number editing mode --- + if (_editMode == EDIT_NUMBER) { + SettingsRowType type = _rows[_cursor].type; + + if (c == 'w' || c == 'W') { + switch (type) { + case ROW_FREQ: _editFloat += 0.1f; break; + case ROW_BW: + // Cycle through common bandwidths + if (_editFloat < 31.25f) _editFloat = 31.25f; + else if (_editFloat < 62.5f) _editFloat = 62.5f; + else if (_editFloat < 125.0f) _editFloat = 125.0f; + else if (_editFloat < 250.0f) _editFloat = 250.0f; + else _editFloat = 500.0f; + break; + case ROW_SF: if (_editInt < 12) _editInt++; break; + case ROW_CR: if (_editInt < 8) _editInt++; break; + case ROW_TX_POWER: if (_editInt < MAX_LORA_TX_POWER) _editInt++; break; + case ROW_UTC_OFFSET: if (_editInt < 14) _editInt++; break; + default: break; + } + return true; + } + if (c == 's' || c == 'S') { + switch (type) { + case ROW_FREQ: _editFloat -= 0.1f; break; + case ROW_BW: + if (_editFloat > 250.0f) _editFloat = 250.0f; + else if (_editFloat > 125.0f) _editFloat = 125.0f; + else if (_editFloat > 62.5f) _editFloat = 62.5f; + else _editFloat = 31.25f; + break; + case ROW_SF: if (_editInt > 5) _editInt--; break; + case ROW_CR: if (_editInt > 5) _editInt--; break; + case ROW_TX_POWER: if (_editInt > 1) _editInt--; break; + case ROW_UTC_OFFSET: if (_editInt > -12) _editInt--; break; + default: break; + } + return true; + } + if (c == '\r' || c == 13) { + // Confirm number edit + switch (type) { + case ROW_FREQ: + _prefs->freq = constrain(_editFloat, 400.0f, 2500.0f); + _radioChanged = true; + break; + case ROW_BW: + _prefs->bw = _editFloat; + _radioChanged = true; + break; + case ROW_SF: + _prefs->sf = (uint8_t)constrain(_editInt, 5, 12); + _radioChanged = true; + break; + case ROW_CR: + _prefs->cr = (uint8_t)constrain(_editInt, 5, 8); + _radioChanged = true; + break; + case ROW_TX_POWER: + _prefs->tx_power_dbm = (uint8_t)constrain(_editInt, 1, MAX_LORA_TX_POWER); + _radioChanged = true; + break; + case ROW_UTC_OFFSET: + _prefs->utc_offset_hours = (int8_t)constrain(_editInt, -12, 14); + the_mesh.savePrefs(); + break; + default: break; + } + _editMode = EDIT_NONE; + return true; + } + if (c == 'q' || c == 'Q') { + _editMode = EDIT_NONE; + return true; + } + return true; + } + + // --- Normal browsing mode --- + + // W/S: navigate + if (c == 'w' || c == 'W') { + if (_cursor > 0) { + _cursor--; + skipNonSelectable(-1); + } + Serial.printf("Settings: cursor=%d/%d row=%d\n", _cursor, _numRows, _rows[_cursor].type); + return true; + } + if (c == 's' || c == 'S') { + if (_cursor < _numRows - 1) { + _cursor++; + skipNonSelectable(1); + } + Serial.printf("Settings: cursor=%d/%d row=%d\n", _cursor, _numRows, _rows[_cursor].type); + return true; + } + + // Enter: start editing the selected row + if (c == '\r' || c == 13) { + SettingsRowType type = _rows[_cursor].type; + switch (type) { + case ROW_NAME: + startEditText(_prefs->node_name); + break; + case ROW_RADIO_PRESET: + startEditPicker(max(0, detectCurrentPreset())); + break; + case ROW_FREQ: + startEditFloat(_prefs->freq); + break; + case ROW_BW: + startEditFloat(_prefs->bw); + break; + case ROW_SF: + startEditInt(_prefs->sf); + break; + case ROW_CR: + startEditInt(_prefs->cr); + break; + case ROW_TX_POWER: + startEditInt(_prefs->tx_power_dbm); + break; + case ROW_UTC_OFFSET: + startEditInt(_prefs->utc_offset_hours); + break; + case ROW_ADD_CHANNEL: + startEditText(""); + break; + case ROW_CHANNEL: + case ROW_PUB_KEY: + case ROW_FIRMWARE: + // Not directly editable on Enter + break; + default: + break; + } + return true; + } + + // X: delete channel (when on a channel row, idx > 0) + if (c == 'x' || c == 'X') { + if (_rows[_cursor].type == ROW_CHANNEL && _rows[_cursor].param > 0) { + _editMode = EDIT_CONFIRM; + _confirmAction = 1; + return true; + } + } + + // Q: back — if radio changed, prompt to apply first + if (c == 'q' || c == 'Q') { + if (_radioChanged) { + _editMode = EDIT_CONFIRM; + _confirmAction = 2; + return true; + } + _onboarding = false; + return false; // Let the caller handle navigation back + } + + return true; // Consume all other keys (don't let caller exit) + } + + // Override handleInput for UIScreen compatibility (used by injectKey) + bool handleInput(char c) override { + return handleKeyInput(c); + } +}; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 47d08ea..ff38ec7 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -33,6 +33,7 @@ #include "ChannelScreen.h" #include "ContactsScreen.h" #include "TextReaderScreen.h" +#include "SettingsScreen.h" class SplashScreen : public UIScreen { UITask* _task; @@ -716,6 +717,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no channel_screen = new ChannelScreen(this, &rtc_clock); contacts_screen = new ContactsScreen(this, &rtc_clock); text_reader = new TextReaderScreen(this); + settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs); setCurrScreen(splash); } @@ -1155,6 +1157,26 @@ void UITask::gotoTextReader() { _next_refresh = 100; } +void UITask::gotoSettingsScreen() { + ((SettingsScreen*)settings_screen)->enter(); + setCurrScreen(settings_screen); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + +void UITask::gotoOnboarding() { + ((SettingsScreen*)settings_screen)->enterOnboarding(); + setCurrScreen(settings_screen); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + uint8_t UITask::getChannelScreenViewIdx() const { return ((ChannelScreen *) channel_screen)->getViewChannelIdx(); } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 1b86dcb..6174a60 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -54,6 +54,7 @@ class UITask : public AbstractUITask { UIScreen* channel_screen; // Channel message history screen UIScreen* contacts_screen; // Contacts list screen UIScreen* text_reader; // *** NEW: Text reader screen *** + UIScreen* settings_screen; // Settings/onboarding screen UIScreen* curr; void userLedHandler(); @@ -79,6 +80,8 @@ public: void gotoChannelScreen(); // Navigate to channel message screen void gotoContactsScreen(); // Navigate to contacts list void gotoTextReader(); // *** NEW: Navigate to text reader *** + void gotoSettingsScreen(); // Navigate to settings + void gotoOnboarding(); // Navigate to settings in onboarding mode void showAlert(const char* text, int duration_millis) override; void forceRefresh() override { _next_refresh = 100; } int getMsgCount() const { return _msgcount; } @@ -87,6 +90,7 @@ public: bool isOnChannelScreen() const { return curr == channel_screen; } bool isOnContactsScreen() const { return curr == contacts_screen; } bool isOnTextReader() const { return curr == text_reader; } // *** NEW *** + bool isOnSettingsScreen() const { return curr == settings_screen; } uint8_t getChannelScreenViewIdx() const; void toggleBuzzer(); @@ -108,6 +112,7 @@ public: UIScreen* getTextReaderScreen() const { return text_reader; } // *** NEW *** UIScreen* getContactsScreen() const { return contacts_screen; } UIScreen* getChannelScreen() const { return channel_screen; } + UIScreen* getSettingsScreen() const { return settings_screen; } // from AbstractUITask void msgRead(int msgcount) override; diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index 39cbf23..ff38ec7 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -1,9 +1,14 @@ #include "UITask.h" -#include #include #include "../MyMesh.h" +#include "target.h" +#ifdef WIFI_SSID + #include +#endif -#define AUTO_OFF_MILLIS 15000 // 15 seconds +#ifndef AUTO_OFF_MILLIS + #define AUTO_OFF_MILLIS 15000 // 15 seconds +#endif #define BOOT_SCREEN_MILLIS 3000 // 3 seconds #ifdef PIN_STATUS_LED @@ -12,81 +17,714 @@ #define LED_CYCLE_MILLIS 4000 #endif -#ifndef USER_BTN_PRESSED -#define USER_BTN_PRESSED LOW +#define LONG_PRESS_MILLIS 1200 + +#ifndef UI_RECENT_LIST_SIZE + #define UI_RECENT_LIST_SIZE 4 #endif -// 'meshcore', 128x13px -static const uint8_t meshcore_logo [] PROGMEM = { - 0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, - 0x3c, 0x03, 0xe3, 0xff, 0xc7, 0xff, 0x8e, 0x03, 0x8f, 0xfe, 0x3f, 0xfe, 0x1f, 0xff, 0x1f, 0xfe, - 0x3e, 0x03, 0xc3, 0xff, 0x8f, 0xff, 0x0e, 0x07, 0x8f, 0xfe, 0x7f, 0xfe, 0x1f, 0xff, 0x1f, 0xfc, - 0x3e, 0x07, 0xc7, 0x80, 0x0e, 0x00, 0x0e, 0x07, 0x9e, 0x00, 0x78, 0x0e, 0x3c, 0x0f, 0x1c, 0x00, - 0x3e, 0x0f, 0xc7, 0x80, 0x1e, 0x00, 0x0e, 0x07, 0x1e, 0x00, 0x70, 0x0e, 0x38, 0x0f, 0x3c, 0x00, - 0x7f, 0x0f, 0xc7, 0xfe, 0x1f, 0xfc, 0x1f, 0xff, 0x1c, 0x00, 0x70, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, - 0x7f, 0x1f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, - 0x7f, 0x3f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x1e, 0x3f, 0xfe, 0x3f, 0xf0, - 0x77, 0x3b, 0x87, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xfc, 0x38, 0x00, - 0x77, 0xfb, 0x8f, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xf8, 0x38, 0x00, - 0x73, 0xf3, 0x8f, 0xff, 0x0f, 0xff, 0x1c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x78, 0x7f, 0xf8, - 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfe, 0x3c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x3c, 0x7f, 0xf8, - 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8, +#if UI_HAS_JOYSTICK + #define PRESS_LABEL "press Enter" +#else + #define PRESS_LABEL "long press" +#endif + +#include "icons.h" +#include "ChannelScreen.h" +#include "ContactsScreen.h" +#include "TextReaderScreen.h" +#include "SettingsScreen.h" + +class SplashScreen : public UIScreen { + UITask* _task; + unsigned long dismiss_after; + char _version_info[12]; + +public: + SplashScreen(UITask* task) : _task(task) { + // strip off dash and commit hash by changing dash to null terminator + // e.g: v1.2.3-abcdef -> v1.2.3 + const char *ver = FIRMWARE_VERSION; + const char *dash = strchr(ver, '-'); + + int len = dash ? dash - ver : strlen(ver); + if (len >= sizeof(_version_info)) len = sizeof(_version_info) - 1; + memcpy(_version_info, ver, len); + _version_info[len] = 0; + + dismiss_after = millis() + BOOT_SCREEN_MILLIS; + } + + int render(DisplayDriver& display) override { + // meshcore logo + display.setColor(DisplayDriver::BLUE); + int logoWidth = 128; + display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); + + // version info + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(2); + display.drawTextCentered(display.width()/2, 22, _version_info); + + display.setTextSize(1); + display.drawTextCentered(display.width()/2, 42, FIRMWARE_BUILD_DATE); + + return 1000; + } + +void poll() override { + if (millis() >= dismiss_after) { + Serial.println(">>> SplashScreen calling gotoHomeScreen() <<<"); + _task->gotoHomeScreen(); + } +} +}; + +class HomeScreen : public UIScreen { + enum HomePage { + FIRST, + RECENT, + RADIO, + BLUETOOTH, + ADVERT, +#if ENV_INCLUDE_GPS == 1 + GPS, +#endif +#if UI_SENSORS_PAGE == 1 + SENSORS, +#endif + SHUTDOWN, + Count // keep as last + }; + + UITask* _task; + mesh::RTCClock* _rtc; + SensorManager* _sensors; + 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 + uint8_t batteryPercentage = 0; + if (batteryMilliVolts > 0) { + const int minMilliVolts = 3000; + const int maxMilliVolts = 4200; + int pct = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts); + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + batteryPercentage = (uint8_t)pct; + } + + display.setColor(DisplayDriver::GREEN); + + // battery icon dimensions (smaller to match tiny percentage text) + int iconWidth = 16; + int iconHeight = 6; + + // measure percentage text width to position icon + text together at right edge + display.setTextSize(0); + char pctStr[5]; + sprintf(pctStr, "%d%%", batteryPercentage); + uint16_t textWidth = display.getTextWidth(pctStr); + + // layout: [icon 16px][cap 2px][gap 2px][text][margin 2px] + int totalWidth = iconWidth + 2 + 2 + textWidth + 2; + int iconX = display.width() - totalWidth; + int iconY = 0; // vertically align with node name text + + // battery outline + display.drawRect(iconX, iconY, iconWidth, iconHeight); + + // battery "cap" + display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 2, iconHeight / 2); + + // fill the battery based on the percentage + int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100; + display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4); + + // draw percentage text after the battery cap, offset upward to center with icon + // (setCursor adds +5 internally for baseline, so compensate for the tiny font) + int textX = iconX + iconWidth + 2 + 2; // after cap + gap + int textY = iconY - 3; // offset up to vertically center with icon + display.setCursor(textX, textY); + display.print(pctStr); + display.setTextSize(1); // restore default text size + } + + CayenneLPP sensors_lpp; + int sensors_nb = 0; + bool sensors_scroll = false; + int sensors_scroll_offset = 0; + int next_sensors_refresh = 0; + + void refresh_sensors() { + if (millis() > next_sensors_refresh) { + sensors_lpp.reset(); + sensors_nb = 0; + sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); + sensors.querySensors(0xFF, sensors_lpp); + LPPReader reader (sensors_lpp.getBuffer(), sensors_lpp.getSize()); + uint8_t channel, type; + while(reader.readHeader(channel, type)) { + reader.skipData(type); + sensors_nb ++; + } + sensors_scroll = sensors_nb > UI_RECENT_LIST_SIZE; +#if AUTO_OFF_MILLIS > 0 + next_sensors_refresh = millis() + 5000; // refresh sensor values every 5 sec +#else + next_sensors_refresh = millis() + 60000; // refresh sensor values every 1 min +#endif + } + } + +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), _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 + _task->shutdown(); + } + } + + int render(DisplayDriver& display) override { + char tmp[80]; + // node name + display.setTextSize(1); + 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.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); + for (uint8_t i = 0; i < HomePage::Count; i++, x += 10) { + if (i == _page) { + display.fillRect(x-1, y-1, 3, 3); + } else { + display.fillRect(x, y, 1, 1); + } + } + + if (_page == HomePage::FIRST) { + display.setColor(DisplayDriver::YELLOW); + display.setTextSize(2); + sprintf(tmp, "MSG: %d", _task->getMsgCount()); + display.drawTextCentered(display.width() / 2, 20, tmp); + + #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); + #endif + 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.setColor(DisplayDriver::RED); + display.setTextSize(2); + sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); + display.drawTextCentered(display.width() / 2, 43, tmp); + } + } else if (_page == HomePage::RECENT) { + the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE); + display.setColor(DisplayDriver::GREEN); + int y = 20; + for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) { + auto a = &recent[i]; + if (a->name[0] == 0) continue; // empty slot + int secs = _rtc->getCurrentTime() - a->recv_timestamp; + if (secs < 60) { + sprintf(tmp, "%ds", secs); + } else if (secs < 60*60) { + sprintf(tmp, "%dm", secs / 60); + } else { + sprintf(tmp, "%dh", secs / (60*60)); + } + + int timestamp_width = display.getTextWidth(tmp); + int max_name_width = display.width() - timestamp_width - 1; + + char filtered_recent_name[sizeof(a->name)]; + display.translateUTF8ToBlocks(filtered_recent_name, a->name, sizeof(filtered_recent_name)); + display.drawTextEllipsized(0, y, max_name_width, filtered_recent_name); + display.setCursor(display.width() - timestamp_width - 1, y); + display.print(tmp); + } + } else if (_page == HomePage::RADIO) { + display.setColor(DisplayDriver::YELLOW); + display.setTextSize(1); + // freq / sf + display.setCursor(0, 20); + sprintf(tmp, "FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf); + display.print(tmp); + + display.setCursor(0, 31); + sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); + display.print(tmp); + + // tx power, noise floor + display.setCursor(0, 42); + sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm); + display.print(tmp); + display.setCursor(0, 53); + sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor()); + display.print(tmp); + } else if (_page == HomePage::BLUETOOTH) { + display.setColor(DisplayDriver::GREEN); + display.drawXbm((display.width() - 32) / 2, 18, + _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, + 32, 32); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL); + } 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) { + 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)"); + } else { + strcpy(buf, gps_state ? "gps on" : "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"); + } else { + strcpy(buf, nmea->isValid()?"fix":"no fix"); + display.drawTextRightAlign(display.width()-1, y, buf); + y = y + 12; + display.drawTextLeftAlign(0, y, "sat"); + sprintf(buf, "%d", nmea->satellitesCount()); + 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.); + display.drawTextRightAlign(display.width()-1, y, buf); + y = y + 12; + display.drawTextLeftAlign(0, y, "alt"); + sprintf(buf, "%.2f", nmea->getAltitude()/1000.); + 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) { + int y = 18; + refresh_sensors(); + char buf[30]; + char name[30]; + LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize()); + + for (int i = 0; i < sensors_scroll_offset; i++) { + uint8_t channel, type; + r.readHeader(channel, type); + r.skipData(type); + } + + for (int i = 0; i < (sensors_scroll?UI_RECENT_LIST_SIZE:sensors_nb); i++) { + uint8_t channel, type; + if (!r.readHeader(channel, type)) { // reached end, reset + r.reset(); + r.readHeader(channel, type); + } + + display.setCursor(0, y); + float v; + switch (type) { + case LPP_GPS: // GPS + float lat, lon, alt; + r.readGPS(lat, lon, alt); + strcpy(name, "gps"); sprintf(buf, "%.4f %.4f", lat, lon); + break; + case LPP_VOLTAGE: + r.readVoltage(v); + strcpy(name, "voltage"); sprintf(buf, "%6.2f", v); + break; + case LPP_CURRENT: + r.readCurrent(v); + strcpy(name, "current"); sprintf(buf, "%.3f", v); + break; + case LPP_TEMPERATURE: + r.readTemperature(v); + strcpy(name, "temperature"); sprintf(buf, "%.2f", v); + break; + case LPP_RELATIVE_HUMIDITY: + r.readRelativeHumidity(v); + strcpy(name, "humidity"); sprintf(buf, "%.2f", v); + break; + case LPP_BAROMETRIC_PRESSURE: + r.readPressure(v); + strcpy(name, "pressure"); sprintf(buf, "%.2f", v); + break; + case LPP_ALTITUDE: + r.readAltitude(v); + strcpy(name, "altitude"); sprintf(buf, "%.0f", v); + break; + case LPP_POWER: + r.readPower(v); + strcpy(name, "power"); sprintf(buf, "%6.2f", v); + break; + default: + r.skipData(type); + strcpy(name, "unk"); sprintf(buf, ""); + } + display.setCursor(0, y); + display.print(name); + display.setCursor( + display.width()-display.getTextWidth(buf)-1, y + ); + display.print(buf); + y = y + 12; + } + if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb; + else sensors_scroll_offset = 0; +#endif + } else if (_page == HomePage::SHUTDOWN) { + display.setColor(DisplayDriver::GREEN); + display.setTextSize(1); + if (_shutdown_init) { + display.drawTextCentered(display.width() / 2, 34, "hibernating..."); + } else { + display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32); + display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL); + } + } + 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; + } + if (c == KEY_NEXT || c == KEY_RIGHT) { + _page = (_page + 1) % HomePage::Count; + if (_page == HomePage::RECENT) { + _task->showAlert("Recent adverts", 800); + } + return true; + } + if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) { + if (_task->isSerialEnabled()) { // toggle Bluetooth on/off + _task->disableSerial(); + } else { + _task->enableSerial(); + } + return true; + } + if (c == KEY_ENTER && _page == HomePage::ADVERT) { + _task->notify(UIEventType::ack); + if (the_mesh.advert()) { + _task->showAlert("Advert sent!", 1000); + } else { + _task->showAlert("Advert failed..", 1000); + } + return true; + } +#if ENV_INCLUDE_GPS == 1 + if (c == KEY_ENTER && _page == HomePage::GPS) { + _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) { + _task->toggleGPS(); + next_sensors_refresh=0; + return true; + } +#endif + if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) { + _shutdown_init = true; // need to wait for button to be released + return true; + } + return false; + } +}; + +class MsgPreviewScreen : public UIScreen { + UITask* _task; + mesh::RTCClock* _rtc; + + struct MsgEntry { + uint32_t timestamp; + char origin[62]; + char msg[78]; + }; + #define MAX_UNREAD_MSGS 32 + int num_unread; + MsgEntry unread[MAX_UNREAD_MSGS]; + +public: + MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc) { num_unread = 0; } + + void addPreview(uint8_t path_len, const char* from_name, const char* msg) { + if (num_unread >= MAX_UNREAD_MSGS) return; // full + + auto p = &unread[num_unread++]; + p->timestamp = _rtc->getCurrentTime(); + if (path_len == 0xFF) { + sprintf(p->origin, "(D) %s:", from_name); + } else { + sprintf(p->origin, "(%d) %s:", (uint32_t) path_len, from_name); + } + StrHelper::strncpy(p->msg, msg, sizeof(p->msg)); + } + + int render(DisplayDriver& display) override { + char tmp[16]; + display.setCursor(0, 0); + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + sprintf(tmp, "Unread: %d", num_unread); + display.print(tmp); + + auto p = &unread[0]; + + int secs = _rtc->getCurrentTime() - p->timestamp; + if (secs < 60) { + sprintf(tmp, "%ds", secs); + } else if (secs < 60*60) { + sprintf(tmp, "%dm", secs / 60); + } else { + sprintf(tmp, "%dh", secs / (60*60)); + } + display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0); + display.print(tmp); + + display.drawRect(0, 11, display.width(), 1); // horiz line + + display.setCursor(0, 14); + display.setColor(DisplayDriver::YELLOW); + char filtered_origin[sizeof(p->origin)]; + display.translateUTF8ToBlocks(filtered_origin, p->origin, sizeof(filtered_origin)); + display.print(filtered_origin); + + display.setCursor(0, 25); + display.setColor(DisplayDriver::LIGHT); + char filtered_msg[sizeof(p->msg)]; + display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg)); + display.printWordWrap(filtered_msg, display.width()); + +#if AUTO_OFF_MILLIS==0 // probably e-ink + return 10000; // 10 s +#else + return 1000; // next render after 1000 ms +#endif + } + + bool handleInput(char c) override { + if (c == KEY_NEXT || c == KEY_RIGHT) { + num_unread--; + if (num_unread == 0) { + _task->gotoHomeScreen(); + } else { + // delete first/curr item from unread queue + for (int i = 0; i < num_unread; i++) { + unread[i] = unread[i + 1]; + } + } + return true; + } + if (c == KEY_ENTER) { + num_unread = 0; // clear unread queue + _task->gotoHomeScreen(); + return true; + } + return false; + } }; void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) { _display = display; _sensors = sensors; _auto_off = millis() + AUTO_OFF_MILLIS; - clearMsgPreview(); + +#if defined(PIN_USER_BTN) + user_btn.begin(); +#endif +#if defined(PIN_USER_BTN_ANA) + analog_btn.begin(); +#endif + _node_prefs = node_prefs; + +#if ENV_INCLUDE_GPS == 1 + // Apply GPS preferences from stored prefs + if (_sensors != NULL && _node_prefs != NULL) { + _sensors->setSettingValue("gps", _node_prefs->gps_enabled ? "1" : "0"); + if (_node_prefs->gps_interval > 0) { + char interval_str[12]; // Max: 24 hours = 86400 seconds (5 digits + null) + sprintf(interval_str, "%u", _node_prefs->gps_interval); + _sensors->setSettingValue("gps_interval", interval_str); + } + } +#endif + if (_display != NULL) { _display->turnOn(); } - // strip off dash and commit hash by changing dash to null terminator - // e.g: v1.2.3-abcdef -> v1.2.3 - char *version = strdup(FIRMWARE_VERSION); - char *dash = strchr(version, '-'); - if (dash) { - *dash = 0; - } - - // v1.2.3 (1 Jan 2025) - sprintf(_version_info, "%s (%s)", version, FIRMWARE_BUILD_DATE); - #ifdef PIN_BUZZER buzzer.begin(); buzzer.quiet(_node_prefs->buzzer_quiet); #endif - // Initialize digital button if available -#ifdef PIN_USER_BTN - _userButton = new Button(PIN_USER_BTN, USER_BTN_PRESSED); - _userButton->begin(); - - // Set up digital button callbacks - _userButton->onShortPress([this]() { handleButtonShortPress(); }); - _userButton->onDoublePress([this]() { handleButtonDoublePress(); }); - _userButton->onTriplePress([this]() { handleButtonTriplePress(); }); - _userButton->onQuadruplePress([this]() { handleButtonQuadruplePress(); }); - _userButton->onLongPress([this]() { handleButtonLongPress(); }); - _userButton->onAnyPress([this]() { handleButtonAnyPress(); }); +#ifdef PIN_VIBRATION + vibration.begin(); #endif - // Initialize analog button if available -#ifdef PIN_USER_BTN_ANA - _userButtonAnalog = new Button(PIN_USER_BTN_ANA, USER_BTN_PRESSED, true, 20); - _userButtonAnalog->begin(); - - // Set up analog button callbacks - _userButtonAnalog->onShortPress([this]() { handleButtonShortPress(); }); - _userButtonAnalog->onDoublePress([this]() { handleButtonDoublePress(); }); - _userButtonAnalog->onTriplePress([this]() { handleButtonTriplePress(); }); - _userButtonAnalog->onQuadruplePress([this]() { handleButtonQuadruplePress(); }); - _userButtonAnalog->onLongPress([this]() { handleButtonLongPress(); }); - _userButtonAnalog->onAnyPress([this]() { handleButtonAnyPress(); }); -#endif ui_started_at = millis(); + _alert_expiry = 0; + + splash = new SplashScreen(this); + home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); + msg_preview = new MsgPreviewScreen(this, &rtc_clock); + channel_screen = new ChannelScreen(this, &rtc_clock); + contacts_screen = new ContactsScreen(this, &rtc_clock); + text_reader = new TextReaderScreen(this); + settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs); + setCurrScreen(splash); +} + +void UITask::showAlert(const char* text, int duration_millis) { + strcpy(_alert, text); + _alert_expiry = millis() + duration_millis; + _next_refresh = millis() + 100; // trigger re-render to show updated text } void UITask::notify(UIEventType t) { @@ -109,32 +747,54 @@ switch(t){ break; } #endif -// Serial.print("DBG: Alert user -> "); -// Serial.println((int) t); + +#ifdef PIN_VIBRATION + // Trigger vibration for all UI events except none + if (t != UIEventType::none) { + vibration.trigger(); + } +#endif } + void UITask::msgRead(int msgcount) { _msgcount = msgcount; if (msgcount == 0) { - clearMsgPreview(); + gotoHomeScreen(); } } -void UITask::clearMsgPreview() { - _origin[0] = 0; - _msg[0] = 0; - _need_refresh = true; -} - void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { _msgcount = msgcount; - if (path_len == 0xFF) { - sprintf(_origin, "(F) %s", from_name); - } else { - sprintf(_origin, "(%d) %s", (uint32_t) path_len, from_name); + // Add to preview screen (for notifications on non-keyboard devices) + ((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text); + + // Determine channel index by looking up the channel name + // For channel messages, from_name is the channel name + // For contact messages, from_name is the contact name (channel_idx = 0xFF) + uint8_t channel_idx = 0xFF; // Default: unknown/contact message + for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + if (the_mesh.getChannel(i, ch) && strcmp(ch.name, from_name) == 0) { + channel_idx = i; + break; + } } - StrHelper::strncpy(_msg, text, sizeof(_msg)); + + // Add to channel history screen with channel index + ((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text); + +#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); +#else + // Other devices: Show full preview screen (legacy behavior) + setCurrScreen(msg_preview); +#endif if (_display != NULL) { if (!_display->isOn() && !hasConnection()) { @@ -142,145 +802,39 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i } if (_display->isOn()) { _auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer - _need_refresh = true; + _next_refresh = 100; // trigger refresh } } } -void UITask::renderBatteryIndicator(uint16_t batteryMilliVolts) { - // Convert millivolts to percentage - const int minMilliVolts = 3000; // Minimum voltage (e.g., 3.0V) - const int maxMilliVolts = 4200; // Maximum voltage (e.g., 4.2V) - int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts); - if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0% - if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100% - - // battery icon - int iconWidth = 24; - int iconHeight = 12; - int iconX = _display->width() - iconWidth - 5; // Position the icon near the top-right corner - int iconY = 0; - _display->setColor(DisplayDriver::GREEN); - - // battery outline - _display->drawRect(iconX, iconY, iconWidth, iconHeight); - - // battery "cap" - _display->fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2); - - // fill the battery based on the percentage - int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100; - _display->fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4); -} - -void UITask::renderCurrScreen() { - if (_display == NULL) return; // assert() ?? - - char tmp[80]; - if (_alert[0]) { - _display->setTextSize(1.4); - uint16_t textWidth = _display->getTextWidth(_alert); - _display->setCursor((_display->width() - textWidth) / 2, 22); - _display->setColor(DisplayDriver::GREEN); - _display->print(_alert); - _alert[0] = 0; - _need_refresh = true; - return; - } else if (_origin[0] && _msg[0]) { // message preview - // render message preview - _display->setCursor(0, 0); - _display->setTextSize(1); - _display->setColor(DisplayDriver::GREEN); - _display->print(_node_prefs->node_name); - - _display->setCursor(0, 12); - _display->setColor(DisplayDriver::YELLOW); - _display->print(_origin); - _display->setCursor(0, 24); - _display->setColor(DisplayDriver::LIGHT); - _display->print(_msg); - - _display->setCursor(_display->width() - 28, 9); - _display->setTextSize(2); - _display->setColor(DisplayDriver::ORANGE); - sprintf(tmp, "%d", _msgcount); - _display->print(tmp); - _display->setColor(DisplayDriver::YELLOW); // last color will be kept on T114 - } else if ((millis() - ui_started_at) < BOOT_SCREEN_MILLIS) { // boot screen - // meshcore logo - _display->setColor(DisplayDriver::BLUE); - int logoWidth = 128; - _display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); - - // version info - _display->setColor(DisplayDriver::LIGHT); - _display->setTextSize(1); - uint16_t textWidth = _display->getTextWidth(_version_info); - _display->setCursor((_display->width() - textWidth) / 2, 22); - _display->print(_version_info); - } else { // home screen - // node name - _display->setCursor(0, 0); - _display->setTextSize(1); - _display->setColor(DisplayDriver::GREEN); - _display->print(_node_prefs->node_name); - - // battery voltage - renderBatteryIndicator(_board->getBattMilliVolts()); - - // freq / sf - _display->setCursor(0, 20); - _display->setColor(DisplayDriver::YELLOW); - sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); - _display->print(tmp); - - // bw / cr - _display->setCursor(0, 30); - sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); - _display->print(tmp); - - // BT pin - if (!_connected && the_mesh.getBLEPin() != 0) { - _display->setColor(DisplayDriver::RED); - _display->setTextSize(2); - _display->setCursor(0, 43); - sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); - _display->print(tmp); - _display->setColor(DisplayDriver::GREEN); - } else { - _display->setColor(DisplayDriver::LIGHT); - } - } - _need_refresh = false; -} - void UITask::userLedHandler() { #ifdef PIN_STATUS_LED - static int state = 0; - static int next_change = 0; - static int last_increment = 0; - int cur_time = millis(); - if (cur_time > next_change) { - if (state == 0) { - state = 1; + if (cur_time > next_led_change) { + if (led_state == 0) { + led_state = 1; if (_msgcount > 0) { - last_increment = LED_ON_MSG_MILLIS; + last_led_increment = LED_ON_MSG_MILLIS; } else { - last_increment = LED_ON_MILLIS; + last_led_increment = LED_ON_MILLIS; } - next_change = cur_time + last_increment; + next_led_change = cur_time + last_led_increment; } else { - state = 0; - next_change = cur_time + LED_CYCLE_MILLIS - last_increment; + led_state = 0; + next_led_change = cur_time + LED_CYCLE_MILLIS - last_led_increment; } - digitalWrite(PIN_STATUS_LED, state == LED_STATE_ON); + digitalWrite(PIN_STATUS_LED, led_state == LED_STATE_ON); } #endif } -/* - hardware-agnostic pre-shutdown activity should be done here +void UITask::setCurrScreen(UIScreen* c) { + curr = c; + _next_refresh = 100; +} + +/* + hardware-agnostic pre-shutdown activity should be done here */ void UITask::shutdown(bool restart){ @@ -300,141 +854,338 @@ void UITask::shutdown(bool restart){ if (restart) { _board->reboot(); } else { + _display->turnOff(); radio_driver.powerOff(); _board->powerOff(); } } +bool UITask::isButtonPressed() const { +#ifdef PIN_USER_BTN + return user_btn.isPressed(); +#else + return false; +#endif +} + void UITask::loop() { - #ifdef PIN_USER_BTN - if (_userButton) { - _userButton->update(); + char c = 0; +#if UI_HAS_JOYSTICK + int ev = user_btn.check(); + if (ev == BUTTON_EVENT_CLICK) { + c = checkDisplayOn(KEY_ENTER); + } else if (ev == BUTTON_EVENT_LONG_PRESS) { + c = handleLongPress(KEY_ENTER); // REVISIT: could be mapped to different key code + } + ev = joystick_left.check(); + if (ev == BUTTON_EVENT_CLICK) { + c = checkDisplayOn(KEY_LEFT); + } else if (ev == BUTTON_EVENT_LONG_PRESS) { + c = handleLongPress(KEY_LEFT); + } + ev = joystick_right.check(); + if (ev == BUTTON_EVENT_CLICK) { + c = checkDisplayOn(KEY_RIGHT); + } else if (ev == BUTTON_EVENT_LONG_PRESS) { + c = handleLongPress(KEY_RIGHT); + } + ev = back_btn.check(); + if (ev == BUTTON_EVENT_TRIPLE_CLICK) { + c = handleTripleClick(KEY_SELECT); + } +#elif defined(PIN_USER_BTN) + int ev = user_btn.check(); + if (ev == BUTTON_EVENT_CLICK) { + c = checkDisplayOn(KEY_NEXT); + } else if (ev == BUTTON_EVENT_LONG_PRESS) { + c = handleLongPress(KEY_ENTER); + } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { + c = handleDoubleClick(KEY_PREV); + } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { + c = handleTripleClick(KEY_SELECT); + } +#endif +#if defined(PIN_USER_BTN_ANA) + if (abs(millis() - _analogue_pin_read_millis) > 10) { + ev = analog_btn.check(); + if (ev == BUTTON_EVENT_CLICK) { + c = checkDisplayOn(KEY_NEXT); + } else if (ev == BUTTON_EVENT_LONG_PRESS) { + c = handleLongPress(KEY_ENTER); + } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { + c = handleDoubleClick(KEY_PREV); + } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { + c = handleTripleClick(KEY_SELECT); } - #endif - #ifdef PIN_USER_BTN_ANA - if (_userButtonAnalog) { - _userButtonAnalog->update(); - } - #endif + _analogue_pin_read_millis = millis(); + } +#endif +#if defined(BACKLIGHT_BTN) + if (millis() > next_backlight_btn_check) { + bool touch_state = digitalRead(PIN_BUTTON2); +#if defined(DISP_BACKLIGHT) + digitalWrite(DISP_BACKLIGHT, !touch_state); +#elif defined(EXP_PIN_BACKLIGHT) + expander.digitalWrite(EXP_PIN_BACKLIGHT, !touch_state); +#endif + next_backlight_btn_check = millis() + 300; + } +#endif + + if (c != 0 && curr) { + curr->handleInput(c); + _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + _next_refresh = 100; // trigger refresh + } + userLedHandler(); #ifdef PIN_BUZZER if (buzzer.isPlaying()) buzzer.loop(); #endif - if (_display != NULL && _display->isOn()) { - static bool _firstBoot = true; - if(_firstBoot && (millis() - ui_started_at) >= BOOT_SCREEN_MILLIS) { - _need_refresh = true; - _firstBoot = false; - } - if (millis() >= _next_refresh && _need_refresh) { - _display->startFrame(); - renderCurrScreen(); - _display->endFrame(); +if (curr) curr->poll(); - _next_refresh = millis() + 1000; // refresh every second + if (_display != NULL && _display->isOn()) { + if (millis() >= _next_refresh && curr) { + _display->startFrame(); + int delay_millis = curr->render(*_display); + if (millis() < _alert_expiry) { // render alert popup + _display->setTextSize(1); + int y = _display->height() / 3; + int p = _display->height() / 32; + _display->setColor(DisplayDriver::DARK); + _display->fillRect(p, y, _display->width() - p*2, y); + _display->setColor(DisplayDriver::LIGHT); // draw box border + _display->drawRect(p, y, _display->width() - p*2, y); + _display->drawTextCentered(_display->width() / 2, y + p*3, _alert); + _next_refresh = _alert_expiry; // will need refresh when alert is dismissed + } else { + _next_refresh = millis() + delay_millis; + } + _display->endFrame(); } +#if AUTO_OFF_MILLIS > 0 if (millis() > _auto_off) { _display->turnOff(); } +#endif } + +#ifdef PIN_VIBRATION + vibration.loop(); +#endif + +#ifdef AUTO_SHUTDOWN_MILLIVOLTS + if (millis() > next_batt_chck) { + uint16_t milliVolts = getBattMilliVolts(); + if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) { + + // show low battery shutdown alert + // we should only do this for eink displays, which will persist after power loss + #if defined(THINKNODE_M1) || defined(LILYGO_TECHO) + if (_display != NULL) { + _display->startFrame(); + _display->setTextSize(2); + _display->setColor(DisplayDriver::RED); + _display->drawTextCentered(_display->width() / 2, 20, "Low Battery."); + _display->drawTextCentered(_display->width() / 2, 40, "Shutting Down!"); + _display->endFrame(); + } + #endif + + shutdown(); + + } + next_batt_chck = millis() + 8000; + } +#endif } -void UITask::handleButtonAnyPress() { - MESH_DEBUG_PRINTLN("UITask: any press triggered"); - // called on any button press before other events, to wake up the display quickly - // do not refresh the display here, as it may block the button handler +char UITask::checkDisplayOn(char c) { if (_display != NULL) { - _displayWasOn = _display->isOn(); // Track display state before any action - if (!_displayWasOn) { - _display->turnOn(); + if (!_display->isOn()) { + _display->turnOn(); // turn display on and consume event + c = 0; } _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + _next_refresh = 0; // trigger refresh } + return c; } -void UITask::handleButtonShortPress() { - MESH_DEBUG_PRINTLN("UITask: short press triggered"); - if (_display != NULL) { - // Only clear message preview if display was already on before button press - if (_displayWasOn) { - // If display was on and showing message preview, clear it - if (_origin[0] && _msg[0]) { - clearMsgPreview(); - } else { - // Otherwise, refresh the display - _need_refresh = true; - } - } else { - _need_refresh = true; // display just turned on, so we need to refresh - } - // Note: Display turn-on and auto-off timer extension are handled by handleButtonAnyPress +char UITask::handleLongPress(char c) { + if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue + the_mesh.enterCLIRescue(); + c = 0; // consume event } + return c; } -void UITask::handleButtonDoublePress() { - MESH_DEBUG_PRINTLN("UITask: double press triggered, sending advert"); - // ADVERT - #ifdef PIN_BUZZER - notify(UIEventType::ack); - #endif - if (the_mesh.advert()) { - MESH_DEBUG_PRINTLN("Advert sent!"); - sprintf(_alert, "Advert sent!"); - } else { - MESH_DEBUG_PRINTLN("Advert failed!"); - sprintf(_alert, "Advert failed.."); - } - _need_refresh = true; +char UITask::handleDoubleClick(char c) { + MESH_DEBUG_PRINTLN("UITask: double click triggered"); + checkDisplayOn(c); + return c; } -void UITask::handleButtonTriplePress() { - MESH_DEBUG_PRINTLN("UITask: triple press triggered"); - // Toggle buzzer quiet mode - #ifdef PIN_BUZZER - if (buzzer.isQuiet()) { - buzzer.quiet(false); - notify(UIEventType::ack); - sprintf(_alert, "Buzzer: ON"); - } else { - buzzer.quiet(true); - sprintf(_alert, "Buzzer: OFF"); - } - _node_prefs->buzzer_quiet = buzzer.isQuiet(); - the_mesh.savePrefs(); - _need_refresh = true; - #endif +char UITask::handleTripleClick(char c) { + MESH_DEBUG_PRINTLN("UITask: triple click triggered"); + checkDisplayOn(c); + toggleBuzzer(); + c = 0; + return c; } -void UITask::handleButtonQuadruplePress() { - MESH_DEBUG_PRINTLN("UITask: quad press triggered"); +bool UITask::getGPSState() { if (_sensors != NULL) { - // toggle GPS onn/off + 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; +} + +void UITask::toggleGPS() { + 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); - sprintf(_alert, "GPS: Disabled"); } else { _sensors->setSettingValue("gps", "1"); + _node_prefs->gps_enabled = 1; notify(UIEventType::ack); - sprintf(_alert, "GPS: Enabled"); } + the_mesh.savePrefs(); + showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800); + _next_refresh = 0; break; } } } - _need_refresh = true; } -void UITask::handleButtonLongPress() { - MESH_DEBUG_PRINTLN("UITask: long press triggered"); - if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue - the_mesh.enterCLIRescue(); - } else { - shutdown(); +void UITask::toggleBuzzer() { + // Toggle buzzer quiet mode + #ifdef PIN_BUZZER + if (buzzer.isQuiet()) { + buzzer.quiet(false); + notify(UIEventType::ack); + } else { + buzzer.quiet(true); + } + _node_prefs->buzzer_quiet = buzzer.isQuiet(); + the_mesh.savePrefs(); + showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800); + _next_refresh = 0; // trigger refresh + #endif +} + +void UITask::injectKey(char c) { + if (c != 0 && curr) { + // Turn on display if it's off + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + curr->handleInput(c); + _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + // 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(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + +bool UITask::isEditingHomeScreen() const { + return curr == home && ((HomeScreen *) home)->isEditingUTC(); +} + +void UITask::gotoChannelScreen() { + ((ChannelScreen *) channel_screen)->resetScroll(); + setCurrScreen(channel_screen); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + +void UITask::gotoContactsScreen() { + ((ContactsScreen *) contacts_screen)->resetScroll(); + setCurrScreen(contacts_screen); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + +void UITask::gotoTextReader() { + TextReaderScreen* reader = (TextReaderScreen*)text_reader; + if (_display != NULL) { + reader->enter(*_display); + } + setCurrScreen(text_reader); + 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; +} + +uint8_t UITask::getChannelScreenViewIdx() const { + return ((ChannelScreen *) channel_screen)->getViewChannelIdx(); +} + +void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) { + // Format the message as "Sender: message" + char formattedMsg[CHANNEL_MSG_TEXT_LEN]; + snprintf(formattedMsg, sizeof(formattedMsg), "%s: %s", sender, text); + + // Add to channel history with path_len=0 (local message) + ((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg); } \ No newline at end of file