Skip to content

Add RSSI-based listen-before-talk - #3383

Open
jirogit wants to merge 4 commits into
meshcore-dev:devfrom
jirogit:feat/rssi-lbt
Open

Add RSSI-based listen-before-talk#3383
jirogit wants to merge 4 commits into
meshcore-dev:devfrom
jirogit:feat/rssi-lbt

Conversation

@jirogit

@jirogit jirogit commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Motivation

MeshCore has two pre-transmit channel checks today, and neither can express the requirement this PR addresses.

cad uses the radio's hardware Channel Activity Detection, which looks for a LoRa preamble. In a band shared with equipment using other modulations, that is exactly the traffic it cannot see.

int.thresh does sample RSSI, but relative to the tracked noise floor, and only once. A deployment that has to hold to an absolute dBm threshold, sense the channel for a defined window rather than instantaneously, or stay quiet for a fixed period after each transmit, has no way to say so.

This PR adds RSSI-based listen-before-talk: modulation-agnostic energy detection with an absolute threshold and a configurable sensing window, plus three transmit-shaping limits that go with it.

What's new

set rssi.lbt on|off
get rssi.lbt -> "> on" / "> off"

set rssi.lbt.params <thr_dBm>,<sense_ms>,<maxwait_ms>,<txmax_ms>,<pause_ms>
get rssi.lbt.params -> "> -80,5,0,4000,50"

get rssi.lbt.params reports the values in the same order set takes them and with the same separator, so its output can be pasted straight back into set — following the existing get radio / set radio pair. Its arguments are:

argument default meaning
thr_dBm -80 absolute RSSI threshold; a sample above this marks the channel busy
sense_ms 5 how long RSSI is sampled for. 0 takes a single instantaneous reading
maxwait_ms 0 how long a busy channel may hold off a pending transmit before it is forced out. 0 = unlimited
txmax_ms 4000 max airtime of one transmit; a longer packet is dropped rather than sent. 0 = unlimited
pause_ms 50 quiet period enforced after each transmit

Throughout, 0 means "unlimited". The defaults are chosen so that a single set rssi.lbt on yields a usable configuration without touching rssi.lbt.params.

Behaviour when disabled

This is the first thing worth checking, so to state it plainly:

  • While rssi.lbt is off — the default — every one of the rssi.lbt.params values is ignored. The sensing loop, the post-transmit pause, the airtime cap and the max-wait are each gated on the enable flag, not merely on their own value being zero. A node that never issues these commands transmits exactly as it does on dev.
  • getCADFailMaxDuration() returns the original hard-coded 4000ms whenever rssi.lbt is off, whether or not cad is on.
  • The legacy binary prefs loader is intentionally not extended. These fields cannot exist in a file written by a build that predates them, so an upgraded node simply starts ith the defaults above.

Design notes

Independent of CAD. The RSSI check is added as a third branch of RadioLibWrapper::isChannelActive(), next to int.thresh and _cad_enabled. The two existing branches are unchanged and the return value stays a plain bool, so isReceiving() remains the single gate Dispatcher::checkSend() consults. Any combination of the three may be active.

Max-wait. rssi.lbt's maxwait_ms and CAD's fixed 4000ms both bound the same busy state in checkSend(). With rssi.lbt off the function returns 4000 unchanged. With it on, the stricter — longer — wait applies, so CAD's fixed value never shortens a wait that rssi.lbt asked to be longer. Since 0 means "never force the transmit", it is normalised to the largest representable duration before any comparison, so it does not lose to a finite value.

The effective value is derived on every call and never written back to prefs, so a computed number cannot end up stored and disagree with what the CLI reports.

Airtime cap. Enforced immediately before startSendRaw(), reusing the existing send-failure cleanup path. There is no counter for dropped packets; the drop is visible only through MESH_DEBUG_PRINTLN, so release builds stay silent.

One implementation, four node types. The prefs live in CommonRadioPrefs, which both NodePrefs::RadioPrefs implementations derive from, so companion, repeater, room server and sensor are all covered without per-target code.

Commits

The PR is one change but is split into four commits that can be taken separately if review stalls on any one of them:

  1. The RSSI sensing mechanism and post-transmit pause in RadioLibWrapper. Inert on its own; nothing enables it yet.
  2. The prefs and CLI wiring.
  3. The airtime cap and the max-wait handling.
  4. Documentation.

