diff --git a/firmware/platformio.ini b/firmware/platformio.ini index cd19558..8ec1b7a 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -17,3 +17,4 @@ build_flags = lib_deps = jgromes/RadioLib@7.7.1 olikraus/U8g2@2.36.18 + rweather/Crypto@0.4.0 ; Ed25519 / Curve25519 for MeshCore identity + signing diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 37432ed..0300bbe 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -26,6 +26,8 @@ #include "mbedtls/aes.h" #include "esp_mac.h" #include "esp_random.h" +#include // rweather/Crypto — MeshCore identity + advert signing +#include // NVS-backed persistent Ed25519 key // --- Heltec V3 SX1262 pin map (from the Heltec V3 schematic) ---------------- static const int PIN_LORA_NSS = 8; @@ -64,13 +66,16 @@ struct PhyConfig { static const PhyConfig CONFIGS[] = { // meshTastic EU868 "LongFast" — confirmed correct (received live packets). { "meshtastic", 'T', 869.525f, 250.0f, 11, 5, 0x2b, 16 }, - // MeshCore compiled default PHY; sync word 0x12 is a GUESS (open item §1). - { "meshcore", 'C', 869.525f, 250.0f, 11, 5, 0x12, 16 }, + // MeshCore narrowband — read live from the "HP Pro Desk" node's SELF_INFO + // (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", 'R', 867.200f, 125.0f, 8, 5, 0x12, 16 }, }; 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 // 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 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. static char lastMsg[40] = {0}; static char lastMsgNet = ' '; @@ -106,6 +117,25 @@ static char nodeId[12] = {0}; // "!xxxxxxxx" static uint32_t lastAnnounceMs = 0; 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 ----------------------------------------------------------- static volatile bool rxFlag = false; 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 // 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; uint32_t sender, pid; memcpy(&sender, pkt + 4, 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) 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 size_t n = payLen < outsz - 1 ? payLen : outsz - 1; memcpy(out, payload, n); out[n] = 0; + if (isText) *isText = true; return true; } if (port == 4) { // NODEINFO_APP -> User protobuf; field 3 = shortName @@ -240,8 +274,106 @@ static void announceMeshtastic() { radio.standby(); 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); + 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(); } @@ -264,11 +396,15 @@ static void drainPacket() { // meshTastic packets we can decode on-chip today. if (CONFIGS[activeIdx].letter == 'T') { - char msg[40]; - if (decodeMeshtastic(buf, len, msg, sizeof(msg))) { + char msg[64]; bool isText = false; uint32_t snd = 0; + if (decodeMeshtastic(buf, len, msg, sizeof(msg), &isText, &snd)) { strncpy(lastMsg, msg, sizeof(lastMsg) - 1); lastMsg[sizeof(lastMsg) - 1] = 0; 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; } +// --- 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() { + 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(); // 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); Serial.printf("Meshtastic node: %s (%s)\n", DEVICE_NAME, nodeId); + initMeshCoreIdentity(); + activeIdx = 0; applyConfig(CONFIGS[0]); 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() { + pollSerialTx(); + // Non-blocking scan: rotate the radio when the dwell window elapses, so the // display keeps animating smoothly the whole time. if (millis() - dwellStart >= DWELL_MS) { @@ -461,6 +724,7 @@ void loop() { applyConfig(CONFIGS[activeIdx]); dwellStart = millis(); } + flushTxOnWindow(); if (rxFlag) { rxFlag = false; drainPacket(); @@ -472,6 +736,13 @@ void loop() { announceMeshtastic(); 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(); frame++; delay(45); // ~20 fps