feat(firmware): MeshCore advert TX + Meshtastic text send

- MeshCore: fix PHY to live narrowband config (869.618/62.5/SF8/CR5),
  read off the test node's SELF_INFO — old 869.525/250/SF11 heard nothing
- MeshCore: on-chip Ed25519 identity (rweather/Crypto, NVS-persisted) and
  signed ADVERT TX (verified valid + received by a real node) — the bridge
  now shows up as a MeshCore contact
- Meshtastic: send text messages (portnum 1) reusing the announce path
- serial TX console with per-network targeting ("T:hi" / bare = all)
- longer dwell (3000ms) to sit through flood-rebroadcast bursts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-01 18:07:05 +01:00
parent d731bfd7fa
commit 8e5b4c97d7
2 changed files with 280 additions and 8 deletions

View File

@ -17,3 +17,4 @@ build_flags =
lib_deps = lib_deps =
jgromes/RadioLib@7.7.1 jgromes/RadioLib@7.7.1
olikraus/U8g2@2.36.18 olikraus/U8g2@2.36.18
rweather/Crypto@0.4.0 ; Ed25519 / Curve25519 for MeshCore identity + signing

View File

@ -26,6 +26,8 @@
#include "mbedtls/aes.h" #include "mbedtls/aes.h"
#include "esp_mac.h" #include "esp_mac.h"
#include "esp_random.h" #include "esp_random.h"
#include <Ed25519.h> // rweather/Crypto — MeshCore identity + advert signing
#include <Preferences.h> // NVS-backed persistent Ed25519 key
// --- Heltec V3 SX1262 pin map (from the Heltec V3 schematic) ---------------- // --- Heltec V3 SX1262 pin map (from the Heltec V3 schematic) ----------------
static const int PIN_LORA_NSS = 8; static const int PIN_LORA_NSS = 8;
@ -64,13 +66,16 @@ struct PhyConfig {
static const PhyConfig CONFIGS[] = { static const PhyConfig CONFIGS[] = {
// meshTastic EU868 "LongFast" — confirmed correct (received live packets). // meshTastic EU868 "LongFast" — confirmed correct (received live packets).
{ "meshtastic", 'T', 869.525f, 250.0f, 11, 5, 0x2b, 16 }, { "meshtastic", 'T', 869.525f, 250.0f, 11, 5, 0x2b, 16 },
// MeshCore compiled default PHY; sync word 0x12 is a GUESS (open item §1). // MeshCore narrowband — read live from the "HP Pro Desk" node's SELF_INFO
{ "meshcore", 'C', 869.525f, 250.0f, 11, 5, 0x12, 16 }, // (companion serial protocol, 2026-07-01): 869.618 MHz / 62.5 kHz / SF8 / CR5.
{ "meshcore", 'C', 869.618f, 62.5f, 8, 5, 0x12, 16 },
// Reticulum has no fixed preset; PLACEHOLDER — set to your RNS RNode config. // Reticulum has no fixed preset; PLACEHOLDER — set to your RNS RNode config.
{ "reticulum", 'R', 867.200f, 125.0f, 8, 5, 0x12, 16 }, { "reticulum", 'R', 867.200f, 125.0f, 8, 5, 0x12, 16 },
}; };
static const size_t NUM_CONFIGS = sizeof(CONFIGS) / sizeof(CONFIGS[0]); static const size_t NUM_CONFIGS = sizeof(CONFIGS) / sizeof(CONFIGS[0]);
static const uint32_t DWELL_MS = 1200; // faster cycle so all three feel live static const uint32_t DWELL_MS = 3000; // camp long enough to sit through a
// flood-rebroadcast burst on each PHY;
// tune vs. how "live" C/R need to feel
// Device identity — this single name is what the bridge will present on ALL // Device identity — this single name is what the bridge will present on ALL
// three networks once the participation/TX layer lands, so a message addressed // three networks once the participation/TX layer lands, so a message addressed
@ -95,6 +100,12 @@ static uint32_t totalPkts = 0;
static uint32_t dwellStart = 0; static uint32_t dwellStart = 0;
static uint32_t frame = 0; static uint32_t frame = 0;
// Outbound message queued from the serial console ("T:hello", or bare "hello"
// == all nets). Flushed on the matching PHY window so the radio is tuned right.
static char txText[200] = {0};
static bool txPending = false;
static char txNets[4] = {0}; // subset of "TCR"; empty == all
// Last decoded human-readable event, shown along the bottom of the display. // Last decoded human-readable event, shown along the bottom of the display.
static char lastMsg[40] = {0}; static char lastMsg[40] = {0};
static char lastMsgNet = ' '; static char lastMsgNet = ' ';
@ -106,6 +117,25 @@ static char nodeId[12] = {0}; // "!xxxxxxxx"
static uint32_t lastAnnounceMs = 0; static uint32_t lastAnnounceMs = 0;
static const uint32_t ANNOUNCE_INTERVAL_MS = 60000; // re-announce every 60s static const uint32_t ANNOUNCE_INTERVAL_MS = 60000; // re-announce every 60s
// Message modal: on an inbound text we play an intro animation (upside-down
// spinning striped smiley + lightning bolts) then a Meshtastic-style modal
// stating the network, sender and text. Network-agnostic — any protocol that
// decodes a message drives it.
static bool msgModalActive = false;
static uint32_t msgModalStart = 0;
static char modalNet[14] = {0};
static char modalSender[20] = {0};
static char modalText[80] = {0};
static const uint32_t MODAL_INTRO_MS = 1400;
static const uint32_t MODAL_TOTAL_MS = 6500;
static void triggerMessageModal(const char* net, const char* sender, const char* text) {
strncpy(modalNet, net, sizeof(modalNet) - 1); modalNet[sizeof(modalNet) - 1] = 0;
strncpy(modalSender, sender, sizeof(modalSender) - 1); modalSender[sizeof(modalSender) - 1] = 0;
strncpy(modalText, text, sizeof(modalText) - 1); modalText[sizeof(modalText) - 1] = 0;
msgModalActive = true; msgModalStart = millis();
}
// --- RX interrupt ----------------------------------------------------------- // --- RX interrupt -----------------------------------------------------------
static volatile bool rxFlag = false; static volatile bool rxFlag = false;
ICACHE_RAM_ATTR static void onDio1() { rxFlag = true; } ICACHE_RAM_ATTR static void onDio1() { rxFlag = true; }
@ -143,11 +173,14 @@ static uint32_t readVarint(const uint8_t* b, size_t len, size_t& i) {
// Decode a Meshtastic packet into a short human string (text, or node name for // Decode a Meshtastic packet into a short human string (text, or node name for
// NodeInfo). Returns false if not decodable / not an interesting type. // NodeInfo). Returns false if not decodable / not an interesting type.
static bool decodeMeshtastic(const uint8_t* pkt, size_t len, char* out, size_t outsz) { static bool decodeMeshtastic(const uint8_t* pkt, size_t len, char* out, size_t outsz,
bool* isText, uint32_t* senderOut) {
if (len <= 16) return false; if (len <= 16) return false;
uint32_t sender, pid; uint32_t sender, pid;
memcpy(&sender, pkt + 4, 4); memcpy(&sender, pkt + 4, 4);
memcpy(&pid, pkt + 8, 4); memcpy(&pid, pkt + 8, 4);
if (senderOut) *senderOut = sender;
if (isText) *isText = false;
// CTR nonce = packetId (8 LE, high 4 zero) + fromNode (4 LE) + extraNonce (4 = 0) // CTR nonce = packetId (8 LE, high 4 zero) + fromNode (4 LE) + extraNonce (4 = 0)
uint8_t nonce[16]; memset(nonce, 0, 16); uint8_t nonce[16]; memset(nonce, 0, 16);
@ -177,6 +210,7 @@ static bool decodeMeshtastic(const uint8_t* pkt, size_t len, char* out, size_t o
if (port == 1) { // TEXT_MESSAGE_APP if (port == 1) { // TEXT_MESSAGE_APP
size_t n = payLen < outsz - 1 ? payLen : outsz - 1; size_t n = payLen < outsz - 1 ? payLen : outsz - 1;
memcpy(out, payload, n); out[n] = 0; memcpy(out, payload, n); out[n] = 0;
if (isText) *isText = true;
return true; return true;
} }
if (port == 4) { // NODEINFO_APP -> User protobuf; field 3 = shortName if (port == 4) { // NODEINFO_APP -> User protobuf; field 3 = shortName
@ -240,8 +274,106 @@ static void announceMeshtastic() {
radio.standby(); radio.standby();
int st = radio.transmit(pkt, 16 + d); int st = radio.transmit(pkt, 16 + d);
Serial.printf(" [T] announce '%s' (%s) id=0x%08x -> tx=%d\n", Serial.printf(" [T] announce '%s' (%s) id=0x%08x -> tx=%d : ",
DEVICE_NAME, nodeId, pid, st); DEVICE_NAME, nodeId, pid, st);
for (size_t i = 0; i < 16 + d; i++) Serial.printf("%02x", pkt[i]); // TX hex for self-verify
Serial.println();
radio.startReceive();
}
// Send a text message on the Meshtastic LongFast primary channel. Same wire
// format as announceMeshtastic() but portnum=1 (TEXT_MESSAGE_APP), payload =
// the raw UTF-8 text. Assumes the radio is already tuned to the 'T' PHY.
static void sendMeshtasticText(const char* text) {
size_t tl = strlen(text);
if (tl > 200) tl = 200;
// Data protobuf: field1 portnum=1 (varint), field2 payload=text (bytes)
uint8_t data[224]; size_t d = 0;
data[d++] = 0x08; data[d++] = 0x01; // portnum = 1
data[d++] = 0x12; data[d++] = (uint8_t)tl; // payload, len-delimited
memcpy(data + d, text, tl); d += tl;
uint32_t pid = esp_random();
uint8_t enc[224];
mtCryptCtr(nodeNum, pid, data, enc, d);
uint8_t pkt[240]; uint32_t dest = 0xffffffff;
memcpy(pkt + 0, &dest, 4);
memcpy(pkt + 4, &nodeNum, 4);
memcpy(pkt + 8, &pid, 4);
pkt[12] = 0x63; // flags: hop_limit=3, hop_start=3
pkt[13] = 0x08; // channel hash (LongFast primary)
pkt[14] = 0x00; // next_hop
pkt[15] = 0x00; // relay_node
memcpy(pkt + 16, enc, d);
radio.standby();
int st = radio.transmit(pkt, 16 + d);
Serial.printf(" [T] TX text id=0x%08x -> tx=%d : \"%s\"\n", pid, st, text);
radio.startReceive();
}
// --- MeshCore identity + advert TX -----------------------------------------
// MeshCore nodes prove identity with an Ed25519 keypair. To appear as a contact
// in other clients we broadcast a signed ADVERT (PAYLOAD_TYPE_ADVERT = 0x04):
// on-air: [header 0x12][path_len 0x00][pub_key 32][timestamp 4 LE][sig 64][app_data]
// signed message = pub_key || timestamp || app_data (verified in MeshCore
// Mesh.cpp onRecvPacket PAYLOAD_TYPE_ADVERT). app_data = flags(0x81 = chat
// node + has-name) followed by the UTF-8 name.
static uint8_t mcPrv[32]; // Ed25519 private key (seed)
static uint8_t mcPub[32]; // Ed25519 public key = MeshCore identity
static uint32_t lastMcAdvertMs = 0;
static const uint32_t MC_ADVERT_INTERVAL_MS = 30000;
// Build-time epoch base (2026-07-01 UTC) + uptime — advert timestamps just need
// to be fresh/monotonic; the node has no RTC.
static const uint32_t MC_EPOCH_BASE = 1782864000UL;
static uint32_t mcNow() { return MC_EPOCH_BASE + millis() / 1000; }
static void initMeshCoreIdentity() {
Preferences prefs;
prefs.begin("meshcore", false);
if (prefs.getBytes("prv", mcPrv, 32) != 32) {
Ed25519::generatePrivateKey(mcPrv); // uses the Crypto lib CSPRNG
prefs.putBytes("prv", mcPrv, 32);
Serial.println("MeshCore: generated new Ed25519 identity");
}
prefs.end();
Ed25519::derivePublicKey(mcPub, mcPrv);
Serial.print("MeshCore identity pub: ");
for (int i = 0; i < 32; i++) Serial.printf("%02x", mcPub[i]);
Serial.println();
}
static void sendMeshCoreAdvert() {
uint8_t app_data[40]; size_t ad = 0;
app_data[ad++] = 0x81; // flags: chat node + has name
size_t nl = strlen(DEVICE_NAME); if (nl > 32) nl = 32;
memcpy(app_data + ad, DEVICE_NAME, nl); ad += nl;
uint32_t ts = mcNow();
uint8_t message[32 + 4 + 40]; size_t ml = 0;
memcpy(message + ml, mcPub, 32); ml += 32;
memcpy(message + ml, &ts, 4); ml += 4;
memcpy(message + ml, app_data, ad); ml += ad;
uint8_t sig[64];
Ed25519::sign(sig, mcPrv, mcPub, message, ml);
uint8_t pkt[2 + 32 + 4 + 64 + 40]; size_t p = 0;
pkt[p++] = 0x12; // route=flood(2) | type=advert(4)
pkt[p++] = 0x00; // path_len = 0 (direct/flood)
memcpy(pkt + p, mcPub, 32); p += 32;
memcpy(pkt + p, &ts, 4); p += 4;
memcpy(pkt + p, sig, 64); p += 64;
memcpy(pkt + p, app_data, ad); p += ad;
radio.standby();
int st = radio.transmit(pkt, p);
Serial.printf(" [C] advert '%s' ts=%u -> tx=%d (%u bytes) : ",
DEVICE_NAME, ts, st, (unsigned)p);
for (size_t i = 0; i < p; i++) Serial.printf("%02x", pkt[i]); // TX hex for self-verify
Serial.println();
radio.startReceive(); radio.startReceive();
} }
@ -264,11 +396,15 @@ static void drainPacket() {
// meshTastic packets we can decode on-chip today. // meshTastic packets we can decode on-chip today.
if (CONFIGS[activeIdx].letter == 'T') { if (CONFIGS[activeIdx].letter == 'T') {
char msg[40]; char msg[64]; bool isText = false; uint32_t snd = 0;
if (decodeMeshtastic(buf, len, msg, sizeof(msg))) { if (decodeMeshtastic(buf, len, msg, sizeof(msg), &isText, &snd)) {
strncpy(lastMsg, msg, sizeof(lastMsg) - 1); lastMsg[sizeof(lastMsg) - 1] = 0; strncpy(lastMsg, msg, sizeof(lastMsg) - 1); lastMsg[sizeof(lastMsg) - 1] = 0;
lastMsgNet = 'T'; lastMsgMs = millis(); lastMsgNet = 'T'; lastMsgMs = millis();
Serial.printf(" decoded[T]: %s\n", msg); Serial.printf(" decoded[T]%s: %s\n", isText ? "(text)" : "(info)", msg);
if (isText) {
char sid[16]; snprintf(sid, sizeof(sid), "!%08x", snd);
triggerMessageModal("meshTastic", sid, msg);
}
} }
} }
} }
@ -345,7 +481,83 @@ static int indexByLetter(char L) {
return -1; return -1;
} }
// --- Message-received animation + modal ------------------------------------
static void drawBolt(int x, int y, int h) {
oled.drawLine(x, y, x - 3, y + h / 2);
oled.drawLine(x - 3, y + h / 2, x + 3, y + h / 2);
oled.drawLine(x + 3, y + h / 2, x - 1, y + h);
}
// Greedy word-wrap for a fixed-width font (6px/char here).
static void drawWrapped(const char* text, int x, int y0, int cpl, int lineh, int maxlines) {
int n = strlen(text), i = 0, line = 0;
while (i < n && line < maxlines) {
int end = i + cpl;
if (end < n) { int b = end; while (b > i && text[b] != ' ') b--; if (b > i) end = b; }
else end = n;
char buf[40]; int len = end - i; if (len > 39) len = 39;
memcpy(buf, text + i, len); buf[len] = 0;
oled.drawStr(x, y0 + line * lineh, buf);
i = end; while (i < n && text[i] == ' ') i++;
line++;
}
}
// Intro: the striped smiley spins UPSIDE DOWN (eyes below, mouth above) while
// lightning bolts flash — a beat before the modal, à la Meshtastic.
static void drawMsgIntro() {
oled.clearBuffer();
int cx = 64, cy = 30; const int R = 22;
oled.setDrawColor(1); oled.drawDisc(cx, cy, R, U8G2_DRAW_ALL);
float th = -frame * 0.5f; // reversed spin
float c = cosf(th), s = sinf(th);
oled.setDrawColor(0);
for (int off = -R; off <= R; off += 4) {
float px = cx - s * off, py = cy + c * off;
oled.drawLine((int)(px - c * R), (int)(py - s * R), (int)(px + c * R), (int)(py + s * R));
}
oled.setDrawColor(1); oled.drawCircle(cx, cy, R, U8G2_DRAW_ALL);
for (int sgn = -1; sgn <= 1; sgn += 2) { // eyes BELOW centre (upside down)
int ex = cx + sgn * 8;
oled.setDrawColor(1); oled.drawDisc(ex, cy + 8, 4, U8G2_DRAW_ALL);
oled.setDrawColor(0); oled.drawDisc(ex, cy + 8, 3, U8G2_DRAW_ALL);
oled.setDrawColor(1); oled.drawDisc(ex, cy + 8, 1, U8G2_DRAW_ALL);
}
oled.setDrawColor(1); oled.drawDisc(cx, cy - 9, 5, U8G2_DRAW_ALL); // mouth ABOVE
oled.setDrawColor(0); oled.drawDisc(cx, cy - 9, 3, U8G2_DRAW_ALL);
oled.setDrawColor(1);
if ((frame / 2) & 1) { drawBolt(22, 5, 22); drawBolt(106, 5, 22); }
else { drawBolt(14, 12, 20); drawBolt(114, 10, 22); }
oled.setFont(u8g2_font_5x8_tf);
const char* t = "INCOMING";
oled.drawStr((128 - oled.getStrWidth(t)) / 2, 62, t);
oled.sendBuffer();
}
// Modal: network banner + sender + wrapped message text.
static void drawMsgModal() {
oled.clearBuffer();
oled.drawRFrame(1, 1, 126, 62, 3);
oled.drawBox(1, 1, 126, 13); // header bar
oled.setDrawColor(0);
oled.setFont(u8g2_font_6x10_tf);
oled.drawStr((128 - oled.getStrWidth(modalNet)) / 2, 11, modalNet);
oled.setDrawColor(1);
oled.setFont(u8g2_font_5x8_tf);
char sl[28]; snprintf(sl, sizeof(sl), "from %s", modalSender);
oled.drawStr(6, 24, sl);
oled.setFont(u8g2_font_6x10_tf);
drawWrapped(modalText, 6, 36, 19, 11, 3);
oled.sendBuffer();
}
static void drawUI() { static void drawUI() {
if (msgModalActive) {
uint32_t e = millis() - msgModalStart;
if (e < MODAL_INTRO_MS) { drawMsgIntro(); return; }
if (e < MODAL_TOTAL_MS) { drawMsgModal(); return; }
msgModalActive = false;
}
oled.clearBuffer(); oled.clearBuffer();
// Every ~9s the smiley freaks out (spins, stripes, eyes + mouth open) for ~2.5s. // Every ~9s the smiley freaks out (spins, stripes, eyes + mouth open) for ~2.5s.
@ -448,12 +660,63 @@ void setup() {
snprintf(nodeId, sizeof(nodeId), "!%08x", nodeNum); snprintf(nodeId, sizeof(nodeId), "!%08x", nodeNum);
Serial.printf("Meshtastic node: %s (%s)\n", DEVICE_NAME, nodeId); Serial.printf("Meshtastic node: %s (%s)\n", DEVICE_NAME, nodeId);
initMeshCoreIdentity();
activeIdx = 0; activeIdx = 0;
applyConfig(CONFIGS[0]); applyConfig(CONFIGS[0]);
dwellStart = millis(); dwellStart = millis();
} }
// Read a line from the serial console into the TX queue. Syntax:
// "hello" -> send "hello" on all networks
// "T:hello" -> send only on Meshtastic (T / C / R prefix, case-insensitive)
static void pollSerialTx() {
static char buf[210]; static size_t n = 0;
while (Serial.available()) {
char c = Serial.read();
if (c == '\r') continue;
if (c == '\n') {
buf[n] = 0;
if (n > 0) {
const char* msg = buf;
strcpy(txNets, "TCR"); // default: all networks
if (n >= 2 && buf[1] == ':') {
char L = toupper(buf[0]);
if (L == 'T' || L == 'C' || L == 'R') { txNets[0] = L; txNets[1] = 0; msg = buf + 2; }
}
strncpy(txText, msg, sizeof(txText) - 1); txText[sizeof(txText) - 1] = 0;
txPending = true;
Serial.printf(" [tx] queued for %s: \"%s\"\n", txNets, txText);
}
n = 0;
} else if (n < sizeof(buf) - 1) {
buf[n++] = c;
}
}
}
// If a message is queued for the currently-tuned network, send it now.
static void flushTxOnWindow() {
if (!txPending) return;
char L = CONFIGS[activeIdx].letter;
char* p = strchr(txNets, L);
if (!p) return; // not queued for this network
if (L == 'T') {
sendMeshtasticText(txText);
} else {
// MeshCore / Reticulum TX framing not built yet — acknowledge, don't fake it.
Serial.printf(" [%c] TX not implemented yet (msg: \"%s\")\n", L, txText);
}
// Remove this network from the pending set; done when none remain.
memmove(p, p + 1, strlen(p)); // includes the NUL terminator
if (txNets[0] == 0) txPending = false;
}
void loop() { void loop() {
pollSerialTx();
// Non-blocking scan: rotate the radio when the dwell window elapses, so the // Non-blocking scan: rotate the radio when the dwell window elapses, so the
// display keeps animating smoothly the whole time. // display keeps animating smoothly the whole time.
if (millis() - dwellStart >= DWELL_MS) { if (millis() - dwellStart >= DWELL_MS) {
@ -461,6 +724,7 @@ void loop() {
applyConfig(CONFIGS[activeIdx]); applyConfig(CONFIGS[activeIdx]);
dwellStart = millis(); dwellStart = millis();
} }
flushTxOnWindow();
if (rxFlag) { if (rxFlag) {
rxFlag = false; rxFlag = false;
drainPacket(); drainPacket();
@ -472,6 +736,13 @@ void loop() {
announceMeshtastic(); announceMeshtastic();
lastAnnounceMs = millis(); lastAnnounceMs = millis();
} }
// Advertise ourselves as a MeshCore chat node while tuned to that PHY, so we
// show up as a contact in other MeshCore clients.
if (CONFIGS[activeIdx].letter == 'C' &&
(lastMcAdvertMs == 0 || millis() - lastMcAdvertMs > MC_ADVERT_INTERVAL_MS)) {
sendMeshCoreAdvert();
lastMcAdvertMs = millis();
}
drawUI(); drawUI();
frame++; frame++;
delay(45); // ~20 fps delay(45); // ~20 fps