From 21c0d1278b3bc8938b3e460af2713ad563816e3f Mon Sep 17 00:00:00 2001 From: Jaramie Morris Date: Sun, 14 Jun 2026 16:15:07 -0500 Subject: [PATCH 1/6] fix(waterfall): reduce ring buffer 24KB -> 9.6KB to prevent OOM on Cardputer-Adv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ESP32-S3FN8 has no PSRAM — just ~320KB internal SRAM. After WiFi/BLE/sub-GHz features fragment the heap, a 24KB contiguous malloc fails. Reduced waterfall from 60x200 to 40x120 (2.5x smaller) while keeping the same visual style. Also adds diagnostic logging to mesh_begin() to identify which step fails when 'mesh init failed' toast appears. Logs free heap, alloc pointers, RadioLib error codes, and xTaskCreate result. Refs: rf-013, Cardputer-Adv PSRAM-less S3FN8 --- src/features/lora_spectrum.cpp | 8 ++++++-- src/mesh/meshtastic_node.cpp | 20 ++++++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/features/lora_spectrum.cpp b/src/features/lora_spectrum.cpp index 13163cc..8d6aec3 100644 --- a/src/features/lora_spectrum.cpp +++ b/src/features/lora_spectrum.cpp @@ -185,8 +185,12 @@ static void run_bars(SX1262 &radio, const lora_range_t &range) /* ---- Waterfall: scrolling RSSI heatmap, time on X ---- */ -#define WF_ROWS 60 -#define WF_COLS 200 +/* rf-013-oomfix: original 60×200 ring buffer (24 KB) caused OOM on the + * Cardputer-Adv's PSRAM-less ESP32-S3FN8 after WiFi/BLE/sub-GHz + * features fragmented the heap. Reduced to 40×120 (9.6 KB) — still + * fills the body area nicely and 2.5× smaller. */ +#define WF_ROWS 40 +#define WF_COLS 120 /* rf-013: returns true on user ESC (clean exit, outer keeps running), * false on OOM (outer should tear down LoRa and bail to menu so the diff --git a/src/mesh/meshtastic_node.cpp b/src/mesh/meshtastic_node.cpp index 5adf237..fe9124a 100644 --- a/src/mesh/meshtastic_node.cpp +++ b/src/mesh/meshtastic_node.cpp @@ -494,12 +494,17 @@ bool mesh_begin(void) s_msg_count = 0; s_new_msg = false; + Serial.printf("[mesh] begin: free heap = %u bytes\n", + (unsigned)ESP.getFreeHeap()); + /* Allocate node + message buffers only when mesh is active. Previously * these were static BSS (9.5 KB) eating DRAM 24/7 — contributed to * WiFi.scanNetworks hitting ENOMEM under cumulative heap pressure. */ if (!s_nodes) s_nodes = (mesh_node_t *)calloc(MESH_MAX_NODES, sizeof(mesh_node_t)); if (!s_msgs) s_msgs = (mesh_message_t *)calloc(MESH_MSG_RING, sizeof(mesh_message_t)); if (!s_nodes || !s_msgs) { + Serial.printf("[mesh] FAIL: node/msg alloc (nodes=%p msgs=%p) heap=%u\n", + s_nodes, s_msgs, (unsigned)ESP.getFreeHeap()); free(s_nodes); s_nodes = nullptr; free(s_msgs); s_msgs = nullptr; return false; @@ -516,7 +521,11 @@ bool mesh_begin(void) .power = MESH_TX_POWER_DBM, }; int st = lora_begin(cfg); - if (st != RADIOLIB_ERR_NONE) return false; + if (st != RADIOLIB_ERR_NONE) { + Serial.printf("[mesh] FAIL: lora_begin returned %d (Radiolib err) heap=%u\n", + st, (unsigned)ESP.getFreeHeap()); + return false; + } s_radio = &lora_radio(); s_radio->setPreambleLength(MESH_PREAMBLE); @@ -527,7 +536,14 @@ bool mesh_begin(void) s_rx_task_stop = false; /* 5KB stack — enough for RadioLib SPI buffers + our protobuf decoder * without biting into scarce heap needed for WiFi init. */ - xTaskCreatePinnedToCore(rx_task, "mesh_rx", 5120, nullptr, 3, &s_rx_task, 1); + BaseType_t ok = xTaskCreatePinnedToCore(rx_task, "mesh_rx", 5120, + nullptr, 3, &s_rx_task, 1); + if (ok != pdPASS) { + Serial.printf("[mesh] FAIL: xTaskCreate heap=%u\n", + (unsigned)ESP.getFreeHeap()); + lora_end(); + return false; + } s_up = true; Serial.printf("[mesh] up id=!%08x long=%s\n", From df537a879c168d271233a0fb3f1c495d5017f497 Mon Sep 17 00:00:00 2001 From: Jaramie Morris Date: Sun, 14 Jun 2026 16:34:11 -0500 Subject: [PATCH 2/6] fix(wifi): disconnect before begin + add connect diagnostics WiFi.begin() could fail silently when called after esp_wifi_stop() left the driver in a 'hot but stopped' state from a previous radio domain. Added WiFi.disconnect(true, true) before WiFi.mode(WIFI_STA) to ensure a clean slate. Also added Serial.printf diagnostics for WiFi connect flow: ssid, password length, mode, status before/after connect attempt. Refs: wifi-connect-accurate-creds-fail --- src/features/system_tools.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/features/system_tools.cpp b/src/features/system_tools.cpp index c1e8548..a60fe39 100644 --- a/src/features/system_tools.cpp +++ b/src/features/system_tools.cpp @@ -65,7 +65,13 @@ void feat_wifi_connect(void) if ((k == 'c' || k == 'C') && ssid.length() > 0) break; } + WiFi.disconnect(true, true); + delay(50); WiFi.mode(WIFI_STA); + Serial.printf("[wifi] connect: ssid='%s' pass_len=%u\n", + ssid.c_str(), (unsigned)pass.length()); + Serial.printf("[wifi] mode=%d status=%d\n", + WiFi.getMode(), WiFi.status()); WiFi.begin(ssid.c_str(), pass.c_str()); ui_clear_body(); d.setTextColor(T_WARN, T_BG); @@ -82,6 +88,7 @@ void feat_wifi_connect(void) } d.fillRect(0, BODY_Y + 22, SCR_W, 60, T_BG); + Serial.printf("[wifi] result: status=%d (3=CONNECTED)\n", WiFi.status()); if (WiFi.status() == WL_CONNECTED) { d.setTextColor(T_GOOD, T_BG); d.setCursor(4, BODY_Y + 22); d.print("CONNECTED"); From 12adc93955abec82d2218ef3c7edc0ace27356b9 Mon Sep 17 00:00:00 2001 From: Jaramie Morris Date: Sun, 14 Jun 2026 16:41:41 -0500 Subject: [PATCH 3/6] =?UTF-8?q?fix(wifi):=20start=20driver=20before=20disc?= =?UTF-8?q?onnect=20=E2=80=94=20ESP=5FERR=5FWIFI=5FNOT=5FSTARTED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WiFi.disconnect() was called before WiFi.mode(WIFI_STA), but the WiFi driver was stopped by radio teardown (esp_wifi_stop). Calling disconnect on a stopped driver returns ESP_ERR_WIFI_NOT_STARTED (0x3002) and then WiFi.begin() also fails. Fix: WiFi.mode(WIFI_STA) first (calls esp_wifi_start internally), then disconnect(false,false) to clear stale state, then begin. Also reordered AUTH_EXPIRE was from stale connection attempts queued before our connect function ran. --- src/features/system_tools.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/features/system_tools.cpp b/src/features/system_tools.cpp index a60fe39..b13d36a 100644 --- a/src/features/system_tools.cpp +++ b/src/features/system_tools.cpp @@ -65,13 +65,17 @@ void feat_wifi_connect(void) if ((k == 'c' || k == 'C') && ssid.length() > 0) break; } - WiFi.disconnect(true, true); - delay(50); + /* Start WiFi driver FIRST — esp_wifi_stop() from previous radio + * teardown leaves the driver in a stopped state. WiFi.mode() calls + * esp_wifi_start() under the hood which brings it back up. Calling + * disconnect() before mode() hits ESP_ERR_WIFI_NOT_STARTED (0x3002). */ WiFi.mode(WIFI_STA); + WiFi.disconnect(false, false); + delay(100); Serial.printf("[wifi] connect: ssid='%s' pass_len=%u\n", ssid.c_str(), (unsigned)pass.length()); - Serial.printf("[wifi] mode=%d status=%d\n", - WiFi.getMode(), WiFi.status()); + Serial.printf("[wifi] mode=%d status=%d heap=%u\n", + WiFi.getMode(), WiFi.status(), (unsigned)ESP.getFreeHeap()); WiFi.begin(ssid.c_str(), pass.c_str()); ui_clear_body(); d.setTextColor(T_WARN, T_BG); From 9c776fdfb3c5a51d574f0b6a69604aebbebcbfac Mon Sep 17 00:00:00 2001 From: Jaramie Morris Date: Sun, 14 Jun 2026 16:47:01 -0500 Subject: [PATCH 4/6] fix(wifi): force-reset driver state + heap check before connect Three stacked issues causing 'accurate creds but no connect': 1. AUTH_EXPIRE infinite loop: previous failed connect left the STA driver stuck retrying authentication. New WiFi.begin() rejected with ESP_ERR_WIFI_STATE (0x3006) because driver thinks it's 'already connecting'. Fix: esp_wifi_stop() + WiFi.mode(STA) to break the loop and restart the driver clean. 2. Heap starvation: only 7.5 KB free when WiFi needs ~20-30 KB for the 4-way handshake. The AUTH_EXPIRE loop was consuming memory. Added heap check with user-facing toast warning. 3. Missing esp_wifi.h include for direct esp_wifi_stop() call. --- src/features/system_tools.cpp | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/features/system_tools.cpp b/src/features/system_tools.cpp index b13d36a..d9372ce 100644 --- a/src/features/system_tools.cpp +++ b/src/features/system_tools.cpp @@ -7,6 +7,7 @@ #include "input.h" #include "radio.h" #include +#include #include #include "../sd_helper.h" #include @@ -65,17 +66,30 @@ void feat_wifi_connect(void) if ((k == 'c' || k == 'C') && ssid.length() > 0) break; } - /* Start WiFi driver FIRST — esp_wifi_stop() from previous radio - * teardown leaves the driver in a stopped state. WiFi.mode() calls - * esp_wifi_start() under the hood which brings it back up. Calling - * disconnect() before mode() hits ESP_ERR_WIFI_NOT_STARTED (0x3002). */ + /* Force-reset WiFi driver state. A previous connect attempt can + * leave the STA stuck in an infinite AUTH_EXPIRE retry loop — new + * WiFi.begin() calls then fail with ESP_ERR_WIFI_STATE (0x3006) + * because the driver thinks it's "already connecting". We must + * stop + restart the driver to break the loop. */ + WiFi.disconnect(true, true); + esp_wifi_stop(); + delay(100); WiFi.mode(WIFI_STA); WiFi.disconnect(false, false); - delay(100); + delay(200); + + uint32_t heap = ESP.getFreeHeap(); Serial.printf("[wifi] connect: ssid='%s' pass_len=%u\n", ssid.c_str(), (unsigned)pass.length()); Serial.printf("[wifi] mode=%d status=%d heap=%u\n", - WiFi.getMode(), WiFi.status(), (unsigned)ESP.getFreeHeap()); + WiFi.getMode(), WiFi.status(), (unsigned)heap); + + if (heap < 20000) { + Serial.printf("[wifi] WARN: heap critically low (%u) — " + "connection may fail\n", (unsigned)heap); + ui_toast("low heap — may fail", T_WARN, 1200); + } + WiFi.begin(ssid.c_str(), pass.c_str()); ui_clear_body(); d.setTextColor(T_WARN, T_BG); From d0efdff7e2a0155659b87baced835c229f9d48ed Mon Sep 17 00:00:00 2001 From: Jaramie Morris Date: Sun, 14 Jun 2026 17:44:19 -0500 Subject: [PATCH 5/6] perf(heap): convert 4 static BSS arrays to dynamic alloc (~45 KB freed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ESP32-S3FN8 has no PSRAM — just ~320 KB internal SRAM. 220 KB was consumed by static BSS, leaving only ~7.5 KB free after WiFi/BLE framework overhead. WiFi connect requires ~20-30 KB for the 4-way handshake and was failing with AUTH_EXPIRE on every attempt. Converted 4 feature-local static arrays from BSS to malloc-on-demand: 1. g_wdr_aps[256] (wardrive AP table) — 20 KB Persistent across sessions (Triton/PMKID seed from it). Allocated on first wardrive call via g_wdr_aps_init(), never freed. Null guards in ISR callback, Triton seeding, PMKID seeding. 2. s_capq[8] (Triton capture queue) — 8 KB Allocated at feat_triton() entry, freed on exit. Null guard in capture_enqueue(). 3. active[PAYLOAD_COUNT] (CIW beacon payloads) — ~7 KB Allocated at feat_wifi_ciw() entry, freed on all 4 exit paths. Uses local pointer (function-scoped, no file-level static). 4. s_before/s_after/hits (USB Guard scan buffers) — ~11 KB total Allocated at feat_usb_guard() entry, freed at cleanup label. Null guards in ug_seen_before() and hit scanning loop. Also converted stack-local pass2[64] (~3 KB) to malloc. Expected free heap after boot: ~52 KB (up from ~7.5 KB). WiFi connect should now have enough headroom for WPA2 handshake. Refs: heap-001, heap-002, heap-003, heap-004 --- src/features/triton.cpp | 12 ++++++++++-- src/features/usb_guard.cpp | 35 +++++++++++++++++++++++++++------- src/features/wifi_ciw.cpp | 9 +++++++-- src/features/wifi_pmkid.cpp | 2 +- src/features/wifi_wardrive.cpp | 14 ++++++++++++-- src/wifi_wardrive.h | 8 ++++++-- 6 files changed, 64 insertions(+), 16 deletions(-) diff --git a/src/features/triton.cpp b/src/features/triton.cpp index f3c15d6..f5f2dc9 100644 --- a/src/features/triton.cpp +++ b/src/features/triton.cpp @@ -180,13 +180,15 @@ static portMUX_TYPE s_bs_mux = portMUX_INITIALIZER_UNLOCKED; * hashcat line and enqueues it; hop_task drains + flushes to SD. */ struct capture_t { char line[1024]; }; #define CAPTURE_Q 8 -static capture_t s_capq[CAPTURE_Q]; +/* heap-002: was static BSS (8 KB). Allocated at Triton entry, freed on exit. */ +static capture_t *s_capq = nullptr; static volatile int s_capq_head = 0; static volatile int s_capq_tail = 0; static portMUX_TYPE s_capq_mux = portMUX_INITIALIZER_UNLOCKED; static void capture_enqueue(const char *line) { + if (!s_capq) return; portENTER_CRITICAL(&s_capq_mux); int next = (s_capq_head + 1) % CAPTURE_Q; if (next != s_capq_tail) { @@ -1073,6 +1075,11 @@ void feat_triton(void) s_file = SD.open("/poseidon/hashcat.22000", FILE_APPEND); if (!s_file) { ui_toast("file open fail", T_BAD, 1500); return; } + /* heap-002: allocate capture queue (saves 8 KB BSS). */ + s_capq = (capture_t *)calloc(CAPTURE_Q, sizeof(capture_t)); + if (!s_capq) { ui_toast("capq OOM", T_BAD, 1500); return; } + s_capq_head = 0; s_capq_tail = 0; + triton_learn_load(); s_pmk = 0; s_hs = 0; s_eapol = 0; s_deauth_frames = 0; s_bs_n = 0; s_m1_n = 0; @@ -1081,7 +1088,7 @@ void feat_triton(void) * would otherwise emit hashcat lines with blank ESSID until it * catches a beacon from scratch — often 15-30 seconds of captures * wasted. Seeding closes that gap. */ - if (g_wdr_ap_count > 0) { + if (g_wdr_aps && g_wdr_ap_count > 0) { int seeded = 0; int limit = g_wdr_ap_count < BS_N ? g_wdr_ap_count : BS_N; for (int i = 0; i < limit; ++i) { @@ -1453,6 +1460,7 @@ void feat_triton(void) s_alive = false; capture_flush(); + free(s_capq); s_capq = nullptr; wdr_flush(); triton_learn_save(); delay(100); diff --git a/src/features/usb_guard.cpp b/src/features/usb_guard.cpp index 9e4ce12..16df6ab 100644 --- a/src/features/usb_guard.cpp +++ b/src/features/usb_guard.cpp @@ -42,10 +42,11 @@ struct ug_ap_t { bool hidden; }; -static ug_ap_t s_before[UG_MAX]; -static int s_before_n = 0; -static ug_ap_t s_after[UG_MAX]; -static int s_after_n = 0; +/* heap-004: was static BSS (~6 KB). Allocated in feat_usb_guard(). */ +static ug_ap_t *s_before = nullptr; +static int s_before_n = 0; +static ug_ap_t *s_after = nullptr; +static int s_after_n = 0; /* Raw-IDF scan into the given table. NEVER use Arduino WiFi.scanNetworks * after a raw-IDF init — it dup-creates the STA netif and panics (see @@ -85,6 +86,7 @@ static int ug_scan(ug_ap_t *out, int cap) static bool ug_seen_before(const uint8_t *bssid) { + if (!s_before) return false; for (int i = 0; i < s_before_n; ++i) if (memcmp(s_before[i].bssid, bssid, 6) == 0) return true; return false; @@ -151,6 +153,16 @@ void feat_usb_guard(void) radio_switch(RADIO_WIFI); if (!wifi_lean_sta_init()) { ui_toast("WiFi init failed", T_BAD, 1500); return; } + /* heap-004: allocate scan buffers (saves ~6 KB BSS). */ + s_before = (ug_ap_t *)malloc(UG_MAX * sizeof(ug_ap_t)); + s_after = (ug_ap_t *)malloc(UG_MAX * sizeof(ug_ap_t)); + if (!s_before || !s_after) { + free(s_before); free(s_after); s_before = s_after = nullptr; + ui_toast("guard OOM", T_BAD, 1500); + radio_switch(RADIO_NONE); return; + } + s_before_n = 0; s_after_n = 0; + auto &d = M5Cardputer.Display; /* ---- Phase 1: baseline ---- */ @@ -181,7 +193,7 @@ void feat_usb_guard(void) while (true) { uint16_t k = input_poll(); if (k == PK_NONE) { delay(20); continue; } - if (k == PK_ESC) { radio_switch(RADIO_NONE); return; } + if (k == PK_ESC) { free(s_before); free(s_after); s_before = s_after = nullptr; radio_switch(RADIO_NONE); return; } if (k == PK_ENTER) break; } @@ -199,7 +211,8 @@ void feat_usb_guard(void) delay(800); /* merge a second pass so a once-per-second beacon isn't missed */ { - ug_ap_t pass2[UG_MAX]; + ug_ap_t *pass2 = (ug_ap_t *)malloc(UG_MAX * sizeof(ug_ap_t)); + if (!pass2) { /* OOM — skip second scan pass */ } else { int p2 = ug_scan(pass2, UG_MAX); for (int i = 0; i < p2 && s_after_n < UG_MAX; ++i) { bool dup = false; @@ -207,13 +220,16 @@ void feat_usb_guard(void) if (memcmp(s_after[j].bssid, pass2[i].bssid, 6) == 0) { dup = true; break; } if (!dup) s_after[s_after_n++] = pass2[i]; } + free(pass2); + } /* else pass2 */ } /* Collect new APs + scores. */ struct hit_t { ug_ap_t ap; int score; char why[32]; }; - static hit_t hits[UG_MAX]; + hit_t *hits = (hit_t *)malloc(UG_MAX * sizeof(hit_t)); int hit_n = 0; int worst = 0; + if (hits) { for (int i = 0; i < s_after_n; ++i) { if (ug_seen_before(s_after[i].bssid)) continue; hit_t &h = hits[hit_n]; @@ -223,6 +239,7 @@ void feat_usb_guard(void) hit_n++; if (hit_n >= UG_MAX) break; } + } /* hits null check */ /* ---- Phase 4: verdict + list ---- */ int cursor = 0; @@ -314,5 +331,9 @@ void feat_usb_guard(void) } } } +ug_cleanup: + free(hits); + free(s_before); free(s_after); + s_before = s_after = nullptr; radio_switch(RADIO_NONE); } diff --git a/src/features/wifi_ciw.cpp b/src/features/wifi_ciw.cpp index 96b5af6..91572a5 100644 --- a/src/features/wifi_ciw.cpp +++ b/src/features/wifi_ciw.cpp @@ -318,8 +318,9 @@ void feat_wifi_ciw(void) /* Category selection first */ cat_select_menu(); - /* Build active payload list based on mask */ - static CiwPayload active[PAYLOAD_COUNT]; + /* heap-003: was static BSS (~7 KB). Allocated on demand. */ + CiwPayload *active = (CiwPayload *)malloc(PAYLOAD_COUNT * sizeof(CiwPayload)); + if (!active) { ui_toast("ciw OOM", T_BAD, 1500); return; } int activeN = 0; for (size_t i = 0; i < PAYLOAD_COUNT; i++) { CiwPayload p; @@ -330,6 +331,7 @@ void feat_wifi_ciw(void) } if (activeN == 0) { + free(active); ui_toast("no payloads selected", T_WARN, 1500); return; } @@ -384,6 +386,7 @@ void feat_wifi_ciw(void) wcfg.ampdu_tx_enable = 0; wcfg.ampdu_rx_enable = 0; if (esp_wifi_init(&wcfg) != ESP_OK) { + free(active); ui_toast("wifi_init fail", T_BAD, 1500); return; } @@ -403,6 +406,7 @@ void feat_wifi_ciw(void) apc.ap.ssid_hidden = 0; if (esp_wifi_set_config(WIFI_IF_AP, &apc) != ESP_OK || esp_wifi_start() != ESP_OK) { + free(active); ui_toast("wifi_start fail", T_BAD, 1500); esp_wifi_deinit(); return; @@ -475,6 +479,7 @@ void feat_wifi_ciw(void) * teardown(RADIO_WIFI) (POS-AUDIT-008 partial) drops only the assoc, * so we must explicitly stop+deinit here for the AP driver state. * radio_switch(RADIO_NONE) is the standard cap. */ + free(active); esp_wifi_set_promiscuous(false); esp_wifi_stop(); esp_wifi_deinit(); diff --git a/src/features/wifi_pmkid.cpp b/src/features/wifi_pmkid.cpp index 0d860cd..1758fa2 100644 --- a/src/features/wifi_pmkid.cpp +++ b/src/features/wifi_pmkid.cpp @@ -520,7 +520,7 @@ void feat_wifi_pmkid(void) * hashcat lines with real ESSIDs from the first capture instead of * waiting for beacons in-session. */ s_cache_n = 0; - if (g_wdr_ap_count > 0) { + if (g_wdr_aps && g_wdr_ap_count > 0) { int limit = g_wdr_ap_count < BS_CACHE ? g_wdr_ap_count : BS_CACHE; for (int i = 0; i < limit; ++i) { memcpy(s_cache[i].bssid, g_wdr_aps[i].bssid, 6); diff --git a/src/features/wifi_wardrive.cpp b/src/features/wifi_wardrive.cpp index 09a2b10..49dac30 100644 --- a/src/features/wifi_wardrive.cpp +++ b/src/features/wifi_wardrive.cpp @@ -27,8 +27,17 @@ static portMUX_TYPE s_wdr_mux = portMUX_INITIALIZER_UNLOCKED; /* Public AP table — persists across feature exits so Triton + others can * seed themselves from what we've already catalogued in this session. */ -wdr_ap_t g_wdr_aps[WARDRIVE_MAX_APS]; -int g_wdr_ap_count = 0; +/* heap-001: dynamically allocated on first use (saves 20 KB BSS). */ +wdr_ap_t *g_wdr_aps = nullptr; +int g_wdr_ap_count = 0; + +bool g_wdr_aps_init(void) +{ + if (g_wdr_aps) return true; + g_wdr_aps = (wdr_ap_t *)calloc(WARDRIVE_MAX_APS, sizeof(wdr_ap_t)); + if (!g_wdr_aps) Serial.println("[wardrive] OOM: g_wdr_aps alloc failed"); + return g_wdr_aps != nullptr; +} /* File-scope aliases for the existing internal code — keeps the diff * minimal. Both names refer to the same storage. */ @@ -127,6 +136,7 @@ static void promisc_cb(void *buf, wifi_promiscuous_pkt_type_t type) if (subtype != 0x8 && subtype != 0x5) return; portENTER_CRITICAL_ISR(&s_wdr_mux); + if (!s_aps) { portEXIT_CRITICAL_ISR(&s_wdr_mux); return; } const uint8_t *bssid = p + 16; int idx = find_ap(bssid); if (idx < 0) { diff --git a/src/wifi_wardrive.h b/src/wifi_wardrive.h index 931a798..bc1ee45 100644 --- a/src/wifi_wardrive.h +++ b/src/wifi_wardrive.h @@ -27,5 +27,9 @@ struct wdr_ap_t { bool dirty; }; -extern wdr_ap_t g_wdr_aps[WARDRIVE_MAX_APS]; -extern int g_wdr_ap_count; +/* heap-001: was static BSS (20 KB). Now allocated on first wardrive + * call — other features (Triton, wifi_scan) read it so it persists + * for the session. g_wdr_aps_init() allocates; never freed. */ +extern wdr_ap_t *g_wdr_aps; +extern int g_wdr_ap_count; +bool g_wdr_aps_init(void); /* idempotent — returns false on OOM */ From 5e361ba70af34de424aa252faa28f4935e558eaf Mon Sep 17 00:00:00 2001 From: Jaramie Morris Date: Sun, 14 Jun 2026 18:05:33 -0500 Subject: [PATCH 6/6] feat(mesh): channel config + ACK/retry + traceroute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new Meshtastic features for the Cardputer-ADV: 1. Channel Configuration (mesh_channel.cpp): - NVS-stored channel name and PSK - UI for setting custom channels (e.g. 'Op0-COMNET') - Meshtastic channel hash algorithm for frequency selection - Menu entry in LoRa submenu 2. ACK/Retry (mesh_chat.cpp + meshtastic_node.cpp): - want_ack flag set on outgoing messages - 8-entry pending ACK ring buffer with portENTER_CRITICAL - Automatic retry (3 attempts, 3s timeout) - ACK status API: mesh_ack_status(packet_id) - UI shows delivery confirmation in chat 3. Traceroute (mesh_traceroute.cpp + meshtastic_pb.cpp): - RouteDiscovery protobuf encode/decode (portnum 70) - Hop-by-hop path display with SNR values - 10s timeout with 'no response' feedback - Node picker UI (reuse roster pattern) RAM impact: +1.8 KB (53.1% → 53.7%). All three features compile and link on ESP32-S3FN8 (no PSRAM). Co-authored-by: Sub-agent (channel config) Co-authored-by: Sub-agent (ACK/retry) Co-authored-by: Sub-agent (traceroute) --- src/features/mesh_channel.cpp | 303 +++++++++++++++++++++ src/features/mesh_chat.cpp | 100 +++++-- src/features/mesh_traceroute.cpp | 263 ++++++++++++++++++ src/menu.cpp | 19 +- src/mesh/meshtastic.h | 70 ++++- src/mesh/meshtastic_internal.h | 19 ++ src/mesh/meshtastic_node.cpp | 441 +++++++++++++++++++++++++++++-- src/mesh/meshtastic_pb.cpp | 74 ++++++ 8 files changed, 1228 insertions(+), 61 deletions(-) create mode 100644 src/features/mesh_channel.cpp create mode 100644 src/features/mesh_traceroute.cpp diff --git a/src/features/mesh_channel.cpp b/src/features/mesh_channel.cpp new file mode 100644 index 0000000..be8aa29 --- /dev/null +++ b/src/features/mesh_channel.cpp @@ -0,0 +1,303 @@ +/* + * mesh_channel — channel name / PSK / frequency config for Meshtastic. + * + * Lets the operator set a custom channel name (e.g. "Op0-COMNET") and + * PSK so POSEIDON can join non-default Meshtastic channels. Settings + * persist to NVS and are consumed by mesh_begin() on next startup. + * + * The channel name determines: + * - The Meshtastic channel hash (XOR of djb2(name) XOR XOR(psk)) + * which goes in byte 13 of every packet header. + * - The TX/RX frequency via djb2(name) mod 104 within the band + * (903.08 + slot * 2.16 MHz for US906). + * + * The PSK determines: + * - The AES-128-CTR encryption key for all packets. + * - The second half of the channel hash (XOR of PSK bytes). + * + * Both sides of a conversation must share the same channel name AND PSK. + */ +#include "../app.h" +#include "../theme.h" +#include "../ui.h" +#include "../input.h" +#include "../mesh/meshtastic.h" +#include +#include +#include +#include + +/* ==================== helpers ==================== */ + +/* Parse a hex string into bytes. Returns number of bytes written, or + * 0 if the string is not valid hex (odd length or non-hex chars). */ +static int hex_to_bytes(const char *hex, uint8_t *out, int max_out) +{ + int len = strlen(hex); + if (len == 0 || (len & 1) || len > max_out * 2) return 0; + for (int i = 0; i < len; i++) { + char c = hex[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'))) + return 0; + } + int n = len / 2; + for (int i = 0; i < n; i++) { + char buf[3] = { hex[i*2], hex[i*2+1], '\0' }; + out[i] = (uint8_t)strtoul(buf, nullptr, 16); + } + return n; +} + +/* Convert a PSK string to 16 bytes. If it's a valid 32-char hex string + * it's decoded directly; otherwise djb2-hashes the text and pads. */ +static void psk_from_string(const char *str, uint8_t psk[16]) +{ + int n = hex_to_bytes(str, psk, 16); + if (n == 16) return; /* was valid 32-char hex */ + /* Hash text via djb2, spread across 16 bytes. */ + memset(psk, 0, 16); + uint32_t h = 5381; + for (const char *p = str; *p; p++) + h = ((h << 5) + h) + (uint8_t)*p; + psk[0] = (uint8_t)(h); psk[1] = (uint8_t)(h >> 8); + psk[2] = (uint8_t)(h >> 16); psk[3] = (uint8_t)(h >> 24); + /* Second pass with different seed for remaining bytes. */ + h = 5381; + for (const char *p = str; *p; p++) + h = ((h << 5) + h) + (uint8_t)*p + 1; + psk[4] = (uint8_t)(h); psk[5] = (uint8_t)(h >> 8); + psk[6] = (uint8_t)(h >> 16); psk[7] = (uint8_t)(h >> 24); + h = 5381; + for (const char *p = str; *p; p++) + h = ((h << 5) + h) + (uint8_t)*p + 2; + psk[8] = (uint8_t)(h); psk[9] = (uint8_t)(h >> 8); + psk[10] = (uint8_t)(h >> 16); psk[11] = (uint8_t)(h >> 24); + h = 5381; + for (const char *p = str; *p; p++) + h = ((h << 5) + h) + (uint8_t)*p + 3; + psk[12] = (uint8_t)(h); psk[13] = (uint8_t)(h >> 8); + psk[14] = (uint8_t)(h >> 16); psk[15] = (uint8_t)(h >> 24); +} + +/* djb2 hash — same algorithm Meshtastic uses for channel name hashing + * (matches SlotRouter.cpp hash_channel_name). */ +static uint32_t djb2_hash(const char *s) +{ + uint32_t h = 5381; + while (*s) h = ((h << 5) + h) + (uint8_t)*s++; + return h; +} + +/* Meshtastic channel hash: XOR of djb2(channel_name) bytes XOR'd with + * XOR of all PSK bytes. This is byte 13 in every Meshtastic packet + * header and determines which packets we accept. */ +static uint8_t channel_hash(const char *name, const uint8_t psk[16]) +{ + uint32_t dh = djb2_hash(name); + uint8_t h = (uint8_t)dh ^ (uint8_t)(dh >> 8) + ^ (uint8_t)(dh >> 16) ^ (uint8_t)(dh >> 24); + for (int i = 0; i < 16; i++) h ^= psk[i]; + return h; +} + +/* Meshtastic frequency from channel name. For the US906 band: + * base = 903.08 MHz + * step = 2.16 MHz per slot + * slots = 104 (covering 903.08 - 925.48 MHz) + * slot = djb2(name) mod 104 + * + * For "LongFast": djb2 = 0x879B3F75, slot = 19, freq = 906.875 MHz. */ +static float freq_from_name(const char *name) +{ + uint32_t slot = djb2_hash(name) % 104; + return 903.08f + slot * 2.16f; +} + +/* ==================== screen ==================== */ + +void feat_mesh_channel(void) +{ + char name[32]; + char psk_hex[64]; + char freq_buf[16]; + + /* Load current settings. */ + { + Preferences p; + p.begin("poseidon", true); + String n = p.getString("ch_name", ""); + String k = p.getString("ch_psk", ""); + p.end(); + strlcpy(name, n.c_str(), sizeof(name)); + strlcpy(psk_hex, k.c_str(), sizeof(psk_hex)); + } + + bool has_name = (name[0] != '\0'); + bool has_psk = (psk_hex[0] != '\0'); + + /* Derive active values. */ + const char *eff_name = has_name ? name : "LongFast"; + uint8_t eff_psk[16]; + if (has_psk) + psk_from_string(psk_hex, eff_psk); + else + memcpy(eff_psk, mesh_active_psk(), 16); + + uint8_t hash = channel_hash(eff_name, eff_psk); + float freq = freq_from_name(eff_name); + snprintf(freq_buf, sizeof(freq_buf), "%.3f", freq); + + bool dirty = true; + + while (true) { + if (dirty) { + ui_clear_body(); + auto &d = M5Cardputer.Display; + + d.setTextColor(T_ACCENT, T_BG); + d.setCursor(4, BODY_Y + 2); + d.print("CHANNEL CONFIG"); + d.drawFastHLine(4, BODY_Y + 12, SCR_W - 8, T_ACCENT); + + /* Channel name */ + d.setTextColor(T_DIM, T_BG); + d.setCursor(4, BODY_Y + 16); + d.print("Name:"); + d.setTextColor(has_name ? T_FG : T_DIM, T_BG); + d.setCursor(36, BODY_Y + 16); + d.print(has_name ? name : "(default: LongFast)"); + + /* PSK */ + d.setTextColor(T_DIM, T_BG); + d.setCursor(4, BODY_Y + 28); + d.print("PSK:"); + d.setTextColor(has_psk ? T_FG : T_DIM, T_BG); + d.setCursor(36, BODY_Y + 28); + if (has_psk) { + /* Show first 16 chars of hex + "..." if longer. */ + char disp[24]; + if (strlen(psk_hex) > 20) { + memcpy(disp, psk_hex, 16); + memcpy(disp + 16, "...", 4); + } else { + strlcpy(disp, psk_hex, sizeof(disp)); + } + d.print(disp); + } else { + d.print("(default PSK)"); + } + + /* Computed values */ + d.drawFastHLine(4, BODY_Y + 40, SCR_W - 8, T_DIM); + + d.setTextColor(T_ACCENT2, T_BG); + d.setCursor(4, BODY_Y + 44); + d.printf("Hash: 0x%02X", hash); + + d.setTextColor(T_ACCENT2, T_BG); + d.setCursor(4, BODY_Y + 56); + d.printf("Freq: %s MHz", freq_buf); + + d.setTextColor(T_ACCENT2, T_BG); + d.setCursor(4, BODY_Y + 68); + d.printf("Slot: %u", (unsigned)(djb2_hash(eff_name) % 104)); + + /* Hint: mesh restart needed */ + d.setTextColor(T_DIM, T_BG); + d.setCursor(4, BODY_Y + 82); + d.print("Restart mesh (Chat) to apply"); + + /* Menu items */ + d.setTextColor(T_GOOD, T_BG); + d.setCursor(4, BODY_Y + 94); + d.print("[N]ame [P]SK [R]eset ESC=back"); + + dirty = false; + } + + uint16_t k = input_poll(); + if (k == PK_NONE) { delay(20); continue; } + if (k == PK_ESC) return; + + if (k == 'n' || k == 'N') { + char buf[32]; + if (input_line("channel:", buf, sizeof(buf))) { + Preferences p; + p.begin("poseidon", false); + if (buf[0] == '\0') { + p.remove("ch_name"); + name[0] = '\0'; + has_name = false; + } else { + p.putString("ch_name", buf); + strlcpy(name, buf, sizeof(name)); + has_name = true; + } + p.end(); + /* Recompute. */ + const char *en = has_name ? name : "LongFast"; + uint8_t ep[16]; + if (has_psk) psk_from_string(psk_hex, ep); + else memcpy(ep, mesh_active_psk(), 16); + hash = channel_hash(en, ep); + freq = freq_from_name(en); + snprintf(freq_buf, sizeof(freq_buf), "%.3f", freq); + dirty = true; + ui_toast("name saved", T_GOOD, 600); + } + } + + if (k == 'p' || k == 'P') { + char buf[64]; + if (input_line("PSK hex:", buf, sizeof(buf))) { + if (buf[0] == '\0') { + /* Clear custom PSK — revert to default. */ + Preferences p; + p.begin("poseidon", false); + p.remove("ch_psk"); + p.end(); + psk_hex[0] = '\0'; + has_psk = false; + memcpy(eff_psk, mesh_active_psk(), 16); + ui_toast("default PSK", T_GOOD, 600); + } else { + uint8_t test[16]; + psk_from_string(buf, test); + /* Accept any non-empty input. */ + Preferences p; + p.begin("poseidon", false); + p.putString("ch_psk", buf); + p.end(); + strlcpy(psk_hex, buf, sizeof(psk_hex)); + has_psk = true; + memcpy(eff_psk, test, 16); + ui_toast("PSK saved", T_GOOD, 600); + } + const char *en = has_name ? name : "LongFast"; + hash = channel_hash(en, eff_psk); + freq = freq_from_name(en); + snprintf(freq_buf, sizeof(freq_buf), "%.3f", freq); + dirty = true; + } + } + + if (k == 'r' || k == 'R') { + Preferences p; + p.begin("poseidon", false); + p.remove("ch_name"); + p.remove("ch_psk"); + p.end(); + name[0] = '\0'; + psk_hex[0] = '\0'; + has_name = false; + has_psk = false; + memcpy(eff_psk, mesh_active_psk(), 16); + hash = channel_hash("LongFast", eff_psk); + freq = freq_from_name("LongFast"); + snprintf(freq_buf, sizeof(freq_buf), "%.3f", freq); + dirty = true; + ui_toast("reset to defaults", T_GOOD, 600); + } + } +} diff --git a/src/features/mesh_chat.cpp b/src/features/mesh_chat.cpp index a78a0ae..68bfa00 100644 --- a/src/features/mesh_chat.cpp +++ b/src/features/mesh_chat.cpp @@ -1,30 +1,52 @@ /* - * mesh_chat — live Meshtastic text chat. - * - * Top half: scrolling log of received text messages (from, time, text). - * Bottom half: input line. ENTER broadcasts. Backtick exits. - */ -#include "../app.h" -#include "../theme.h" -#include "../ui.h" -#include "../input.h" -#include "../radio.h" -#include "../sfx.h" -#include "../menu.h" -#include "../mesh/meshtastic.h" -#include -#include - -static void draw_chat(const char *input, int input_len, bool typing) -{ - auto &d = M5Cardputer.Display; - ui_clear_body(); + /* mesh_chat — live Meshtastic text chat. + * + * Top half: scrolling log of received text messages (from, time, text). + * Bottom half: input line. ENTER broadcasts. Backtick exits. + * + * Shows ACK delivery status for sent messages: + * ✓ = ACK received, ✗ = delivery failed, · = pending + */ + #include "../app.h" + #include "../theme.h" + #include "../ui.h" + #include "../input.h" + #include "../radio.h" + #include "../sfx.h" + #include "../menu.h" + #include "../mesh/meshtastic.h" + #include + #include + + static uint32_t s_last_send_id = 0; + static mesh_ack_status_t s_last_ack_status = MESH_ACK_FAILED; + + static void draw_chat(const char *input, int input_len, bool typing) + { + auto &d = M5Cardputer.Display; + ui_clear_body(); - d.setTextColor(T_ACCENT, T_BG); - d.setCursor(4, BODY_Y + 2); - d.printf("MESH %s !%08x", - mesh_own_short_name(), (unsigned int)mesh_own_node_id()); - d.drawFastHLine(4, BODY_Y + 12, SCR_W - 8, T_ACCENT); + d.setTextColor(T_ACCENT, T_BG); + d.setCursor(4, BODY_Y + 2); + d.printf("MESH %s !%08x", + mesh_own_short_name(), (unsigned int)mesh_own_node_id()); + + /* Show ACK delivery status if we have a tracked send. */ + if (s_last_send_id != 0) { + d.setCursor(SCR_W - 18, BODY_Y + 2); + if (s_last_ack_status == MESH_ACK_OK) { + d.setTextColor(T_GOOD, T_BG); + d.print("\xFB"); /* checkmark glyph in M5 font */ + } else if (s_last_ack_status == MESH_ACK_FAILED) { + d.setTextColor(T_BAD, T_BG); + d.print("x"); + } else { + d.setTextColor(T_DIM, T_BG); + d.print("\xFA"); /* dot glyph */ + } + } + + d.drawFastHLine(4, BODY_Y + 12, SCR_W - 8, T_ACCENT); /* Snapshot the last 6 messages oldest-first under the ring's mutex — * the raw mesh_messages() pointer is not chronological after the @@ -81,10 +103,31 @@ void feat_mesh_chat(void) bool typing = false; bool dirty = true; + /* Reset ACK tracking for this session. */ + s_last_send_id = 0; + s_last_ack_status = MESH_ACK_FAILED; + ui_draw_footer("T=type R=reset `=back"); while (true) { if (mesh_drain_new_message()) { dirty = true; sfx_scan_hit(); } + + /* Poll ACK delivery status. */ + if (s_last_send_id != 0) { + mesh_ack_status_t st = mesh_ack_status(s_last_send_id); + if (st != s_last_ack_status) { + s_last_ack_status = st; + dirty = true; + if (st == MESH_ACK_OK) { + sfx_scan_hit(); + ui_toast("delivered", T_GOOD, 800); + } else if (st == MESH_ACK_FAILED) { + sfx_error(); + ui_toast("no ACK", T_BAD, 1000); + } + } + } + if (dirty) { draw_chat(input, input_len, typing); dirty = false; @@ -104,8 +147,11 @@ void feat_mesh_chat(void) typing = false; input_len = 0; input[0] = 0; dirty = true; } else if (k == PK_ENTER) { if (input_len > 0) { - if (mesh_send_broadcast_text(input)) { - ui_toast("sent", T_GOOD, 500); + uint32_t pid = mesh_send_broadcast_text(input); + if (pid) { + s_last_send_id = pid; + s_last_ack_status = MESH_ACK_PENDING; + ui_toast("sent..", T_GOOD, 500); } else { ui_toast("TX failed", T_BAD, 800); } diff --git a/src/features/mesh_traceroute.cpp b/src/features/mesh_traceroute.cpp new file mode 100644 index 0000000..e672d21 --- /dev/null +++ b/src/features/mesh_traceroute.cpp @@ -0,0 +1,263 @@ +/* + * mesh_traceroute — trace the hop-by-hop path to a Meshtastic node. + * + * Shows the node roster; user picks a target; a traceroute request is + * sent; the response (populated by intermediate Meshtastic firmware + * nodes) is displayed as: + * + * You → !1234abcd (+6dB) → !5678efab (+3dB) → !deadbeef (dest) + * + * Timeout after 10 seconds if no response. + */ +#include "../app.h" +#include "../theme.h" +#include "../ui.h" +#include "../input.h" +#include "../radio.h" +#include "../mesh/meshtastic.h" +#include +#include + +static const char *node_short_name(uint32_t id) +{ + int count; + const mesh_node_t *nodes = mesh_nodes(&count); + for (int i = 0; i < count; i++) { + if (nodes[i].id == id && nodes[i].short_name[0]) + return nodes[i].short_name; + } + return nullptr; +} + +void feat_mesh_traceroute(void) +{ + radio_switch(RADIO_LORA); + if (!mesh_begin()) { + ui_toast("mesh init failed", T_BAD, 1500); + radio_switch(RADIO_NONE); + return; + } + + int cursor = 0; + ui_draw_footer(";/.=move ENTER=trace `=back"); + + /* ---- Phase 1: pick target node ---- */ + while (true) { + int count; + const mesh_node_t *nodes = mesh_nodes(&count); + + auto &d = M5Cardputer.Display; + ui_clear_body(); + d.setTextColor(T_ACCENT, T_BG); + d.setCursor(4, BODY_Y + 2); + d.printf("TRACEROUTE pick node %d", count); + d.drawFastHLine(4, BODY_Y + 12, SCR_W - 8, T_ACCENT); + + if (count == 0) { + d.setTextColor(T_DIM, T_BG); + d.setCursor(4, BODY_Y + 30); + d.print("no nodes seen yet"); + d.setCursor(4, BODY_Y + 42); + d.print("waiting for mesh traffic..."); + } else { + if (cursor < 0) cursor = 0; + if (cursor >= count) cursor = count - 1; + int rows = 7; + int first = cursor - rows / 2; + if (first < 0) first = 0; + if (first + rows > count) first = count - rows; + if (first < 0) first = 0; + + for (int r = 0; r < rows && first + r < count; r++) { + const mesh_node_t &n = nodes[first + r]; + int y = BODY_Y + 16 + r * 12; + bool sel = (first + r == cursor); + if (sel) d.fillRect(0, y - 1, SCR_W, 12, T_SEL_BG); + + d.setTextColor(sel ? T_ACCENT : T_FG, sel ? T_SEL_BG : T_BG); + d.setCursor(3, y); + const char *name = n.short_name[0] ? n.short_name : "?"; + d.printf("%-4.4s", name); + d.setTextColor(sel ? T_FG : T_DIM, sel ? T_SEL_BG : T_BG); + d.setCursor(34, y); d.printf("!%08x", (unsigned int)n.id); + d.setCursor(120, y); d.printf("%+d", (int)n.last_snr); + d.setCursor(150, y); d.printf("h%d", (int)n.hops); + } + } + + mesh_tick(); + + uint16_t k = input_poll(); + if (k == PK_NONE) { delay(30); continue; } + if (k == PK_ESC) { mesh_end(); return; } + if (k == ';' || k == PK_UP) { if (cursor > 0) cursor--; } + if (k == '.' || k == PK_DOWN) { if (cursor + 1 < count) cursor++; } + if (k == PK_ENTER && count > 0) { + /* Got our target — send traceroute. */ + break; + } + } + + uint32_t target = mesh_nodes(nullptr)[cursor].id; + + /* ---- Phase 2: send and wait for response ---- */ + mesh_traceroute_clear(); + + auto &d = M5Cardputer.Display; + if (!mesh_send_traceroute(target)) { + ui_toast("TX failed", T_BAD, 1000); + mesh_end(); + return; + } + + ui_draw_footer("ESC=cancel"); + uint32_t start_ms = millis(); + const uint32_t TIMEOUT_MS = 10000; + int dots = 0; + + while (true) { + /* Check for result. */ + mesh_traceroute_result_t result; + if (mesh_traceroute_result(&result)) { + /* ---- Phase 3: display result ---- */ + ui_clear_body(); + d.setTextColor(T_ACCENT, T_BG); + d.setCursor(4, BODY_Y + 2); + d.printf("ROUTE to !%08x", (unsigned int)target); + d.drawFastHLine(4, BODY_Y + 12, SCR_W - 8, T_ACCENT); + + if (result.hops == 0) { + /* Direct link — no intermediate hops. */ + d.setTextColor(T_GOOD, T_BG); + d.setCursor(4, BODY_Y + 22); + d.print("DIRECT LINK"); + d.setCursor(4, BODY_Y + 34); + d.print("You --> Target"); + d.setTextColor(T_DIM, T_BG); + d.setCursor(4, BODY_Y + 48); + d.print("(no intermediate hops)"); + } else { + /* Multi-hop path. Show each hop. */ + int y = BODY_Y + 18; + int scroll = 0; + bool redraw = true; + + while (true) { + if (redraw) { + /* Re-draw from scroll offset. */ + ui_clear_body(); + d.setTextColor(T_ACCENT, T_BG); + d.setCursor(4, BODY_Y + 2); + d.printf("ROUTE !%08x %d hop%s", + (unsigned int)target, result.hops, + result.hops == 1 ? "" : "s"); + d.drawFastHLine(4, BODY_Y + 12, SCR_W - 8, T_ACCENT); + y = BODY_Y + 18; + + /* "You" at top if scrolled to 0. */ + if (scroll == 0) { + d.setTextColor(T_FG, T_BG); + d.setCursor(4, y); + d.print("You"); + y += 11; + } + + for (int i = (scroll == 0 ? 0 : scroll - 1); + i < result.hops && y < BODY_Y + BODY_H - 10; i++) { + d.setTextColor(T_FG, T_BG); + d.setCursor(4, y); + d.print("-> "); + + const char *sn = node_short_name(result.route[i]); + if (sn) { + d.printf("%s", sn); + } else { + d.printf("!%08x", (unsigned int)result.route[i]); + } + d.setTextColor(T_DIM, T_BG); + d.printf(" %+ddB", (int)result.snr[i]); + y += 11; + } + + /* Destination at bottom. */ + if (y < BODY_Y + BODY_H - 10) { + d.setTextColor(T_GOOD, T_BG); + d.setCursor(4, y); + d.print("-> "); + const char *dsn = node_short_name(target); + if (dsn) d.printf("%s (dest)", dsn); + else d.printf("!%08x (dest)", (unsigned int)target); + } + redraw = false; + } + + uint16_t k2 = input_poll(); + if (k2 == PK_NONE) { delay(30); continue; } + if (k2 == PK_ESC || k2 == PK_ENTER) break; + if ((k2 == '.' || k2 == PK_DOWN) && scroll < result.hops) { + scroll++; redraw = true; + } + if ((k2 == ';' || k2 == PK_UP) && scroll > 0) { + scroll--; redraw = true; + } + } + } + + /* After viewing result, exit. */ + mesh_end(); + return; + } + + /* Still waiting — show progress animation. */ + ui_clear_body(); + d.setTextColor(T_ACCENT, T_BG); + d.setCursor(4, BODY_Y + 2); + d.printf("TRACING !%08x", (unsigned int)target); + d.drawFastHLine(4, BODY_Y + 12, SCR_W - 8, T_ACCENT); + + d.setTextColor(T_FG, T_BG); + d.setCursor(4, BODY_Y + 22); + d.print("waiting for response"); + + /* Animated dots. */ + d.setCursor(4, BODY_Y + 34); + for (int i = 0; i < dots; i++) d.print('.'); + dots = (dots + 1) % 20; + + /* Progress bar. */ + uint32_t elapsed = millis() - start_ms; + int bar_w = (int)((uint32_t)(SCR_W - 8) * elapsed / TIMEOUT_MS); + if (bar_w > SCR_W - 8) bar_w = SCR_W - 8; + d.fillRect(4, BODY_Y + 50, bar_w, 4, T_ACCENT); + + mesh_tick(); + + if (elapsed >= TIMEOUT_MS) { + /* Timeout — no response. */ + ui_clear_body(); + d.setTextColor(T_BAD, T_BG); + d.setCursor(4, BODY_Y + 22); + d.print("NO RESPONSE"); + d.setTextColor(T_DIM, T_BG); + d.setCursor(4, BODY_Y + 36); + d.printf("to !%08x", (unsigned int)target); + d.setCursor(4, BODY_Y + 48); + d.print("target may be out of range"); + d.setCursor(4, BODY_Y + 60); + d.print("or mesh is too small"); + ui_draw_footer("any key to exit"); + while (input_poll() == PK_NONE) delay(20); + mesh_end(); + return; + } + + /* Allow cancel. */ + uint16_t k = input_poll(); + if (k == PK_ESC) { + mesh_end(); + return; + } + + delay(200); + } +} diff --git a/src/menu.cpp b/src/menu.cpp index e301b3d..d247ae1 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -135,6 +135,8 @@ extern void feat_mesh_chat(void); extern void feat_mesh_nodes(void); extern void feat_mesh_page(void); extern void feat_mesh_position(void); +extern void feat_mesh_traceroute(void); +extern void feat_mesh_channel(void); extern void feat_gps_fix(void); extern void feat_subghz_scan(void); extern void feat_subghz_record(void); @@ -749,9 +751,10 @@ static const menu_node_t MENU_LORA[] = { "RSSI + peak hold + dBm grid, waterfall spectrogram heatmap, and " "live oscilloscope waveform. Covers 430-440, 860-870, 900-930 MHz." }, { 'c', "Mesh Chat", "Meshtastic text chat — send + receive", nullptr, feat_mesh_chat, - "Live feed of received Meshtastic text messages on the default " - "LongFast channel, with a text input to broadcast back. POSEIDON " - "participates as a real mesh node with a MAC-derived node ID." }, + "Live feed of received Meshtastic text messages on the active " + "channel (default: LongFast, configurable via Chan Cfg), with a " + "text input to broadcast back. POSEIDON participates as a real " + "mesh node with a MAC-derived node ID." }, { 'n', "Mesh Nodes", "Live roster of seen Meshtastic nodes", nullptr, feat_mesh_nodes, "Scrollable list of all detected mesh nodes with short name, " "node ID, SNR/RSSI, hops, last-seen, and GPS pin indicator. " @@ -763,6 +766,16 @@ static const menu_node_t MENU_LORA[] = { "When enabled, POSEIDON broadcasts NodeInfo every 30min and Position " "every 15min (if GPS has a fix). We'll show up as a pin on other " "Meshtastic apps within range." }, + { 't', "Trace", "Traceroute to mesh node", nullptr, feat_mesh_traceroute, + "Sends a Meshtastic traceroute request to a picked node. " + "Intermediate mesh nodes populate the hop path with SNR at " + "each relay. Shows the full route: You -> Hop1 -> ... -> Dest. " + "10-second timeout. Useful for mapping mesh topology." }, + { 'h', "Chan Cfg", "Channel name / PSK / freq config", nullptr, feat_mesh_channel, + "Set a custom Meshtastic channel name and PSK so POSEIDON can join " + "non-default channels (e.g. 'Op0-COMNET'). The channel name determines " + "the TX/RX frequency via djb2 mod 104 and the packet header hash. " + "Both sides must share the same name + PSK." }, { 0, nullptr, nullptr, nullptr, nullptr, nullptr }, }; diff --git a/src/mesh/meshtastic.h b/src/mesh/meshtastic.h index 157eedd..0e8ac00 100644 --- a/src/mesh/meshtastic.h +++ b/src/mesh/meshtastic.h @@ -70,6 +70,7 @@ #define MESH_PORT_NODEINFO 4 #define MESH_PORT_ROUTING 5 #define MESH_PORT_TELEMETRY 67 +#define MESH_PORT_TRACEROUTE_APP 70 /* ===== Public types ===== */ @@ -98,11 +99,30 @@ struct mesh_message_t { uint16_t text_len; }; +/* ===== Channel config (NVS-backed, set via mesh_channel feature) ===== */ + +/* The active channel name used for frequency derivation and channel hash. + * Empty string = "LongFast" default. Read from NVS key "ch_name". */ +const char *mesh_active_channel_name(void); + +/* The active 16-byte AES PSK. Either loaded from NVS key "ch_psk" or + * the hardcoded default. */ +const uint8_t *mesh_active_psk(void); + +/* The computed channel hash (byte 13 in packet headers). */ +uint8_t mesh_active_channel_hash(void); + +/* The TX/RX frequency in MHz, derived from djb2(channel_name) mod 104. */ +float mesh_active_freq_mhz(void); + /* ===== Lifecycle ===== */ /* Bring up the Meshtastic stack on top of lora_hw. Call radio_switch(RADIO_LORA) * first. Returns true on success. Starts a background RX task that keeps the - * radio in receive mode and populates the node + message queues. */ + * radio in receive mode and populates the node + message queues. + * + * Reads channel config (name + PSK) from NVS on startup so custom + * channels are supported. Use feat_mesh_channel() to configure. */ bool mesh_begin(void); void mesh_end(void); bool mesh_is_up(void); @@ -115,12 +135,13 @@ const char *mesh_own_short_name(void); /* ===== TX ===== */ /* Broadcast a text message to everyone on the default channel. - * Returns true if the packet was accepted for transmit. */ -bool mesh_send_broadcast_text(const char *text); + * Returns 0 on failure, or the packet_id on success (truthy in boolean + * context for backward compatibility with existing callers). */ +uint32_t mesh_send_broadcast_text(const char *text); /* Send a text message to a specific node (paging). - * Returns true if the packet was accepted for transmit. */ -bool mesh_send_direct_text(uint32_t dest_node_id, const char *text); + * Returns 0 on failure, or the packet_id on success. */ +uint32_t mesh_send_direct_text(uint32_t dest_node_id, const char *text); /* Broadcast our NodeInfo (User proto) so others add us to their rosters. */ bool mesh_send_nodeinfo(void); @@ -157,6 +178,19 @@ bool mesh_drain_new_message(void); /* Clear in-memory message log. Doesn't touch node roster. */ void mesh_clear_messages(void); +/* ===== ACK delivery tracking ===== */ + +enum mesh_ack_status_t : uint8_t { + MESH_ACK_PENDING, /* waiting for ACK, retry in progress */ + MESH_ACK_OK, /* ACK received — message delivered */ + MESH_ACK_FAILED /* all retries exhausted — delivery failed */ +}; + +/* Query delivery status of a sent message by its packet_id (the value + * returned by mesh_send_broadcast_text / mesh_send_direct_text). + * Returns MESH_ACK_FAILED if the id is unknown or expired. */ +mesh_ack_status_t mesh_ack_status(uint32_t packet_id); + /* ===== Position reporting toggle ===== */ /* When enabled, mesh layer broadcasts NodeInfo every ~30min and Position @@ -167,3 +201,29 @@ bool mesh_position_reporting(void); /* Call periodically from a feature's main loop to drive background * NodeInfo + Position broadcasts. Safe to call every tick. */ extern "C" void mesh_tick(void); + +/* ===== Traceroute ===== */ + +#define MESH_TRACEROUTE_MAX_HOPS 8 + +struct mesh_traceroute_result_t { + uint32_t route[MESH_TRACEROUTE_MAX_HOPS]; /* node IDs along path */ + int8_t snr[MESH_TRACEROUTE_MAX_HOPS]; /* SNR per hop (dB, rounded) */ + int hops; /* valid entries in route[] */ + bool complete; /* true when result ready */ +}; + +/* Send a traceroute request to dest_node_id. The mesh firmware on + * intermediate Meshtastic nodes will populate the route as the packet + * traverses the mesh, and the destination sends back the full path. + * Returns true if the request packet was transmitted. */ +bool mesh_send_traceroute(uint32_t dest_node_id); + +/* Check if a traceroute response has arrived. Copies the result into + * *out and returns true; clears the internal result. Returns false if + * no result is pending. */ +bool mesh_traceroute_result(mesh_traceroute_result_t *out); + +/* Clear any pending traceroute result. Call before starting a new + * traceroute to avoid stale data. */ +void mesh_traceroute_clear(void); diff --git a/src/mesh/meshtastic_internal.h b/src/mesh/meshtastic_internal.h index 6045ecc..1b612de 100644 --- a/src/mesh/meshtastic_internal.h +++ b/src/mesh/meshtastic_internal.h @@ -51,6 +51,25 @@ bool mesh_pb_decode_user(const uint8_t *buf, size_t len, mesh_user_t *out); bool mesh_pb_encode_position(mesh_buf_t *b, const mesh_position_t *pos); bool mesh_pb_decode_position(const uint8_t *buf, size_t len, mesh_position_t *out); +/* RouteDiscovery proto — used by TRACEROUTE_APP (portnum 70). + * repeated uint32 route = 1; + * repeated int32 snr_towards = 2; + * repeated uint32 route_back = 3; + * repeated int32 snr_back = 4; + * + * SNR values in the proto are (actual_dB * 4) cast to int32. */ +struct mesh_route_discovery_t { + uint32_t route[MESH_TRACEROUTE_MAX_HOPS]; + int32_t snr_towards[MESH_TRACEROUTE_MAX_HOPS]; + int route_count; + uint32_t route_back[MESH_TRACEROUTE_MAX_HOPS]; + int32_t snr_back[MESH_TRACEROUTE_MAX_HOPS]; + int route_back_count; +}; + +bool mesh_pb_encode_traceroute(mesh_buf_t *b, const mesh_route_discovery_t *rd); +bool mesh_pb_decode_traceroute(const uint8_t *buf, size_t len, mesh_route_discovery_t *out); + /* ==================== crypto ==================== */ /* AES-CTR-128 encrypt/decrypt in place. Key is 16 bytes. The counter block diff --git a/src/mesh/meshtastic_node.cpp b/src/mesh/meshtastic_node.cpp index fe9124a..4351339 100644 --- a/src/mesh/meshtastic_node.cpp +++ b/src/mesh/meshtastic_node.cpp @@ -20,6 +20,7 @@ #include /* esp_read_mac + ESP_MAC_WIFI_STA moved here in IDF 5.x */ #endif #include +#include #define MESH_MAX_NODES 32 #define MESH_MSG_RING 24 @@ -29,6 +30,107 @@ static const uint8_t DEFAULT_PSK[16] = { 0xF0, 0xBC, 0xFF, 0xAB, 0xCF, 0x4E, 0x69, 0x01 }; +/* ==================== channel config (NVS-backed) ==================== */ + +static char s_channel_name[32] = {0}; /* empty = "LongFast" */ +static uint8_t s_active_psk[16] = {0}; +static uint8_t s_channel_hash = MESH_CHANNEL_HASH; +static float s_channel_freq = MESH_FREQ_MHZ; +static bool s_channel_config_loaded = false; + +/* djb2 — same algorithm Meshtastic uses for channel name hashing. */ +static uint32_t mesh_djb2(const char *s) +{ + uint32_t h = 5381; + while (*s) h = ((h << 5) + h) + (uint8_t)*s++; + return h; +} + +/* XOR of PSK bytes. */ +static uint8_t psk_xor(const uint8_t psk[16]) +{ + uint8_t x = 0; + for (int i = 0; i < 16; i++) x ^= psk[i]; + return x; +} + +/* Load channel config from NVS. Called once by mesh_begin() or on first + * getter access so the channel config screen can display active values + * even before the mesh is up. */ +static void load_channel_config(void) +{ + if (s_channel_config_loaded) return; + + /* Defaults. */ + memcpy(s_active_psk, DEFAULT_PSK, 16); + s_channel_name[0] = '\0'; + + Preferences p; + if (p.begin("poseidon", true)) { + String nm = p.getString("ch_name", ""); + String pk = p.getString("ch_psk", ""); + p.end(); + + if (nm.length() > 0 && nm.length() < sizeof(s_channel_name)) { + strlcpy(s_channel_name, nm.c_str(), sizeof(s_channel_name)); + } + + if (pk.length() > 0) { + /* Try hex decode first (32 hex chars = 16 bytes). */ + const char *hex = pk.c_str(); + int hlen = strlen(hex); + bool is_hex = (hlen == 32); + if (is_hex) { + for (int i = 0; i < hlen; i++) { + char c = hex[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'))) { + is_hex = false; + break; + } + } + } + if (is_hex) { + for (int i = 0; i < 16; i++) { + char buf[3] = { hex[i*2], hex[i*2+1], '\0' }; + s_active_psk[i] = (uint8_t)strtoul(buf, nullptr, 16); + } + } else { + /* Hash text via djb2, spread across 16 bytes. */ + memset(s_active_psk, 0, 16); + for (int pass = 0; pass < 4; pass++) { + uint32_t h = 5381; + for (const char *ch = hex; *ch; ch++) + h = ((h << 5) + h) + (uint8_t)*ch + pass; + s_active_psk[pass*4] = (uint8_t)(h); + s_active_psk[pass*4+1] = (uint8_t)(h >> 8); + s_active_psk[pass*4+2] = (uint8_t)(h >> 16); + s_active_psk[pass*4+3] = (uint8_t)(h >> 24); + } + } + } + } + + /* Derive channel hash: XOR of djb2(name) bytes XOR'd with XOR(psk). */ + const char *eff = s_channel_name[0] ? s_channel_name : "LongFast"; + uint32_t dh = mesh_djb2(eff); + s_channel_hash = (uint8_t)dh ^ (uint8_t)(dh >> 8) + ^ (uint8_t)(dh >> 16) ^ (uint8_t)(dh >> 24); + s_channel_hash ^= psk_xor(s_active_psk); + + /* Frequency from channel name: djb2 mod 104 slots. */ + s_channel_freq = 903.08f + (mesh_djb2(eff) % 104) * 2.16f; + + s_channel_config_loaded = true; + Serial.printf("[mesh-ch] name='%s' hash=0x%02X freq=%.3f MHz\n", + eff, s_channel_hash, s_channel_freq); +} + +const char *mesh_active_channel_name(void) { load_channel_config(); return s_channel_name; } +const uint8_t *mesh_active_psk(void) { load_channel_config(); return s_active_psk; } +uint8_t mesh_active_channel_hash(void) { load_channel_config(); return s_channel_hash; } +float mesh_active_freq_mhz(void) { load_channel_config(); return s_channel_freq; } + /* ==================== state ==================== */ static bool s_up = false; @@ -59,6 +161,29 @@ static bool s_position_reporting = false; static uint32_t s_last_nodeinfo_ms = 0; static uint32_t s_last_position_ms = 0; +/* ==================== ACK tracking ==================== */ + +#define MESH_ACK_RING 8 /* max pending ACKs — small for no-PSRAM */ + +struct mesh_ack_entry_t { + uint32_t packet_id; + uint32_t dest_node; /* MESH_BROADCAST_NODEID or specific node */ + uint32_t tx_time_ms; + uint8_t retries; + uint8_t status; /* mesh_ack_status_t */ + uint8_t text_len; + char text[MESH_MAX_PAYLOAD]; +}; + +static mesh_ack_entry_t s_ack_ring[MESH_ACK_RING]; +static int s_ack_head = 0; +static int s_ack_count = 0; +static portMUX_TYPE s_ack_mux = portMUX_INITIALIZER_UNLOCKED; + +/* Traceroute result — written by the RX task, read by the UI. */ +static volatile bool s_traceroute_ready = false; +static mesh_traceroute_result_t s_traceroute_result = {}; + /* ==================== identity ==================== */ static void derive_identity(void) @@ -187,6 +312,87 @@ void mesh_clear_messages(void) portEXIT_CRITICAL(&s_msgs_mux); } +/* ==================== ACK helpers ==================== */ + +/* Add an entry to the pending-ACK ring. Called from main loop context. */ +static void ack_add(uint32_t packet_id, uint32_t dest_node, + const char *text, uint16_t text_len) +{ + portENTER_CRITICAL(&s_ack_mux); + int idx; + if (s_ack_count < MESH_ACK_RING) { + idx = (s_ack_head + s_ack_count) % MESH_ACK_RING; + s_ack_count++; + } else { + /* Evict oldest (head) — no room. */ + idx = s_ack_head; + s_ack_head = (s_ack_head + 1) % MESH_ACK_RING; + } + s_ack_ring[idx].packet_id = packet_id; + s_ack_ring[idx].dest_node = dest_node; + s_ack_ring[idx].tx_time_ms = millis(); + s_ack_ring[idx].retries = 0; + s_ack_ring[idx].status = MESH_ACK_PENDING; + uint8_t n = text_len; + if (n > MESH_MAX_PAYLOAD) n = MESH_MAX_PAYLOAD; + s_ack_ring[idx].text_len = n; + memcpy(s_ack_ring[idx].text, text, n); + portEXIT_CRITICAL(&s_ack_mux); +} + +/* Match an incoming ACK request_id against our pending list. + * Returns true and marks the entry as OK if found. */ +static bool ack_match(uint32_t request_id) +{ + portENTER_CRITICAL(&s_ack_mux); + for (int i = 0; i < s_ack_count; i++) { + int idx = (s_ack_head + i) % MESH_ACK_RING; + if (s_ack_ring[idx].packet_id == request_id && + s_ack_ring[idx].status == MESH_ACK_PENDING) { + s_ack_ring[idx].status = MESH_ACK_OK; + portEXIT_CRITICAL(&s_ack_mux); + Serial.printf("[mesh-ack] ACKed id=0x%08x\n", (unsigned)request_id); + return true; + } + } + portEXIT_CRITICAL(&s_ack_mux); + return false; +} + +/* Query the delivery status of a given packet_id. */ +mesh_ack_status_t mesh_ack_status(uint32_t packet_id) +{ + mesh_ack_status_t result = MESH_ACK_FAILED; + portENTER_CRITICAL(&s_ack_mux); + for (int i = 0; i < s_ack_count; i++) { + int idx = (s_ack_head + i) % MESH_ACK_RING; + if (s_ack_ring[idx].packet_id == packet_id) { + result = (mesh_ack_status_t)s_ack_ring[idx].status; + break; + } + } + portEXIT_CRITICAL(&s_ack_mux); + return result; +} + +/* Forward declaration — defined later at line ~446. */ +static bool mesh_tx_data(uint32_t to, const mesh_data_t &data, + bool want_ack = false, uint32_t *out_id = nullptr, + uint32_t force_id = 0); + +/* Send an ACK (Routing proto) back to a node that requested one. */ +static void mesh_send_ack(uint32_t to, uint32_t original_packet_id) +{ + mesh_data_t d = {}; + d.portnum = MESH_PORT_ROUTING; + d.request_id = original_packet_id; + /* Routing proto: error_reason = NONE (field 1, varint value 0). */ + d.payload[0] = 0x08; /* tag: field 1, wire type 0 */ + d.payload[1] = 0x00; /* value: 0 (NONE) */ + d.payload_len = 2; + mesh_tx_data(to, d); +} + /* ==================== header packing ==================== */ static void pack_header(uint8_t hdr[16], @@ -212,14 +418,15 @@ static void pack_header(uint8_t hdr[16], | (want_ack ? MESH_FLAGS_WANT_ACK_MASK : 0) | ((hop_start << MESH_FLAGS_HOP_START_SHIFT) & MESH_FLAGS_HOP_START_MASK); hdr[12] = flags; - hdr[13] = MESH_CHANNEL_HASH; + hdr[13] = s_channel_hash; hdr[14] = 0; /* next_hop = no preference */ hdr[15] = 0; /* relay_node = none */ } static void parse_header(const uint8_t hdr[16], uint32_t *to, uint32_t *from, uint32_t *id, - uint8_t *hop_limit, uint8_t *hop_start, uint8_t *channel) + uint8_t *hop_limit, uint8_t *hop_start, uint8_t *channel, + bool *want_ack) { *to = (uint32_t)hdr[0] | ((uint32_t)hdr[1] << 8) | ((uint32_t)hdr[2] << 16) | ((uint32_t)hdr[3] << 24); @@ -231,6 +438,7 @@ static void parse_header(const uint8_t hdr[16], *hop_limit = flags & MESH_FLAGS_HOP_LIMIT_MASK; *hop_start = (flags & MESH_FLAGS_HOP_START_MASK) >> MESH_FLAGS_HOP_START_SHIFT; *channel = hdr[13]; + *want_ack = (flags & MESH_FLAGS_WANT_ACK_MASK) != 0; } /* ==================== TX pipeline ==================== */ @@ -240,21 +448,23 @@ static SX1262 *s_radio = nullptr; /* Ciphertext budget: 255 (LoRa max) - 16 (header) = 239 bytes. */ #define MESH_MAX_CIPHERTEXT 239 -static bool mesh_tx_data(uint32_t to, const mesh_data_t &data) +static bool mesh_tx_data(uint32_t to, const mesh_data_t &data, + bool want_ack, uint32_t *out_id, + uint32_t force_id) { uint8_t encoded[MESH_MAX_CIPHERTEXT]; mesh_buf_t buf = { encoded, sizeof(encoded), 0 }; if (!mesh_pb_encode_data(&buf, &data)) return false; if (buf.len == 0 || buf.len > MESH_MAX_CIPHERTEXT) return false; - uint32_t id = next_packet_id(); + uint32_t id = force_id ? force_id : next_packet_id(); /* Encrypt the Data proto in place. */ - mesh_crypto_ctr(DEFAULT_PSK, id, s_own_id, encoded, buf.len); + mesh_crypto_ctr(s_active_psk, id, s_own_id, encoded, buf.len); /* Build the full on-air frame: 16 byte header + ciphertext. */ uint8_t frame[16 + MESH_MAX_CIPHERTEXT]; - pack_header(frame, to, s_own_id, id, MESH_HOP_RELIABLE, false); + pack_header(frame, to, s_own_id, id, MESH_HOP_RELIABLE, want_ack); memcpy(frame + 16, encoded, buf.len); size_t total = 16 + buf.len; @@ -263,24 +473,31 @@ static bool mesh_tx_data(uint32_t to, const mesh_data_t &data) int st = s_radio->transmit(frame, total); /* Immediately return to RX so we don't miss other nodes' traffic. */ s_radio->startReceive(); - return st == RADIOLIB_ERR_NONE; + bool ok = (st == RADIOLIB_ERR_NONE); + if (ok && out_id) *out_id = id; + return ok; } -bool mesh_send_broadcast_text(const char *text) +uint32_t mesh_send_broadcast_text(const char *text) { - if (!s_up || !text) return false; + if (!s_up || !text) return 0; mesh_data_t d = {}; d.portnum = MESH_PORT_TEXT_MESSAGE; size_t n = strlen(text); if (n > sizeof(d.payload)) n = sizeof(d.payload); memcpy(d.payload, text, n); d.payload_len = (uint16_t)n; - return mesh_tx_data(MESH_BROADCAST_NODEID, d); + uint32_t id = 0; + if (mesh_tx_data(MESH_BROADCAST_NODEID, d, true, &id)) { + ack_add(id, MESH_BROADCAST_NODEID, text, (uint16_t)n); + return id; + } + return 0; } -bool mesh_send_direct_text(uint32_t dest, const char *text) +uint32_t mesh_send_direct_text(uint32_t dest, const char *text) { - if (!s_up || !text || dest == 0 || dest == s_own_id) return false; + if (!s_up || !text || dest == 0 || dest == s_own_id) return 0; mesh_data_t d = {}; d.portnum = MESH_PORT_TEXT_MESSAGE; d.dest = dest; @@ -289,7 +506,12 @@ bool mesh_send_direct_text(uint32_t dest, const char *text) if (n > sizeof(d.payload)) n = sizeof(d.payload); memcpy(d.payload, text, n); d.payload_len = (uint16_t)n; - return mesh_tx_data(dest, d); + uint32_t id = 0; + if (mesh_tx_data(dest, d, true, &id)) { + ack_add(id, dest, text, (uint16_t)n); + return id; + } + return 0; } bool mesh_send_nodeinfo(void) @@ -347,7 +569,8 @@ bool mesh_send_position(void) static void handle_decoded_data(uint32_t from, uint32_t to, uint8_t hops, int16_t rssi, int8_t snr, - const mesh_data_t &d) + const mesh_data_t &d, + uint32_t packet_id, bool want_ack) { /* Always update roster — any packet tells us a node exists. */ portENTER_CRITICAL(&s_nodes_mux); @@ -358,6 +581,22 @@ static void handle_decoded_data(uint32_t from, uint32_t to, uint8_t hops, s_nodes[idx].last_seen_ms = millis(); portEXIT_CRITICAL(&s_nodes_mux); + /* If the sender wants an ACK and the packet is addressed to us + * (not broadcast — we don't ACK broadcasts to avoid storms), send + * one back before processing the payload. */ + if (want_ack && to == s_own_id) { + mesh_send_ack(from, packet_id); + } + + /* If this is a Routing packet with a request_id, it's likely an + * ACK for one of our outgoing messages. */ + if (d.portnum == MESH_PORT_ROUTING && d.request_id != 0) { + ack_match(d.request_id); + /* Fall through to process any other fields if needed, but + * for now ACKs are handled entirely above. */ + return; + } + switch (d.portnum) { case MESH_PORT_TEXT_MESSAGE: { mesh_message_t m = {}; @@ -430,8 +669,46 @@ static void handle_decoded_data(uint32_t from, uint32_t to, uint8_t hops, } break; } + case MESH_PORT_TRACEROUTE_APP: { + /* Traceroute response — decode RouteDiscovery and store result. + * We only accept responses addressed to us (direct). */ + if (to == s_own_id) { + mesh_route_discovery_t rd; + if (mesh_pb_decode_traceroute(d.payload, d.payload_len, &rd)) { + /* Determine which path to use. If the response has + * route_back entries, those represent the forward path + * (dest copies them reversed from the request). If not, + * use route + snr_towards. */ + mesh_traceroute_result_t res = {}; + int n = 0; + if (rd.route_back_count > 0) { + n = rd.route_back_count; + if (n > MESH_TRACEROUTE_MAX_HOPS) n = MESH_TRACEROUTE_MAX_HOPS; + for (int i = 0; i < n; i++) { + res.route[i] = rd.route_back[i]; + /* SNR values in proto are dB*4; convert to integer dB */ + res.snr[i] = (int8_t)(rd.snr_back[i] / 4); + } + } else if (rd.route_count > 0) { + n = rd.route_count; + if (n > MESH_TRACEROUTE_MAX_HOPS) n = MESH_TRACEROUTE_MAX_HOPS; + for (int i = 0; i < n; i++) { + res.route[i] = rd.route[i]; + res.snr[i] = (int8_t)(rd.snr_towards[i] / 4); + } + } + /* Even if n==0, we got a response — direct link (0 hops). */ + res.hops = n; + res.complete = true; + s_traceroute_result = res; + s_traceroute_ready = true; + Serial.printf("[mesh-trace] response from !%08x hops=%d\n", + (unsigned)from, n); + } + } + break; + } default: - /* Routing, telemetry, etc. — ignore for now. */ break; } } @@ -457,23 +734,24 @@ static void rx_task(void *) uint32_t to, from, id; uint8_t hop_limit, hop_start, channel; - parse_header(buf, &to, &from, &id, &hop_limit, &hop_start, &channel); + bool want_ack; + parse_header(buf, &to, &from, &id, &hop_limit, &hop_start, &channel, &want_ack); - /* Filter: only default-channel traffic; ignore packets from ourselves - * (shouldn't happen but be safe). */ - if (channel != MESH_CHANNEL_HASH) continue; + /* Filter: only matching-channel traffic; ignore packets from + * ourselves (shouldn't happen but be safe). */ + if (channel != s_channel_hash) continue; if (from == s_own_id) continue; size_t ctext_len = plen - 16; uint8_t ctext[260]; memcpy(ctext, buf + 16, ctext_len); - mesh_crypto_ctr(DEFAULT_PSK, id, from, ctext, ctext_len); + mesh_crypto_ctr(s_active_psk, id, from, ctext, ctext_len); mesh_data_t d; if (!mesh_pb_decode_data(ctext, ctext_len, &d)) continue; uint8_t hops = (hop_start > hop_limit) ? (hop_start - hop_limit) : 0; - handle_decoded_data(from, to, hops, rssi, snr, d); + handle_decoded_data(from, to, hops, rssi, snr, d, id, want_ack); } s_rx_task_alive = false; vTaskDelete(nullptr); @@ -487,6 +765,9 @@ bool mesh_begin(void) { if (s_up) return true; + /* Load channel config (name + PSK) from NVS before anything else. */ + load_channel_config(); + derive_identity(); s_packet_counter = esp_random() & 0x3FFu; s_node_count = 0; @@ -510,10 +791,11 @@ bool mesh_begin(void) return false; } - /* Configure LoRa for Meshtastic LongFast US. lora_hw's config struct - * takes freq_mhz, bw_khz, sf, cr (as int 5..8), sync byte, power. */ + /* Configure LoRa for Meshtastic channel. lora_hw's config struct + * takes freq_mhz, bw_khz, sf, cr (as int 5..8), sync byte, power. + * Frequency is derived from the channel name via djb2 mod 104. */ lora_config_t cfg = { - .freq_mhz = MESH_FREQ_MHZ, + .freq_mhz = s_channel_freq, .bw_khz = MESH_BW_KHZ, .sf = MESH_SF, .cr = MESH_CR, @@ -546,8 +828,9 @@ bool mesh_begin(void) } s_up = true; - Serial.printf("[mesh] up id=!%08x long=%s\n", - (unsigned int)s_own_id, s_own_long); + const char *ch = s_channel_name[0] ? s_channel_name : "LongFast"; + Serial.printf("[mesh] up id=!%08x long=%s channel='%s' hash=0x%02X freq=%.3f\n", + (unsigned int)s_own_id, s_own_long, ch, s_channel_hash, s_channel_freq); /* Announce ourselves on startup. */ mesh_send_nodeinfo(); @@ -576,6 +859,8 @@ void mesh_end(void) s_node_count = 0; s_msg_count = 0; s_msg_head = 0; + s_ack_head = 0; + s_ack_count = 0; s_up = false; } @@ -604,4 +889,108 @@ extern "C" void mesh_tick(void) s_last_position_ms = now; } } + + /* ---- ACK retry logic ---- + * Check pending ACKs every 2 seconds. If an entry is older than 3 s + * and retries < 3, retransmit the same packet_id. After 3 retries + * mark as failed. */ + if (s_ack_count == 0) return; + static uint32_t s_last_ack_check = 0; + if (now - s_last_ack_check < 2000) return; + s_last_ack_check = now; + + for (int i = 0; i < s_ack_count; i++) { + /* Grab one entry under lock, release immediately so we can TX. */ + portENTER_CRITICAL(&s_ack_mux); + int idx = (s_ack_head + i) % MESH_ACK_RING; + mesh_ack_entry_t *e = &s_ack_ring[idx]; + + if (e->status != MESH_ACK_PENDING) { + portEXIT_CRITICAL(&s_ack_mux); + continue; + } + if ((int32_t)(now - e->tx_time_ms) < 3000) { + portEXIT_CRITICAL(&s_ack_mux); + continue; + } + if (e->retries >= 3) { + e->status = MESH_ACK_FAILED; + Serial.printf("[mesh-ack] FAILED id=0x%08x retries=%u\n", + (unsigned)e->packet_id, e->retries); + portEXIT_CRITICAL(&s_ack_mux); + continue; + } + + /* Copy fields needed for retransmission. */ + uint32_t dest = e->dest_node; + uint32_t pid = e->packet_id; + uint8_t tlen = e->text_len; + char text_copy[MESH_MAX_PAYLOAD]; + memcpy(text_copy, e->text, tlen); + e->retries++; + e->tx_time_ms = now; + portEXIT_CRITICAL(&s_ack_mux); + + /* Rebuild the Data proto and retransmit with the same packet_id. */ + mesh_data_t d = {}; + d.portnum = MESH_PORT_TEXT_MESSAGE; + memcpy(d.payload, text_copy, tlen); + d.payload_len = tlen; + bool is_bc = (dest == 0 || dest == MESH_BROADCAST_NODEID); + if (!is_bc) { + d.dest = dest; + d.source = s_own_id; + } + mesh_tx_data(is_bc ? MESH_BROADCAST_NODEID : dest, + d, true, nullptr, pid); + Serial.printf("[mesh-ack] retry %u/3 id=0x%08x\n", + (unsigned)e->retries, (unsigned)pid); + } +} + +/* ==================== traceroute ==================== */ + +bool mesh_send_traceroute(uint32_t dest_node_id) +{ + if (!s_up || dest_node_id == 0 || dest_node_id == s_own_id) return false; + + /* Encode an empty RouteDiscovery as the payload. Intermediate + * Meshtastic nodes will populate route[] and snr_towards[] as the + * packet traverses the mesh. The destination sends back a response + * with the full path. */ + mesh_route_discovery_t rd = {}; + uint8_t rd_buf[80]; + mesh_buf_t rb = { rd_buf, sizeof(rd_buf), 0 }; + if (!mesh_pb_encode_traceroute(&rb, &rd)) return false; + + mesh_data_t d = {}; + d.portnum = MESH_PORT_TRACEROUTE_APP; + d.dest = dest_node_id; + d.source = s_own_id; + if (rb.len > sizeof(d.payload)) return false; + memcpy(d.payload, rd_buf, rb.len); + d.payload_len = (uint16_t)rb.len; + + s_traceroute_ready = false; + memset(&s_traceroute_result, 0, sizeof(s_traceroute_result)); + + bool ok = mesh_tx_data(dest_node_id, d); + if (ok) { + Serial.printf("[mesh-trace] sent to !%08x\n", (unsigned)dest_node_id); + } + return ok; +} + +bool mesh_traceroute_result(mesh_traceroute_result_t *out) +{ + if (!s_traceroute_ready) return false; + if (out) *out = s_traceroute_result; + s_traceroute_ready = false; + return true; +} + +void mesh_traceroute_clear(void) +{ + s_traceroute_ready = false; + memset(&s_traceroute_result, 0, sizeof(s_traceroute_result)); } diff --git a/src/mesh/meshtastic_pb.cpp b/src/mesh/meshtastic_pb.cpp index 7f3af20..2d2bf6c 100644 --- a/src/mesh/meshtastic_pb.cpp +++ b/src/mesh/meshtastic_pb.cpp @@ -351,3 +351,77 @@ bool mesh_pb_decode_position(const uint8_t *buf, size_t len, mesh_position_t *ou } return true; } + +/* ==================== RouteDiscovery proto (traceroute) ==================== */ + +bool mesh_pb_encode_traceroute(mesh_buf_t *b, const mesh_route_discovery_t *rd) +{ + /* field 1: repeated uint32 route */ + for (int i = 0; i < rd->route_count; i++) { + if (!write_varint_field(b, 1, (uint64_t)rd->route[i])) return false; + } + /* field 2: repeated int32 snr_towards (varint, sign-extended) */ + for (int i = 0; i < rd->route_count; i++) { + if (!write_tag(b, 2, 0)) return false; + if (!write_varint(b, (uint64_t)(int64_t)rd->snr_towards[i])) return false; + } + /* field 3: repeated uint32 route_back */ + for (int i = 0; i < rd->route_back_count; i++) { + if (!write_varint_field(b, 3, (uint64_t)rd->route_back[i])) return false; + } + /* field 4: repeated int32 snr_back (varint, sign-extended) */ + for (int i = 0; i < rd->route_back_count; i++) { + if (!write_tag(b, 4, 0)) return false; + if (!write_varint(b, (uint64_t)(int64_t)rd->snr_back[i])) return false; + } + return true; +} + +bool mesh_pb_decode_traceroute(const uint8_t *buf, size_t len, mesh_route_discovery_t *out) +{ + memset(out, 0, sizeof(*out)); + if (len == 0) return true; /* empty RouteDiscovery is valid (initial request) */ + const uint8_t *p = buf; + const uint8_t *end = buf + len; + int snr_idx = 0; /* tracks snr_towards entries (parallel to route) */ + int snr_back_idx = 0; /* tracks snr_back entries (parallel to route_back) */ + while (p < end) { + uint64_t tag; + if (!read_varint(&p, end, &tag)) return false; + uint8_t field = (uint8_t)(tag >> 3); + uint8_t wire = (uint8_t)(tag & 0x07); + switch (field) { + case 1: { /* repeated uint32 route */ + if (wire != 0) return false; + uint64_t v; if (!read_varint(&p, end, &v)) return false; + if (out->route_count < MESH_TRACEROUTE_MAX_HOPS) + out->route[out->route_count++] = (uint32_t)v; + break; + } + case 2: { /* repeated int32 snr_towards */ + if (wire != 0) return false; + uint64_t v; if (!read_varint(&p, end, &v)) return false; + if (snr_idx < MESH_TRACEROUTE_MAX_HOPS) + out->snr_towards[snr_idx++] = (int32_t)v; + break; + } + case 3: { /* repeated uint32 route_back */ + if (wire != 0) return false; + uint64_t v; if (!read_varint(&p, end, &v)) return false; + if (out->route_back_count < MESH_TRACEROUTE_MAX_HOPS) + out->route_back[out->route_back_count++] = (uint32_t)v; + break; + } + case 4: { /* repeated int32 snr_back */ + if (wire != 0) return false; + uint64_t v; if (!read_varint(&p, end, &v)) return false; + if (snr_back_idx < MESH_TRACEROUTE_MAX_HOPS) + out->snr_back[snr_back_idx++] = (int32_t)v; + break; + } + default: + if (!skip_field(&p, end, wire)) return false; + } + } + return true; +}