Skip to content

How a Repeater Routes

The MeshCore routing and timing mechanics behind the settings that trials and the tuner change. Without knowing what each parameter controls, tuning them is guesswork.

How MeshCore Routes a Packet

Every packet carries a route type in its header (docs/packet_format.md): flood (ROUTE_TYPE_FLOOD/ _TRANSPORT_FLOOD) or direct (ROUTE_TYPE_DIRECT/_TRANSPORT_DIRECT). The two build and consume their path very differently, and the tunable timing parameters below apply almost entirely to the flood case:

  • Flood: no path is set by the sender — each repeater that forwards it appends its own identity hash to the packet's path buffer before re-queuing it (Mesh::routeRecvPacket()). The path buffer has a fixed capacity (MAX_PATH_SIZE), so a flood packet that has already changed hands too many times gets dropped rather than appended to (RX_DISP_PATH_FULL) — this is flood's de facto hop limit, not an explicit TTL counter.
  • Direct: the sender supplies the full path up front. Each hop checks whether it is the next hash in that path; if so, it strips itself off the front and forwards to whatever's left (removeSelfFromPath()). A node that isn't the expected next hop logs RX_DISP_WRONG_HOP and drops.

Because a flood packet has no path to begin with, the exact same packet reaches a repeater from multiple neighbors, multiple times, as it spreads outward. Deduplication is what keeps this from cascading forever: every received packet's hash (Packet::calculatePacketHash(), essentially a digest of its payload) is checked against a rolling table of recently-seen hashes. First sight: process it (and, for a repeater, schedule a forward). Every repeat sight after that: RX_DISP_DUP, dropped without forwarding. This table's size (MAX_PACKET_HASHES) is a boundary condition, not a tuning knob — the parameters below govern when a genuinely new flood packet gets forwarded, not whether a duplicate does.

A repeater's own forward-or-drop decision for a freshly-received packet is one of (RxDisposition, src/Dispatcher.h):

Disposition What it means
RX_DISP_FORWARDED Repeater accepted and scheduled a retransmission (flood or direct).
RX_DISP_NO_FORWARD This node is the correct next hop, but packet forwarding is administratively disabled here (client_repeat/repeat_mode off).
RX_DISP_WRONG_HOP Direct-routed packet; this node isn't the path's next hop.
RX_DISP_PATH_FULL Would forward, but the path buffer has no room left to append/consume.
RX_DISP_DUP Already seen this packet's hash — dropped, not forwarded.
RX_DISP_FILTERED Rejected pre-dispatch (e.g. region/scope filtering).

beebo monitor's "RX conditions" histogram and the offline delivery_proxy of beebo analyze tuning indicators are built from this disposition breakdown. The tuners themselves do not score it: they reward confirmed delivery instead (see The Objective Function).

Where This Sits in Routing Taxonomy

It's worth being precise about what kind of routing this actually is, since the term "dynamic routing" usually means something more specific: protocols like RIP (distance-vector), OSPF (link-state), or BGP (path-vector), where routers periodically exchange routing information and maintain forwarding tables computed from a metric (hop count, cost, path attributes) — the "dynamic" part being that the tables update automatically as topology changes, ahead of any actual data needing to move (see Dynamic routing).

MeshCore doesn't do any of that:

  • No routing tables, no route advertisements, no periodic exchange at all. Nodes don't know the topology and don't compute paths in advance.
  • Flood + path accumulation is the discovery mechanism. A flood packet has no path; each repeater that forwards it appends its own hash to the packet's path buffer. By the time it reaches the destination, the packet itself carries a complete accumulated path.
  • Direct packets are then source-routed using exactly that accumulated path, hop by hop stripping itself off the front — no lookup, no table.

That combination — on-demand path discovery via flooding, with the discovered path embedded directly in subsequent packets rather than stored in per-node tables — sits much closer to the reactive/source-routing family of MANET protocols (DSR is the closest analogue) than to any of the table-maintaining dynamic routing protocols the term usually refers to. If forced onto that taxonomy at all, MeshCore sits outside "dynamic routing" as classically defined — arguably closer to the absence of a routing table, with the path riding along in the data itself instead.

This is also why the tuning beebo adds is not "route optimization" in the RIP/OSPF/BGP sense: the parameters it adjusts shape when a node retransmits and how it avoids collisions (forwarding-timing/collision- avoidance discipline), never which path a packet takes or any routing metric — there is no metric to optimize, since there's no routing table to begin with.

