From 8c3622b8790249b3a3e6422e73b8d30c04b9a351 Mon Sep 17 00:00:00 2001 From: vanous Date: Fri, 29 May 2026 21:49:48 +0200 Subject: [PATCH] Nearby Nodes: Add Ping --- .../tools_screen/tls_scr_2_ping_oled.png | Bin 0 -> 1038 bytes .../tools_screen/tools_screen.md | 11 +- examples/companion_radio/MyMesh.cpp | 123 ++++++- examples/companion_radio/MyMesh.h | 28 +- .../companion_radio/ui-new/NearbyScreen.h | 346 +++++++++++++----- examples/companion_radio/ui-new/UITask.cpp | 62 +++- examples/companion_radio/ui-new/UITask.h | 19 + 7 files changed, 482 insertions(+), 107 deletions(-) create mode 100644 docs/solo_features/tools_screen/tls_scr_2_ping_oled.png diff --git a/docs/solo_features/tools_screen/tls_scr_2_ping_oled.png b/docs/solo_features/tools_screen/tls_scr_2_ping_oled.png new file mode 100644 index 0000000000000000000000000000000000000000..4c6e5f813b938c95ca9e4b237ee1c5ef391c7f8a GIT binary patch literal 1038 zcmV+p1o8WcP)^2#sgW|NnB%gEKU86A+_T_DgG;D}+r5cImyh!gcsrKbB>IqO(=v9cx(@soL5V z`cz-_hcsn%>GXR^xUztt0jGART-Z z5D*-#5ED16TBJ^6&#X<{83_~=pb>lh#|hMne}OimM0TYQ5{%LPcT2yuhAq7j7N#XY z5f&c+4Rs6K9ZRXBm)smrIE%W4sJT z;b01i$rl*}sE{=sW1la?BcQs*g8)XwLOq(%9 zb}w|o7F(Zp74aUq2Vu+!7mV;-#-X@Zu^lBUPWP^84b;a-p@!1cO^*OnT^4JYX&LMc zjq|x=&c=a$;{XVhV%IyUA&3SQ(&)k7KYb07MviDQZ09$%fDvf+k{lILQtOd=GtP&O zrKols5siDT$+BR40D{E4I*c35;yGvpr@tet&*uzZbp#9roiRTtDX)(u@xB)`H@+84 zK7f7H0rsAol&7x>urCiFzh&v?VMy6Q^I7;>_|Gcl=Vtf?fW&A=4+&STv6bXkzhp?;k9+ZuB4uXk)-c(?*nVfQ zJ87K_ycA)qKY>4Ya>6v4wa7?ni@@@-;QGo1DSm)7QjILK>z>%07*qo IM6N<$f`<6wLI3~& literal 0 HcmV?d00001 diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index 2cedc98a..61df0a50 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -31,7 +31,15 @@ Browse nodes that have recently advertised on the mesh. Filter by category with Select a node to see its coordinates, distance, bearing with cardinal direction, type, and last-heard time. -**Hold Enter** → context menu → **Discover nearby** to send a live `NODE_DISCOVER_REQ` ping. +From the list view, **Hold Enter** opens the context menu, where **Discover nearby** sends a live `NODE_DISCOVER_REQ` scan. + +From either node detail view, **Hold Enter** opens the Ping popup: + +| OLED | +| :-----------------------: | +| ![](./tls_scr_2_ping_oled.png) | + +Use **Enter** on the popup’s `Ping` row to send a direct mesh ping to that node. The popup then shows the RTT and SNR values on the next lines, and can be used again immediately for another ping. > [!TIP] > Combined with **Auto-Advert** on the other device, Nearby Nodes becomes a passive location tracker — as long as the tracked device periodically broadcasts its GPS position, you can see its current distance and bearing without any manual interaction on either end. @@ -51,6 +59,7 @@ Results show as 2-line cards: node name + type, then RSSI / SNR / remote SNR. - **UP/DOWN** — navigate results - **Enter** — open full-screen detail (public key, signal data, contact status) - **Hold Enter** — rescan +- **Hold Enter** in full-screen detail — open the Ping popup for the selected node - **Cancel / Back** — return to nearby list --- diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f1cb40c6..b6012f2b 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -799,6 +799,79 @@ int MyMesh::getDiscoverResults(DiscoverResult dest[], int max_count) { return n; } +// ── Ping/Trace functionality ───────────────────────────────────────────────── + +uint32_t MyMesh::sendPing(const uint8_t* dest_pubkey, uint8_t hash_width) { + if (hash_width == 0 || hash_width > 2) return 0; + + // Generate random tag and auth code + uint32_t tag, auth; + getRNG()->random((uint8_t*)&tag, 4); + getRNG()->random((uint8_t*)&auth, 4); + + // Find a free slot in ping results + int slot = -1; + for (int i = 0; i < PING_RESULT_MAX; i++) { + if (!_ping_results[i].received && _ping_results[i].tag == 0) { + slot = i; + break; + } + } + if (slot < 0) { + return 0; + } + + // Initialize ping result tracking + PingResult& result = _ping_results[slot]; + result.tag = tag; + result.auth_code = auth; + result.snr_out_x4 = 0; + result.snr_back_x4 = 0; + result.rtt_ms = 0; + result.received = false; + result.sent_ms = millis(); + + // Create path hash from destination public key + uint8_t path_len = hash_width; + uint8_t path[MAX_PATH_SIZE]; + memcpy(path, dest_pubkey, path_len); + + // Create and send trace packet + auto pkt = createTrace(tag, auth, hash_width - 1); // flags = hash_width - 1 + if (pkt) { + sendDirect(pkt, path, path_len); + return tag; + } + + // Failed to create packet + memset(&result, 0, sizeof(result)); + return 0; +} + +void MyMesh::setPingCallback(PingCallback cb, void* arg) { + _ping_callback = cb; + _ping_callback_arg = arg; +} + +void MyMesh::clearPingResult(uint32_t tag) { + if (tag == 0) return; + for (int i = 0; i < PING_RESULT_MAX; i++) { + if (_ping_results[i].tag == tag) { + memset(&_ping_results[i], 0, sizeof(_ping_results[i])); + return; + } + } +} + +MyMesh::PingResult* MyMesh::getPingResult(uint32_t tag) { + for (int i = 0; i < PING_RESULT_MAX; i++) { + if (_ping_results[i].tag == tag) { + return &_ping_results[i]; + } + } + return NULL; +} + void MyMesh::onControlDataRecv(mesh::Packet *packet) { // If we have an active standalone discover, check if this is a matching response. // Tag matching provides isolation — no isBLEConnected check needed. @@ -879,6 +952,49 @@ void MyMesh::onRawDataRecv(mesh::Packet *packet) { void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags, const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) { uint8_t path_sz = flags & 0x03; // NEW v1.11+ + + // Check if this is a response to our local ping + for (int i = 0; i < PING_RESULT_MAX; i++) { + if (_ping_results[i].tag == tag && _ping_results[i].auth_code == auth_code && !_ping_results[i].received) { + PingResult& result = _ping_results[i]; + + // Extract SNR values + // path_snrs contains signed SNR values in dB×4 for each hop (in order). + // The last value is the SNR at the destination hearing our request (snr_out). + // The final SNR from packet is the SNR at us hearing the response (snr_back). + uint8_t snr_count = path_len >> path_sz; + + if (snr_count > 0) { + // Keep the encoded quarter-dB value; the UI converts it back to dB. + result.snr_out_x4 = (int16_t)(int8_t)path_snrs[0]; + } else { + result.snr_out_x4 = 0; + } + + // SNR back is from the final SNR in the packet (SNR at us receiving response). + result.snr_back_x4 = (int16_t)(packet->getSNR() * 4); + + // Calculate RTT + unsigned long now = millis(); + if (now >= result.sent_ms) { + result.rtt_ms = now - result.sent_ms; + } else { + result.rtt_ms = 0xFFFFFFFF; // Overflow + } + + result.received = true; + + // Notify callback if set + if (_ping_callback) { + _ping_callback(tag, result.snr_out_x4, result.snr_back_x4, result.rtt_ms); + } + // The UI copies the result out of the callback, so reuse the slot immediately. + memset(&result, 0, sizeof(result)); + break; + } + } + + // Forward to serial app regardless (for compatibility) if (12 + path_len + (path_len >> path_sz) + 1 > sizeof(out_frame)) { MESH_DEBUG_PRINTLN("onTraceRecv(), path_len is too long: %d", (uint32_t)path_len); return; @@ -938,6 +1054,11 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _pending_node_discover_tag = 0; _pending_node_discover_until = 0; + // Ping state + _ping_callback = NULL; + _ping_callback_arg = NULL; + memset(_ping_results, 0, sizeof(_ping_results)); + // defaults memset(&_prefs, 0, sizeof(_prefs)); _prefs.airtime_factor = 1.0; @@ -2070,7 +2191,7 @@ void MyMesh::handleCmdFrame(size_t len) { #ifdef ENABLE_SCREENSHOT void MyMesh::handleScreenshotRequest() { #ifdef DISPLAY_CLASS - UITask* ui_task = getUITask(); + UITask* ui_task = static_cast(getUITask()); if (!ui_task || !ui_task->hasDisplay()) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); return; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 9e95a139..fecab6c2 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -122,6 +122,27 @@ public: int getRecentlyHeard(AdvertPath dest[], int max_num); int getDiscoverResults(DiscoverResult dest[], int max_count); + // Ping/Trace functionality + #define PING_RESULT_MAX 4 + typedef void (*PingCallback)(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms); + + struct PingResult { + uint32_t tag; + uint32_t auth_code; + int16_t snr_out_x4; // SNR out (to first hop) × 4 + int16_t snr_back_x4; // SNR back (from last hop to us) × 4 + uint32_t rtt_ms; // Round-trip time in milliseconds + bool received; + unsigned long sent_ms; + }; + + uint32_t sendPing(const uint8_t* dest_pubkey, uint8_t hash_width = 1); + void setPingCallback(PingCallback cb, void* arg); + void clearPingResult(uint32_t tag); + PingResult* getPingResult(uint32_t tag); + PingCallback getPingCallback() const { return _ping_callback; } + AbstractUITask* getUITask() const { return _ui; } + protected: float getAirtimeBudgetFactor() const override; int getInterferenceThreshold() const override; @@ -232,8 +253,6 @@ private: void sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffer, uint16_t bufferSize); #endif - UITask* getUITask() { return (UITask*)_ui; } - // helpers, short-cuts void saveChannels() { _store->saveChannels(this); } void saveContacts() { _store->saveContacts(this); } @@ -293,6 +312,11 @@ private: int _discover_count; uint32_t _pending_node_discover_tag; unsigned long _pending_node_discover_until; + + // ── Ping/Trace state ────────────────────────────────────────────────────── + PingResult _ping_results[PING_RESULT_MAX]; + PingCallback _ping_callback; + void* _ping_callback_arg; }; extern MyMesh the_mesh; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index bdb14e6f..9b1c2366 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -38,6 +38,15 @@ class NearbyScreen : public UIScreen { static const unsigned long DETAIL_REFRESH_MS = 10000UL; PopupMenu _ctx_menu; + PopupMenu _ping_menu; + char _ping_time_str[24]; // For storing ping RTT result + char _ping_snr_out_str[24]; // For storing ping SNR out result + char _ping_snr_back_str[24];// For storing ping SNR back result + + // ── ping state ───────────────────────────────────────────────────────────── + bool _pinging; + unsigned long _ping_started_ms; + static const unsigned long PING_TIMEOUT_MS = 3000UL; // ── discover sub-screen state ──────────────────────────────────────────────── bool _discover_mode; @@ -186,6 +195,204 @@ class NearbyScreen : public UIScreen { the_mesh.sendNodeDiscoverReq(); } + void resetPingLines() { + _ping_time_str[0] = '\0'; + _ping_snr_out_str[0] = '\0'; + _ping_snr_back_str[0] = '\0'; + } + + void openPingMenu() { + _ping_menu.begin("Ping", 4); + _ping_menu.addItem("Ping"); + _ping_menu.addItem(_ping_time_str); + _ping_menu.addItem(_ping_snr_out_str); + _ping_menu.addItem(_ping_snr_back_str); + } + + bool renderPingMenuIfActive(DisplayDriver& display) { + if (!_ping_menu.active) return false; + _ping_menu.render(display); + return true; + } + + void closePingMenu(bool clear_task = true) { + _ping_menu.active = false; + _pinging = false; + if (clear_task && _task) _task->clearPing(); + resetPingLines(); + } + + void startPingForKey(const uint8_t* pub_key) { + resetPingLines(); + snprintf(_ping_time_str, sizeof(_ping_time_str), "Ping: ..."); + if (_task && _task->startPing(pub_key)) { + _pinging = true; + _ping_started_ms = millis(); + } else { + _ping_time_str[0] = '\0'; + } + } + + void updatePingMenuState() { + if (!_ping_menu.active || !_task || (!_pinging && !_task->isPingActive())) return; + + int16_t snr_out = 0, snr_back = 0; + uint32_t rtt = 0; + _task->getPingResult(snr_out, snr_back, rtt); + + if (_pinging && (snr_out != 0 || snr_back != 0 || rtt != 0)) { + if (rtt > 0 && rtt < 10000) { + snprintf(_ping_time_str, sizeof(_ping_time_str), "Ping: %lumS", rtt); + } else { + snprintf(_ping_time_str, sizeof(_ping_time_str), "Ping: timeout"); + } + if (snr_out != 0) { + snprintf(_ping_snr_out_str, sizeof(_ping_snr_out_str), "SNR out: %.1f", snr_out / 4.0f); + } + if (snr_back != 0) { + snprintf(_ping_snr_back_str, sizeof(_ping_snr_back_str), "SNR back: %.1f", snr_back / 4.0f); + } + _pinging = false; + } else if (_pinging && millis() - _ping_started_ms >= PING_TIMEOUT_MS) { + snprintf(_ping_time_str, sizeof(_ping_time_str), "Ping: timeout"); + _ping_snr_out_str[0] = '\0'; + _ping_snr_back_str[0] = '\0'; + _pinging = false; + if (_task) _task->clearPing(); + } + } + + bool handlePingMenuInput(char c, const uint8_t* pub_key, bool allow_enter_to_open = false) { + if (!_ping_menu.active) { + if (c == KEY_CONTEXT_MENU || (allow_enter_to_open && c == KEY_ENTER)) { + openPingMenu(); + return true; + } + return false; + } + + auto res = _ping_menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + if (!_pinging && pub_key) { + startPingForKey(pub_key); + } + // Keep the popup open so Ping stays available after completion. + _ping_menu.active = true; + } else if (res == PopupMenu::CANCELLED) { + closePingMenu(); + } + return true; + } + + bool selectedStoredPubKey(uint8_t* out_pub_key) const { + ContactInfo ci; + if (_task && _sel < _count && _entries[_sel].contact_idx >= 0 && + the_mesh.getContactByIdx(_entries[_sel].contact_idx, ci)) { + memcpy(out_pub_key, ci.id.pub_key, PUB_KEY_SIZE); + return true; + } + return false; + } + + const uint8_t* selectedDiscoverPubKey() const { + return (_dsel < _dresult_count) ? _dresults[_dsel].pub_key : nullptr; + } + + bool renderDiscoverDetail(DisplayDriver& display) { + const DiscoverResult& r = _dresults[_dsel]; + const int hdr = display.headerH(); + const char* fullType = (r.type == ADV_TYPE_REPEATER) ? "Repeater" : + (r.type == ADV_TYPE_SENSOR) ? "Sensor" : + (r.type == ADV_TYPE_ROOM) ? "Room" : "Node"; + + char label[32]; + if (r.name[0]) { strncpy(label, r.name, 31); label[31] = '\0'; } + else { snprintf(label, sizeof(label), "[%s]", fullType); } + char filtered[32]; + display.translateUTF8ToBlocks(filtered, label, sizeof(filtered)); + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, 0, display.width(), hdr - 1); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(2, 1, display.width() - 4, filtered); + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, hdr - 1, display.width(), 1); + + char b64[48]; + pubKeyToBase64(r.pub_key, b64, sizeof(b64)); + { + int max_chars = (display.width() - 4) / display.getCharWidth(); + int b64_len = strlen(b64); + char b64_line[48]; + if (b64_len > max_chars) { + strncpy(b64_line, b64, max_chars - 3); + b64_line[max_chars - 3] = '\0'; + strcat(b64_line, "..."); + } else { + strncpy(b64_line, b64, sizeof(b64_line) - 1); + b64_line[sizeof(b64_line) - 1] = '\0'; + } + display.setCursor(2, hdr); + display.print(b64_line); + } + + int step = display.lineStep(); + if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5; + char buf[32]; + snprintf(buf, sizeof(buf), "RSSI: %d dBm", (int)r.rssi); + display.setCursor(2, hdr + step); display.print(buf); + snprintf(buf, sizeof(buf), "SNR: %d dB", (int)(r.snr_x4 / 4)); + display.setCursor(2, hdr + step * 2); display.print(buf); + snprintf(buf, sizeof(buf), "Rem: %d dB", (int)(r.remote_snr_x4 / 4)); + display.setCursor(2, hdr + step * 3); display.print(buf); + display.setCursor(2, hdr + step * 4); + display.print(r.is_known ? "Status: known" : "Status: new"); + + updatePingMenuState(); + if (renderPingMenuIfActive(display)) return true; + return true; + } + + bool renderStoredDetail(DisplayDriver& display) { + const Entry& e = _entries[_sel]; + const int hdr = display.headerH(); + + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, 0, display.width(), hdr - 1); + display.setColor(DisplayDriver::DARK); + char filtered[32]; + display.translateUTF8ToBlocks(filtered, e.name, sizeof(filtered)); + display.drawTextEllipsized(2, 1, display.width() - 4, filtered); + display.setColor(DisplayDriver::LIGHT); + + int step = display.lineStep(); + if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5; + char buf[32]; + snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6); + display.setCursor(2, hdr); display.print(buf); + snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6); + display.setCursor(2, hdr + step); display.print(buf); + + if (e.dist_km >= 0.0f) { + char dist[12]; + fmtDist(dist, sizeof(dist), e.dist_km); + int az = bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6); + snprintf(buf, sizeof(buf), "Dist: %s %s", dist, bearingCardinal(az)); + } else { + snprintf(buf, sizeof(buf), "Dist: no GPS"); + } + display.setCursor(2, hdr + step * 2); display.print(buf); + snprintf(buf, sizeof(buf), "Type: %s", typeName(e.type)); + display.setCursor(2, hdr + step * 3); display.print(buf); + char age[16]; + fmtAge(age, sizeof(age), e.lastmod); + snprintf(buf, sizeof(buf), "Seen: %s", age); + display.drawTextEllipsized(2, hdr + step * 4, display.width() - 4, buf); + + updatePingMenuState(); + if (renderPingMenuIfActive(display)) return true; + return true; + } + int renderDiscover(DisplayDriver& display) { int lh = display.getLineHeight(); int hdr = display.headerH(); @@ -196,61 +403,8 @@ class NearbyScreen : public UIScreen { if (_d_visible < 1) _d_visible = 1; if (_ddetail) { - // ── full-screen detail for selected node ────────────────────────────── - const DiscoverResult& r = _dresults[_dsel]; - const char* fullType = (r.type == ADV_TYPE_REPEATER) ? "Repeater" : - (r.type == ADV_TYPE_SENSOR) ? "Sensor" : - (r.type == ADV_TYPE_ROOM) ? "Room" : "Node"; - - // title bar: node name inverted - char label[32]; - if (r.name[0]) { strncpy(label, r.name, 31); label[31] = '\0'; } - else { snprintf(label, sizeof(label), "[%s]", fullType); } - char filtered[32]; - display.translateUTF8ToBlocks(filtered, label, sizeof(filtered)); - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, 0, display.width(), hdr - 1); - display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(2, 1, display.width() - 4, filtered); - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, hdr - 1, display.width(), 1); - - // public key as base64 — truncate to one line via charWidth - char b64[48]; - pubKeyToBase64(r.pub_key, b64, sizeof(b64)); - { - int max_chars = (display.width() - 4) / display.getCharWidth(); - int b64_len = strlen(b64); - char b64_line[48]; - if (b64_len > max_chars) { - strncpy(b64_line, b64, max_chars - 3); - b64_line[max_chars - 3] = '\0'; - strcat(b64_line, "..."); - } else { - strncpy(b64_line, b64, sizeof(b64_line) - 1); - b64_line[sizeof(b64_line) - 1] = '\0'; - } - display.setCursor(2, hdr); - display.print(b64_line); - } - - // distribute 4 remaining lines below b64 — compact step, shrink only if needed - int step = display.lineStep(); - if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5; - char buf[32]; - snprintf(buf, sizeof(buf), "RSSI: %d dBm", (int)r.rssi); - display.setCursor(2, hdr + step); display.print(buf); - - snprintf(buf, sizeof(buf), "SNR: %d dB", (int)(r.snr_x4 / 4)); - display.setCursor(2, hdr + step * 2); display.print(buf); - - snprintf(buf, sizeof(buf), "Rem: %d dB", (int)(r.remote_snr_x4 / 4)); - display.setCursor(2, hdr + step * 3); display.print(buf); - - display.setCursor(2, hdr + step * 4); - display.print(r.is_known ? "Status: known" : "Status: new"); - - return 5000; + renderDiscoverDetail(display); + return _ping_menu.active ? 50 : 5000; } // ── list view ───────────────────────────────────────────────────────────── @@ -330,16 +484,26 @@ class NearbyScreen : public UIScreen { { display.setCursor(display.width() - cw, d_start_y + (_d_visible - 1) * d_item_h); display.print("v"); } } + updatePingMenuState(); + + // Show ping popup menu if active + if (_ping_menu.active) { + _ping_menu.render(display); + return 50; + } + return _discovering ? 200 : 2000; } bool handleInputDiscover(char c) { if (_ddetail) { - if (c == KEY_CANCEL) { _ddetail = false; return true; } - if (c == KEY_CONTEXT_MENU || c == KEY_ENTER) { - enterDiscoverMode(); // re-scan; clears _ddetail - return true; + if (c == KEY_CANCEL) { + _ddetail = false; + closePingMenu(); + return true; } + + if (handlePingMenuInput(c, selectedDiscoverPubKey())) return true; return true; } if (c == KEY_CANCEL) { @@ -374,7 +538,12 @@ public: _own_lat(0), _own_lon(0), _own_gps(false), _filter(0), _detail_refresh_ms(0), _discover_mode(false), _discovering(false), _discover_started_ms(0), - _dresult_count(0), _dscroll(0), _dsel(0), _ddetail(false) {} + _dresult_count(0), _dscroll(0), _dsel(0), _ddetail(false), + _pinging(false), _ping_started_ms(0) { + _ping_time_str[0] = '\0'; + _ping_snr_out_str[0] = '\0'; + _ping_snr_back_str[0] = '\0'; + } void enter() { _sel = _scroll = 0; @@ -384,6 +553,12 @@ public: _ddetail = false; _dsel = 0; _ctx_menu.active = false; + _ping_menu.active = false; + _pinging = false; + _ping_time_str[0] = '\0'; + _ping_snr_out_str[0] = '\0'; + _ping_snr_back_str[0] = '\0'; + _task->clearPing(); refresh(); } @@ -409,46 +584,8 @@ public: // ── detail view ────────────────────────────────────────────────────────── if (_detail && _sel < _count) { - const Entry& e = _entries[_sel]; - int hdr = display.headerH(); - - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, 0, display.width(), hdr - 1); - display.setColor(DisplayDriver::DARK); - char filtered[32]; - display.translateUTF8ToBlocks(filtered, e.name, sizeof(filtered)); - display.drawTextEllipsized(2, 1, display.width() - 4, filtered); - display.setColor(DisplayDriver::LIGHT); - - // 5 lines: lat, lon, dist+bearing, type, seen — compact step, shrink only if needed - int step = display.lineStep(); - if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5; - char buf[32]; - snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6); - display.setCursor(2, hdr); display.print(buf); - - snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6); - display.setCursor(2, hdr + step); display.print(buf); - - if (e.dist_km >= 0.0f) { - char dist[12]; - fmtDist(dist, sizeof(dist), e.dist_km); - int az = bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6); - snprintf(buf, sizeof(buf), "Dist: %s %s", dist, bearingCardinal(az)); - } else { - snprintf(buf, sizeof(buf), "Dist: no GPS"); - } - display.setCursor(2, hdr + step * 2); display.print(buf); - - snprintf(buf, sizeof(buf), "Type: %s", typeName(e.type)); - display.setCursor(2, hdr + step * 3); display.print(buf); - - char age[16]; - fmtAge(age, sizeof(age), e.lastmod); - snprintf(buf, sizeof(buf), "Seen: %s", age); - display.setCursor(2, hdr + step * 4); display.print(buf); - - return 2000; + renderStoredDetail(display); + return _ping_menu.active ? 50 : 2000; } // ── list view ──────────────────────────────────────────────────────────── @@ -509,7 +646,14 @@ public: // ── detail view ───────────────────────────────────────────────────────── if (_detail) { - if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _detail = false; return true; } + if (c == KEY_CANCEL) { + _detail = false; + closePingMenu(); + return true; + } + + uint8_t pub_key[PUB_KEY_SIZE]; + if (selectedStoredPubKey(pub_key) && handlePingMenuInput(c, pub_key)) return true; return true; } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 8b7eb135..79eb08e4 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1113,6 +1113,14 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no ui_started_at = millis(); _alert_expiry = 0; _batt_mv = AbstractUITask::getBattMilliVolts(); // seed EMA with first reading + + // Initialize ping state + _ping_active = false; + _ping_tag = 0; + _ping_sent_ms = 0; + _ping_snr_out_x4 = 0; + _ping_snr_back_x4 = 0; + _ping_rtt_ms = 0; splash = new SplashScreen(this); home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); @@ -1171,6 +1179,58 @@ void UITask::gotoAutoAdvertScreen() { setCurrScreen(auto_advert_screen); } +// Public method to handle ping result callback +void UITask::handlePingResult(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms) { + if (_ping_active && _ping_tag == tag) { + _ping_snr_out_x4 = snr_out_x4; + _ping_snr_back_x4 = snr_back_x4; + _ping_rtt_ms = rtt_ms; + // Release the in-flight slot immediately; the UI keeps the result values. + clearPing(); + } +} + +// Static ping callback (for MyMesh) +static void onPingResult(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms) { + AbstractUITask* ui = the_mesh.getUITask(); + if (ui) { + UITask* task = static_cast(ui); + task->handlePingResult(tag, snr_out_x4, snr_back_x4, rtt_ms); + } +} + +void UITask::clearPing() { + if (_ping_tag != 0) { + the_mesh.clearPingResult(_ping_tag); + } + _ping_active = false; + _ping_tag = 0; +} + +bool UITask::startPing(const uint8_t* pub_key) { + if (_ping_active || !pub_key) return false; + if (_node_prefs && _node_prefs->path_hash_mode > 1) { + showAlert("Ping not supported with 3-byte path hashes", 3000); + return false; + } + + _ping_active = true; + _ping_tag = 0; + _ping_sent_ms = millis(); + _ping_snr_out_x4 = 0; + _ping_snr_back_x4 = 0; + _ping_rtt_ms = 0; + + // Always install the callback before sending so the response cannot race it. + the_mesh.setPingCallback(onPingResult, NULL); + _ping_tag = the_mesh.sendPing(pub_key, _node_prefs ? _node_prefs->path_hash_mode + 1 : 1); + if (_ping_tag == 0) { + clearPing(); + return false; + } + return true; +} + void UITask::playMelody(const char* melody) { #ifdef PIN_BUZZER buzzer.playForced(melody); @@ -1849,13 +1909,11 @@ char UITask::handleLongPress(char c) { } char UITask::handleDoubleClick(char c) { - MESH_DEBUG_PRINTLN("UITask: double click triggered"); checkDisplayOn(c); return c; } char UITask::handleTripleClick(char c) { - MESH_DEBUG_PRINTLN("UITask: triple click triggered"); checkDisplayOn(c); toggleBuzzer(); return 0; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 1b975451..7b9eac70 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -80,6 +80,14 @@ class UITask : public AbstractUITask { TrailStore _trail; uint32_t _next_trail_sample_ms = 0; + // Ping state + bool _ping_active = false; + uint32_t _ping_tag = 0; + unsigned long _ping_sent_ms = 0; + int16_t _ping_snr_out_x4 = 0; + int16_t _ping_snr_back_x4 = 0; + uint32_t _ping_rtt_ms = 0; + void userLedHandler(); // Button action handlers @@ -149,6 +157,17 @@ public: bool hasDisplay() const { return _display != NULL; } DisplayDriver* getDisplay() const { return _display; } + // Ping helpers + bool startPing(const uint8_t* pub_key); + bool isPingActive() const { return _ping_active; } + void getPingResult(int16_t& snr_out_x4, int16_t& snr_back_x4, uint32_t& rtt_ms) const { + snr_out_x4 = _ping_snr_out_x4; + snr_back_x4 = _ping_snr_back_x4; + rtt_ms = _ping_rtt_ms; + } + void clearPing(); + void handlePingResult(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms); + // Favourites dial helpers. Slot index 0..FAVOURITES_COUNT-1. int findFavouriteSlot(const uint8_t* pub_key) const { if (!_node_prefs || !pub_key) return -1;