Bridging Two LoRa Frequencies

Joining a backhaul band to a local mesh without hand-rolling a relay

Updated 11 September 2026 20 min read
MeshCoreOpenHopESP-NOWLoRaRepeater
On this page
  1. What a bridge has to get right
  2. Method 1: OpenHop Repeater RF Fabric
  3. Hardware
  4. Configuring the Fabric
  5. Three things that bite on a mixed-backend bridge
  6. What tx_mode actually does
  7. The gap: stock bridge mode is silent on its own band
  8. Repeating on both sides
  9. How the fan-out stays correct
  10. Verifying
  11. Method 2: MeshCore ESP-NOW bridge
  12. You need a bridge-enabled build
  13. Configuring over the CLI
  14. The settings, and their defaults
  15. Choosing bridge.source
  16. What actually crosses the wire
  17. Deployment notes
  18. Choosing between them
  19. Shared concerns
  20. Co-site interference
  21. Duty cycle
  22. Tuning the delay
  23. Naming
  24. 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:

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.

Warning

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:

yaml
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: 32

If 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.

Important

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:

bash
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:

bash
sudo usermod -aG dialout openhop
Tip

On 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:

bash
sudo systemctl restart openhop-repeater
journalctl -u openhop-repeater -f

The 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:

yaml
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 one

repeat_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.

Note

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 Packet object, 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.

Note

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:

bash
pio run -e heltec_v3_repeater_bridge_espnow -t upload

If your board has no ready-made environment, the whole of it is two additions to that board's PlatformIO environment:

ini
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>
Tip

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:

meshcore
get bridge.type
> espnow

rs232 means you flashed the serial-bridge build; none means the bridge is not compiled in and the rest of these commands will not exist.

meshcore
set bridge.secret <shared secret>
set bridge.channel 6
set bridge.enabled on
set bridge.delay 500
set bridge.source logTx

Then read it back on both units and compare:

meshcore
get bridge.enabled
get bridge.channel
get bridge.secret
get bridge.delay
get bridge.source

bridge.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 114 1 Wi-Fi channel; must match on both units
bridge.secret up to 15 chars LVSITANOS Must match on both units
bridge.delay 010000 ms 500 Added to packets crossing the bridge
bridge.source logTx / logRx logTx Which packets get mirrored across
Caution

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.
  • logRx mirrors 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 with bridge.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.delay added, 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: and fabric: schema, plus every other block in config.yaml.
  • MeshCore firmware is the upstream repository. Bridge implementations live in src/helpers/bridges/; the build environments are in variants/*/platformio.ini.
  • MeshCore CLI reference is the full get / set command surface that the bridge.* settings sit inside.