The Tunable Parameters

Six parameters govern a repeater's forwarding timing, collision avoidance, and airtime discipline, and a seventh, route retry, covers a forward nobody echoed. All are persisted NodePrefs/ComPrefs fields, reachable via beebo settings repeater.routing.*. rx_delay_base/ airtime_factor also have a separate, independent companion-role copy at companion.routing.* (the stock upstream CMD_GET/SET_TUNING_PARAMS opcode) — tuning the repeater's forwarding timing never affects a simultaneous companion session's behavior, or vice versa. See the Settings reference for the exact leaf paths.

rx_delay_base — flood forward jitter

Only applies to flood packets a repeater is about to forward for the first time. calcRxDelay(score, air_time) computes:

delay = (rx_delay_base ^ (0.85 - score) - 1) * air_time

score (Radio::packetScore(), roughly 0-1) reflects how good the received link was (SNR-driven) — a stronger link gives a higher score, which shrinks the exponent and pulls the delay toward zero. In effect: the node that heard the flood packet best jitters least and is likely to retransmit first, giving the mesh a natural, no-coordination-needed ordering that thins out redundant near-simultaneous retransmissions from several repeaters that all heard the same flood packet. Raising rx_delay_base widens the spread between a well-scored and poorly-scored node's delay — more separation, generally fewer collisions, at the cost of slower propagation. rx_delay_base <= 0 disables this jitter entirely (fires immediately).

tx_delay_factor / direct_tx_delay_factor — retransmit jitter window

Once a repeater has decided to forward (flood or direct respectively), it still doesn't transmit instantly — getRetransmitDelay()/ getDirectRetransmitDelay() pick a random delay in [0, 5 * estimated_airtime * factor]. Raising the factor widens this random window, spreading out when different repeaters (or the same repeater's queued packets) actually key up, at the direct cost of added latency per hop. This is the primary knob for reducing collisions between repeaters that all decided to forward around the same time — rx_delay_base orders who goes first, this decides how spread out the actual transmissions are.

interference_threshold — RSSI-over-noise-floor busy check