Testing

Firmware builds and basic on-device smoke testing:

  • M5Stack Unit C6L (ESP32-C6 / SX1262) — repeater
  • Seeed T1000-E (nRF52840 / LR1110) — repeater
  • RAK WisMesh Tag (nRF52840 / SX1262) — companion, over both BLE and USB

Nodes come up, advertise and forward packets as before, and with rssi.lbt left at its default of off I saw no behaviour change from current dev. This was smoke testing rather than a measured comparison; I have not run the mechanisms under sustained traffic.

Open question

pause_ms is currently a blocking delay() in onSendFinished(). At the default of 50ms that seemed acceptable, but I am happy to make it non-blocking if you prefer.

Hardware CAD only detects a LoRa preamble, so it is blind to any other
modulation sharing the band. int.thresh does sample RSSI, but relative
to the tracked noise floor and only once, which cannot express an
absolute threshold or a sensing window.

Add a third, independent channel-busy check to isChannelActive(): while
enabled, sample getCurrentRSSI() continuously for sense_ms and report
the channel busy if any sample exceeds an absolute dBm threshold. The
loop always takes at least one sample, so sense_ms of 0 degrades to a
single instantaneous reading rather than to no reading at all.

The two existing checks are untouched and the return value stays a plain
bool, so isReceiving() remains the single gate that Dispatcher::checkSend()
consults. CAD and this check are fully independent; either, both, or
neither may be active.

Also enforce an optional quiet period after each transmit in
onSendFinished(). delay() is used rather than a bare spin so the pause
yields to the scheduler.

Nothing calls setRssiLbtParams() yet, so this commit is inert on its own:
the mechanism stays disabled until the prefs are wired up.
Add the six parameters to CommonRadioPrefs, which both NodePrefs
implementations derive from, so companion, repeater, room server and
sensor are all covered by one implementation:

  set rssi.lbt on|off
  set rssi.lbt.params <thr_dBm>,<sense_ms>,<maxwait_ms>,<txmax_ms>,<pause_ms>
  get rssi.lbt
  get rssi.lbt.params    ->  "> -80,5,0,4000,50"

The params are comma separated in both directions, following `set radio`
and `get radio`, so the output of the getter can be pasted straight back
into the setter. Defaults are chosen so that a single `set rssi.lbt on`
brings up a usable configuration.

Dispatcher pushes the sensing parameters down to the radio from the same
place it already refreshes the noise floor threshold and the CAD flag.
Only thr_dbm, sense_ms and pause_ms are the radio's concern; maxwait_ms
and txmax_ms are acted on by the dispatcher itself.

The legacy binary prefs loader is deliberately left alone: these fields
cannot exist in a file written by a build that predates them, so such a
file simply leaves them at their defaults.
Two dispatcher-side limits, both gated on rssi.lbt being enabled so that
the default-off path is byte-for-byte the previous behaviour:

txmax_ms caps the airtime of a single transmit. A packet whose estimated
airtime exceeds the cap is dropped just before startSendRaw(), reusing
the existing send-failure cleanup. There is no counter for this; the
drop is only visible through MESH_DEBUG_PRINTLN, so release builds stay
silent.

maxwait_ms feeds getCADFailMaxDuration(), which gates the same busy check
in checkSend() as CAD's own fixed 4000ms wait. With rssi.lbt off, the
function is untouched -- still a hard-coded 4000ms. With rssi.lbt on, its
max-wait applies; if CAD is also enabled, the stricter (longer) of the
two wins, since CAD's fixed wait must never shorten a wait rssi.lbt asked
to be longer. A max-wait of zero means "never force the transmit", which
is stricter than any finite value, hence the normalisation to the largest
representable duration before comparing.

The effective value is recomputed on every call rather than stored, so a
derived number can never end up in prefs and disagree with what the CLI
reports.
Follows the layout of the existing cad section. Spells out that rssi.lbt
is independent of cad and int.thresh, that all of rssi.lbt.params is
ignored while rssi.lbt is off, the 0-means-unlimited convention on each
field that has it, and how its max-wait interacts with CAD's fixed
4000ms wait in getCADFailMaxDuration().
jirogit added a commit to jirogit/MeshCore that referenced this pull request Sep 9, 2026
Updated the PR reference for the Japanese build to meshcore-dev#3383.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant