Faro & sixi-observer: building an open-source handheld UI, Linux console, and SDR bridge for MeshCore LoRa radios
Most LoRa mesh nodes are headless radios that treat your smartphone as their screen and keyboard. That works right up until your phone battery dies, Bluetooth pairing flakes out in the rain, or you want a standalone field terminal that operates without a companion phone at all.
Over the past few months I built and open-sourced the device and telemetry layers of my off-grid packet-radio stack on top of MeshCore:
- sixi-tdeck-plus (Faro) (Apache-2.0): a clean-room LVGL 9.3 touch, keyboard, and trackball UI for the LilyGo T-Deck / T-Deck Plus / T-Deck MAX, companion builds for the LilyGo T-Beam S3 Supreme and Heltec WiFi LoRa 32 V3, an SDL2 Linux desktop console (
faro-console), a headless PNG simulator (faro-sim), and a 1-click browser WebSerial flasher. - sixi-observer (MIT): a Python observer bridge for USB/serial MeshCore radios and RTL-SDR Blog V4 receivers that verifies 64-byte
Ed25519node advertisements locally on-device, computes ML Bayesian ghost-hop repeater triangulation, and exposes a 0 ms local WebSocket telemetry stream (ws://127.0.0.1:8765/ws). - Upstream MeshCore contributions: dynamic Wi-Fi support for the LilyGO T-Beam Supreme SX1262 and fixing unsigned conversion of negative TX power in radio preferences.
Together with Sigillo (the 87-byte Ed25519-signed EU CAMF alert frame), they form a complete, auditable stack from the wire format to the handheld screen and desktop workbench.
1. Attaching an on-device UI without forking the radio protocol
Custom firmware forks usually rot because they patch the underlying routing state machine and invent private serial opcodes. Faro takes the opposite approach: it pins unmodified upstream MeshCore (companion-v1.18.0) as a submodule and attaches solely through upstream's platformio.local.ini build hook and the AbstractUITask listener interface.
That architectural constraint buys three things immediately:
- Zero custom companion opcodes: The standard MeshCore BLE phone app and Wi-Fi TCP clients (port
5000) keep working simultaneously while you use the T-Deck's physical keyboard and touchscreen. - One UI codebase across handheld, desktop, and CI: The exact same LVGL 9.3 screen code compiles to ESP32-S3 firmware, to a native Ubuntu Linux desktop console (
faro-consoleover SDL2 talking to a companion radio via USB serial or TCP:5000), and to a deterministic headless simulator (faro-sim) that renders every 320×240 screen in light and dark themes to PNG. - Clean upstream upgrades: Moving to a new MeshCore release means bumping the submodule commit and running the simulator and hardware checks, not rebasing a diverged radio stack.
2. Hardware targets and power/RF realities
Writing C/C++ for battery-powered ESP32-S3 + SX1262 hardware quickly surfaces bugs that never appear in a simulator:
| Board | Build environment | Mode | Input & interfaces |
|---|---|---|---|
| LilyGo T-Deck / T-Deck Plus | faro_tdeck |
Handheld UI + Companion | 320×240 IPS touch, physical keyboard, trackball, GPS, microSD, BLE, Wi-Fi TCP |
| LilyGo T-Deck MAX | faro_tdeck_max |
Handheld UI + Companion | 320×240 IPS touch, keyboard, trackball, microSD, BLE, Wi-Fi TCP |
| Elecrow ThinkNode M9 | faro_thinknode_m9 |
Screen UI (experimental) | I2C keypad (STC8H at 0x6C), piezo alarm/message tones |
| LilyGo T-Beam S3 Supreme | faro_tbeam_supreme |
Headless Companion | SX1262, GPS, PMU, BLE + dynamic Wi-Fi TCP (:5000) + USB serial |
| Heltec WiFi LoRa 32 V3 | faro_heltec_v3 |
Headless Companion | SX1262, BLE + Wi-Fi TCP (:5000) + USB serial |
Two hardware lessons from the 0.10.x releases stand out:
- The T-Deck Plus battery ADC bootloop (fixed in
0.10.1): Early builds hit a reset loop on certain T-Deck Plus units during battery ADC initialization before the display rail stabilized. Isolating the ADC sampling setup eliminated the bootloop. - Idle power draw (fixed in
0.10.2): An always-on ESP32-S3 backlight and idle Wi-Fi modem drain a handheld battery in hours.0.10.2turns the screen off after 5 minutes of inactivity, lowers the default backlight PWM, and powers down the Wi-Fi radio completely when TCP companion mode is disabled. - Negative TX power serialization upstream: While testing low-power bench links, I found that negative TX power values in MeshCore's radio preferences suffered an unsigned conversion bug. I fixed that upstream alongside adding dynamic Wi-Fi support for the LilyGO T-Beam Supreme SX1262.
3. Enforcing regional spectrum rules in firmware, every second
Because Faro also exposes upstream's USB serial CLI, BLE companion protocol, and TCP port 5000, an external phone app or script can issue a set radio command behind the UI's back. If a user in Switzerland or the EU accidentally applies a 915 MHz US preset or cranks transmit power past the legal ERP limit, the radio transmits out of band.
Faro enforces regional spectrum ceilings directly in a 1 Hz supervisor loop across five region definitions:
| Region | Allowed sub-band (MHz) | Power ceiling | Duty cycle |
|---|---|---|---|
| EU / UK / CH 868 | 869.400 – 869.650 |
27 dBm e.r.p. (500 mW) | 10% (airtime factor 9) |
| EU / UK 433 | 433.050 – 434.790 |
10 dBm e.r.p. (10 mW) | 10% (airtime factor 9) |
| USA / Canada 915 | 902.000 – 928.000 |
30 dBm conducted / 36 dBm e.i.r.p. | — |
| Australia / NZ 915 | 915.000 – 928.000 |
30 dBm e.i.r.p. | — |
| Brazil 915 | 902–907.5, 915–928 |
30 dBm e.i.r.p. | — |
Every second, Faro checks the active radio parameters (including full channel bandwidth, centre ± BW/2, and antenna gain FARO_ANTENNA_GAIN_DBI, defaulting to 3 dBi where e.r.p. = conducted + gain − 2.15 dB). If an external client moves settings inside a valid region, Faro clamps power and duty cycle to that region's ceiling; if the frequency falls outside every allowed sub-band, Faro immediately restores the last valid configuration and logs the rejection.
4. Hardening the companion surface
Reviewing the companion attack surface led to several deliberate defaults in Faro that differ from stock firmware:
- Private-key export over the companion protocol is disabled: Upstream MeshCore allows a connected BLE phone or unauthenticated TCP
:5000client to export or replace the node's private key. Faro strips those opcodes at compile time. Key backup is only possible locally to the physical microSD card after an explicit on-screen confirmation tap. - Hardware RNG for private channels & strict BLE PINs: New private channels draw keys from the ESP32-S3 hardware RNG. Screen-equipped boards generate a random BLE pairing PIN and reject weak user-chosen PINs (
123456, repeated digits, or sequential runs). - Zero message text on serial logs: Diagnostic serial output never prints direct-message or channel plaintext.
On top of messaging (direct messages with ACK/RTT, #hashtag channels, QR contact sharing, store-and-forward) and remote repeater administration (login, telemetry, neighbour tables, ACLs, remote CLI), the UI gives field operators real RF instrumentation: a 2D mesh topology graph, an SX1262 link-budget and Time-on-Air calculator, per-hop SNR path traces, zero-hop neighbour scans, and offline SD-card map tiles with GPX track recording and EN/IT/DE/FR localization.
5. sixi-observer: local Ed25519 verification and Bayesian ghost-hop triangulation
While Faro turns a handheld into a self-contained field terminal, I also wanted a lightweight Python daemon for headless USB radios (T-Beam Supreme SX1262, Heltec V3/V4, RAK4631) and RTL-SDR Blog V4 dongles running on a Linux mini-PC or Raspberry Pi. That project is sixi-observer.
It solves three practical telemetry problems in a multi-hop LoRa network:
- Local 64-byte
Ed25519advertisement verification: MeshCore node advertisements (ADVERTframes) carry a 64-byte Ed25519 signature over the node's public key, timestamp, coordinates, and name. Rather than trusting unverified metadata,sixi-obserververifies the 64-byte signature locally on the observer host before marking a nodeverified_local = true. - ML Bayesian ghost-hop repeater triangulation: In MeshCore flood routing, packets record a path of 1-byte repeater hashes (
Origin → ~A3 → ~7F → Your Radio) alongside the final hop's sub-noise SNR and RSSI. Frequently, intermediate repeaters (~HASH) forward traffic without having broadcast a recent GPS-taggedADVERTof their own.sixi-observeruses Bayesian spatial inference over observed multi-hop paths, known anchor coordinates, and free-space/terrain path-loss posteriors to estimate coordinates and an uncertainty radius (±km) for those unidentified "ghost-hop" repeaters. - Zero-latency local WebSocket stream & strict privacy: Running
sixi-observerexposes a local WebSocket server atws://127.0.0.1:8765/wswith 0 ms cloud latency, and can be launched in one command withuvxor Docker. By design, encrypted direct messages (TXT_MSG/ ECDH) and private channel keys are never decoded or transmitted off the device.
# Run sixi-observer directly via uvx against a USB-connected T-Beam Supreme or Heltec
uvx --from git+https://github.com/radek-sixi/sixi-observer sixi-observer run \
--serial /dev/ttyACM0 \
--name "Field Observer" \
--lat 47.37 --lon 8.54
All three repositories in the LoRa stack are open source: sigillo (Apache-2.0 / CC BY 4.0), sixi-tdeck-plus (Faro) (Apache-2.0), and sixi-observer (MIT).