Rename to RTNode-HeltecV4, replace 'boundary' with 'transport' in docs
- Rename project from RNodeTHV4 to RTNode-HeltecV4 - Update GitHub repo URL, firmware binary names (rtnode_heltec_v4.bin, rtnode_heltec_v3.bin) - Replace 'boundary node' with 'transport node' in README and flash.py descriptions - Update OLED title bar to 'RTNode' - Bump version to v1.0.18
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,7 +3,11 @@
|
||||
*.pyc
|
||||
TODO
|
||||
rnodethv4_firmware.bin
|
||||
rtnode_heltecv4_firmware.bin
|
||||
rtnode_heltec_v4.bin
|
||||
rnodethv3_firmware.bin
|
||||
rtnode_heltecv3_firmware.bin
|
||||
rtnode_heltec_v3.bin
|
||||
.firmware_cache/
|
||||
Release/*.hex
|
||||
Release/*.zip
|
||||
|
||||
@@ -265,6 +265,32 @@ static void config_send_html() {
|
||||
html += F(" dBm (with PA)</p>");
|
||||
#endif
|
||||
|
||||
// ── IFAC (Interface Access Code) Section ──
|
||||
html += F(
|
||||
"<h2>🔒 Network Access (IFAC)</h2>"
|
||||
"<p class='note'>Set a network name and/or passphrase to restrict LoRa interface access. "
|
||||
"Only nodes with matching settings can communicate. Both fields are optional.</p>"
|
||||
"<label>IFAC</label>"
|
||||
"<select name='ifac_en'>"
|
||||
);
|
||||
html += F("<option value='0'");
|
||||
if (!boundary_state.ifac_enabled) html += F(" selected");
|
||||
html += F(">Disabled</option>");
|
||||
html += F("<option value='1'");
|
||||
if (boundary_state.ifac_enabled) html += F(" selected");
|
||||
html += F(">Enabled</option>");
|
||||
html += F("</select>");
|
||||
|
||||
html += F("<label>Network Name</label>");
|
||||
html += F("<input name='ifac_name' maxlength='32' placeholder='e.g. MyNetwork' value='");
|
||||
html += String(boundary_state.ifac_netname);
|
||||
html += F("'>");
|
||||
|
||||
html += F("<label>Passphrase</label>");
|
||||
html += F("<input name='ifac_pass' type='password' maxlength='32' placeholder='Shared secret' value='");
|
||||
html += String(boundary_state.ifac_passphrase);
|
||||
html += F("'>");
|
||||
|
||||
// ── Options Section ──
|
||||
html += F(
|
||||
"<h2>⚙ Options</h2>"
|
||||
@@ -362,6 +388,24 @@ static void config_handle_save() {
|
||||
boundary_state.ap_tcp_port = (uint16_t)config_server->arg("ap_tcp_port").toInt();
|
||||
if (boundary_state.ap_tcp_port == 0) boundary_state.ap_tcp_port = 4242;
|
||||
|
||||
// ── IFAC settings ──
|
||||
boundary_state.ifac_enabled = (config_server->arg("ifac_en").toInt() == 1);
|
||||
|
||||
String ifac_name = config_server->arg("ifac_name");
|
||||
memset(boundary_state.ifac_netname, 0, sizeof(boundary_state.ifac_netname));
|
||||
strncpy(boundary_state.ifac_netname, ifac_name.c_str(), sizeof(boundary_state.ifac_netname) - 1);
|
||||
|
||||
String ifac_pass = config_server->arg("ifac_pass");
|
||||
memset(boundary_state.ifac_passphrase, 0, sizeof(boundary_state.ifac_passphrase));
|
||||
strncpy(boundary_state.ifac_passphrase, ifac_pass.c_str(), sizeof(boundary_state.ifac_passphrase) - 1);
|
||||
|
||||
// If IFAC is enabled but both fields are empty, disable it
|
||||
if (boundary_state.ifac_enabled &&
|
||||
boundary_state.ifac_netname[0] == '\0' &&
|
||||
boundary_state.ifac_passphrase[0] == '\0') {
|
||||
boundary_state.ifac_enabled = false;
|
||||
}
|
||||
|
||||
// Save boundary config to EEPROM
|
||||
boundary_save_config();
|
||||
|
||||
|
||||
@@ -68,7 +68,12 @@
|
||||
#define ADDR_CONF_AP_SSID 0x93 // AP SSID (33 bytes, null-terminated)
|
||||
#define ADDR_CONF_AP_PSK 0xB4 // AP PSK (33 bytes, null-terminated)
|
||||
#define ADDR_CONF_WIFI_EN 0xD5 // WiFi enable flag (1 byte, 0x73 = enabled)
|
||||
// Total: 0xD6 (214 bytes used of 256 CONFIG area)
|
||||
// IFAC (Interface Access Code) settings for LoRa interface
|
||||
#define ADDR_CONF_IFAC_EN 0xD6 // IFAC enable flag (1 byte, 0x73 = enabled)
|
||||
#define ADDR_CONF_IFAC_NAME 0xD7 // Network name (33 bytes, null-terminated)
|
||||
#define ADDR_CONF_IFAC_PASS 0xF8 // Passphrase (33 bytes, null-terminated)
|
||||
// Total: 0x119 (281 bytes — extends beyond 256-byte CONFIG area into
|
||||
// unused EEPROM gap; safe on ESP32 where EEPROM starts at 824)
|
||||
|
||||
#define BOUNDARY_ENABLE_BYTE 0x73
|
||||
|
||||
@@ -89,6 +94,11 @@ struct BoundaryState {
|
||||
char ap_ssid[33]; // AP SSID
|
||||
char ap_psk[33]; // AP PSK (empty = open)
|
||||
|
||||
// IFAC settings for LoRa interface
|
||||
bool ifac_enabled; // Whether IFAC is configured
|
||||
char ifac_netname[33]; // Network name (empty = not set)
|
||||
char ifac_passphrase[33]; // Passphrase (empty = not set)
|
||||
|
||||
// Runtime state
|
||||
bool wifi_connected;
|
||||
bool tcp_connected; // Backbone (WAN) connected
|
||||
@@ -123,6 +133,9 @@ inline void boundary_load_config() {
|
||||
boundary_state.ap_tcp_port = 4242;
|
||||
boundary_state.ap_ssid[0] = '\0';
|
||||
boundary_state.ap_psk[0] = '\0';
|
||||
boundary_state.ifac_enabled = false;
|
||||
boundary_state.ifac_netname[0] = '\0';
|
||||
boundary_state.ifac_passphrase[0] = '\0';
|
||||
// Mark as enabled since we're compiled with BOUNDARY_MODE
|
||||
boundary_state.enabled = true;
|
||||
return;
|
||||
@@ -181,6 +194,22 @@ inline void boundary_load_config() {
|
||||
}
|
||||
boundary_state.ap_psk[32] = '\0';
|
||||
|
||||
// Load IFAC settings
|
||||
boundary_state.ifac_enabled =
|
||||
(EEPROM.read(config_addr(ADDR_CONF_IFAC_EN)) == BOUNDARY_ENABLE_BYTE);
|
||||
|
||||
for (int i = 0; i < 32; i++) {
|
||||
boundary_state.ifac_netname[i] = EEPROM.read(config_addr(ADDR_CONF_IFAC_NAME + i));
|
||||
if (boundary_state.ifac_netname[i] == (char)0xFF) boundary_state.ifac_netname[i] = '\0';
|
||||
}
|
||||
boundary_state.ifac_netname[32] = '\0';
|
||||
|
||||
for (int i = 0; i < 32; i++) {
|
||||
boundary_state.ifac_passphrase[i] = EEPROM.read(config_addr(ADDR_CONF_IFAC_PASS + i));
|
||||
if (boundary_state.ifac_passphrase[i] == (char)0xFF) boundary_state.ifac_passphrase[i] = '\0';
|
||||
}
|
||||
boundary_state.ifac_passphrase[32] = '\0';
|
||||
|
||||
// Reset runtime state
|
||||
boundary_state.packets_bridged_lora_to_tcp = 0;
|
||||
boundary_state.packets_bridged_tcp_to_lora = 0;
|
||||
@@ -218,6 +247,18 @@ inline void boundary_save_config() {
|
||||
}
|
||||
EEPROM.write(config_addr(ADDR_CONF_AP_PSK + 32), 0x00);
|
||||
|
||||
// IFAC settings
|
||||
EEPROM.write(config_addr(ADDR_CONF_IFAC_EN),
|
||||
boundary_state.ifac_enabled ? BOUNDARY_ENABLE_BYTE : 0x00);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
EEPROM.write(config_addr(ADDR_CONF_IFAC_NAME + i), boundary_state.ifac_netname[i]);
|
||||
}
|
||||
EEPROM.write(config_addr(ADDR_CONF_IFAC_NAME + 32), 0x00);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
EEPROM.write(config_addr(ADDR_CONF_IFAC_PASS + i), boundary_state.ifac_passphrase[i]);
|
||||
}
|
||||
EEPROM.write(config_addr(ADDR_CONF_IFAC_PASS + 32), 0x00);
|
||||
|
||||
EEPROM.commit();
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ struct BoundaryState {
|
||||
uint16_t ap_tcp_port;
|
||||
char ap_ssid[33];
|
||||
char ap_psk[33];
|
||||
// IFAC settings for LoRa interface
|
||||
bool ifac_enabled;
|
||||
char ifac_netname[33];
|
||||
char ifac_passphrase[33];
|
||||
bool wifi_connected;
|
||||
bool tcp_connected; // Backbone (WAN) connected
|
||||
bool ap_tcp_connected; // Local TCP server (LAN) has client
|
||||
@@ -940,7 +944,7 @@ void draw_disp_area() {
|
||||
disp_area.setTextColor(SSD1306_BLACK);
|
||||
disp_area.setTextSize(1);
|
||||
disp_area.setCursor(4, 7);
|
||||
disp_area.print("RNodeTHV4");
|
||||
disp_area.print("RTNode");
|
||||
|
||||
disp_area.setTextColor(SSD1306_WHITE);
|
||||
|
||||
|
||||
@@ -415,7 +415,7 @@ MTU CLAMP: path=8192 ph=1064 nh=1064 -> clamped=1064
|
||||
### Test Reproduction
|
||||
|
||||
```bash
|
||||
cd test-harnesses/RNodeTHV4
|
||||
cd test-harnesses/RTNode-HeltecV4
|
||||
bash run_test.sh
|
||||
```
|
||||
|
||||
|
||||
64
README.md
64
README.md
@@ -1,6 +1,6 @@
|
||||
# RNodeTHV4 — Reticulum Boundary Node for Heltec WiFi LoRa 32 V3 / V4
|
||||
# RTNode-HeltecV4 — Reticulum Transport Node for Heltec WiFi LoRa 32 V3 / V4
|
||||
|
||||
A custom firmware for the **Heltec WiFi LoRa 32 V3** and **V4** (ESP32-S3 + SX1262) that operates as a **Boundary Node** — bridging a local LoRa radio network with a remote TCP/IP backbone (such as [rmap.world](https://rmap.world)) over WiFi.
|
||||
A custom firmware for the **Heltec WiFi LoRa 32 V3** and **V4** (ESP32-S3 + SX1262) that operates as a **Transport Node** — bridging a local LoRa radio network with a remote TCP/IP backbone (such as [rmap.world](https://rmap.world)) over WiFi.
|
||||
|
||||
```
|
||||
Android / Sideband Remote
|
||||
@@ -10,8 +10,8 @@ A custom firmware for the **Heltec WiFi LoRa 32 V3** and **V4** (ESP32-S3 + SX12
|
||||
└──────────┘ │ rmap.world)
|
||||
LoRa Radio ▲
|
||||
│ ┌──────────────┐ WiFi │
|
||||
◄── RF mesh ──────► │ RNodeTHV4 │ ◄─TCP──┘
|
||||
│ │ Boundary Node│ ▲
|
||||
◄── RF mesh ──────► │ RTNode-HV4 │ ◄─TCP──┘
|
||||
│ │Transport Node│ ▲
|
||||
Other RNodes └──────────────┘ │
|
||||
┌───┴───┐
|
||||
│ Router│
|
||||
@@ -56,8 +56,8 @@ The easiest way to flash a pre-built firmware. You only need Python 3 and a USB
|
||||
pip install esptool
|
||||
|
||||
# Clone this repo (or download just flash.py + the firmware binary)
|
||||
git clone https://github.com/jrl290/RNodeTHV4.git
|
||||
cd RNodeTHV4
|
||||
git clone https://github.com/jrl290/RTNode-HeltecV4.git
|
||||
cd RTNode-HeltecV4
|
||||
|
||||
# Download latest firmware from GitHub Releases and flash
|
||||
# (auto-detects V3 vs V4 from flash size)
|
||||
@@ -68,7 +68,7 @@ python flash.py --download --board v3
|
||||
python flash.py --download --board v4
|
||||
|
||||
# Or flash a local binary
|
||||
python flash.py --file rnodethv4_firmware.bin
|
||||
python flash.py --file rtnode_heltec_v4.bin
|
||||
```
|
||||
|
||||
The flash utility auto-detects whether a V3 or V4 is connected by querying the flash size (8MB = V3, 16MB = V4). You can override with `--board v3` or `--board v4`. It will list all available serial ports and prompt you to choose one. If no ports are detected, you may need to hold the **BOOT** button while pressing **RESET** to enter download mode.
|
||||
@@ -80,8 +80,8 @@ For development or customization:
|
||||
```bash
|
||||
# Prerequisites: PlatformIO installed (VS Code extension or CLI)
|
||||
|
||||
git clone https://github.com/jrl290/RNodeTHV4.git
|
||||
cd RNodeTHV4
|
||||
git clone https://github.com/jrl290/RTNode-HeltecV4.git
|
||||
cd RTNode-HeltecV4
|
||||
|
||||
# Build for V4
|
||||
pio run -e heltec_V4_boundary
|
||||
@@ -102,12 +102,12 @@ pio device monitor -e heltec_V4_boundary
|
||||
|
||||
### Option C: Manual esptool Flash
|
||||
|
||||
If you have the merged binary (`rnodethv4_firmware.bin`), you can flash it with a single esptool command:
|
||||
If you have the merged binary (`rtnode_heltec_v4.bin`), you can flash it with a single esptool command:
|
||||
|
||||
```bash
|
||||
esptool.py --chip esp32s3 --port /dev/ttyACM0 --baud 921600 \
|
||||
write_flash -z --flash_mode qio --flash_freq 80m --flash_size 16MB \
|
||||
0x0 rnodethv4_firmware.bin
|
||||
0x0 rtnode_heltec_v4.bin
|
||||
```
|
||||
|
||||
Replace `/dev/ttyACM0` with your serial port (`/dev/cu.usbmodem*` on macOS, `COM3` on Windows).
|
||||
@@ -183,7 +183,7 @@ The 128×64 OLED is split into two panels:
|
||||
### Right Panel — Device Info (64×64)
|
||||
|
||||
```
|
||||
▓▓ RNodeTHV4 ▓▓ ← title bar (inverted)
|
||||
▓▓ RTNode-HV4 ▓▓ ← title bar (inverted)
|
||||
867.200MHz ← LoRa frequency
|
||||
SF7 125k ← spreading factor & bandwidth
|
||||
──────────────── ← separator
|
||||
@@ -204,14 +204,14 @@ The firmware runs up to **three RNS interfaces** simultaneously, using different
|
||||
The LoRa radio operates in **Access Point mode**. In Reticulum, this means:
|
||||
- The interface broadcasts its own announces but **blocks rebroadcast of remote announces** from crossing to LoRa
|
||||
- This prevents backbone announces (hundreds of remote destinations) from flooding the limited-bandwidth LoRa channel
|
||||
- Local nodes discover the boundary node directly; the boundary node answers path requests for remote destinations from its cache
|
||||
- Local nodes discover the transport node directly; the transport node answers path requests for remote destinations from its cache
|
||||
|
||||
### TCP Backbone Interface — `MODE_BOUNDARY`
|
||||
|
||||
The TCP backbone connection uses `MODE_BOUNDARY` (`0x20`), a custom implementation of the Reticulum boundary concept adapted for the memory-constrained ESP32 environment. In this implementation, boundary mode means:
|
||||
The TCP backbone connection uses `MODE_BOUNDARY` (`0x20`), a custom transport mode adapted for the memory-constrained ESP32 environment. In this mode:
|
||||
- Incoming announces from the backbone are received and cached, but **not stored in the path table by default** — only stored when specifically requested via a path request from a local LoRa node
|
||||
- This prevents the path table (limited to 48 entries on ESP32) from being overwhelmed by thousands of backbone destinations
|
||||
- When the path table needs to be culled, **boundary-mode paths are evicted first**, preserving locally-needed LoRa paths
|
||||
- When the path table needs to be culled, **backbone-learned paths are evicted first**, preserving locally-needed LoRa paths
|
||||
|
||||
### Optional Local TCP Server — `MODE_ACCESS_POINT`
|
||||
|
||||
@@ -228,18 +228,18 @@ The ESP32-S3 has limited RAM compared to a desktop Reticulum node. Several custo
|
||||
|
||||
### Table Size Limits
|
||||
|
||||
| Table | Default (Desktop) | RNodeTHV4 | Rationale |
|
||||
| Table | Default (Desktop) | RTNode-HeltecV4 | Rationale |
|
||||
|-------|-------------------|-----------|-----------|
|
||||
| Path table (`_destination_table`) | Unbounded | **48 entries** | Prevents unbounded growth; boundary paths evicted first |
|
||||
| Path table (`_destination_table`) | Unbounded | **48 entries** | Prevents unbounded growth; backbone-learned paths evicted first |
|
||||
| Hash list (`_hashlist`) | 1,000,000 | **32** | Packet dedup list; small is fine for low-throughput LoRa |
|
||||
| Path request tags (`_max_pr_tags`) | 32,000 | **32** | Pending path requests rarely exceed a few dozen |
|
||||
| Known destinations | 100 | **24** | Identity cache; rarely need more on a boundary node |
|
||||
| Known destinations | 100 | **24** | Identity cache; rarely need more on a transport node |
|
||||
| Max queued announces | 16 | **4** | Outbound announce queue; LoRa is slow, no point queuing many |
|
||||
| Max receipts | 1,024 | **20** | Packet receipt tracking |
|
||||
|
||||
### Timeout Reductions
|
||||
|
||||
| Setting | Default | RNodeTHV4 | Rationale |
|
||||
| Setting | Default | RTNode-HeltecV4 | Rationale |
|
||||
|---------|---------|-----------|-----------|
|
||||
| Destination timeout | 7 days | **1 day** | Free memory faster; stale paths re-resolve automatically |
|
||||
| Pathfinder expiry | 7 days | **1 day** | Same as above |
|
||||
@@ -254,18 +254,18 @@ The most critical optimization: **backbone announces are not stored in the path
|
||||
|
||||
Instead:
|
||||
1. Backbone announces are received and their packets cached to flash storage
|
||||
2. When a local LoRa node requests a path, the boundary checks its cache and responds directly
|
||||
2. When a local LoRa node requests a path, the transport node checks its cache and responds directly
|
||||
3. Only **specifically requested** paths get a path table entry
|
||||
4. Path table culling prioritizes evicting backbone entries over local ones
|
||||
|
||||
### Default Route Forwarding
|
||||
|
||||
When a transport-addressed packet arrives from LoRa but the boundary has no path table entry for it, the firmware:
|
||||
When a transport-addressed packet arrives from LoRa but the transport node has no path table entry for it, the firmware:
|
||||
1. Strips the transport headers (converts `HEADER_2` → `HEADER_1/BROADCAST`)
|
||||
2. Forwards the raw packet to the backbone interface
|
||||
3. Creates reverse-table entries so proofs can route back to the sender
|
||||
|
||||
This acts as a **default route** — any packet the boundary can't route locally gets forwarded to the backbone.
|
||||
This acts as a **default route** — any packet the transport node can't route locally gets forwarded to the backbone.
|
||||
|
||||
### Cached Packet Unpacking Fix
|
||||
|
||||
@@ -279,7 +279,7 @@ This was changed to call `unpack()` instead, which parses all packet fields AND
|
||||
|
||||
The C++ `std::map::insert()` method silently does nothing when a key already exists — unlike Python's `dict[key] = value` which replaces. The original microReticulum code used `insert()` to update path table entries, meaning stale LoRa paths were never replaced by newer TCP paths (or vice versa).
|
||||
|
||||
This was fixed by calling `erase()` before `insert()`, ensuring updated path entries always replace stale ones. Without this fix, the boundary node would continue routing packets via an old interface even after a better path was learned.
|
||||
This was fixed by calling `erase()` before `insert()`, ensuring updated path entries always replace stale ones. Without this fix, the transport node would continue routing packets via an old interface even after a better path was learned.
|
||||
|
||||
### Interface Name Uniqueness
|
||||
|
||||
@@ -310,21 +310,21 @@ On your server, configure `rnsd` with a TCP Server Interface in `~/.reticulum/co
|
||||
listen_port = 4242
|
||||
```
|
||||
|
||||
Then configure the boundary node as a **Client** pointing to your server's IP.
|
||||
Then configure the transport node as a **Client** pointing to your server's IP.
|
||||
|
||||
### Example: rnsd Connects to Boundary
|
||||
### Example: rnsd Connects to Transport Node
|
||||
|
||||
On your server, configure `rnsd` with a TCP Client Interface:
|
||||
|
||||
```ini
|
||||
[interfaces]
|
||||
[[TCP Client to Boundary]]
|
||||
[[TCP Client to Transport Node]]
|
||||
type = TCPClientInterface
|
||||
target_host = <boundary-node-ip>
|
||||
target_host = <transport-node-ip>
|
||||
target_port = 4242
|
||||
```
|
||||
|
||||
Set the boundary node's **Local TCP Server** to **Enabled** (port 4242).
|
||||
Set the transport node's **Local TCP Server** to **Enabled** (port 4242).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -332,11 +332,11 @@ Set the boundary node's **Local TCP Server** to **Enabled** (port 4242).
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `RNode_Firmware.ino` | Main firmware — boundary mode initialization, interface setup, button handling |
|
||||
| `BoundaryMode.h` | Boundary state struct, EEPROM load/save, configuration defaults |
|
||||
| `RNode_Firmware.ino` | Main firmware — transport mode initialization, interface setup, button handling |
|
||||
| `BoundaryMode.h` | Transport node state struct, EEPROM load/save, configuration defaults |
|
||||
| `BoundaryConfig.h` | Web-based captive portal for configuration |
|
||||
| `TcpInterface.h` | TCP interface for both backbone and local server (implements `RNS::InterfaceImpl`) with HDLC framing, unique naming, and 10 Mbps bitrate |
|
||||
| `Display.h` | OLED display layout — boundary-specific status page |
|
||||
| `Display.h` | OLED display layout — transport node status page |
|
||||
| `flash.py` | Flash utility — list serial ports, download from GitHub, merge & flash firmware |
|
||||
| `Boards.h` | Board variant definitions for V3 and V4 |
|
||||
| `platformio.ini` | Build targets: `heltec_V3_boundary`, `heltec_V4_boundary`, and `heltec_V4_boundary-local` |
|
||||
@@ -347,7 +347,7 @@ The firmware depends on [microReticulum](https://github.com/attermann/microRetic
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `Transport.cpp` | Selective caching, default route forwarding, boundary-aware culling, `get_cached_packet()` unpack fix, path table `erase()+insert()` fix, memory limits |
|
||||
| `Transport.cpp` | Selective caching, default route forwarding, transport-aware culling, `get_cached_packet()` unpack fix, path table `erase()+insert()` fix, memory limits |
|
||||
| `Transport.h` | `MODE_BOUNDARY`, `PacketEntry`, `Callbacks`, `cull_path_table()`, configurable table sizes |
|
||||
| `Identity.cpp` | `_known_destinations_maxsize` = 24, `cull_known_destinations()` |
|
||||
| `Type.h` | `MODE_BOUNDARY` = 0x20, reduced `MAX_QUEUED_ANNOUNCES`, `MAX_RECEIPTS`, shorter timeouts |
|
||||
|
||||
@@ -565,7 +565,10 @@ void setup() {
|
||||
|
||||
#ifdef BOUNDARY_MODE
|
||||
// Initialize bt_devname for WiFi hostname when BT is disabled
|
||||
if (!bt_init_ran) {
|
||||
#if HAS_BLUETOOTH || HAS_BLE == true
|
||||
if (!bt_init_ran)
|
||||
#endif
|
||||
{
|
||||
uint8_t mac[6];
|
||||
esp_read_mac(mac, ESP_MAC_WIFI_STA);
|
||||
sprintf(bt_devname, "RNode %02X%02X", mac[4], mac[5]);
|
||||
@@ -730,6 +733,20 @@ void setup() {
|
||||
RNS::Transport::path_table_maxpersist(12);
|
||||
boundary_load_config();
|
||||
|
||||
// Set up IFAC on the LoRa interface if configured
|
||||
if (boundary_state.ifac_enabled &&
|
||||
(boundary_state.ifac_netname[0] != '\0' || boundary_state.ifac_passphrase[0] != '\0')) {
|
||||
HEAD("Setting up IFAC on LoRa interface...", RNS::LOG_TRACE);
|
||||
lora_interface.setup_ifac(boundary_state.ifac_netname, boundary_state.ifac_passphrase);
|
||||
{
|
||||
char _ifac_msg[96];
|
||||
snprintf(_ifac_msg, sizeof(_ifac_msg), "IFAC configured: netname=%s, passphrase=%s",
|
||||
boundary_state.ifac_netname[0] ? boundary_state.ifac_netname : "(none)",
|
||||
boundary_state.ifac_passphrase[0] ? "***" : "(none)");
|
||||
HEAD(_ifac_msg, RNS::LOG_TRACE);
|
||||
}
|
||||
}
|
||||
|
||||
// Start WiFi if enabled
|
||||
if (boundary_state.wifi_enabled) {
|
||||
if (!wifi_initialized) {
|
||||
|
||||
18
flash.py
18
flash.py
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RNodeTHV4 Flash Utility
|
||||
RTNode-HeltecV4 Flash Utility
|
||||
|
||||
Flash the RNodeTHV4 boundary node firmware to a Heltec WiFi LoRa 32 V3 or V4.
|
||||
Flash the RTNode-HeltecV4 transport node firmware to a Heltec WiFi LoRa 32 V3 or V4.
|
||||
No PlatformIO required — just Python 3 and a USB cable.
|
||||
|
||||
By default, downloads the latest firmware from GitHub Releases (if newer than
|
||||
@@ -47,11 +47,11 @@ import time
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────────────────────
|
||||
|
||||
VERSION = "1.0.17"
|
||||
VERSION = "1.0.18"
|
||||
CHIP = "esp32s3"
|
||||
FLASH_MODE = "qio" # Global default; overridden by board profile
|
||||
FLASH_FREQ = "80m"
|
||||
GITHUB_REPO = "jrl290/RNodeTHV4"
|
||||
GITHUB_REPO = "jrl290/RTNode-HeltecV4"
|
||||
|
||||
# Runtime state (set automatically during main())
|
||||
_flash_mode_override = None # CLI --flash-mode sets this; otherwise board profile wins
|
||||
@@ -72,7 +72,7 @@ BOARD_PROFILES = {
|
||||
"pio_env": "heltec_V4_boundary",
|
||||
"build_dir": ".pio/build/heltec_V4_boundary",
|
||||
"firmware_bin": "rnode_firmware_heltec32v4_boundary.bin",
|
||||
"merged_filename": "rnodethv4_firmware.bin",
|
||||
"merged_filename": "rtnode_heltec_v4.bin",
|
||||
"flash_size": "16MB",
|
||||
"baud_rate": "921600",
|
||||
"flash_mode": "dio", # DIO is universally compatible with all flash chips
|
||||
@@ -82,7 +82,7 @@ BOARD_PROFILES = {
|
||||
"pio_env": "heltec_V3_boundary",
|
||||
"build_dir": ".pio/build/heltec_V3_boundary",
|
||||
"firmware_bin": "rnode_firmware_heltec32v3.bin",
|
||||
"merged_filename": "rnodethv3_firmware.bin",
|
||||
"merged_filename": "rtnode_heltec_v3.bin",
|
||||
"flash_size": "8MB",
|
||||
"baud_rate": "460800",
|
||||
"flash_mode": "dio", # V3 uses DIO — some flash chips do not support QIO
|
||||
@@ -933,7 +933,7 @@ def _monitor_boot(port, timeout=8):
|
||||
def main():
|
||||
global _board
|
||||
parser = argparse.ArgumentParser(
|
||||
description="RNodeTHV4 Flash Utility — flash boundary node firmware to Heltec V3/V4",
|
||||
description="RTNode-HeltecV4 Flash Utility — flash transport node firmware to Heltec V3/V4",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
@@ -1017,7 +1017,7 @@ Examples:
|
||||
|
||||
print()
|
||||
print("╔══════════════════════════════════════════╗")
|
||||
print("║ RNodeTHV4 Flash Utility ║")
|
||||
print("║ RTNode-HeltecV4 Flash Utility ║")
|
||||
print(f"║ {bp['name']:^40s} ║")
|
||||
print("╚══════════════════════════════════════════╝")
|
||||
print()
|
||||
@@ -1192,7 +1192,7 @@ Examples:
|
||||
|
||||
print(f"\n App-only update: {os.path.basename(firmware_path)} → 0x{APP_ADDR:05x}")
|
||||
print(f" Size: {os.path.getsize(firmware_path):,} bytes")
|
||||
print(f" WiFi/boundary settings will be preserved")
|
||||
print(f" WiFi/transport settings will be preserved")
|
||||
|
||||
# ── Interactive options ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,12 +2,61 @@
|
||||
|
||||
#include "Identity.h"
|
||||
#include "Transport.h"
|
||||
#include "Reticulum.h"
|
||||
#include "Cryptography/Hashes.h"
|
||||
#include "Cryptography/HKDF.h"
|
||||
|
||||
using namespace RNS;
|
||||
using namespace RNS::Type::Interface;
|
||||
|
||||
/*static*/ uint8_t Interface::DISCOVER_PATHS_FOR = MODE_ACCESS_POINT | MODE_GATEWAY;
|
||||
|
||||
void Interface::setup_ifac(const char* ifac_netname, const char* ifac_netkey) {
|
||||
assert(_impl);
|
||||
if (ifac_netname == nullptr && ifac_netkey == nullptr) {
|
||||
return;
|
||||
}
|
||||
// If both are empty strings, treat as no IFAC
|
||||
bool has_netname = (ifac_netname != nullptr && ifac_netname[0] != '\0');
|
||||
bool has_netkey = (ifac_netkey != nullptr && ifac_netkey[0] != '\0');
|
||||
if (!has_netname && !has_netkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
TRACE("Interface::setup_ifac: setting up IFAC for " + _impl->_name);
|
||||
|
||||
// Build ifac_origin = SHA256(netname) || SHA256(netkey)
|
||||
Bytes ifac_origin;
|
||||
if (has_netname) {
|
||||
Bytes netname_bytes((const uint8_t*)ifac_netname, strlen(ifac_netname));
|
||||
Bytes hash = Identity::full_hash(netname_bytes);
|
||||
ifac_origin = ifac_origin + hash;
|
||||
}
|
||||
if (has_netkey) {
|
||||
Bytes netkey_bytes((const uint8_t*)ifac_netkey, strlen(ifac_netkey));
|
||||
Bytes hash = Identity::full_hash(netkey_bytes);
|
||||
ifac_origin = ifac_origin + hash;
|
||||
}
|
||||
|
||||
// Hash the combined origin
|
||||
Bytes ifac_origin_hash = Identity::full_hash(ifac_origin);
|
||||
|
||||
// Derive ifac_key via HKDF(salt=IFAC_SALT, ikm=ifac_origin_hash, length=64)
|
||||
Bytes salt(IFAC_SALT, IFAC_SALT_SIZE);
|
||||
_impl->_ifac_key = Cryptography::hkdf(64, ifac_origin_hash, salt);
|
||||
|
||||
// Create an identity from the derived key (64 bytes = 32 X25519 + 32 Ed25519)
|
||||
Identity ifac_id(false); // don't auto-generate keys
|
||||
ifac_id.load_private_key(_impl->_ifac_key);
|
||||
_impl->_ifac_id = ifac_id;
|
||||
|
||||
// Set _ifac_identity to non-empty to flag IFAC as enabled
|
||||
// (Transport checks this with operator bool)
|
||||
_impl->_ifac_identity = ifac_id.get_public_key();
|
||||
|
||||
TRACE("Interface::setup_ifac: IFAC configured, ifac_size=" + std::to_string(_impl->_ifac_size));
|
||||
}
|
||||
|
||||
void InterfaceImpl::handle_outgoing(const Bytes& data) {
|
||||
//TRACE("InterfaceImpl.handle_outgoing: data: " + data.toHex());
|
||||
TRACE("InterfaceImpl.handle_outgoing");
|
||||
|
||||
@@ -72,6 +72,9 @@ namespace RNS {
|
||||
size_t _txb = 0;
|
||||
bool _online = false;
|
||||
Bytes _ifac_identity;
|
||||
Bytes _ifac_key;
|
||||
Identity _ifac_id = {Type::NONE};
|
||||
uint8_t _ifac_size = 8; // DEFAULT_IFAC_SIZE for LoRa-type interfaces
|
||||
Type::Interface::modes _mode = Type::Interface::MODE_NONE;
|
||||
uint32_t _bitrate = 0;
|
||||
uint16_t _HW_MTU = 0;
|
||||
@@ -187,6 +190,11 @@ namespace RNS {
|
||||
inline bool online() const { assert(_impl); return _impl->_online; }
|
||||
inline std::string name() const { assert(_impl); return _impl->_name; }
|
||||
inline const Bytes& ifac_identity() const { assert(_impl); return _impl->_ifac_identity; }
|
||||
inline const Bytes& ifac_key() const { assert(_impl); return _impl->_ifac_key; }
|
||||
inline const Identity& ifac_id() const { assert(_impl); return _impl->_ifac_id; }
|
||||
inline uint8_t ifac_size() const { assert(_impl); return _impl->_ifac_size; }
|
||||
inline void ifac_size(uint8_t size) { assert(_impl); _impl->_ifac_size = size; }
|
||||
void setup_ifac(const char* ifac_netname, const char* ifac_netkey);
|
||||
inline Type::Interface::modes mode() const { assert(_impl); return _impl->_mode; }
|
||||
inline void mode(Type::Interface::modes mode) { assert(_impl); _impl->_mode = mode; }
|
||||
inline uint32_t bitrate() const { assert(_impl); return _impl->_bitrate; }
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
|
||||
namespace RNS {
|
||||
|
||||
// IFAC salt used for key derivation (matches Python RNS Reticulum.IFAC_SALT)
|
||||
static const uint8_t IFAC_SALT[] = {
|
||||
0xad, 0xf5, 0x4d, 0x88, 0x2c, 0x9a, 0x9b, 0x80,
|
||||
0x77, 0x1e, 0xb4, 0x99, 0x5d, 0x70, 0x2d, 0x4a,
|
||||
0x3e, 0x73, 0x33, 0x91, 0xb2, 0xa0, 0xf5, 0x3f,
|
||||
0x41, 0x6d, 0x9f, 0x90, 0x7e, 0x55, 0xcf, 0xf8
|
||||
};
|
||||
static const size_t IFAC_SALT_SIZE = sizeof(IFAC_SALT);
|
||||
|
||||
class Reticulum {
|
||||
|
||||
public:
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "Interface.h"
|
||||
#include "Log.h"
|
||||
#include "Cryptography/Random.h"
|
||||
#include "Cryptography/HKDF.h"
|
||||
#include "Utilities/OS.h"
|
||||
#include "Utilities/Persistence.h"
|
||||
|
||||
@@ -734,43 +735,48 @@ static bool is_backbone_interface(const Interface& iface) {
|
||||
try {
|
||||
//if hasattr(interface, "ifac_identity") and interface.ifac_identity != None:
|
||||
if (interface.ifac_identity()) {
|
||||
// TODO
|
||||
/*p
|
||||
// Calculate packet access code
|
||||
ifac = interface.ifac_identity.sign(raw)[-interface.ifac_size:]
|
||||
// Calculate packet access code by signing the raw packet
|
||||
// and taking the last ifac_size bytes of the signature
|
||||
Bytes signature = interface.ifac_id().sign(raw);
|
||||
Bytes ifac = signature.right(interface.ifac_size());
|
||||
|
||||
// Generate mask
|
||||
mask = RNS.Cryptography.hkdf(
|
||||
length=len(raw)+interface.ifac_size,
|
||||
derive_from=ifac,
|
||||
salt=interface.ifac_key,
|
||||
context=None,
|
||||
)
|
||||
// Generate mask via HKDF
|
||||
Bytes mask = Cryptography::hkdf(
|
||||
raw.size() + interface.ifac_size(),
|
||||
ifac,
|
||||
interface.ifac_key()
|
||||
);
|
||||
|
||||
// Set IFAC flag
|
||||
new_header = bytes([raw[0] | 0x80, raw[1]])
|
||||
// Set IFAC flag in header byte 0
|
||||
uint8_t new_header0 = raw[0] | 0x80;
|
||||
uint8_t new_header1 = raw[1];
|
||||
|
||||
// Assemble new payload: new_header + ifac + raw[2:]
|
||||
Bytes new_raw;
|
||||
new_raw.append(new_header0);
|
||||
new_raw.append(new_header1);
|
||||
new_raw.append(ifac);
|
||||
new_raw.append(raw.mid(2));
|
||||
|
||||
// Assemble new payload with IFAC
|
||||
new_raw = new_header+ifac+raw[2:]
|
||||
|
||||
// Mask payload
|
||||
i = 0; masked_raw = b""
|
||||
for byte in new_raw:
|
||||
if i == 0:
|
||||
// Mask first header byte, but make sure the
|
||||
// IFAC flag is still set
|
||||
masked_raw += bytes([byte ^ mask[i] | 0x80])
|
||||
elif i == 1 or i > interface.ifac_size+1:
|
||||
Bytes masked_raw;
|
||||
for (size_t i = 0; i < new_raw.size(); i++) {
|
||||
if (i == 0) {
|
||||
// Mask first header byte, keep IFAC flag set
|
||||
masked_raw.append((uint8_t)((new_raw[i] ^ mask[i]) | 0x80));
|
||||
}
|
||||
else if (i == 1 || i > (size_t)(interface.ifac_size() + 1)) {
|
||||
// Mask second header byte and payload
|
||||
masked_raw += bytes([byte ^ mask[i]])
|
||||
else:
|
||||
masked_raw.append((uint8_t)(new_raw[i] ^ mask[i]));
|
||||
}
|
||||
else {
|
||||
// Don't mask the IFAC itself
|
||||
masked_raw += bytes([byte])
|
||||
i += 1
|
||||
masked_raw.append(new_raw[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Send it
|
||||
interface.on_outgoing(masked_raw)
|
||||
*/
|
||||
interface.send_outgoing(masked_raw);
|
||||
}
|
||||
else {
|
||||
interface.send_outgoing(raw);
|
||||
@@ -1258,8 +1264,8 @@ static bool is_backbone_interface(const Interface& iface) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/*static*/ void Transport::inbound(const Bytes& raw, const Interface& interface /*= {Type::NONE}*/) {
|
||||
TRACEF("Transport::inbound: received %d bytes", raw.size());
|
||||
/*static*/ void Transport::inbound(const Bytes& raw_in, const Interface& interface /*= {Type::NONE}*/) {
|
||||
TRACEF("Transport::inbound: received %d bytes", raw_in.size());
|
||||
++_packets_received;
|
||||
|
||||
// Heap telemetry: snapshot at entry
|
||||
@@ -1267,79 +1273,94 @@ static bool is_backbone_interface(const Interface& iface) {
|
||||
// CBA
|
||||
if (_callbacks._receive_packet) {
|
||||
try {
|
||||
_callbacks._receive_packet(raw, interface);
|
||||
_callbacks._receive_packet(raw_in, interface);
|
||||
}
|
||||
catch (std::exception& e) {
|
||||
DEBUG("Error while executing receive packet callback. The contained exception was: " + std::string(e.what()));
|
||||
}
|
||||
}
|
||||
// TODO
|
||||
/*p
|
||||
|
||||
// Mutable copy of raw data for IFAC processing
|
||||
Bytes raw = raw_in;
|
||||
|
||||
// If interface access codes are enabled,
|
||||
// we must authenticate each packet.
|
||||
//if len(raw) > 2:
|
||||
if (raw.size() > 2) {
|
||||
if interface != None and hasattr(interface, "ifac_identity") and interface.ifac_identity != None:
|
||||
if (interface && interface.ifac_identity()) {
|
||||
// Check that IFAC flag is set
|
||||
if raw[0] & 0x80 == 0x80:
|
||||
if len(raw) > 2+interface.ifac_size:
|
||||
if ((raw[0] & 0x80) == 0x80) {
|
||||
if (raw.size() > (size_t)(2 + interface.ifac_size())) {
|
||||
// Extract IFAC
|
||||
ifac = raw[2:2+interface.ifac_size]
|
||||
Bytes ifac = raw.mid(2, interface.ifac_size());
|
||||
|
||||
// Generate mask
|
||||
mask = RNS.Cryptography.hkdf(
|
||||
length=len(raw),
|
||||
derive_from=ifac,
|
||||
salt=interface.ifac_key,
|
||||
context=None,
|
||||
)
|
||||
Bytes mask = Cryptography::hkdf(
|
||||
raw.size(),
|
||||
ifac,
|
||||
interface.ifac_key()
|
||||
);
|
||||
|
||||
// Unmask payload
|
||||
i = 0; unmasked_raw = b""
|
||||
for byte in raw:
|
||||
if i <= 1 or i > interface.ifac_size+1:
|
||||
Bytes unmasked_raw;
|
||||
for (size_t i = 0; i < raw.size(); i++) {
|
||||
if (i <= 1 || i > (size_t)(interface.ifac_size() + 1)) {
|
||||
// Unmask header bytes and payload
|
||||
unmasked_raw += bytes([byte ^ mask[i]])
|
||||
else:
|
||||
unmasked_raw.append((uint8_t)(raw[i] ^ mask[i]));
|
||||
}
|
||||
else {
|
||||
// Don't unmask IFAC itself
|
||||
unmasked_raw += bytes([byte])
|
||||
i += 1
|
||||
raw = unmasked_raw
|
||||
unmasked_raw.append(raw[i]);
|
||||
}
|
||||
}
|
||||
raw = unmasked_raw;
|
||||
|
||||
// Unset IFAC flag
|
||||
new_header = bytes([raw[0] & 0x7f, raw[1]])
|
||||
uint8_t new_header0 = raw[0] & 0x7F;
|
||||
uint8_t new_header1 = raw[1];
|
||||
|
||||
// Re-assemble packet
|
||||
new_raw = new_header+raw[2+interface.ifac_size:]
|
||||
// Re-assemble packet without IFAC bytes
|
||||
Bytes new_raw;
|
||||
new_raw.append(new_header0);
|
||||
new_raw.append(new_header1);
|
||||
new_raw.append(raw.mid(2 + interface.ifac_size()));
|
||||
|
||||
// Calculate expected IFAC
|
||||
expected_ifac = interface.ifac_identity.sign(new_raw)[-interface.ifac_size:]
|
||||
Bytes expected_signature = interface.ifac_id().sign(new_raw);
|
||||
Bytes expected_ifac = expected_signature.right(interface.ifac_size());
|
||||
|
||||
// Check it
|
||||
if ifac == expected_ifac:
|
||||
raw = new_raw
|
||||
else:
|
||||
return
|
||||
|
||||
else:
|
||||
return
|
||||
|
||||
else:
|
||||
// If the IFAC flag is not set, but should be,
|
||||
// drop the packet.
|
||||
return
|
||||
|
||||
else:
|
||||
if (ifac == expected_ifac) {
|
||||
raw = new_raw;
|
||||
}
|
||||
else {
|
||||
TRACE("Transport::inbound: IFAC authentication failed, dropping packet");
|
||||
return;
|
||||
}
|
||||
}
|
||||
else {
|
||||
TRACE("Transport::inbound: packet too short for IFAC, dropping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// If the IFAC flag is not set, but should be, drop the packet
|
||||
TRACE("Transport::inbound: IFAC required but flag not set, dropping packet");
|
||||
return;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// If the interface does not have IFAC enabled,
|
||||
// check the received packet IFAC flag.
|
||||
if raw[0] & 0x80 == 0x80:
|
||||
if ((raw[0] & 0x80) == 0x80) {
|
||||
// If the flag is set, drop the packet
|
||||
return
|
||||
TRACE("Transport::inbound: IFAC flag set but interface has no IFAC, dropping packet");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
while (_jobs_running) {
|
||||
TRACE("Transport::inbound: sleeping...");
|
||||
|
||||
Reference in New Issue
Block a user