From 01d95ee0e90339cc9fc1de0df108eb552427befe Mon Sep 17 00:00:00 2001 From: drjones Date: Sat, 21 Mar 2026 17:58:20 -0700 Subject: [PATCH] Feature: ESP-NOW auto mesh + Nodes (ESP-NOW) UI - Broadcast beacons with magic KLNK; no MAC pairing - Track up to 8 peer MACs; stale after 12s - Telemetry/health JSON: espnow_ok, espnow_peers - Web metric + OLED health line - README: ESP-NOW section and channel requirement Made-with: Cursor --- README.md | 29 ++++++++- include/config.h | 5 ++ src/main.cpp | 149 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 176 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f26e0b0..214a726 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ Connect to the WiFi access point, then open the control panel in a browser. Temperature (Celsius) Free heap (KB) - [METRICS GRID - 12 stats updated every 1 second] + [METRICS GRID - stats updated every 1 second] Effective TX power (dBm) Radio 1 status @@ -249,6 +249,7 @@ Connect to the WiFi access point, then open the control panel in a browser. Hop count Radio 2 (total since boot) Combined hops per second WiFi clients on AP + Nodes (ESP-NOW): count of other boards running this firmware in range Uptime 24-hour mission progress bar in the header @@ -262,6 +263,32 @@ Connect to the WiFi access point, then open the control panel in a browser. --- +## ESP-NOW NODE MESH + + Multiple boards running the same firmware discover each other automatically + over ESP-NOW. No MAC address entry and no pairing step. + + How it works: + Each unit broadcasts a small beacon every 750 ms to the ESP-NOW + broadcast address. The payload starts with a fixed magic signature + so only this firmware is counted. + When a unit hears a valid beacon, it records the sender MAC and + refreshes a last-seen time. The web UI metric "Nodes (ESP-NOW)" is + the number of other units heard within the last 12 seconds. + The OLED health page shows the same count after "ESPNOW". + + Requirements for links to work: + All units must share the same Wi-Fi radio channel as the soft-AP. + This build starts the AP on channel 1. Do not run different channel + settings on different boards unless you change the code consistently. + Range is typical 2.4 GHz ESP-NOW range (often tens of meters indoors, + more line-of-sight). + + Note: This release only counts peers and logs new MACs. It does not yet + sync jamming state or share telemetry over ESP-NOW. + +--- + ## RELIABILITY FEATURES (24-HOUR OPERATION) The system is designed to run unattended at full power indefinitely. diff --git a/include/config.h b/include/config.h index 4305f6f..659e48f 100644 --- a/include/config.h +++ b/include/config.h @@ -89,4 +89,9 @@ #define CAP_DURATION_S 4 // max capture window (seconds) #define CAP_BUF_BYTES ((CAP_SAMPLE_HZ * CAP_DURATION_S) / 8 + 8) // ~50 KB +// ESP-NOW mesh: auto-discover other boards on same firmware (same AP WiFi channel) +#define ESPNOW_BEACON_MS 750 // broadcast presence interval +#define ESPNOW_PEER_STALE_MS 12000 // drop peer if silent this long +#define ESPNOW_MAX_PEERS 8 // max other nodes tracked (4+ boards) + #endif diff --git a/src/main.cpp b/src/main.cpp index e608074..a761b6a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,6 +18,8 @@ #include #include #include +#include +#include #include "config.h" // Shared SPI; each Module uses its own CS. @@ -168,6 +170,119 @@ static String jsonEscape(const String& in) { return out; } +// ─── ESP-NOW peer mesh (auto-discover same firmware, no MAC entry) ─────────── +// All units must use the same WiFi AP channel (softAP uses ch 1). Beacons go to +// broadcast; packets with magic "KLNK" mark another board running this build. +static constexpr uint8_t kEspNowMagic[4] = { 'K', 'L', 'N', 'K' }; + +struct EspNowPeerEntry { + uint8_t mac[6]; + uint32_t lastSeenMs; +}; + +static EspNowPeerEntry s_espNowPeers[ESPNOW_MAX_PEERS]; +static uint8_t s_espNowPeerCount = 0; +static uint32_t s_espNowBootToken = 0; +static bool s_espNowReady = false; +static uint32_t s_espNowLastTxMs = 0; +static uint8_t s_espNowSelfMac[6]; + +static uint8_t espNowActivePeerCount() { + const uint32_t now = millis(); + uint8_t n = 0; + for (uint8_t i = 0; i < s_espNowPeerCount; i++) { + if (now - s_espNowPeers[i].lastSeenMs < ESPNOW_PEER_STALE_MS) n++; + } + return n; +} + +static void espNowTouchPeer(const uint8_t mac[6]) { + if (memcmp(mac, s_espNowSelfMac, 6) == 0) return; + + for (uint8_t i = 0; i < s_espNowPeerCount; i++) { + if (memcmp(s_espNowPeers[i].mac, mac, 6) == 0) { + s_espNowPeers[i].lastSeenMs = millis(); + return; + } + } + + if (s_espNowPeerCount < ESPNOW_MAX_PEERS) { + memcpy(s_espNowPeers[s_espNowPeerCount].mac, mac, 6); + s_espNowPeers[s_espNowPeerCount].lastSeenMs = millis(); + s_espNowPeerCount++; + char m[24]; + snprintf(m, sizeof(m), "%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + logLine("[ESPNOW] node " + String(m)); + return; + } + + uint8_t bi = 0; + uint32_t oldest = s_espNowPeers[0].lastSeenMs; + for (uint8_t i = 1; i < ESPNOW_MAX_PEERS; i++) { + if (s_espNowPeers[i].lastSeenMs < oldest) { + oldest = s_espNowPeers[i].lastSeenMs; + bi = i; + } + } + memcpy(s_espNowPeers[bi].mac, mac, 6); + s_espNowPeers[bi].lastSeenMs = millis(); +} + +static void espNowOnRecv(const uint8_t* mac, const uint8_t* data, int len) { + if (len < 12 || mac == nullptr || data == nullptr) return; + if (memcmp(data, kEspNowMagic, 4) != 0) return; + espNowTouchPeer(mac); +} + +static void espNowInit() { + s_espNowBootToken = esp_random(); + if (s_espNowBootToken == 0) s_espNowBootToken = 0xC0FFEE01u; + + if (esp_read_mac(s_espNowSelfMac, ESP_MAC_WIFI_SOFTAP) != ESP_OK) { + memset(s_espNowSelfMac, 0, 6); + } + + if (esp_now_init() != ESP_OK) { + logLine("[ESPNOW] esp_now_init failed"); + return; + } + esp_now_register_recv_cb(espNowOnRecv); + + uint8_t bcast[6] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; + esp_now_peer_info_t peer = {}; + memcpy(peer.peer_addr, bcast, 6); + peer.channel = 0; + peer.encrypt = false; + peer.ifidx = WIFI_IF_AP; + + esp_err_t e = esp_now_add_peer(&peer); + if (e != ESP_OK) { + logLine("[ESPNOW] add_peer(broadcast) failed: " + String((int)e)); + esp_now_deinit(); + return; + } + + s_espNowReady = true; + logLine("[ESPNOW] mesh listening; beacons every " + String(ESPNOW_BEACON_MS) + " ms (same AP channel)"); +} + +static void espNowTick() { + if (!s_espNowReady) return; + const uint32_t now = millis(); + if (now - s_espNowLastTxMs < ESPNOW_BEACON_MS) return; + s_espNowLastTxMs = now; + + uint8_t pkt[12]; + memcpy(pkt, kEspNowMagic, 4); + memcpy(pkt + 4, &s_espNowBootToken, 4); + uint32_t up = now - uptimeStart; + memcpy(pkt + 8, &up, 4); + + uint8_t bcast[6] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; + (void)esp_now_send(bcast, pkt, sizeof(pkt)); +} + // Forward declarations static void noiseGenStart(); static void startJamming(); @@ -829,7 +944,8 @@ static void oledDrawHealth() { snprintf(buf, sizeof(buf), "PWR %ddBm / %umW", effDbm, min(effMw, (uint32_t)9999)); u8g2.drawStr(0, 55, buf); - snprintf(buf, sizeof(buf), "WIFI %d client(s)", WiFi.softAPgetStationNum()); + snprintf(buf, sizeof(buf), "WIFI %d ESPNOW %u", + WiFi.softAPgetStationNum(), (unsigned)espNowActivePeerCount()); u8g2.drawStr(0, 63, buf); } @@ -1090,6 +1206,7 @@ h1{animation:flicker .4s ease-out}
Hops R2
Hops/sec
AP Clients
+
Nodes (ESP-NOW)
@@ -1362,6 +1479,12 @@ function applyTelemetry(t){ document.getElementById('mH2').textContent=h2.toLocaleString(); document.getElementById('mHR').innerHTML=rate+'/s'; document.getElementById('mCl').textContent=t.ap_clients??'—'; + const en=document.getElementById('mEn'); + if(en){ + if(typeof t.espnow_ok==='boolean'){ + en.textContent=t.espnow_ok?String(t.espnow_peers!==undefined?t.espnow_peers:0):'off'; + }else en.textContent='—'; + } // Trails + sweep canvases const sp1=Math.max(t.sweep_span1||20,0.001),sp2=Math.max(t.sweep_span2||46,0.001); updTr(tr1,(t.sweep_freq1-(t.sweep_center1-sp1/2))/sp1); @@ -1562,7 +1685,9 @@ static void handleTelemetry() { "\"hop_count1\":%lu," "\"hop_count2\":%lu," "\"min_heap\":%lu," - "\"ap_clients\":%d" + "\"ap_clients\":%d," + "\"espnow_ok\":%s," + "\"espnow_peers\":%u" "}", (unsigned long)(millis() - uptimeStart), (unsigned long)ESP.getFreeHeap(), @@ -1593,7 +1718,9 @@ static void handleTelemetry() { (unsigned long)hopCount1, (unsigned long)hopCount2, (unsigned long)(minFreeHeap == 0xFFFFFFFF ? ESP.getFreeHeap() : minFreeHeap), - (int)WiFi.softAPgetStationNum() + (int)WiFi.softAPgetStationNum(), + s_espNowReady ? "true" : "false", + (unsigned)espNowActivePeerCount() ); server.send(200, "application/json; charset=utf-8", jsonBuf); @@ -1728,12 +1855,15 @@ static void handleAmpSettings() { } static void handleHealth() { - static char buf[128]; + static char buf[160]; snprintf(buf, sizeof(buf), - "{\"ok\":true,\"uptime_ms\":%lu,\"heap\":%lu,\"ap_clients\":%d}", + "{\"ok\":true,\"uptime_ms\":%lu,\"heap\":%lu,\"ap_clients\":%d," + "\"espnow_ok\":%s,\"espnow_peers\":%u}", (unsigned long)(millis() - uptimeStart), (unsigned long)ESP.getFreeHeap(), - (int)WiFi.softAPgetStationNum()); + (int)WiFi.softAPgetStationNum(), + s_espNowReady ? "true" : "false", + (unsigned)espNowActivePeerCount()); server.send(200, "application/json; charset=utf-8", buf); } @@ -1954,6 +2084,12 @@ void setup() { Serial.println("[WIFI] AP SSID: " + String(WIFI_AP_SSID)); Serial.println("[WIFI] AP IP: " + ip.toString()); Serial.flush(); + + if (apOk) { + espNowInit(); + } else { + logLine("[ESPNOW] skipped (AP not up — need WiFi channel for ESP-NOW)"); + } if (MDNS.begin("killer")) { MDNS.addService("http", "tcp", WEB_PORT); @@ -2051,6 +2187,7 @@ static void tickSweepFast(uint8_t csPin, uint8_t& step, uint8_t steps, } void loop() { + espNowTick(); ArduinoOTA.handle(); server.handleClient(); oledTick();