Dorian a62d989c9e feat(firmware): Reticulum announce RX — decode + verify heard announces
- handleReticulumPacket: parse RNS packet header (HEADER_1/2, flags), extract
  ANNOUNCE payload, reconstruct signed_data, verify Ed25519 signature with the
  announced signing key, and confirm dest_hash = SHA256(name_hash ||
  SHA256(pubkey)[:16])[:16]; register the destination (pubkey/app_data table)
- "RXR:<hex>" serial hook injects raw packets for radio-free validation
- verified against real RNS-generated announces: valid one decodes with correct
  dest + app_data; tampered signature is rejected

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:22:42 +01:00

1107 lines
46 KiB
C++

// archy-messh Phase 1 — tri-protocol RX scanner + trippy acid display
// ---------------------------------------------------------------------------
// Target: Heltec WiFi LoRa 32 V3 (ESP32-S3 + single SX1262 + 128x64 SSD1306).
//
// The radio rotates through the Reticulum / meshTastic / meshCore PHY presets
// (RTC), listening in each dwell window and logging every raw packet it
// demodulates. The OLED shows all three networks live — R T C — behind an
// animated acid-house smiley, with per-network activity blips. Received
// Meshtastic packets are decoded on-chip (AES-128-CTR + protobuf).
//
// HONEST HARDWARE NOTE: the single onboard SX1262 can only be tuned to ONE
// PHY at a time (ARCHITECTURE.md §2), so "all three active" is fast time-
// slicing, not literal simultaneity. True concurrent reception needs added
// radios (Phase 2 / Option B). The UI presents all three as armed; the scan
// cycles between them faster than a human notices.
//
// This increment still does NOT decode/bridge/transmit — that is the next
// roadmap step (firmware/README.md). It proves RX + the device UX.
// ---------------------------------------------------------------------------
#include <Arduino.h>
#include <RadioLib.h>
#include <U8g2lib.h>
#include <Wire.h>
#include <math.h>
#include "mbedtls/aes.h"
#include "esp_mac.h"
#include "esp_random.h"
#include <AES.h> // rweather/Crypto — AES-128-ECB for MeshCore DM cipher
#include <SHA256.h> // rweather/Crypto — HMAC-SHA256 (MeshCore) + SHA256 (RNS)
#include <Curve25519.h> // rweather/Crypto — X25519 encryption key for Reticulum
#include <Preferences.h> // NVS-backed persistent identity seed
extern "C" {
#include "ed_25519.h" // orlp/ed25519 (vendored, lib/ed25519) — identity, sign, ECDH
}
// --- Heltec V3 SX1262 pin map (from the Heltec V3 schematic) ----------------
static const int PIN_LORA_NSS = 8;
static const int PIN_LORA_DIO1 = 14;
static const int PIN_LORA_NRST = 12;
static const int PIN_LORA_BUSY = 13;
static const int PIN_LORA_SCK = 9;
static const int PIN_LORA_MISO = 11;
static const int PIN_LORA_MOSI = 10;
static const float TCXO_VOLTAGE = 1.8f;
static const int8_t RX_TX_POWER = 10;
// --- Heltec V3 OLED + power ------------------------------------------------
static const int PIN_OLED_SDA = 17;
static const int PIN_OLED_SCL = 18;
static const int PIN_OLED_RST = 21;
static const int PIN_VEXT = 36; // drives Vext rail; LOW = ON (powers OLED)
SX1262 radio = new Module(PIN_LORA_NSS, PIN_LORA_DIO1, PIN_LORA_NRST, PIN_LORA_BUSY);
U8G2_SSD1306_128X64_NONAME_F_HW_I2C oled(U8G2_R0, PIN_OLED_RST, PIN_OLED_SCL, PIN_OLED_SDA);
// --- The three PHY presets (RTC) -------------------------------------------
// Values from docs/ARCHITECTURE.md §1. Items flagged are open questions the
// scan is meant to test against reality.
struct PhyConfig {
const char* name;
char letter; // R / T / C for the display
float freqMHz;
float bwKHz;
uint8_t sf;
uint8_t cr; // 4/cr
uint8_t syncWord;
uint16_t preamble;
};
static const PhyConfig CONFIGS[] = {
// meshTastic EU868 "LongFast" — confirmed correct (received live packets).
{ "meshtastic", 'T', 869.525f, 250.0f, 11, 5, 0x2b, 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 RNode PHY (operator-configured) — user's RNS interface:
// 869.525 MHz / 125 kHz / SF8 / CR4:5. Sync word 0x12 is RNode's default.
{ "reticulum", 'R', 869.525f, 125.0f, 8, 5, 0x12, 16 },
};
static const size_t NUM_CONFIGS = sizeof(CONFIGS) / sizeof(CONFIGS[0]);
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
// to "Reticutasticore" on Meshtastic, MeshCore or Reticulum reaches this device:
// - Meshtastic: User.long_name = DEVICE_NAME, short_name = DEVICE_SHORT (<=4)
// - MeshCore: advertised contact name = DEVICE_NAME
// - Reticulum: LXMF delivery display_name = DEVICE_NAME
// (RX-only today; the constants exist now so TX inherits them with no churn.)
static const char* DEVICE_NAME = "Reticutasticore";
static const char* DEVICE_SHORT = "RTC";
// --- Runtime stats ----------------------------------------------------------
struct NetStat {
uint32_t pkts = 0;
int16_t lastRssi = 0;
bool seen = false;
uint32_t lastRxMs = 0; // for the RX activity blip
};
static NetStat stats[NUM_CONFIGS];
static int activeIdx = 0;
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 = ' ';
static uint32_t lastMsgMs = 0;
// Meshtastic node identity (this device's presence on the mesh).
static uint32_t nodeNum = 0;
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; }
static bool applyConfig(const PhyConfig& c) {
radio.standby();
bool ok = true;
ok &= radio.setFrequency(c.freqMHz) == RADIOLIB_ERR_NONE;
ok &= radio.setBandwidth(c.bwKHz) == RADIOLIB_ERR_NONE;
ok &= radio.setSpreadingFactor(c.sf) == RADIOLIB_ERR_NONE;
ok &= radio.setCodingRate(c.cr) == RADIOLIB_ERR_NONE;
ok &= radio.setSyncWord(c.syncWord) == RADIOLIB_ERR_NONE;
ok &= radio.setPreambleLength(c.preamble) == RADIOLIB_ERR_NONE;
if (!ok) return false;
return radio.startReceive() == RADIOLIB_ERR_NONE;
}
// --- On-chip Meshtastic decode ---------------------------------------------
// Default-channel ("LongFast") packets use AES-128-CTR with a PUBLIC key.
// Validated offline against real captured packets (firmware/tools/
// meshtastic_decode.py). Wire format: 16-byte header + encrypted Data protobuf.
static const uint8_t MT_KEY[16] = {
0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59,
0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01};
static uint32_t readVarint(const uint8_t* b, size_t len, size_t& i) {
uint32_t v = 0; int shift = 0;
while (i < len) {
uint8_t c = b[i++];
v |= (uint32_t)(c & 0x7f) << shift; shift += 7;
if (!(c & 0x80)) break;
}
return v;
}
// 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,
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);
memcpy(nonce + 0, &pid, 4);
memcpy(nonce + 8, &sender, 4);
size_t plen = len - 16;
if (plen > 240) return false;
uint8_t plain[240];
mbedtls_aes_context aes; mbedtls_aes_init(&aes);
mbedtls_aes_setkey_enc(&aes, MT_KEY, 128);
size_t nc_off = 0; uint8_t stream[16]; memset(stream, 0, 16);
mbedtls_aes_crypt_ctr(&aes, plen, &nc_off, nonce, stream, pkt + 16, plain);
mbedtls_aes_free(&aes);
// Data protobuf: field 1 = portnum (varint), field 2 = payload (bytes)
size_t i = 0; uint8_t port = 0; const uint8_t* payload = nullptr; size_t payLen = 0;
while (i < plen) {
uint8_t tag = plain[i++]; uint8_t field = tag >> 3, wt = tag & 7;
if (wt == 0) { uint32_t v = readVarint(plain, plen, i); if (field == 1) port = (uint8_t)v; }
else if (wt == 2) { uint32_t l = readVarint(plain, plen, i);
if (field == 2) { payload = plain + i; payLen = l; } i += l; }
else break;
}
if (!payload) return false;
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
size_t j = 0; const uint8_t* sn = nullptr; size_t snl = 0;
while (j < payLen) {
uint8_t tag = payload[j++]; uint8_t f = tag >> 3, w = tag & 7;
if (w == 2) { uint32_t l = readVarint(payload, payLen, j);
if (f == 3) { sn = payload + j; snl = l; } j += l; }
else if (w == 0) { readVarint(payload, payLen, j); }
else break;
}
if (sn) { size_t n = snl < outsz - 1 ? snl : outsz - 1;
memcpy(out, sn, n); out[n] = 0; return true; }
}
return false;
}
// AES-128-CTR (symmetric, so same call encrypts) with the Meshtastic default key.
static void mtCryptCtr(uint32_t sender, uint32_t pid, const uint8_t* in, uint8_t* out, size_t n) {
uint8_t nonce[16]; memset(nonce, 0, 16);
memcpy(nonce + 0, &pid, 4);
memcpy(nonce + 8, &sender, 4);
mbedtls_aes_context aes; mbedtls_aes_init(&aes);
mbedtls_aes_setkey_enc(&aes, MT_KEY, 128);
size_t nc_off = 0; uint8_t stream[16]; memset(stream, 0, 16);
mbedtls_aes_crypt_ctr(&aes, n, &nc_off, nonce, stream, in, out);
mbedtls_aes_free(&aes);
}
// Announce this device as a Meshtastic node named DEVICE_NAME. Broadcasts a
// NodeInfo (User protobuf) on the LongFast primary channel (hash 0x08) so it
// appears in other Meshtastic clients — and, because we advertise no public
// key, DMs to us fall back to channel encryption, which we can already decode.
static void announceMeshtastic() {
// User protobuf: field1 id, field2 long_name, field3 short_name
uint8_t user[64]; size_t u = 0;
size_t idl = strlen(nodeId), lnl = strlen(DEVICE_NAME), snl = strlen(DEVICE_SHORT);
user[u++] = 0x0a; user[u++] = idl; memcpy(user + u, nodeId, idl); u += idl;
user[u++] = 0x12; user[u++] = lnl; memcpy(user + u, DEVICE_NAME, lnl); u += lnl;
user[u++] = 0x1a; user[u++] = snl; memcpy(user + u, DEVICE_SHORT, snl); u += snl;
// Data protobuf: portnum=4 (NODEINFO_APP), payload=User
uint8_t data[96]; size_t d = 0;
data[d++] = 0x08; data[d++] = 0x04; // portnum = 4
data[d++] = 0x12; data[d++] = (uint8_t)u; memcpy(data + d, user, u); d += u;
uint32_t pid = esp_random();
uint8_t enc[96];
mtCryptCtr(nodeNum, pid, data, enc, d);
// 16-byte header + ciphertext
uint8_t pkt[128]; 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] 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 mcSeed[32]; // persistent 32-byte identity seed (NVS)
static uint8_t mcPrv[64]; // orlp expanded private key, derived from 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);
// NVS key "prv" holds the 32-byte seed (unchanged from the first identity so
// peers that already added us keep recognising the same public key).
if (prefs.getBytes("prv", mcSeed, 32) != 32) {
for (int i = 0; i < 32; i++) mcSeed[i] = (uint8_t)esp_random(); // HW CSPRNG
prefs.putBytes("prv", mcSeed, 32);
Serial.println("MeshCore: generated new Ed25519 identity");
}
prefs.end();
ed25519_create_keypair(mcPub, mcPrv, mcSeed); // seed -> (pub, expanded prv)
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, message, ml, mcPub, mcPrv);
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();
}
// Send an encrypted MeshCore direct message to a contact (Ed25519 pubkey).
// Mirror of the RX path: plaintext = timestamp(4) + flags(1) + text, then
// encryptThenMAC = HMAC-SHA256(2) prefix + AES-128-ECB(zero-padded plaintext),
// wrapped as [header][path_len 0][dest_hash][src_hash][cipher].
static void sendMeshCoreText(const uint8_t* dest_pub, const char* text) {
uint32_t ts = mcNow();
uint8_t plain[176]; size_t pl = 0;
memcpy(plain + pl, &ts, 4); pl += 4;
plain[pl++] = 0x00; // flags: attempt 0, TXT_TYPE_PLAIN
size_t tl = strlen(text); if (tl > 140) tl = 140;
memcpy(plain + pl, text, tl); pl += tl;
uint8_t secret[32];
ed25519_key_exchange(secret, dest_pub, mcPrv); // ECDH shared secret
// AES-128-ECB over the plaintext, zero-padded to a 16-byte boundary.
uint8_t cipher[2 + 176 + 16]; uint8_t* enc = cipher + 2; size_t el = 0;
AES128 aes; aes.setKey(secret, 16);
size_t off = 0;
while (pl - off >= 16) { aes.encryptBlock(enc + el, plain + off); el += 16; off += 16; }
if (pl - off > 0) {
uint8_t tmp[16]; memset(tmp, 0, 16); memcpy(tmp, plain + off, pl - off);
aes.encryptBlock(enc + el, tmp); el += 16;
}
// 2-byte truncated HMAC-SHA256 over the ciphertext (key = 32-byte secret).
SHA256 sha; sha.resetHMAC(secret, 32); sha.update(enc, el);
sha.finalizeHMAC(secret, 32, cipher, 2);
size_t clen = 2 + el;
uint8_t pkt[4 + sizeof(cipher)]; size_t p = 0;
pkt[p++] = 0x0A; // route=flood(2) | type=TXT_MSG(2)
pkt[p++] = 0x00; // path_len = 0
pkt[p++] = dest_pub[0]; // dest_hash
pkt[p++] = mcPub[0]; // src_hash
memcpy(pkt + p, cipher, clen); p += clen;
radio.standby();
int st = radio.transmit(pkt, p);
Serial.printf(" [C] TX dm -> tx=%d (%u bytes) : \"%s\"\n", st, (unsigned)p, text);
radio.startReceive();
}
// --- Reticulum (RNS) identity + announce -----------------------------------
// An RNS identity is an X25519 encryption keypair + an Ed25519 signing keypair.
// public_key = X25519_pub(32) || Ed25519_pub(32). A destination hash is
// SHA256(name_hash || SHA256(public_key)[:16])[:16]. An ANNOUNCE packet is
// [flags 0x01][hops 0x00][dest_hash 16][context 0x00][announce_data]
// announce_data = public_key(64) || name_hash(10) || random_hash(10) ||
// signature(64) || app_data, where the Ed25519 signature covers
// dest_hash || public_key || name_hash || random_hash || app_data
// (no ratchet: context_flag is unset). Format validated against RNS 1.3.5.
static uint8_t rnsXPrv[32], rnsXPub[32]; // X25519 (encryption) keypair
static uint8_t rnsEdSeed[32], rnsEdPrv[64], rnsEdPub[32]; // Ed25519 (signing)
static uint8_t rnsPub[64]; // X25519_pub || Ed25519_pub
static uint8_t rnsNameHash[10]; // SHA256(app name)[:10]
static uint8_t rnsDestHash[16]; // SHA256(name_hash||ident_hash)[:16]
static const char* RNS_APP_NAME = "archy.messh";
static const char* RNS_APP_DATA = "archy-messh";
static uint32_t lastRnsAnnounceMs = 0;
static const uint32_t RNS_ANNOUNCE_INTERVAL_MS = 30000;
static void rnsSha256(const uint8_t* in, size_t n, uint8_t* out, int outlen) {
SHA256 sha; sha.reset(); sha.update(in, n);
uint8_t full[32]; sha.finalize(full, 32);
memcpy(out, full, outlen);
}
static void initReticulumIdentity() {
Preferences prefs;
prefs.begin("reticulum", false);
if (prefs.getBytes("xprv", rnsXPrv, 32) != 32) {
for (int i = 0; i < 32; i++) rnsXPrv[i] = (uint8_t)esp_random();
rnsXPrv[0] &= 248; rnsXPrv[31] &= 127; rnsXPrv[31] |= 64; // X25519 clamp
prefs.putBytes("xprv", rnsXPrv, 32);
}
if (prefs.getBytes("edseed", rnsEdSeed, 32) != 32) {
for (int i = 0; i < 32; i++) rnsEdSeed[i] = (uint8_t)esp_random();
prefs.putBytes("edseed", rnsEdSeed, 32);
}
prefs.end();
Curve25519::eval(rnsXPub, rnsXPrv, 0); // X25519 pub (base point)
ed25519_create_keypair(rnsEdPub, rnsEdPrv, rnsEdSeed);
memcpy(rnsPub, rnsXPub, 32); memcpy(rnsPub + 32, rnsEdPub, 32);
uint8_t ident_hash[16]; rnsSha256(rnsPub, 64, ident_hash, 16);
rnsSha256((const uint8_t*)RNS_APP_NAME, strlen(RNS_APP_NAME), rnsNameHash, 10);
uint8_t nh_ih[26];
memcpy(nh_ih, rnsNameHash, 10); memcpy(nh_ih + 10, ident_hash, 16);
rnsSha256(nh_ih, 26, rnsDestHash, 16);
Serial.print("Reticulum dest hash: ");
for (int i = 0; i < 16; i++) Serial.printf("%02x", rnsDestHash[i]);
Serial.println();
}
static void sendReticulumAnnounce() {
uint8_t random_hash[10];
for (int i = 0; i < 10; i++) random_hash[i] = (uint8_t)esp_random();
size_t adl = strlen(RNS_APP_DATA);
uint8_t signed_data[16 + 64 + 10 + 10 + 32]; size_t sl = 0;
memcpy(signed_data + sl, rnsDestHash, 16); sl += 16;
memcpy(signed_data + sl, rnsPub, 64); sl += 64;
memcpy(signed_data + sl, rnsNameHash, 10); sl += 10;
memcpy(signed_data + sl, random_hash, 10); sl += 10;
memcpy(signed_data + sl, RNS_APP_DATA, adl); sl += adl;
uint8_t sig[64];
ed25519_sign(sig, signed_data, sl, rnsEdPub, rnsEdPrv);
uint8_t pkt[2 + 16 + 1 + 64 + 10 + 10 + 64 + 32]; size_t p = 0;
pkt[p++] = 0x01; // flags: HEADER_1|BROADCAST|SINGLE|ANNOUNCE
pkt[p++] = 0x00; // hops
memcpy(pkt + p, rnsDestHash, 16); p += 16;
pkt[p++] = 0x00; // context = NONE
memcpy(pkt + p, rnsPub, 64); p += 64;
memcpy(pkt + p, rnsNameHash, 10); p += 10;
memcpy(pkt + p, random_hash, 10); p += 10;
memcpy(pkt + p, sig, 64); p += 64;
memcpy(pkt + p, RNS_APP_DATA, adl); p += adl;
radio.standby();
int st = radio.transmit(pkt, p);
Serial.printf(" [R] announce -> tx=%d (%u bytes) : ", st, (unsigned)p);
for (size_t i = 0; i < p; i++) Serial.printf("%02x", pkt[i]); // for RNS cross-validation
Serial.println();
radio.startReceive();
}
// Known Reticulum destinations we've heard announces from.
struct RnsDest { uint8_t hash[16]; uint8_t pub[64]; char app[24]; bool used; };
static const int RNS_MAX_DESTS = 6;
static RnsDest rnsDests[RNS_MAX_DESTS];
static int rnsDestRR = 0;
static void rnsStoreDest(const uint8_t* hash, const uint8_t* pub, const char* app) {
for (int i = 0; i < RNS_MAX_DESTS; i++)
if (rnsDests[i].used && memcmp(rnsDests[i].hash, hash, 16) == 0) return;
int slot = -1;
for (int i = 0; i < RNS_MAX_DESTS; i++) if (!rnsDests[i].used) { slot = i; break; }
if (slot < 0) { slot = rnsDestRR; rnsDestRR = (rnsDestRR + 1) % RNS_MAX_DESTS; }
rnsDests[slot].used = true;
memcpy(rnsDests[slot].hash, hash, 16); memcpy(rnsDests[slot].pub, pub, 64);
strncpy(rnsDests[slot].app, app, sizeof(rnsDests[slot].app) - 1);
rnsDests[slot].app[sizeof(rnsDests[slot].app) - 1] = 0;
}
// Parse a received Reticulum packet: decode + verify ANNOUNCE, register dest.
static void handleReticulumPacket(const uint8_t* buf, size_t len) {
if (len < 19) return;
uint8_t flags = buf[0];
uint8_t header_type = (flags >> 6) & 0x01;
uint8_t context_flag = (flags >> 5) & 0x01;
uint8_t ptype = flags & 0x03; // packet_type (2 bits)
size_t i = 2; // skip flags + hops
if (header_type == 0x01) i += 16; // HEADER_2 carries a transport_id
if (i + 16 + 1 > len) return;
const uint8_t* dest_hash = buf + i; i += 16;
i += 1; // context byte
const uint8_t* data = buf + i;
int data_len = (int)len - (int)i;
if (ptype != 0x01) return; // only ANNOUNCE for now
int ratch = context_flag ? 32 : 0;
if (data_len < 64 + 10 + 10 + ratch + 64) return;
const uint8_t* pub = data;
const uint8_t* name_hash = data + 64;
const uint8_t* random_hash = data + 74;
const uint8_t* ratchet = data + 84;
const uint8_t* signature = data + 84 + ratch;
const uint8_t* app_data = data + 84 + ratch + 64;
int app_len = data_len - (84 + ratch + 64);
// signed_data = dest_hash || pub || name_hash || random_hash || ratchet || app_data
uint8_t sd[16 + 64 + 10 + 10 + 32 + 64]; size_t sl = 0;
memcpy(sd + sl, dest_hash, 16); sl += 16;
memcpy(sd + sl, pub, 64); sl += 64;
memcpy(sd + sl, name_hash, 10); sl += 10;
memcpy(sd + sl, random_hash, 10); sl += 10;
if (ratch) { memcpy(sd + sl, ratchet, 32); sl += 32; }
int app_take = app_len; if (app_take > 64) app_take = 64;
if (app_take > 0) { memcpy(sd + sl, app_data, app_take); sl += app_take; }
// Verify the Ed25519 signature with the announced signing key (pub[32:64]).
if (!ed25519_verify(signature, sd, sl, pub + 32)) {
Serial.println(" [R] announce with bad signature — dropped");
return;
}
// Confirm dest hash = SHA256(name_hash || SHA256(pub)[:16])[:16].
uint8_t ih[16]; rnsSha256(pub, 64, ih, 16);
uint8_t nh_ih[26]; memcpy(nh_ih, name_hash, 10); memcpy(nh_ih + 10, ih, 16);
uint8_t expect[16]; rnsSha256(nh_ih, 26, expect, 16);
if (memcmp(expect, dest_hash, 16) != 0) {
Serial.println(" [R] announce dest-hash mismatch — dropped");
return;
}
char app[24] = {0};
int an = app_take < 23 ? app_take : 23; if (an > 0) memcpy(app, app_data, an);
rnsStoreDest(dest_hash, pub, app);
strncpy(lastMsg, app[0] ? app : "announce", sizeof(lastMsg) - 1);
lastMsg[sizeof(lastMsg) - 1] = 0; lastMsgNet = 'R'; lastMsgMs = millis();
Serial.printf(" decoded[R](announce): dest=");
for (int k = 0; k < 8; k++) Serial.printf("%02x", dest_hash[k]);
Serial.printf(" app_data=\"%s\"\n", app);
}
// --- MeshCore contacts (heard adverts) + DM receive ------------------------
// To decrypt a direct message we need the SENDER's full public key. MeshCore
// packets only carry a 1-byte src_hash (pubkey prefix), so we remember pubkeys
// from the adverts we hear and match on that hash.
struct McContact { uint8_t pub[32]; char name[24]; bool used; };
static const int MC_MAX_CONTACTS = 8;
static McContact mcContacts[MC_MAX_CONTACTS];
static int mcContactRR = 0; // round-robin eviction cursor
static void mcStoreContact(const uint8_t* pub, const char* name) {
for (int i = 0; i < MC_MAX_CONTACTS; i++) // update existing
if (mcContacts[i].used && memcmp(mcContacts[i].pub, pub, 32) == 0) {
strncpy(mcContacts[i].name, name, sizeof(mcContacts[i].name) - 1);
return;
}
for (int i = 0; i < MC_MAX_CONTACTS; i++) // fill a free slot
if (!mcContacts[i].used) {
mcContacts[i].used = true; memcpy(mcContacts[i].pub, pub, 32);
strncpy(mcContacts[i].name, name, sizeof(mcContacts[i].name) - 1);
return;
}
int i = mcContactRR; mcContactRR = (mcContactRR + 1) % MC_MAX_CONTACTS; // evict
memcpy(mcContacts[i].pub, pub, 32);
strncpy(mcContacts[i].name, name, sizeof(mcContacts[i].name) - 1);
mcContacts[i].name[sizeof(mcContacts[i].name) - 1] = 0;
}
// MeshCore Utils::MACThenDecrypt — verify a 2-byte truncated HMAC-SHA256 over
// the ciphertext (key = 32-byte shared secret), then AES-128-ECB decrypt (key =
// first 16 bytes). Returns plaintext length (multiple of 16) or 0 on bad MAC.
static int mcMACThenDecrypt(const uint8_t* secret, uint8_t* dest,
const uint8_t* src, int src_len) {
if (src_len <= 2 || (src_len - 2) % 16 != 0) return 0;
uint8_t hmac[2];
SHA256 sha;
sha.resetHMAC(secret, 32);
sha.update(src + 2, src_len - 2);
sha.finalizeHMAC(secret, 32, hmac, 2);
if (memcmp(hmac, src, 2) != 0) return 0; // MAC mismatch
AES128 aes; aes.setKey(secret, 16);
int n = src_len - 2; const uint8_t* sp = src + 2; uint8_t* dp = dest;
for (int o = 0; o < n; o += 16) { aes.decryptBlock(dp, sp); dp += 16; sp += 16; }
return n;
}
// Parse a received MeshCore packet: store adverts, decrypt DMs addressed to us.
static void handleMeshCorePacket(const uint8_t* buf, size_t len) {
if (len < 2) return;
uint8_t header = buf[0];
uint8_t route = header & 0x03;
uint8_t ptype = (header >> 2) & 0x0F;
size_t i = 1;
if (route == 0x00 || route == 0x03) i += 4; // transport codes
if (i >= len) return;
uint8_t plb = buf[i++];
uint8_t hsize = (plb >> 6) + 1, hcount = plb & 0x3F;
i += (size_t)hsize * hcount; // skip path hashes
if (i >= len) return;
const uint8_t* payload = buf + i;
int payload_len = (int)len - (int)i;
if (ptype == 0x04) { // ADVERT
if (payload_len < 32 + 4 + 64 + 1) return;
char name[24] = {0};
const uint8_t* app = payload + 32 + 4 + 64;
int app_len = payload_len - (32 + 4 + 64);
if (app_len > 1) { int nl = app_len - 1; if (nl > 23) nl = 23; memcpy(name, app + 1, nl); }
mcStoreContact(payload, name);
strncpy(lastMsg, name[0] ? name : "advert", sizeof(lastMsg) - 1);
lastMsg[sizeof(lastMsg) - 1] = 0; lastMsgNet = 'C'; lastMsgMs = millis();
Serial.printf(" decoded[C](advert): %s\n", name);
return;
}
if (ptype == 0x02) { // TXT_MSG (direct message)
if (payload_len < 2 + 2 + 16) return;
uint8_t dest_hash = payload[0], src_hash = payload[1];
if (dest_hash != mcPub[0]) return; // not addressed to us
const uint8_t* macData = payload + 2;
int macLen = payload_len - 2;
for (int c = 0; c < MC_MAX_CONTACTS; c++) {
if (!mcContacts[c].used || mcContacts[c].pub[0] != src_hash) continue;
uint8_t secret[32];
ed25519_key_exchange(secret, mcContacts[c].pub, mcPrv); // ECDH
uint8_t plain[192];
int n = mcMACThenDecrypt(secret, plain, macData, macLen);
if (n > 5) {
plain[n < (int)sizeof(plain) ? n : (int)sizeof(plain) - 1] = 0;
uint8_t txt_type = plain[4] & 0x03;
const char* text = (const char*)&plain[5];
if (txt_type == 0) { // TXT_TYPE_PLAIN
strncpy(lastMsg, text, sizeof(lastMsg) - 1); lastMsg[sizeof(lastMsg) - 1] = 0;
lastMsgNet = 'C'; lastMsgMs = millis();
Serial.printf(" decoded[C](dm from %s): %s\n", mcContacts[c].name, text);
triggerMessageModal("meshCore", mcContacts[c].name, text);
}
return;
}
}
Serial.printf(" [C] DM for us but no matching contact (src_hash=%02x)\n", src_hash);
}
}
static void drainPacket() {
size_t len = radio.getPacketLength();
uint8_t buf[256];
if (len > sizeof(buf)) len = sizeof(buf);
int state = radio.readData(buf, len);
if (state != RADIOLIB_ERR_NONE) {
Serial.printf(" [%s] RX error state=%d\n", CONFIGS[activeIdx].name, state);
return;
}
NetStat& s = stats[activeIdx];
s.pkts++; s.seen = true; s.lastRssi = (int16_t)radio.getRSSI(); s.lastRxMs = millis();
totalPkts++;
Serial.printf(" [%s] PACKET len=%u rssi=%ddBm snr=%.1fdB : ",
CONFIGS[activeIdx].name, (unsigned)len, s.lastRssi, radio.getSNR());
for (size_t i = 0; i < len; i++) Serial.printf("%02x", buf[i]);
Serial.println();
// meshTastic packets we can decode on-chip today.
if (CONFIGS[activeIdx].letter == 'T') {
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: %s\n", isText ? "(text)" : "(info)", msg);
if (isText) {
char sid[16]; snprintf(sid, sizeof(sid), "!%08x", snd);
triggerMessageModal("meshTastic", sid, msg);
}
}
}
// MeshCore: store heard adverts, decrypt direct messages addressed to us.
if (CONFIGS[activeIdx].letter == 'C') {
handleMeshCorePacket(buf, len);
}
// Reticulum: decode + verify announces we hear.
if (CONFIGS[activeIdx].letter == 'R') {
handleReticulumPacket(buf, len);
}
}
// --- The trippy acid display ------------------------------------------------
// Left: an acid-house smiley wobbling inside expanding psychedelic rings.
// Right: "RMC" and the three network rows with live counts + RX blips.
static void drawAcidSmiley(int cx, int cy) {
// expanding ripple rings (the trip)
for (int k = 0; k < 3; k++) {
int r = (int)((frame * 2 + k * 13) % 42);
if (r > 3) oled.drawCircle(cx, cy, r, U8G2_DRAW_ALL);
}
// face: filled disc, eyes + smile cut back out in background colour
oled.setDrawColor(1);
oled.drawDisc(cx, cy, 18, U8G2_DRAW_ALL);
oled.setDrawColor(0);
oled.drawBox(cx - 8, cy - 8, 3, 7); // left eye
oled.drawBox(cx + 5, cy - 8, 3, 7); // right eye
for (int dx = -10; dx <= 10; dx++) { // grinning acid smile
int dy = 4 + (100 - dx * dx) / 22; // corners up, dips in the middle
oled.drawPixel(cx + dx, cy + dy);
oled.drawPixel(cx + dx, cy + dy + 1);
}
oled.setDrawColor(1);
}
// Periodic freak-out: the face becomes a spinning striped ball with wide-open
// googly eyes and a gaping mouth.
static void drawAcidSmileyFreaky(int cx, int cy) {
const int R = 18;
oled.setDrawColor(1);
oled.drawDisc(cx, cy, R, U8G2_DRAW_ALL);
// rotating stripes carved into the ball (the spin)
float th = frame * 0.35f;
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); // crisp rim
// wide-open googly eyes: white halo -> dark iris -> white pupil highlight,
// so they read against the striped ball (a plain white disc would vanish)
for (int sgn = -1; sgn <= 1; sgn += 2) {
int ex = cx + sgn * 7;
oled.setDrawColor(1); oled.drawDisc(ex, cy - 6, 4, U8G2_DRAW_ALL); // halo
oled.setDrawColor(0); oled.drawDisc(ex, cy - 6, 3, U8G2_DRAW_ALL); // iris
oled.setDrawColor(1); oled.drawDisc(ex, cy - 6, 1, U8G2_DRAW_ALL); // pupil
}
// gaping mouth: white ring around a dark O
oled.setDrawColor(1); oled.drawDisc(cx, cy + 7, 5, U8G2_DRAW_ALL);
oled.setDrawColor(0); oled.drawDisc(cx, cy + 7, 3, U8G2_DRAW_ALL);
oled.setDrawColor(1);
}
static void drawMarchingBorder() {
for (int x = 0; x < 128; x++) {
if (((x + frame) & 3) == 0) { oled.drawPixel(x, 0); oled.drawPixel(x, 63); }
}
for (int y = 0; y < 64; y++) {
if (((y + frame) & 3) == 0) { oled.drawPixel(0, y); oled.drawPixel(127, y); }
}
}
static const PhyConfig* configByLetter(char L) {
for (size_t i = 0; i < NUM_CONFIGS; i++) if (CONFIGS[i].letter == L) return &CONFIGS[i];
return nullptr;
}
static int indexByLetter(char L) {
for (size_t i = 0; i < NUM_CONFIGS; i++) if (CONFIGS[i].letter == L) return (int)i;
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.
bool freaky = (millis() % 9000) < 2500;
if (freaky) drawAcidSmileyFreaky(31, 33);
else drawAcidSmiley(31, 33);
drawMarchingBorder();
// "RTC" punk header on the right
oled.setFont(u8g2_font_bubble_tr);
const char* rtc = "RTC";
int rw = oled.getStrWidth(rtc);
int rx = 64 + (64 - rw) / 2;
if ((frame / 10) & 1) { // strobe invert
oled.drawBox(64, 0, 64, 22);
oled.setDrawColor(0); oled.drawStr(rx, 19, rtc); oled.setDrawColor(1);
} else {
oled.drawStr(rx, 19, rtc);
}
// three network rows, in R T C order
const char order[3] = {'R', 'T', 'C'};
oled.setFont(u8g2_font_6x10_tf);
uint32_t now = millis();
for (int r = 0; r < 3; r++) {
int idx = indexByLetter(order[r]);
if (idx < 0) continue;
const NetStat& s = stats[idx];
int y = 32 + r * 11; // baseline (below the taller header)
bool active = (idx == activeIdx);
if (active) oled.drawFrame(64, y - 9, 64, 11);
char line[24];
if (s.seen) snprintf(line, sizeof(line), "%c %4lu", order[r], (unsigned long)s.pkts);
else snprintf(line, sizeof(line), "%c --", order[r]);
oled.drawStr(67, y, line);
// RX activity blip (filled when a packet landed in the last 400ms)
bool blip = s.seen && (now - s.lastRxMs < 400);
if (blip) oled.drawDisc(121, y - 4, 3, U8G2_DRAW_ALL);
else oled.drawCircle(121, y - 4, 3, U8G2_DRAW_ALL);
}
// last decoded event along the bottom of the right column
if (lastMsg[0]) {
oled.setFont(u8g2_font_4x6_tf);
char line[24];
snprintf(line, sizeof(line), "%c:%s", lastMsgNet, lastMsg);
line[15] = 0; // truncate to fit the ~60px column
oled.drawStr(66, 62, line);
}
oled.sendBuffer();
}
void setup() {
Serial.begin(115200);
uint32_t t0 = millis();
while (!Serial && millis() - t0 < 2000) { }
Serial.println("\n=== archy-messh Phase 1 — RTC scanner + acid display ===");
// Power the OLED rail, then reset the panel, then bring up U8g2.
pinMode(PIN_VEXT, OUTPUT); digitalWrite(PIN_VEXT, LOW); delay(60);
pinMode(PIN_OLED_RST, OUTPUT);
digitalWrite(PIN_OLED_RST, LOW); delay(20);
digitalWrite(PIN_OLED_RST, HIGH); delay(20);
oled.begin();
oled.setBusClock(400000);
// Boot splash: the device identity, shared across all three networks.
oled.clearBuffer();
drawAcidSmiley(64, 24);
oled.setFont(u8g2_font_6x10_tf);
int nw = oled.getStrWidth(DEVICE_NAME);
oled.drawStr((128 - nw) / 2, 52, DEVICE_NAME);
oled.setFont(u8g2_font_5x8_tf);
const char* sub = "R T C bridge";
int sw = oled.getStrWidth(sub);
oled.drawStr((128 - sw) / 2, 62, sub);
oled.sendBuffer();
Serial.printf("Device identity (all networks): %s / %s\n", DEVICE_NAME, DEVICE_SHORT);
delay(2200);
SPI.begin(PIN_LORA_SCK, PIN_LORA_MISO, PIN_LORA_MOSI, PIN_LORA_NSS);
const PhyConfig& first = CONFIGS[0];
int state = radio.begin(first.freqMHz, first.bwKHz, first.sf, first.cr,
first.syncWord, RX_TX_POWER, first.preamble, TCXO_VOLTAGE);
Serial.printf("radio.begin -> %d %s\n", state, state == RADIOLIB_ERR_NONE ? "(OK)" : "(ERR)");
if (state != RADIOLIB_ERR_NONE) {
Serial.println("!! SX1262 init failed — check wiring/TCXO. Halting.");
while (true) { drawUI(); frame++; delay(60); } // still show the trip
}
radio.setDio2AsRfSwitch(true);
radio.setDio1Action(onDio1);
radio.setOutputPower(14); // EU868-safe TX power for announces
// Derive this device's Meshtastic NodeNum from the last 4 bytes of the MAC.
uint8_t mac[6]; esp_read_mac(mac, ESP_MAC_WIFI_STA);
nodeNum = ((uint32_t)mac[2] << 24) | ((uint32_t)mac[3] << 16) |
((uint32_t)mac[4] << 8) | mac[5];
snprintf(nodeId, sizeof(nodeId), "!%08x", nodeNum);
Serial.printf("Meshtastic node: %s (%s)\n", DEVICE_NAME, nodeId);
initMeshCoreIdentity();
initReticulumIdentity();
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[560]; static size_t n = 0; // large enough for RXR: hex inject
while (Serial.available()) {
char c = Serial.read();
if (c == '\r') continue;
if (c == '\n') {
buf[n] = 0;
// Test hook: "RXR:<hex>" injects raw bytes into the Reticulum RX path so
// the announce parser can be validated against RNS vectors without a radio.
if (n > 4 && strncmp(buf, "RXR:", 4) == 0) {
static uint8_t inj[256]; size_t il = 0;
for (size_t k = 4; buf[k] && buf[k + 1] && il < sizeof(inj); k += 2) {
auto hx = [](char ch) -> int {
if (ch >= '0' && ch <= '9') return ch - '0';
if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
return 0; };
inj[il++] = (uint8_t)((hx(buf[k]) << 4) | hx(buf[k + 1]));
}
Serial.printf(" [inject] RNS packet %u bytes\n", (unsigned)il);
handleReticulumPacket(inj, il);
n = 0;
continue;
}
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 if (L == 'C') {
// DM the text to every MeshCore contact we've heard an advert from.
int sent = 0;
for (int c = 0; c < MC_MAX_CONTACTS; c++)
if (mcContacts[c].used) { sendMeshCoreText(mcContacts[c].pub, txText); sent++; }
if (sent == 0) Serial.printf(" [C] no known contacts yet — nobody to DM\n");
} else {
// Reticulum TX 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) {
activeIdx = (activeIdx + 1) % NUM_CONFIGS;
applyConfig(CONFIGS[activeIdx]);
dwellStart = millis();
}
flushTxOnWindow();
if (rxFlag) {
rxFlag = false;
drainPacket();
radio.startReceive();
}
// Announce ourselves as a Meshtastic node while tuned to that PHY.
if (CONFIGS[activeIdx].letter == 'T' &&
(lastAnnounceMs == 0 || millis() - lastAnnounceMs > ANNOUNCE_INTERVAL_MS)) {
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();
}
// Announce ourselves as a Reticulum destination while tuned to that PHY.
if (CONFIGS[activeIdx].letter == 'R' &&
(lastRnsAnnounceMs == 0 || millis() - lastRnsAnnounceMs > RNS_ANNOUNCE_INTERVAL_MS)) {
sendReticulumAnnounce();
lastRnsAnnounceMs = millis();
}
drawUI();
frame++;
delay(45); // ~20 fps
}