feat: Phase 1 firmware — time-multiplexed tri-protocol RX scanner

First Phase 1 increment toward the single-device 3-in-1: one Heltec V3
(ESP32-S3 + single SX1262) rotating the radio through the Meshtastic /
MeshCore / Reticulum PHY presets, logging every raw packet it demodulates
per dwell window. No framing/crypto/bridge yet — proves the RX loop first.

Validated on hardware: radio.begin() clean, all three presets cycle, and
the Meshtastic window received live broadcast packets (-36..-41 dBm). The
single-radio time-multiplex premise (ARCHITECTURE.md §2) holds.

MeshCore sync word and Reticulum PHY are flagged placeholders in CONFIGS[].
Deps pinned (espressif32 7.0.1, RadioLib 7.7.1). Adds venv/pycache/storage
to .gitignore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-01 15:45:36 +01:00
parent dbfdf1c851
commit c3f404872e
4 changed files with 250 additions and 0 deletions

9
.gitignore vendored
View File

@ -3,3 +3,12 @@
build/
.pio/
.vscode/
# Python (host-bridge)
venv/
__pycache__/
*.pyc
# Reticulum/LXMF runtime storage
rns-storage/
rns-store/

55
firmware/README.md Normal file
View File

@ -0,0 +1,55 @@
# firmware (Phase 1)
Embedded firmware for the single-device **3-in-1**: one Heltec WiFi LoRa 32 V3
(ESP32-S3 + one SX1262) time-multiplexing its single radio across the
Meshtastic / MeshCore / Reticulum PHY configs. See `../docs/ARCHITECTURE.md`
§2-§3 for why Phase 1 is a time-multiplexed single radio (Option A).
## Current increment: tri-protocol RX scanner
`src/main.cpp` is the **first** Phase 1 step. It does not bridge, decode, or
transmit yet — it rotates the SX1262 through each network's PHY preset, listens
for a fixed dwell window, and logs every raw LoRa packet it demodulates
(length, RSSI, SNR, hex). The point is to answer the project's central open
question before building any protocol stacks: *can one re-tuned radio actually
hear all three networks?*
**Validated on hardware (2026-07-01):** flashed to a Heltec V3, the scanner
`radio.begin()`'d cleanly, cycled all three presets, and received **live
Meshtastic broadcast packets** (payloads prefixed `ffffffff`) at 36 to
41 dBm during the Meshtastic dwell. The single-radio time-multiplex premise
holds. MeshCore/Reticulum windows were silent — expected, see open items below.
## Open items baked into the config table
- **MeshCore sync word** (`src/main.cpp`, `CONFIGS[]`) is a guess (`0x12`,
RadioLib's private default). Confirm against MeshCore `src/` — this is the
same open item as `../docs/ARCHITECTURE.md` §1/§5.
- **Reticulum PHY** has no protocol-mandated preset; it's operator-configured.
The entry is a placeholder — set it to your actual RNS RNode interface
config or that window hears nothing.
- **Meshtastic frequency** is set to the documented 869.525 MHz, but Meshtastic
hashes the channel name to a slot within the region plan, so real
deployments may sit on a different slot.
## Build / flash / monitor
Requires PlatformIO (`pip install platformio`). Versions are pinned in
`platformio.ini` (espressif32 7.0.1, RadioLib 7.7.1) — do not float them.
```bash
pio run # compile
pio run -t upload --upload-port /dev/cu.usbserial-0001 # flash (adjust port)
pio device monitor -b 115200 # watch the scan
```
## Roadmap from here
1. **RX decode per protocol** — turn logged raw packets into `{sender, text}`
(Meshtastic 16-byte header + AES-CTR; MeshCore framing; Reticulum via
microReticulum). Start with Meshtastic since we already receive it.
2. **On-device bridge/dedup** — port the Phase 0 `bridge_core` relay+dedup
logic to C++.
3. **TX** — re-originate a decoded message onto the other two networks within
their dwell windows (queue between windows).
4. Revisit dwell timing / RAM budget with real per-protocol state (§3).

18
firmware/platformio.ini Normal file
View File

@ -0,0 +1,18 @@
; archy-messh Phase 1 firmware — single Heltec WiFi LoRa 32 V3, one SX1262
; time-multiplexed across the Meshtastic / MeshCore / Reticulum PHY configs.
;
; See ../docs/ARCHITECTURE.md §2-§3 for why Phase 1 is a single time-multiplexed
; radio (Option A) rather than the 3-separate-boards host bridge of Phase 0.
;
; Versions pinned exactly — do not float.
[env:heltec_wifi_lora_32_V3]
platform = platformio/espressif32@7.0.1
board = heltec_wifi_lora_32_V3
framework = arduino
monitor_speed = 115200
monitor_filters = time, default
build_flags =
-D ARCHY_MESSH_FW
lib_deps =
jgromes/RadioLib@7.7.1

168
firmware/src/main.cpp Normal file
View File

@ -0,0 +1,168 @@
// archy-messh Phase 1 — time-multiplexed tri-protocol RX scanner
// ---------------------------------------------------------------------------
// Target: Heltec WiFi LoRa 32 V3 (ESP32-S3 + single Semtech SX1262).
//
// This is the FIRST Phase 1 increment. It does not yet bridge, decode, or
// transmit anything. Its only job is to answer the project's central open
// question (see docs/ARCHITECTURE.md §1-§2):
//
// Can one SX1262, re-tuned on a schedule, actually hear raw LoRa traffic
// from the Meshtastic, MeshCore and Reticulum networks — and do the PHY
// configs even work on this board?
//
// It rotates the radio through each network's PHY config, listens for a fixed
// dwell window, and logs every raw packet it demodulates (length, RSSI, SNR,
// hex). No framing/crypto/dedup yet — those layers build on top of a working
// RX loop, so we prove the RX loop first.
//
// What this increment proves WITHOUT any other radios nearby:
// - correct Heltec V3 SX1262 wiring / TCXO / RF-switch (radio.begin() == OK)
// - clean reconfiguration of the single radio between three PHY presets
// What it can only prove WITH real traffic on air:
// - whether time-multiplexed dwell actually catches other nodes' packets
// ---------------------------------------------------------------------------
#include <Arduino.h>
#include <RadioLib.h>
// --- Heltec WiFi LoRa 32 V3 SX1262 pin map (from Heltec V3 schematic) -------
static const int PIN_LORA_NSS = 8; // SPI chip select
static const int PIN_LORA_DIO1 = 14; // IRQ
static const int PIN_LORA_NRST = 12; // reset
static const int PIN_LORA_BUSY = 13; // busy
static const int PIN_LORA_SCK = 9;
static const int PIN_LORA_MISO = 11;
static const int PIN_LORA_MOSI = 10;
// Heltec V3 drives the SX1262 reference oscillator from a 1.8V TCXO and uses
// DIO2 as the TX/RX RF switch. Both are board facts, not tunables.
static const float TCXO_VOLTAGE = 1.8f;
static const int8_t RX_TX_POWER = 10; // unused in RX-only scan; begin() needs a value
SX1262 radio = new Module(PIN_LORA_NSS, PIN_LORA_DIO1, PIN_LORA_NRST, PIN_LORA_BUSY);
// --- The three PHY presets --------------------------------------------------
// Values from docs/ARCHITECTURE.md §1. Items flagged CONFIRM/PLACEHOLDER are
// the project's known open questions — the whole point of this scan is to test
// them against reality, so they are deliberately explicit here.
struct PhyConfig {
const char* name;
float freqMHz;
float bwKHz;
uint8_t sf;
uint8_t cr; // coding-rate denominator: 4/cr (so 5 == 4/5)
uint8_t syncWord;
uint16_t preamble;
};
static const PhyConfig CONFIGS[] = {
// Meshtastic EU868 "LongFast" — well documented. NOTE: Meshtastic actually
// hashes the channel name to a slot within the region plan, so a single
// fixed frequency may not match every deployment (ARCHITECTURE.md §1).
{ "meshtastic", 869.525f, 250.0f, 11, 5, 0x2b, 16 },
// MeshCore compiled default is the same PHY as Meshtastic. Its sync word is
// UNCONFIRMED (ARCHITECTURE.md §1 open item) — 0x12 is RadioLib's private
// default and only a GUESS here. Community has also moved to narrowband
// SF7-8 / BW62.5kHz (Oct 2025), not reflected yet.
{ "meshcore", 869.525f, 250.0f, 11, 5, 0x12, 16 },
// Reticulum has NO fixed preset — freq/BW/SF/CR are operator-configured per
// deployment. This is a PLACEHOLDER; set it to whatever your actual RNS
// RNode interface uses or this window will hear nothing.
{ "reticulum", 867.200f, 125.0f, 8, 5, 0x12, 16 },
};
static const size_t NUM_CONFIGS = sizeof(CONFIGS) / sizeof(CONFIGS[0]);
// Dwell per network before rotating to the next. 2s is a starting point; the
// coverage/latency tradeoff of this value is exactly what Phase 1 must measure.
static const uint32_t DWELL_MS = 2000;
// --- RX interrupt plumbing --------------------------------------------------
static volatile bool rxFlag = false;
ICACHE_RAM_ATTR static void onDio1() { rxFlag = true; }
static void logResult(const char* what, int state) {
Serial.printf(" %s -> %d%s\n", what, state,
state == RADIOLIB_ERR_NONE ? " (OK)" : " (ERR)");
}
// Apply a PHY config to the already-initialised radio and start listening.
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;
}
static void drainPacket(const PhyConfig& c) {
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", c.name, state);
return;
}
Serial.printf(" [%s] PACKET len=%u rssi=%.1fdBm snr=%.1fdB : ",
c.name, (unsigned)len, radio.getRSSI(), radio.getSNR());
for (size_t i = 0; i < len; i++) Serial.printf("%02x", buf[i]);
Serial.println();
}
void setup() {
Serial.begin(115200);
uint32_t t0 = millis();
while (!Serial && millis() - t0 < 3000) { /* wait briefly for USB CDC */ }
Serial.println();
Serial.println("=== archy-messh Phase 1 — tri-protocol RX scanner ===");
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);
logResult("radio.begin", state);
if (state != RADIOLIB_ERR_NONE) {
Serial.println("!! SX1262 init failed — check wiring/TCXO. Halting.");
while (true) delay(1000);
}
// DIO2 controls the antenna RF switch on the Heltec V3.
logResult("setDio2AsRfSwitch", radio.setDio2AsRfSwitch(true));
radio.setDio1Action(onDio1);
Serial.printf("Scanning %u configs, %lums dwell each.\n",
(unsigned)NUM_CONFIGS, (unsigned long)DWELL_MS);
}
void loop() {
for (size_t i = 0; i < NUM_CONFIGS; i++) {
const PhyConfig& c = CONFIGS[i];
Serial.printf("[%s] %.3fMHz BW%.1f SF%u CR4/%u sync0x%02x\n",
c.name, c.freqMHz, c.bwKHz, c.sf, c.cr, c.syncWord);
if (!applyConfig(c)) {
Serial.printf(" [%s] !! failed to apply PHY config — skipping\n", c.name);
continue;
}
uint32_t start = millis();
while (millis() - start < DWELL_MS) {
if (rxFlag) {
rxFlag = false;
drainPacket(c);
radio.startReceive(); // re-arm for the rest of the dwell window
}
delay(2);
}
}
}