A custom firmware for the Heltec WiFi LoRa 32 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) over WiFi.
Android / Sideband Remote
ββββββββββββ ββββββββββββββ Reticulum
β Sideband ββββ BT βββΊβ RNode (BT) β Backbone
β App β βββββββ¬βββββββ (rnsd /
ββββββββββββ β rmap.world)
LoRa Radio β²
β ββββββββββββββββ WiFi β
βββ RF mesh βββββββΊβ RNodeTHV4 βββTCPβββ
β β Boundary Nodeβ β²
Other RNodes ββββββββββββββββ β
βββββ΄ββββ
β Router β
βββββββββ
Built on microReticulum (a C++ port of the Reticulum network stack) and the RNode firmware by Mark Qvist.
- Bidirectional LoRa β TCP bridging β local LoRa mesh nodes can reach the global Reticulum backbone and vice versa
- Web-based configuration portal β WiFi SSID/password, backbone host/port, LoRa parameters, all configurable via captive portal
- OLED status display β real-time status indicators for LoRa, WiFi, WAN (backbone), LAN (local TCP), plus IP address, port, and airtime
- Optional local TCP server β serve local devices on your WiFi in addition to the backbone connection
- Automatic reconnection β WiFi and TCP connections recover from drops with exponential backoff
- ESP32 memory-optimized β table sizes, timeouts, and caching tuned for the constrained MCU environment
The Heltec WiFi LoRa 32 V4 was chosen because it ships standard with 2 MB PSRAM and 16 MB flash β enough headroom for the microReticulum transport tables, packet caching to flash storage, and the web-based configuration portal. Many other LoRa dev boards come with only 4β8 MB flash and no PSRAM, which would require significant compromises to the boundary node's caching and routing capabilities.
| Component | Spec |
|---|---|
| Board | Heltec WiFi LoRa 32 V4 |
| MCU | ESP32-S3, 2MB PSRAM, 16MB Flash |
| Radio | SX1262 + GC1109 PA (up to 28 dBm) |
| Display | SSD1306 OLED 128Γ64 |
| WiFi | 2.4 GHz 802.11 b/g/n |
The easiest way to flash a pre-built firmware. You only need Python 3 and a USB cable.
# Install esptool (one time)
pip install esptool
# Clone this repo (or download just flash.py + the firmware binary)
git clone https://github.com/jrl290/RNodeTHV4.git
cd RNodeTHV4
# Download latest firmware from GitHub Releases and flash
python flash.py --download
# Or flash a local binary
python flash.py --file rnodethv4_firmware.binThe flash utility 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.
For development or customization:
# Prerequisites: PlatformIO installed (VS Code extension or CLI)
git clone https://github.com/jrl290/RNodeTHV4.git
cd RNodeTHV4
# Build
pio run -e heltec_V4_boundary
# Flash (via PlatformIO)
pio run -e heltec_V4_boundary -t upload
# Or create a merged binary and flash with the utility
python flash.py --merge-only # creates rnodethv4_firmware.bin
python flash.py # flash it
# Monitor serial output (optional)
pio device monitor -e heltec_V4_boundaryIf you have the merged binary (rnodethv4_firmware.bin), you can flash it with a single esptool command:
esptool.py --chip esp32s3 --port /dev/ttyACM0 --baud 921600 \
write_flash -z --flash_mode qio --flash_freq 80m --flash_size 16MB \
0x0 rnodethv4_firmware.binReplace /dev/ttyACM0 with your serial port (/dev/cu.usbmodem* on macOS, COM3 on Windows).
On first boot (or if no configuration is found), the device automatically enters the Configuration Portal.
The config portal activates automatically on:
- First boot β when no saved configuration exists
- Button hold >5 seconds β hold the PRG button for 5+ seconds, the device reboots into config mode
When active, the device creates a WiFi access point named RNode-Boundary-Setup (open network). A captive portal should appear automatically when you connect; if not, browse to http://192.168.4.1.
The web form has four sections:
| Field | Description |
|---|---|
| WiFi | Enable/Disable (disable for LoRa-only repeater mode) |
| SSID | Your WiFi network name |
| Password | WiFi password |
| Field | Description |
|---|---|
| Mode | Disabled or Client (connect to backbone) |
| Backbone Host | IP address or hostname of backbone server (e.g. rmap.world) |
| Backbone Port | TCP port (default: 4242) |
| Field | Description |
|---|---|
| Local TCP Server | Enable/Disable β runs a TCP server on your WiFi for local Reticulum nodes to connect |
| TCP Port | Port to listen on (default: 4242) |
| Field | Description |
|---|---|
| Frequency | e.g. 867.200 MHz β must match your other RNodes |
| Bandwidth | 7.8 kHz β 500 kHz (typically 125 kHz) |
| Spreading Factor | SF6 β SF12 (typically SF7 for backbone, SF10 for long range) |
| Coding Rate | 4/5 β 4/8 |
| TX Power | 2 β 28 dBm |
After saving, the device reboots with the new configuration applied.
The 128Γ64 OLED is split into two panels:
β LORA β filled circle = radio online
β wifi β unfilled circle = WiFi disconnected
β WAN β filled = backbone TCP connected
β LAN β filled = local TCP client connected
ββββββββββββββββ
Air:0.3% β current LoRa airtime
βββββ ||||||| β battery, signal quality
- Filled circle (β) = active/connected
- Unfilled circle (β) = inactive/disconnected
- Labels are UPPERCASE when active, lowercase when inactive (except LAN which is always uppercase)
- LAN row is hidden when the Local TCP Server is disabled in configuration β the remaining layout stays in place
ββ RNodeTHV4 ββ β title bar (inverted)
867.200MHz β LoRa frequency
SF7 125k β spreading factor & bandwidth
ββββββββββββββββ β separator
192.168.1.42 β WiFi IP address (or "No WiFi")
Port:4242 β Local TCP server port
ββββββββββββββββ β separator
- Port shows the Local TCP server port (the port local nodes connect to), not the backbone port
- Port line is hidden when the Local TCP Server is disabled
The firmware runs up to three RNS interfaces simultaneously, using different interface modes to control announce propagation and routing behavior:
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
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:
- 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
If enabled, a TCP server on the WiFi network allows local Reticulum nodes to connect. It also uses Access Point mode, with the same announce filtering as LoRa.
Implementation details:
- Each TCP interface must have a unique name to produce a unique interface hash β the backbone uses
"TcpInterface"and the local server uses"LocalTcpInterface". Without distinct names, both interfaces produce the same hash, causing the interface map lookup to fail when routing packets. - TCP interfaces are configured with a 10 Mbps bitrate, which causes Reticulum's Transport to prefer TCP paths over LoRa paths (typically ~1β10 kbps) when both are available for the same destination.
- When the Local TCP Server is disabled, its status indicator (LAN) and port number are hidden from the OLED display.
The ESP32-S3 has limited RAM compared to a desktop Reticulum node. Several customizations were made to the microReticulum library to operate reliably within these constraints:
| Table | Default (Desktop) | RNodeTHV4 | Rationale |
|---|---|---|---|
Path table (_destination_table) |
Unbounded | 48 entries | Prevents unbounded growth; boundary 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 |
| Max queued announces | 16 | 4 | Outbound announce queue; LoRa is slow, no point queuing many |
| Max receipts | 1,024 | 20 | Packet receipt tracking |
| Setting | Default | RNodeTHV4 | Rationale |
|---|---|---|---|
| Destination timeout | 7 days | 1 day | Free memory faster; stale paths re-resolve automatically |
| Pathfinder expiry | 7 days | 1 day | Same as above |
| AP path time | 24 hours | 6 hours | AP paths go stale faster in mesh environments |
| Roaming path time | 6 hours | 1 hour | Mobile nodes change paths frequently |
| Table cull interval | 5 seconds | 60 seconds | Less CPU overhead on culling |
| Job/Clean/Persist intervals | 5m/15m/12h | 60s/60s/60s | More frequent housekeeping for MCU stability |
The most critical optimization: backbone announces are not stored in the path table by default. A backbone like rmap.world may advertise hundreds of destinations. Storing them all would evict every local LoRa path.
Instead:
- Backbone announces are received and their packets cached to flash storage
- When a local LoRa node requests a path, the boundary checks its cache and responds directly
- Only specifically requested paths get a path table entry
- Path table culling prioritizes evicting backbone entries over local ones
When a transport-addressed packet arrives from LoRa but the boundary has no path table entry for it, the firmware:
- Strips the transport headers (converts
HEADER_2βHEADER_1/BROADCAST) - Forwards the raw packet to the backbone interface
- 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.
The original microReticulum get_cached_packet() function called update_hash() after deserializing cached packets from flash. However, update_hash() only computes the packet hash β it does not parse the raw bytes into fields like destination_hash, data, flags, etc.
This was changed to call unpack() instead, which parses all packet fields AND computes the hash. Without this fix, path responses contained empty destination hashes and were silently dropped by LoRa nodes.
Note:
unpack()only parses the plaintext routing envelope (destination hash, flags, hops, transport headers). It does not decrypt the end-to-end encrypted payload. Every Reticulum transport node performs equivalent header parsing during normal routing β this is standard behavior, not a security concern.
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.
Each RNS interface must have a unique name because the name is hashed to produce the interface identifier used in path table lookups. If two interfaces share the same name, they produce the same hash, and std::map can only store one β causing the Transport layer to fail to resolve the correct outbound interface for packets.
The TcpInterface constructor accepts an explicit name parameter: the backbone uses "TcpInterface" and the local server uses "LocalTcpInterface".
In the configuration portal:
- Set WiFi SSID and password
- Set TCP Backbone Mode to Client
- Set Backbone Host to
rmap.world - Set Backbone Port to
4242 - Save and reboot
On your server, configure rnsd with a TCP Server Interface in ~/.reticulum/config:
[interfaces]
[[TCP Server Interface]]
type = TCPServerInterface
listen_host = 0.0.0.0
listen_port = 4242Then configure the boundary node as a Client pointing to your server's IP.
On your server, configure rnsd with a TCP Client Interface:
[interfaces]
[[TCP Client to Boundary]]
type = TCPClientInterface
target_host = <boundary-node-ip>
target_port = 4242Set 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 |
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 |
flash.py |
Flash utility β list serial ports, download from GitHub, merge & flash firmware |
Boards.h |
Board variant definition for heltec32v4_boundary |
platformio.ini |
Build targets: heltec_V4_boundary and heltec_V4_boundary-local |
The firmware depends on microReticulum 0.2.4, automatically fetched by PlatformIO on first build. After the first build, the library sources under .pio/libdeps/heltec_V4_boundary/microReticulum/src/ need the patches described in "Routing & Memory Customizations" above. Key files modified:
| 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.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 |
| Resource | Used | Available |
|---|---|---|
| RAM | ~21.7% | 320 KB |
| Flash | ~18.1% | 16 MB |
| PSRAM | Dynamic | 2 MB |
This project is licensed under the GNU General Public License v3.0 β see LICENSE for details.
Based on:
- RNode Firmware by Mark Qvist (GPL-3.0)
- microReticulum by Chris Attermann (GPL-3.0)
- Reticulum by Mark Qvist (MIT)