One of the channel busy/clear checks checkSend() makes before every transmit. isReceiving() runs three, ORed: a frame in progress (the radio's preamble or valid-header flag, always on and not tunable, see tx_wait_cad), then this RSSI check and the hardware scan (radio.cad) inside isChannelActive(). This check is busy when getCurrentRSSI() > noise_floor + interference_threshold, where noise_floor is tracked separately via periodic RSSI sampling (triggerNoiseFloorCalibrate() only resets that sampling, it does not use the threshold itself). A threshold of 0 turns this RSSI check off; the hardware scan, if radio.cad is on, still runs. Raising the threshold makes the radio tolerate a noisier floor before treating it as channel activity — useful in a persistently noisy RF environment, at the cost of being slower to detect genuine channel activity (more collision risk) if raised too far. The check reads wideband RSSI, so any energy above the threshold counts, LoRa or not. Not used by packetScore(), which scores purely from SNR/SF/packet length.

tx_wait_cad includes reception in progress

Before the two checks above, isReceiving() returns busy while the radio is receiving a frame: the SX1262 preamble-detected or header-valid flag (CustomSX1262::isReceiving()). It is always on, so tx_wait_cad is non-zero with interference_threshold 0 and radio.cad off: a packet ready to send waits out the frame and then a 120-480 ms backoff. A preamble with no header is dropped as noise after _preambleMillis, a header that never completes after _maxPayloadMillis. Time in the indicator therefore tracks on-air traffic heard by the node, plus those flag timeouts.

beebo monitor splits tx_wait_cad by the check behind each busy verdict: tx_wait_cad_frame (a frame in progress), tx_wait_cad_rssi (interference_threshold) and tx_wait_cad_hw (radio.cad, plus any backoff not set by a verdict). tx_cad_verdicts counts the verdicts per minute, saturating at 255. The backoff after a verdict is charged to the check that caused it.

radio.cad — hardware channel activity detection

A switch (0/1, default off; PREFS_TLV_CAD, upstream's set cad on/off) that adds the radio's own channel scan to isChannelActive(): when on and the RSSI check above did not already say busy, the radio runs scanChannel(), which looks for a LoRa preamble at the node's SF and BW, and anything but "channel free" counts as busy. The two checks are ORed, RSSI first, so turning both on can only produce more busy verdicts than either alone. CAD is more selective than the RSSI check (LoRa only) and catches a weak transmission under the RSSI threshold, but it can also fire on a distant transmission that could not have collided, and each scan interrupts RX (the radio is put back in receive afterwards, the scan's interrupt flag cleared so it is not counted as a receive error).

A busy verdict defers the send by a random 120, 240, 360 or 480 ms (Mesh::getCADFailRetryDelay()). After 4 s of continuous busy (getCADFailMaxDuration()) the node sends anyway and raises the ERR_EVENT_CAD_TIMEOUT fault, so a channel that stays busy delays every send by up to that long but never blocks forever. All of that waiting is the tx_wait_cad time (the CAD Busy indicator); it does not depend on radio.cad being on, and stays non-zero with the threshold at 0 and CAD off (see above). With a tight interference_threshold CAD adds little and costs scan time; with a loose or zero one it is the main collision avoidance. radio.cad is a trial switch, so a trial can decide it from measured goodput.

agc_reset_interval — AGC re-convergence cadence

Purely a radio-frontend concern: on a timer, the radio does a brief sleep/wake cycle (doResetAGC()) to force its automatic gain control to re-converge, preventing a "stuck" gain state from skewing RSSI/noise-floor readings (which, again, feed packetScore()). Not really a collision/delivery lever on its own — its effect is indirect, through keeping the inputs to the other parameters accurate.

airtime_factor — duty-cycle budget

Independent of the above: a token-bucket budget (tx_budget_ms) refills over time at a rate set by duty_cycle = 1 / (1 + airtime_factor) and drains by each transmission's measured airtime. When the budget runs low, checkSend() blocks new transmissions until it recovers — this is airtime discipline (regulatory/fairness budget enforcement), separate from CAD's collision avoidance (reacting to a channel that's busy right now). Raising airtime_factor tightens the budget (lower effective duty cycle, less total airtime spent, but transmissions queue up and wait longer once the budget is exhausted).

Route retry

A repeater that forwards a flood and never hears a neighbor rebroadcast it can send it again, at a more robust LoRa coding rate. One setting controls it, also trialed (see Setting Trials):

Setting Range Meaning
repeater.routing.retry 0, or 1 to 3 coding rates from 5 to 8 separated by \|, default 0 The coding rate 4/x of each retry of a forward whose echo timed out, in order: 8 is one retry at 4/8, 5\|6 a first retry at 4/5 and a second at 4/6. 5 is the rate of the first send; 0 turns retry off

A retry has the same packet hash as its first send. beebo analyze retries reports how many retries were heard, went unanswered or are pending, the rescue rate and the extra airtime; beebo analyze packets marks each retry in its Try column. Only floods the repeater forwards are retried, never direct packets or packets the node originates.

A flood's echo window starts when the packet is sent, so it measures the neighbor's turnaround only: 4500 ms plus 6 x the packet's airtime, rounded up from a fit to measured captures. A packet that is never sent gets no window, and a forward heard from another repeater before ours went out does not count as an echo. The window is fixed in the firmware, not a setting: echoes of a repeater's forward can arrive later than the window allows for, and beebo analyze timing shows the share past the window, the window that would cover 95% and 99% of the echoes, and a fit of base + factor x airtime to compare with the one in use.

How They Interweave

None of these parameters acts alone:

interference_threshold ──▶ noise floor ──▶ packetScore() ──▶ rx_delay_base's exponent
                                               │
agc_reset_interval ──▶ (keeps the above accurate over time)
                                               │
                                               ▼
                              flood forward jitter (who retransmits first)
                                               │
                                               ▼
                    tx_delay_factor / direct_tx_delay_factor (when they actually key up)
                                               │
                                               ▼
                              airtime_factor's budget (whether they're allowed to yet)

A change to one can shift the effective behavior of another: e.g. raising interference_threshold changes packetScore()'s inputs, which changes how much spread rx_delay_base actually produces between two given nodes, even though rx_delay_base itself didn't change. This is exactly why the tuning work changes one parameter at a time and waits to observe the result before touching another (see Setting Trials) — changing several simultaneously would make it impossible to attribute an outcome to any single knob.