diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 5af265d..5667df3 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -620,6 +620,36 @@ void MyMesh::queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, co } } +bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) { + 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 expected_ack, est_timeout; + int result = sendMessage(*recipient, timestamp, 0, text, expected_ack, est_timeout); + + if (result == MSG_SEND_FAILED) { + MESH_DEBUG_PRINTLN("UI: DM send failed to %s", recipient->name); + return false; + } + + // Track expected ACK for delivery confirmation + if (expected_ack) { + expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis(); + expected_ack_table[next_ack_idx].ack = expected_ack; + expected_ack_table[next_ack_idx].contact = recipient; + next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; + } + + MESH_DEBUG_PRINTLN("UI: DM sent to %s (%s), ack=0x%08X timeout=%dms", + recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct", + expected_ack, 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) { @@ -1080,23 +1110,14 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(&msg_timestamp, &cmd_frame[i], 4); i += 4; const char *text = (char *)&cmd_frame[i]; - int text_len = len - i; if (txt_type != TXT_TYPE_PLAIN) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); } else { ChannelDetails channel; bool success = getChannel(channel_idx, channel); - if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, text_len)) { + if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) { writeOKFrame(); -#ifdef DISPLAY_CLASS - // Show BLE-app-sent message on device channel screen - if (_ui) { - // Null-terminate text from BLE frame (frame buffer may have residual data) - cmd_frame[i + text_len] = 0; - _ui->addSentChannelMessage(channel_idx, _prefs.node_name, text); - } -#endif } else { writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 6c14718..199e43b 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.1" +#define FIRMWARE_VERSION "Meck v0.8" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -105,6 +105,9 @@ public: // Queue a sent channel message for BLE app sync void queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text); + // Send a direct message from the UI (no BLE dependency) + bool uiSendDirectMessage(uint32_t contact_idx, const char* text); + protected: float getAirtimeBudgetFactor() const override; int getInterferenceThreshold() const override; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 3e9ae3f..691ff51 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -22,6 +22,11 @@ static unsigned long lastComposeRefresh = 0; static bool composeNeedsRefresh = false; #define COMPOSE_REFRESH_INTERVAL 600 // ms between e-ink refreshes while typing (refresh takes ~650ms) + + // DM compose mode (direct message to a specific contact) + static bool composeDM = false; + static int composeDMContactIdx = -1; + static char composeDMName[32]; // AGC reset - periodically re-assert RX boosted gain to prevent sensitivity drift #define AGC_RESET_INTERVAL_MS 500 static unsigned long lastAGCReset = 0; @@ -483,11 +488,18 @@ void handleKeyboardInput() { if (composePos > 0) { sendComposedMessage(); } + bool wasDM = composeDM; composeMode = false; emojiPickerMode = false; + composeDM = false; + composeDMContactIdx = -1; composeBuffer[0] = '\0'; composePos = 0; - ui_task.gotoHomeScreen(); + if (wasDM) { + ui_task.gotoContactsScreen(); + } else { + ui_task.gotoHomeScreen(); + } return; } @@ -496,11 +508,18 @@ void handleKeyboardInput() { if (keyboard.wasShiftRecentlyPressed(500)) { // Shift+Backspace = Cancel (works anytime) Serial.println("Compose: Shift+Backspace, cancelling..."); + bool wasDM = composeDM; composeMode = false; emojiPickerMode = false; + composeDM = false; + composeDMContactIdx = -1; composeBuffer[0] = '\0'; composePos = 0; - ui_task.gotoHomeScreen(); + if (wasDM) { + ui_task.gotoContactsScreen(); + } else { + ui_task.gotoHomeScreen(); + } return; } // Regular backspace - delete last character (or entire emoji including pads) @@ -520,8 +539,8 @@ void handleKeyboardInput() { return; } - // A/D keys switch channels (only when buffer is empty or as special function) - if ((key == 'a' || key == 'A') && composePos == 0) { + // A/D keys switch channels (only when buffer is empty, not in DM mode) + if ((key == 'a' || key == 'A') && composePos == 0 && !composeDM) { // Previous channel if (composeChannelIdx > 0) { composeChannelIdx--; @@ -540,7 +559,7 @@ void handleKeyboardInput() { return; } - if ((key == 'd' || key == 'D') && composePos == 0) { + if ((key == 'd' || key == 'D') && composePos == 0 && !composeDM) { // Next channel ChannelDetails ch; uint8_t nextIdx = composeChannelIdx + 1; @@ -595,6 +614,8 @@ void handleKeyboardInput() { // C key: allow entering compose mode from reader if (key == 'c' || key == 'C') { + composeDM = false; + composeDMContactIdx = -1; composeMode = true; composeBuffer[0] = '\0'; composePos = 0; @@ -613,17 +634,36 @@ void handleKeyboardInput() { switch (key) { case 'c': case 'C': - // Enter compose mode - 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(); + // 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(); } - Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx); - drawComposeScreen(); - lastComposeRefresh = millis(); break; case 'm': @@ -692,9 +732,29 @@ void handleKeyboardInput() { break; case '\r': - // Select/Enter - Serial.println("Nav: Enter/Select"); - ui_task.injectKey(13); // KEY_ENTER + // Select/Enter - if on contacts screen, enter DM compose for chat contacts + 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 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 { + Serial.println("Nav: Enter/Select"); + ui_task.injectKey(13); // KEY_ENTER + } break; case 'q': @@ -725,12 +785,16 @@ void drawComposeScreen() { display.setCursor(0, 0); // Get the channel name for display - ChannelDetails channel; char headerBuf[40]; - if (the_mesh.getChannel(composeChannelIdx, channel)) { - snprintf(headerBuf, sizeof(headerBuf), "To: %s", channel.name); + if (composeDM) { + snprintf(headerBuf, sizeof(headerBuf), "DM: %s", composeDMName); } else { - snprintf(headerBuf, sizeof(headerBuf), "To: Channel %d", composeChannelIdx); + ChannelDetails channel; + if (the_mesh.getChannel(composeChannelIdx, channel)) { + snprintf(headerBuf, sizeof(headerBuf), "To: %s", channel.name); + } else { + snprintf(headerBuf, sizeof(headerBuf), "To: Channel %d", composeChannelIdx); + } } display.print(headerBuf); @@ -855,27 +919,37 @@ void drawEmojiPicker() { void sendComposedMessage() { if (composePos == 0) return; - // Get the selected channel + // Convert escape bytes back to UTF-8 for mesh transmission and BLE app + char utf8Buf[512]; + emojiUnescape(composeBuffer, utf8Buf, sizeof(utf8Buf)); + + if (composeDM) { + // Direct message to a specific contact + if (composeDMContactIdx >= 0) { + if (the_mesh.uiSendDirectMessage((uint32_t)composeDMContactIdx, utf8Buf)) { + ui_task.showAlert("DM sent!", 1500); + } else { + ui_task.showAlert("DM failed!", 1500); + } + } else { + ui_task.showAlert("No contact!", 1500); + } + return; + } + + // Channel (group) message ChannelDetails channel; if (the_mesh.getChannel(composeChannelIdx, channel)) { uint32_t timestamp = rtc_clock.getCurrentTime(); - - // Convert escape bytes back to UTF-8 for mesh transmission and BLE app - // Worst case: each escape byte → 8 bytes UTF-8 (flag emoji), plus ASCII chars - char utf8Buf[512]; - emojiUnescape(composeBuffer, utf8Buf, sizeof(utf8Buf)); int utf8Len = strlen(utf8Buf); - // Send UTF-8 version to mesh (so other devices/apps see real emoji) if (the_mesh.sendGroupMessage(timestamp, channel.channel, the_mesh.getNodePrefs()->node_name, utf8Buf, utf8Len)) { - // Add to local display (UTF-8 gets sanitized to escape bytes by addMessage) ui_task.addSentChannelMessage(composeChannelIdx, the_mesh.getNodePrefs()->node_name, utf8Buf); - // Queue UTF-8 version for BLE app sync (so companion app shows real emoji) the_mesh.queueSentChannelMessage(composeChannelIdx, timestamp, the_mesh.getNodePrefs()->node_name, utf8Buf); diff --git a/examples/companion_radio/ui-new/Contactsscreen.h b/examples/companion_radio/ui-new/Contactsscreen.h index 533ed5e..ce8e5cb 100644 --- a/examples/companion_radio/ui-new/Contactsscreen.h +++ b/examples/companion_radio/ui-new/Contactsscreen.h @@ -148,6 +148,26 @@ public: return _filteredIdx[_scrollPos]; } + // Get the adv_type of the currently highlighted contact + // Returns 0xFF if no valid selection + uint8_t getSelectedContactType() const { + if (_filteredCount == 0) return 0xFF; + ContactInfo contact; + if (!the_mesh.getContactByIdx(_filteredIdx[_scrollPos], contact)) return 0xFF; + return contact.type; + } + + // Copy the name of the currently highlighted contact into buf + // Returns false if no valid selection + bool getSelectedContactName(char* buf, size_t bufLen) const { + if (_filteredCount == 0) return false; + ContactInfo contact; + if (!the_mesh.getContactByIdx(_filteredIdx[_scrollPos], contact)) return false; + strncpy(buf, contact.name, bufLen); + buf[bufLen - 1] = '\0'; + return true; + } + int render(DisplayDriver& display) override { if (!_cacheValid) rebuildCache(); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 8e1f0c3..1cc44f1 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -990,6 +990,15 @@ void UITask::injectKey(char c) { } } +void UITask::gotoHomeScreen() { + setCurrScreen(home); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + void UITask::gotoChannelScreen() { ((ChannelScreen *) channel_screen)->resetScroll(); setCurrScreen(channel_screen); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 374fb9c..00c2151 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -75,7 +75,7 @@ public: } void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs); - void gotoHomeScreen() { setCurrScreen(home); } + void gotoHomeScreen(); void gotoChannelScreen(); // Navigate to channel message screen void gotoContactsScreen(); // Navigate to contacts list void gotoTextReader(); // *** NEW: Navigate to text reader ***