feat: Meshtastic TX — device announces as a named node (messageable)

The device now transmits a NodeInfo (User protobuf: id/long_name/short_name)
on the LongFast primary channel every 60s, so it appears in other Meshtastic
clients as 'Reticutasticore' / 'RTC'. NodeNum derived from the MAC
(!4359d2d0). No public key advertised, so DMs to us fall back to channel
encryption — which the existing RX path already decodes and displays.

Confirmed on hardware: announce tx=0 (success) on the first T-window.

Also spec the planned message-received animation (upside-down spinning
smiley + lightning bolts -> Meshtastic-style modal) in the README.

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

View File

@ -53,6 +53,13 @@ pio run -t upload --upload-port /dev/cu.usbserial-0001 # flash (adjust port)
pio device monitor -b 115200 # watch the scan
```
## Planned: message-received animation
When a text message arrives, play a short intro before showing it: the striped
smiley spins **upside-down / rotated**, little lightning bolts flash, then a
Meshtastic-style **message modal** slides in with the sender + text. (Spec'd,
not yet built.)
## Roadmap from here
1. **RX decode per protocol** — turn logged raw packets into `{sender, text}`

View File

@ -24,6 +24,8 @@
#include <Wire.h>
#include <math.h>
#include "mbedtls/aes.h"
#include "esp_mac.h"
#include "esp_random.h"
// --- Heltec V3 SX1262 pin map (from the Heltec V3 schematic) ----------------
static const int PIN_LORA_NSS = 8;
@ -98,6 +100,12 @@ 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
// --- RX interrupt -----------------------------------------------------------
static volatile bool rxFlag = false;
ICACHE_RAM_ATTR static void onDio1() { rxFlag = true; }
@ -186,6 +194,57 @@ static bool decodeMeshtastic(const uint8_t* pkt, size_t len, char* out, size_t o
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\n",
DEVICE_NAME, nodeId, pid, st);
radio.startReceive();
}
static void drainPacket() {
size_t len = radio.getPacketLength();
uint8_t buf[256];
@ -380,6 +439,14 @@ void setup() {
}
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);
activeIdx = 0;
applyConfig(CONFIGS[0]);
@ -399,6 +466,12 @@ void loop() {
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();
}
drawUI();
frame++;
delay(45); // ~20 fps