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