diff --git a/.gitignore b/.gitignore index 49b5f93..0677e85 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,12 @@ build/ .pio/ .vscode/ + +# Python (host-bridge) +venv/ +__pycache__/ +*.pyc + +# Reticulum/LXMF runtime storage +rns-storage/ +rns-store/ diff --git a/firmware/README.md b/firmware/README.md new file mode 100644 index 0000000..1943bed --- /dev/null +++ b/firmware/README.md @@ -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). diff --git a/firmware/platformio.ini b/firmware/platformio.ini new file mode 100644 index 0000000..5884249 --- /dev/null +++ b/firmware/platformio.ini @@ -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 diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp new file mode 100644 index 0000000..d3f4e9e --- /dev/null +++ b/firmware/src/main.cpp @@ -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 +#include + +// --- 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); + } + } +}