Standalone Reticulum transport node firmware for ESP32-S3 boards with an SX1262 or SX1276/78 LoRa transceiver (LilyGO T3-S3 and friends — the chip is detected at boot, one build covers both variants).
📖 Documentation: docs/ — getting started, architecture,
configuration, Reticulum integration, hardware, HTTP API, examples,
troubleshooting, development. The node runs a Wi-Fi access point with a
captive-portal status page and bridges stock Reticulum clients (Sideband,
rnsd, MeshChat) onto a LoRa channel — no host computer, no internet.
phone / laptop (RNS client) RetiMesh Node LoRa channel
───────────────────────────────────────────────────────────────────────────────
Sideband ── Wi-Fi ── TCP :4242 ──► HDLC deframe ─► txRing ─► CSMA+TX ──► RF
Sideband ◄─ Wi-Fi ◄─ TCP :4242 ◄── HDLC frame ◄─ rxRing ◄─ reassemble ◄─ RF
browser ── Wi-Fi ── HTTP :80 ──► status page + public bulletin board
- Transparent bridge. Raw RNS packets pass through unmodified in both directions. The node never parses, decrypts or routes packet contents — end-to-end encryption stays entirely between the Reticulum peers, using their own keys.
- A Reticulum Transport node. The firmware embeds
microReticulum (Apache-2.0),
a C++ port of RNS, with transport enabled: it keeps a path table, propagates
announces, answers path requests and forwards packets hop by hop — exactly
rnsdwithenable_transport = yes. Each interface has its own mode (full,gateway,access_point,roaming,boundary— rnsd's vocabulary), set on the settings page: the LoRa channel defaults tofull, Wi-Fi clients toaccess_pointso phones never receive the announce flood. Transport can be disabled to fall back to a plain bridge. - RNode-compatible RF layer. The LoRa framing (1-byte header with sequence nibble + split flag, ≤255-byte frames, two-fragment packets up to RNS's 500-byte MTU) is byte-identical to RNode_Firmware, so real RNodes on the same channel parameters interoperate with this gateway.
| Port | Protocol | Purpose |
|---|---|---|
| — | Wi-Fi SoftAP retimesh-XXXXXX (last three MAC octets), 10.42.0.1/24 |
open network, captive portal DNS |
| 80 | HTTP (ESPAsyncWebServer) | status page, neighbour list, unencrypted community bulletin board; /settings.html admin page (user admin, default password retimesh — change it there) |
| 4242 | raw TCP, RNS HDLC framing | Reticulum transport — connect any stock RNS client |
Client-side config (~/.reticulum/config — in Sideband just add a
TCP Client Interface with the same host/port):
[[RetiMesh Gateway]]
type = TCPClientInterface
enabled = yes
target_host = 10.42.0.1
target_port = 4242- Browser (easiest): open the web flasher, pick your board, click Install (Chrome/Edge, Web Serial).
- Terminal:
Interactive board/port selection, checksum verification,
pipx run --spec "git+https://github.com/dobrevit/RetiMesh_Node#subdirectory=tools/retimesh-flash" retimesh-flash install--mode appto keep settings,--mode fsfor the web app only — see tools/retimesh-flash. - Manual: every release ships
retimesh-node-<ver>-<board>-merged.bin(flash at0x0with esptool) plus a zip with the individual partitions; offsets and SHA-256 hashes are in the release'srelease.json.
pio run -e t3s3 # compile (LilyGO T3-S3)
pio run -e t3s3 -t upload # flash firmware
pio run -e t3s3 -t uploadfs # flash the web app (data/ -> LittleFS)
pio device monitort3s3 matches LilyGO's own board definition for the T3-S3 v1.2/v1.3
(ESP32-S3FH4R2: 4 MB flash, 2 MB in-package quad PSRAM → qio_qspi,
huge_app.csv partitions). esp32s3-qspi targets a generic 8 MB
DevKitC-1 with an external SX1262 — override the PIN_LORA_* build flags
there to match your wiring. If esptool.py flash_id reports a 16 MB
N16R8 module instead, switch that env to qio_opi / 16MB.
All tunables (pins, RF parameters, SSID, ports, buffer sizes) live in
src/Config.h and can be overridden per-environment with
-D build flags.
Radio parameters must match every node on the channel — frequency,
bandwidth, spreading factor, coding rate and sync word (0x12). Defaults
are 868.100 MHz, BW 125 kHz, SF8, CR 4/5, 7 dBm; change them at runtime on
the settings page (the page prints the matching rnsd RNodeInterface
block). Check your local regulations before changing frequency or power.
The node has a persistent Reticulum identity (X25519 + Ed25519 keys in
NVS, kept across settings resets) and a retimesh.node destination. It
announces it on boot and every announce_interval seconds (default 10
min; 0 = off) — on LoRa and to connected Wi-Fi clients — so every RNS peer
learns a path to it (rnpath -t lists it, rnstatus counts it). Announces
heard from either side are parsed, signature-verified and listed as
neighbours with aspect (lxmf.delivery = Sideband/LXMF peers,
nomadnetwork.node, retimesh.node, …), hop count, display name and signal.
That is the normal Reticulum way to see who is on the mesh, and it costs no
protocol violations anywhere. /api/status exposes the node's identity
and destination hashes.
Beacons (beacon_interval, default 0 = off) are a RetiMesh-only quick
probe: RM1 I <callsign> <version> after that many seconds of TX silence,
a hello (RM1 H …) on boot answered by other RetiMesh nodes (RM1 R …)
within seconds. Everything heard is on the status page and counted on the
OLED (NB).
Beacons are valid Reticulum packets: a broadcast to the PLAIN destination
retimesh.beacon. RNS peers parse and silently drop them (no protocol
violation, never forwarded past one hop), and any RNS program can receive
them:
import RNS, time
RNS.Reticulum()
d = RNS.Destination(None, RNS.Destination.IN, RNS.Destination.PLAIN, "retimesh", "beacon")
d.set_packet_callback(lambda data, packet: print(packet.receiving_interface, data.decode()))
while True: time.sleep(1)The node also lists RNode station IDs — the raw callsign an RNS
RNodeInterface transmits — so an RNode with
id_interval = 45
id_callsign = MYCALL(note: beacon/beacon_interval are not RNS config keys — id_* are)
appears in the neighbour list too. The callsign defaults to the SSID.
- Core 0 — Wi-Fi/LwIP stack, the single AsyncTCP event task (socket I/O for ports 80 and 4242) and the captive-portal DNS poller.
- Core 1 —
radioTask(SX1262 IRQ service, CSMA, RNode framing) andbridgeTask(LoRa→TCP fan-out). Radio timing is never blocked by web or Wi-Fi work. - Two FreeRTOS ring buffers (
RINGBUF_TYPE_NOSPLIT, one item = one RNS packet) bridge the cores; both directions drop on overflow rather than block, because a stalled radio task is worse than a lost packet (Reticulum links tolerate loss).
Per-file tour: main.cpp (task layout, ring buffers) · WifiManager (AP, captive portal, web API) · RetiTransportServer (port 4242, HDLC, hub relay) · LoRaRadio (RadioLib SX1262, CSMA, fragmentation) · HDLC.h (RNS TCP wire framing).
| Workflow | Trigger | What it does |
|---|---|---|
| CI | push to main, PRs |
builds every board (boards.json), builds the LittleFS image, packages bundles as artifacts, reports flash/RAM in the job summary, smoke-tests the CLI |
| Release Drafter | merges to main, PR events |
maintains a draft release with categorised notes; auto-labels PRs from branch names / conventional-commit titles |
| Release | tag vX.Y.Z |
builds all boards with FW_VERSION = tag, attaches per-board zips, merged images, release.json and sha256sums.txt to the draft |
| Web flasher | release published | rebuilds the GitHub Pages site from the last 5 published releases |
| PlatformIO deps | monthly | Dependabot stand-in for lib_deps: opens a PR bumping library versions from the PlatformIO registry |
| Dependabot | weekly | GitHub Actions versions and the CLI's Python deps |
Cutting a release: merge PRs (labels drive the version: major/breaking,
minor/feature, everything else → patch) → check the draft → push the tag
it names (git tag v1.2.0 && git push origin v1.2.0) → wait for assets →
Publish. Publishing deploys the web flasher.
One-time repo setup: Settings → Pages → Source: GitHub Actions; optionally
a DEPS_PR_TOKEN fine-grained PAT so dependency PRs trigger CI.
Adding a board = a [env:…] in platformio.ini, an entry in boards.json,
and the env name in the two workflow matrices.
-
Settings (radio channel, access point, admin password) live in NVS and are edited at
http://10.42.0.1/settings.html(useradmin, default passwordretimesh— change it). Radio changes apply live; Wi-Fi changes restart the node. -
WPA3 on the access point is not available on this build. SoftAP-side SAE requires ESP-IDF 5; the pinned Arduino core (2.0.17 / IDF 4.4.7) rejects the mode, so the node runs WPA2 and greys out the WPA3 options. The code path is in place for a core-3 migration.
-
Max
RNS_MAX_CLIENTS(4) simultaneous TCP peers; slow consumers get packets dropped, not queued forever. -
The bulletin board is deliberately public/plaintext and local to the node (no RTC → posts are ordered, not timestamped). Capped at 50 posts, rotated oldest-first.
-
The captive portal serves HTTP only; modern OSes show the "sign in to network" sheet via their connectivity probes, all of which redirect to
http://10.42.0.1/. -
CSMA is a simplified listen-before-talk (random slotted backoff + CAD probe), not RNode's full DIFS/contention-window machine.
Copyright © 2026 Dobrev IT Ltd. RetiMesh Node is free software, released under the GNU General Public License v3.0 or later — see LICENSE. If you distribute or modify it, you must adhere to the GPLv3: provide the corresponding source, keep copyright and license notices, and inform users of their rights.
Third-party components keep their own licenses: microReticulum, microStore and their Crypto fork (Apache-2.0 / MIT), RadioLib (MIT), ArduinoJson (MIT), MsgPack (MIT), Adafruit GFX/SSD1306/BusIO (BSD/MIT), ESPAsyncWebServer and AsyncTCP (LGPL-3.0). The LoRa wire format is implemented for interoperability with RNode_Firmware (GPLv3, © Mark Qvist); no RNode source code is included.