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
This commit is contained in:
drjones
2026-03-21 17:58:20 -07:00
parent 9f5428c55f
commit 01d95ee0e9
3 changed files with 176 additions and 7 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -18,6 +18,8 @@
#include <math.h>
#include <Wire.h>
#include <U8g2lib.h>
#include <esp_now.h>
#include <esp_wifi.h>
#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}
<div class="s"><div class="sl">Hops R2</div><div class="sv" id="mH2">—</div></div>
<div class="s"><div class="sl">Hops/sec</div><div class="sv" id="mHR">—</div></div>
<div class="s"><div class="sl">AP Clients</div><div class="sv" id="mCl">—</div></div>
<div class="s"><div class="sl">Nodes (ESP-NOW)</div><div class="sv" id="mEn">—</div></div>
</div>
</div>
@@ -1362,6 +1479,12 @@ function applyTelemetry(t){
document.getElementById('mH2').textContent=h2.toLocaleString();
document.getElementById('mHR').innerHTML=rate+'<span class="su">/s</span>';
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);
}
@@ -1955,6 +2085,12 @@ void setup() {
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);
Serial.println("[MDNS] Started: http://killer.local");
@@ -2051,6 +2187,7 @@ static void tickSweepFast(uint8_t csPin, uint8_t& step, uint8_t steps,
}
void loop() {
espNowTick();
ArduinoOTA.handle();
server.handleClient();
oledTick();