Bridging Two LoRa Frequencies
Joining a backhaul band to a local mesh without hand-rolling a relay
On this page
- What a bridge has to get right
- Method 1: OpenHop Repeater RF Fabric
- Hardware
- Configuring the Fabric
- Three things that bite on a mixed-backend bridge
- What tx_mode actually does
- The gap: stock bridge mode is silent on its own band
- Repeating on both sides
- How the fan-out stays correct
- Verifying
- Method 2: MeshCore ESP-NOW bridge
- You need a bridge-enabled build
- Configuring over the CLI
- The settings, and their defaults
- Choosing bridge.source
- What actually crosses the wire
- Deployment notes
- Choosing between them
- Shared concerns
- Co-site interference
- Duty cycle
- Tuning the delay
- Naming
- Other resources
Most MeshCore deployments eventually outgrow one set of radio settings. A busy local mesh wants a fast, short-range preset; the links between hilltops want a slow, robust one. Once you split them, something has to carry traffic across the seam.
That something is a bridge: one logical node with a foot in both networks, passing packets between them so the rest of the mesh never has to know the boundary exists. To a companion on either side, a packet that crossed the bridge looks like it took one ordinary hop.
This guide covers the two methods that work today with off-the-shelf software:
- OpenHop Repeater's RF Fabric: two LoRa radios on one Linux host, joined in software.
- MeshCore's ESP-NOW bridge: two ESP32 repeaters, each on its own frequency, linked over 2.4 GHz.
Both preserve MeshCore's encryption, paths, and routing, because in both cases the packet goes through the real mesh pipeline rather than being replayed as raw radio bytes.
What a bridge has to get right#
Three things separate a working bridge from a packet cannon:
It must not duplicate work. A packet should be validated, deduplicated, and path-mutated exactly once, no matter how many radios end up carrying it. Run the forwarding logic twice and you get two different path bytes for the same logical hop, and the mesh starts treating one packet as two.
It must decide whether to repeat on the side it heard from. This is the subtle one, and it is where the two methods differ most. A packet arrives on frequency A. Obviously it should go out on frequency B. But should it also go back out on A?
- If yes, the bridge is a normal repeater on both networks that happens to also cross-connect them. Local nodes on A get coverage from it.
- If no, the bridge is a pure crossover. It is invisible as a repeater to the network it is sitting in. Nodes on A hear it only when traffic arrives from B.
Neither is wrong. A bridge at a site that already has good local repeater coverage is better off staying quiet on its own band. A bridge that is the local repeater needs to repeat on both sides, or the local pocket loses a hop.
It must look like one hop, not zero. A wired or 2.4 GHz crossover is far faster than a LoRa hop. Delivering the packet on the far side instantly can outrun the mesh's own timing assumptions, so both implementations add a configurable delay to make the crossing behave like RF.
Method 1: OpenHop Repeater RF Fabric#
OpenHop Repeater is a Python MeshCore repeater daemon for Linux hosts. Its RF Fabric layer lets one daemon drive several physical radios at once and choose which one transmits, so a single Raspberry Pi with two radios attached is one node that lives on two frequencies.
This is the tidier of the two methods: one host, one identity, one config file, one set of logs. The cost is a Linux host, plus two radios the host can address independently.
Hardware#
Fabric addresses radios by ID, not by how they are attached, so the two sides do not have to use the same backend. That opens up an easier path than wiring two SX1262s to the same host.
| Backend | radio_type |
What the host talks to |
|---|---|---|
| MeshCore KISS modem | kiss |
A LoRa board running MeshCore KISS firmware, over serial |
| openHop Modem, USB | modem_usb |
A board running openHop Modem firmware, over USB serial |
| openHop Modem, network | modem_tcp |
The same firmware, reached over LAN or Wi-Fi |
| Direct SPI | sx1262 |
A radio on the host's own SPI bus and GPIO |
| CH341 USB-SPI | sx1262_ch341 |
A radio behind a CH341 adapter |
The modem-backed backends are the path of least resistance. Each board owns
its own radio hardware, so the host never maps a GPIO pin, and the two radios
cannot contend for one SPI bus. Both firmwares are a browser flash away: MeshCore
KISS is an option in the official flasher (upstream
also carries 92 *_kiss_modem PlatformIO environments), and openHop Modem has
its own flasher.
A bridge built from one of each works well, and is the configuration shown below: a MeshCore KISS modem on the local mesh, an openHop Modem on the backhaul.
For direct SPI or CH341 instead, each radio needs its own chip-select and control
pins (cs_id / cs_pin / reset_pin / busy_pin / irq_pin), and two CH341
adapters are told apart by bus / address from lsusb -t, or by
serial_number.
Two LoRa radios in one box, on adjacent channels, will desense each other if you let them. See Co-site interference below before you pick antennas.
Configuring the Fabric#
Two blocks do the work: radios: replaces the legacy single-radio configuration,
and fabric: says how to use them. You can set them from the repeater's own
dashboard under System → Configuration → Radio, or by editing
/etc/openhop_repeater/config.yaml directly. OpenHop's documentation recommends
the dashboard for routine changes, and it saves you a YAML syntax error on a node
you may be holding a ladder under.
Either way the shape is the same, and the file is worth reading even if you never edit it by hand:
fabric:
default_radio: local
tx_mode: bridge # default | sticky | bridge
radios:
# Local mesh, on a MeshCore board running KISS modem firmware.
- id: local
radio_type: kiss
kiss:
port: "/dev/serial/by-id/usb-1a86_USB_Single_Serial_-if00"
baud_rate: 115200
kiss_persistence: 255 # transmit as soon as the channel is clear
radio:
frequency: 910525000
tx_power: 14
bandwidth: 62500
spreading_factor: 8
coding_rate: 8
preamble_length: 32
# Backhaul, on a board running openHop Modem firmware.
- id: link
radio_type: modem_usb
modem_usb:
port: "/dev/openhop-modem"
baudrate: 921600
lbt_enabled: true
lbt_max_attempts: 5
radio:
frequency: 911450000
tx_power: 22
bandwidth: 62500
spreading_factor: 11 # a different preset per radio is fine
coding_rate: 8
preamble_length: 32If the backhaul modem lives on another board rather than on the host's USB, swap
that entry to radio_type: modem_tcp with a modem_tcp: block naming its host
and port: 5055. Everything else stays the same.
Changes to radios: or fabric: need a service restart to take effect, whether
you made them in the dashboard or in the file.
Each entry needs a unique id. Entries inherit the top-level radio_type,
radio: air settings, and hardware sections unless they supply their own.
A section supplied by an entry replaces the whole top-level section for that
radio. Nested values are not merged individually: if you set radio: on an
entry, every air setting for that radio has to be in it.
Three things that bite on a mixed-backend bridge#
The baud key is spelled differently per backend. KISS uses baud_rate; the
openHop USB modem uses baudrate. One underscore, and the misspelled one is
silently ignored in favor of the default.
Set every air setting explicitly, on both radios. When a value is missing the
backend fills in its own default, and the defaults do not agree: KISS falls back
to tx_power: 14 and preamble_length: 32, while the modem backends fall back
to tx_power: 22 and preamble_length: 16. A preamble mismatch with the rest of
the network is a quiet failure, not an error.
Give both serial devices stable names. Two USB serial devices enumerate in
whatever order the kernel finds them, so /dev/ttyUSB0 and /dev/ttyACM0 can
trade places across a reboot and hand each network the other one's radio. Use a
/dev/serial/by-id/ path, or a udev rule that pins each board to a name of your
own:
ls -l /dev/serial/by-id/The service user also has to be able to open both devices, which usually means
adding it to dialout:
sudo usermod -aG dialout openhopOn the KISS side, kiss_persistence: 255 drops the firmware's probabilistic
CSMA wait once the channel is clear. Carrier sensing still happens. The
repeater already staggers its own retransmissions, so the extra firmware
backoff mostly adds latency to the crossover leg. Leave kiss_full_duplex
alone: disabling carrier sense is not a latency fix.
What tx_mode actually does#
| Mode | Transmit radio |
|---|---|
default |
Always default_radio |
sticky |
The radio that last received (a same-band reply) |
bridge |
The other radio: RX local → TX link, RX link → TX local |
bridge is the frequency-crossing mode, and it is deterministic: one ingress
radio maps to exactly one egress radio. It does not broadcast every packet
through every configured radio.
Restart and confirm:
sudo systemctl restart openhop-repeater
journalctl -u openhop-repeater -fThe gap: stock bridge mode is silent on its own band#
Read that mapping again. With tx_mode: bridge, a packet heard on local is
transmitted only on link. The bridge hears traffic from the local mesh,
passes it through, and repeats it on the far side—and never puts it back on the
air locally.
For a site that already has a local repeater, that is exactly right. For a site where the bridge node is the local coverage, it is a hole: neighbors on the local mesh get no repeat from the one node standing right next to them.
Repeating on both sides#
The feat/fabric-bridge-repeat branch adds two Fabric options for an
exactly-two-radio node:
fabric:
default_radio: local
tx_mode: bridge
repeat_on_ingress: true # also repeat back onto the radio that heard it
local_tx_mode: all # originate on every radio, not just the selected onerepeat_on_ingress changes relayed traffic:
RX on local |
RX on link |
|
|---|---|---|
false (default) |
TX link |
TX local |
true |
TX link, then local |
TX local, then link |
The bridge radio is always attempted first, so the crossover is not delayed by
the local repeat. Requires tx_mode: bridge and exactly two radios.
local_tx_mode: all changes packets this node originates: companion
traffic, the repeater's own adverts, room servers, protocol and discovery
replies. Instead of going out on the selected radio only, they transmit once on
every Fabric radio, default_radio first. Without it, the bridge advertises
itself on one band and stays anonymous on the other.
Both default to the pre-fan-out behavior, so upgrading changes nothing until you opt in. Invalid combinations are rejected at startup, before any radio hardware is opened.
Set these two in config.yaml. They are branch-specific keys, so a dashboard
build that predates them will not offer fields for them; read the fabric block
back after any dashboard save to confirm they are still there.
These options are not in upstream OpenHop Repeater or its published configuration reference yet. Run the branch from agessaman/openhop_repeater if you want them.
How the fan-out stays correct#
Sending the same packet twice is easy to get wrong, so it is worth knowing what the implementation guarantees:
- MeshCore processing runs once per logical packet. Policy, deduplication, and path mutation happen a single time. Only the physical transmission is duplicated.
- Flood scope and path-hash mode are fixed before copying, so every radio carries byte-identical frames and each duty-cycle gate meters the same thing.
- Each egress gets its own
Packetobject, serialized through the TX lock. The sends are sequential, never simultaneous, so one send's metadata cannot overwrite another's. - Each egress is gated independently for duty cycle and link health. A later radio can be refused after an earlier one has already transmitted. The packet counts as forwarded when at least one radio sends it.
- A directed companion message still waits for one ACK, accepted from either radio. The ACK waiter is registered before the first transmission, so a reply arriving mid-fan-out is not lost to the dispatcher's short unclaimed-ACK cache.
- Multi-ack extras share the egress set of the packet they belong to.
Verifying#
Packet records gain a tx_radio_ids column (a JSON list of every radio that
carried the packet) alongside the existing scalar tx_radio_id, which remains
the primary successful egress. The schema migration runs automatically on
startup.
A relayed packet on a working two-sided bridge shows both radio IDs. A packet
that shows one is either pre-upgrade, refused by a duty-cycle gate on the second
radio, or evidence that repeat_on_ingress is not actually on.
Method 2: MeshCore ESP-NOW bridge#
MeshCore itself ships a bridge. It lives in
src/helpers/bridges/ESPNowBridge.cpp
and it is a real dual-protocol repeater feature, not a separate device role: LoRa
stays the primary radio, and ESP-NOW is added as a second transport feeding the
same routing logic.
The topology is two separate ESP32 repeaters, one per frequency, sitting next to
each other and talking over 2.4 GHz. Each is a complete, ordinary repeater on its
own band, so this method repeats on both sides by construction. There is no
equivalent of repeat_on_ingress to configure, because neither unit ever stops
being a repeater.
ESP-NOW is an Espressif protocol on the 2.4 GHz Wi-Fi radio, so this method is ESP32-only and needs no Wi-Fi router or access point. It uses the broadcast MAC internally, so there is no manual peer pairing either.
You need a bridge-enabled build#
The bridge is compile-time optional. A stock repeater build does not have it, and
none of the bridge.* settings will exist until you flash firmware that does.
Upstream ships dedicated PlatformIO environments for it, named
*_repeater_bridge_espnow. There are currently 32, covering most ESP32 boards:
| Family | Environments |
|---|---|
| Heltec | Heltec_v2, Heltec_v3, heltec_v4, heltec_v4_tft, Heltec_WSL3, Heltec_ct62, Heltec_E213, Heltec_E290, Heltec_Wireless_Paper, Heltec_Wireless_Tracker, heltec_tracker_v2, heltec_rc32, heltec_rc32_without_display |
| LilyGo | Tbeam_SX1262, Tbeam_SX1276, LilyGo_TBeam_1W, T_Beam_S3_Supreme_SX1262, LilyGo_TLora_V2_1_1_6, LilyGo_T3S3_sx1262, LilyGo_T3S3_sx1276 |
| RAK / Station | RAK_3112, Station_G2, Station_G2_logging |
| Seeed Xiao | Xiao_S3, Xiao_S3_WIO |
| Generic / other | Generic_E22_sx1262, Generic_E22_sx1268, Meshadventurer_sx1262, Meshadventurer_sx1268, Tenstar_C3_sx1262, Tenstar_C3_sx1268, meshnology_w12 |
Each name takes the _repeater_bridge_espnow suffix. Build one with:
pio run -e heltec_v3_repeater_bridge_espnow -t uploadIf your board has no ready-made environment, the whole of it is two additions to that board's PlatformIO environment:
build_flags =
${your_board.build_flags}
-D WITH_ESPNOW_BRIDGE=1
build_src_filter = ${your_board.build_src_filter}
+<helpers/bridges/ESPNowBridge.cpp>
+<../examples/simple_repeater>You do not set WITH_BRIDGE yourself. It is derived automatically from
WITH_ESPNOW_BRIDGE (or WITH_RS232_BRIDGE, or WITH_MQTT_BRIDGE) and is what
gates the shared bridge.* CLI commands.
Keep your board's normal LORA_FREQ: one unit per frequency, so the two units
are flashed with different LoRa settings and identical bridge settings.
Configuring over the CLI#
Everything else is runtime configuration over serial or BLE, identical on both units except for the LoRa side. First confirm the firmware is the right one:
get bridge.type
> espnowrs232 means you flashed the serial-bridge build; none means the bridge is not
compiled in and the rest of these commands will not exist.
set bridge.secret <shared secret>
set bridge.channel 6
set bridge.enabled on
set bridge.delay 500
set bridge.source logTxThen read it back on both units and compare:
get bridge.enabled
get bridge.channel
get bridge.secret
get bridge.delay
get bridge.sourcebridge.enabled, bridge.channel and bridge.secret all take effect
immediately: enabling starts the bridge, and changing the channel or secret
restarts it in place. No reboot required.
The settings, and their defaults#
| Setting | Values | Default | Notes |
|---|---|---|---|
bridge.type |
read-only | n/a | espnow, rs232, or none |
bridge.enabled |
on / off |
on |
Already on in a fresh bridge build |
bridge.channel |
1–14 |
1 |
Wi-Fi channel; must match on both units |
bridge.secret |
up to 15 chars | LVSITANOS |
Must match on both units |
bridge.delay |
0–10000 ms |
500 |
Added to packets crossing the bridge |
bridge.source |
logTx / logRx |
logTx |
Which packets get mirrored across |
The default secret is the same string on every bridge build in the world. Two
stock units on default channel 1 will happily join each other's mesh. Set
bridge.secret to something of your own on both units before you deploy, and
set it before you enable the bridge.
Choosing bridge.source#
This is the setting people get wrong, and the difference is real:
logTx(default) mirrors packets the repeater actually transmitted on LoRa. Everything has already passed policy, deduplication, and hop limits, so only traffic this node chose to forward crosses the bridge.logRxmirrors packets the repeater received, before its own forwarding decision. Everything it hears crosses the bridge, including packets it would not have repeated.
logTx is the conservative default and the right choice for a frequency bridge.
logRx puts more traffic on the far side, some of which the far side will
immediately discard.
Either way, both units keep repeating normally on their own LoRa band. The bridge setting only governs the crossover.
What actually crosses the wire#
Worth knowing when you are reading a packet capture or debugging a bridge that half-works:
- Frame layout. 2-byte magic (
0xC03E), 2-byte Fletcher-16 checksum, then the MeshCore packet. The checksum and payload are XOR-scrambled withbridge.secret; the magic is not. - The secret is obfuscation, not encryption. A repeating XOR key gives you network isolation and nothing more: frames from a different secret fail the checksum and are discarded. MeshCore's own end-to-end encryption is what actually protects the payload, and it is untouched by the bridge.
- Size limit. ESP-NOW caps a frame at 250 bytes, leaving 246 for the MeshCore packet. Larger packets are dropped rather than fragmented. MeshCore's payload cap is 184 bytes with a 255-byte MTU, so only a near-maximum packet carrying a long path can hit this.
- Loops are handled. Each bridge tracks packets it has already seen, on both the transmit and receive side, so a packet cannot ping-pong across the link.
- The delay is applied on arrival. The receiving unit queues the packet
inbound with
bridge.delayadded, which is what makes an instant 2.4 GHz crossing behave like an RF hop. - Adverts announce it. A bridge repeater sets a feature bit in its status reply, so the bridge is visible to tooling that looks for it.
Deployment notes#
Range is short. ESP-NOW is a 2.4 GHz link with the ESP32's onboard antenna. Treat this as a same-enclosure or same-mast method: two boards in one box, or a few meters apart at most.
Channel choice matters. Pick a Wi-Fi channel that is quiet at your site, and set the same one on both units. A busy 2.4 GHz environment will cost you crossings.
The node cannot deep sleep while the bridge is running—it holds the Wi-Fi radio. Budget power accordingly.
Choosing between them#
| OpenHop RF Fabric | MeshCore ESP-NOW | |
|---|---|---|
| Hardware | One Linux host, two attached radios | Two ESP32 repeater boards |
| Crossover link | In-process, same daemon | 2.4 GHz ESP-NOW |
| Node identity | One node, two bands | Two independent nodes |
| Repeats on both sides | Opt-in (repeat_on_ingress) |
Always, each unit is a full repeater |
| Configuration | Dashboard or config.yaml, restart to apply |
CLI over serial/BLE, applies live |
| Firmware needed | Stock daemon, or the branch for fan-out | Bridge-enabled build required |
| Power draw | Host-class | Two boards, no deep sleep |
| Observability | Dashboard, API, SQLite packet log | Serial log, status bit in adverts |
Roughly: RF Fabric if you already run a Linux host at the site and want one node, one identity, and real logging. ESP-NOW if you want two cheap boards in a box with no host to maintain, and you are happy for the site to appear as two nodes.
There is also a third option this guide does not cover in depth: MeshCore's
RS232 bridge, which links two repeaters over a three-wire UART connection
instead of 2.4 GHz. It is configured almost identically (bridge.enabled,
bridge.source, bridge.delay, plus bridge.baud instead of channel and
secret) and needs a *_repeater_bridge_rs232* build. It suits the same
same-enclosure deployments as ESP-NOW, with a wire instead of a radio link. See
Other resources for a full build guide.
Shared concerns#
Co-site interference#
Whichever method you pick, you end up with two LoRa radios close together, often on adjacent channels. The far-side receiver will be desensed by the near-side transmitter unless you plan for it.
- Vertical separation is the main tool. Mount the antennas as far apart vertically as the site allows. An omni has a null along its vertical axis, so putting a directional antenna below it exploits that null. Even two to three meters helps a lot.
- Channel separation does real work. At 62.5 kHz bandwidth with about 1 MHz of offset, the LoRa channel filter gives meaningful adjacent-channel rejection. Widen the bandwidth and that margin shrinks. Plan for more physical separation.
- A panel antenna's ground plane is a shield. For the backhaul side, a panel with a solid backplane blocks energy radiating back toward the local omni far better than a yagi or grid.
Duty cycle#
Repeating on both sides roughly doubles the airtime the bridge node consumes for relayed traffic, because each logical packet now goes out twice. In regions with a duty-cycle limit this is not free.
Both implementations meter each transmission independently, so the second send can be refused while the first succeeds: the mesh stays legal, but your two-sided coverage degrades under load rather than failing loudly. If you are near a limit, check whether the bridge really needs to repeat locally or whether a neighboring repeater already covers it.
Tuning the delay#
Both methods default to 500 ms, and it is a reasonable starting point. The delay exists so the mesh's timing assumptions treat the crossing as a hop rather than as teleportation.
Raise it if you see retransmission churn around the bridge. Lower it if responsiveness matters more than timing fidelity, but going much below ~200 ms starts to defeat the purpose.
Naming#
Give the two sides names that say what they are (BRIDGE-WEST-BH and
BRIDGE-WEST-LM, say). On the ESP-NOW and RS232 methods you are looking at two
independent nodes on the map, and six months later you will not remember which
is which from the hex.
Other resources#
- RS232 Bridge Build Guide is Cisien's end-to-end build for a two-RAK4631 UART bridge: bill of materials with prices, the cross-wired TX/RX/GND pinout, flashing and CLI steps, enclosure and antenna assembly, and a genuinely good section on co-site antenna isolation. The closest thing to a hardware companion for this guide.
- OpenHop Repeater configuration reference
is the authoritative
radios:andfabric:schema, plus every other block inconfig.yaml. - MeshCore firmware is the
upstream repository. Bridge implementations live in
src/helpers/bridges/; the build environments are invariants/*/platformio.ini. - MeshCore CLI reference is the full
get/setcommand surface that thebridge.*settings sit inside.