Skip to content

Add Japan LoRa LBT compliance (ARIB STD-T108) with runtime frequency detection#2218

Open
jirogit wants to merge 10 commits into
meshcore-dev:devfrom
jirogit:feature/jp-lbt
Open

Add Japan LoRa LBT compliance (ARIB STD-T108) with runtime frequency detection#2218
jirogit wants to merge 10 commits into
meshcore-dev:devfrom
jirogit:feature/jp-lbt

Conversation

@jirogit

@jirogit jirogit commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

Closes #2079

Summary

This PR implements Listen-Before-Talk (LBT) for Japan LoRa operation, as required by ARIB STD-T108 / Giteki (技適) certification. Activation is automatic based on operating frequency — no build flags required.

Changes

Radio accessor methods (RadioLibWrappers.h/cpp, Wrapper classes):

  • getCodingRate() — returns current LoRa coding rate at runtime
  • getFreqMHz() — returns operating frequency in MHz
  • getMaxTextLen() — returns dynamic DM message length limit based on CR
  • getMaxGroupTextLen() — returns dynamic channel message length limit based on CR
  • isJapanMode() — returns true when operating on JP LoRa frequencies (920.800 / 921.000 / 921.200 MHz)

Japan LBT (BaseChatMesh.cpp, RadioLibWrappers.cpp, Dispatcher.h):

  • 5ms continuous RSSI sensing before TX (−80dBm absolute threshold, ARIB STD-T108 §4.4)
  • Exponential backoff when channel busy (base 2000ms, max 16000ms)
  • Small jitter on channel-free detection (0–500ms) to desynchronize simultaneous TX
  • 50ms post-TX wait
  • Override getCADFailMaxDuration() to UINT32_MAX in Japan mode — prevents forced TX during LBT backoff (Dispatcher default is 4s, shorter than max backoff of 16s)
  • Dynamic message length limits per coding rate, derived from ARIB STD-T108 4-second maximum TX time limit, verified by measuring getTimeOnAir() on RAK WisMesh Tag (SF12/BW125):
CR DM limit airtime Channel limit airtime
4/5 64 bytes 3874ms 64 bytes 3710ms
4/6 48 bytes 3874ms 48 bytes 3678ms
4/7 32 bytes 3678ms 39 bytes 3907ms
4/8 24 bytes 3547ms 29 bytes 3809ms

DM and channel packets differ by 1 byte overhead, warranting separate limits for CR4/7 and CR4/8. Non-JP returns default bytes unchanged.

Build:

  • Fix build for non-FreeRTOS and non-SX1262 platforms (YIELD_TASK() macro)

Note on commit history

Early commits include CAD-based channel detection based on weebl2000's work (PR #1727). This was subsequently replaced with RSSI-based LBT, as ARIB STD-T108 requires energy detection, not LoRa preamble detection.

As of the current implementation (rebased onto upstream/dev which includes merged #1727), RSSI LBT and CAD operate independently. JP nodes run RSSI sensing first (ARIB requirement), then fall through to CAD if _cad_enabled.
Non-JP nodes use the existing _threshold and CAD path unchanged.
The commit history reflects the earlier exploration path before #1727 was merged.

Testing

Devices: RAK WisMesh Tag (SX1262), Wio Tracker L1 Pro (SX1262)
Settings: SF12 / BW125 / CR4-8 (JP standard)

  • 16-byte simultaneous DM: 3/3 success

  • 48-byte simultaneous DM: 3/3 success

  • Channel messages: all CR rates confirmed (CR4/5–CR4/8).
    Effective text limit is (channel_limit - 2 - sender_name_length) bytes,
    where 2 bytes are consumed by the ": " separator.
    Example (8-byte sender name): CR4/5=54B, CR4/6=38B, CR4/7=29B, CR4/8=19B

Known limitations

  • In Japan mode, the UI does not reflect the reduced message length limits. Channel messages longer than the CR-dependent limit will be silently truncated at send time. DM messages exceeding the limit will fail to send. A UI-level fix requires exposing getMaxTextLen()/getMaxGroupTextLen() to the UI layer and is left for a follow-up.
  • TX power limit (≤13dBm, ARIB STD-T108) is not enforced — relies on user configuration.
  • If the ambient noise floor exceeds −80 dBm, transmission will be blocked indefinitely. No forced TX occurs — this is intentional, as ARIB STD-T108 prohibits transmission while the channel is busy. Users can monitor the noise floor via the MeshCore companion app: tap the three-dot menu (⋮) in the top right corner → Tools → Noise Floor.

@jirogit
jirogit marked this pull request as ready for review April 1, 2026 09:23
Comment thread src/helpers/radiolib/RadioLibWrappers.h Outdated
virtual uint8_t getCodingRate() const { return 8; } // default CR4/8, override in subclass
virtual float getFreqMHz() const { return 0.0f; } // default unknown, override in subclass
//
bool isJapanMode() const {

@4np 4np Apr 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function name doesn't match the frequencies being checked. 920.8, 921.0, and 921.2 MHz are the default channels for AS923-2, which is the plan for Indonesia and Vietnam, not Japan. Japan uses AS923-1 with default channels at 923.2 and 923.4 MHz. This means the function will return false for most Japanese devices and true for Indonesian/Vietnamese ones, a false positive for an entirely different region. Suggest renaming to isAS923_2Mode() or correcting the frequencies to 923.2/923.4 MHz depending on the intended behavior.

@jirogit jirogit Apr 2, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function name doesn't match the frequencies being checked. 920.8, 921.0, and 921.2 MHz are the default channels for AS923-2, which is the plan for Indonesia and Vietnam, not Japan. Japan uses AS923-1 with default channels at 923.2 and 923.4 MHz. This means the function will return false for most Japanese devices and true for Indonesian/Vietnamese ones, a false positive for an entirely different region. Suggest renaming to isAS923_2Mode() or correcting the frequencies to 923.2/923.4 MHz depending on the intended behavior.

Thank you for the review. You are correct that 920.8/921.0/921.2 MHz overlap with AS923-2 (Indonesia/Vietnam in LoRaWAN terms). However, these frequencies are legal under Japanese radio law (ARIB STD-T108, 920.6–928.0 MHz) and are within the band permitted for use in Japan.

Regarding the false positive concern for Indonesian/Vietnamese users: MeshCore does not currently have a defined default frequency for Indonesia. Vietnam's current default is 920.250 MHz, which does not match any of the three frequencies checked by isJapanMode() (920.800 / 921.000 / 921.200 MHz). In practice, the risk of unintended activation on non-Japanese nodes is low.

That said, the concern is valid in principle and points toward a broader architectural question: as MeshCore expands into regions with different regulatory requirements — duty cycle limits in the EU, LBT variants in other Asian countries, TX power caps, airtime limits — a more general regional compliance framework would be valuable.

A future "set radio_law / get radio_law" command (distinct from the existing "set region") could allow users to explicitly select their regulatory domain (e.g. JP, EU, FCC), enabling the firmware to apply the appropriate constraints automatically.

The current frequency-based isJapanMode() is intentionally minimal and pragmatic given the near-zero Japanese node count today, but we recognize it is not a scalable long-term solution. We are open to adding such a mechanism if the maintainer prefers a more structured approach, and would welcome guidance on the preferred direction for regional compliance in MeshCore going forward.

@jirogit

jirogit commented Apr 9, 2026

Copy link
Copy Markdown
Contributor Author

Related RFC Discussion: #2285

@dreirund

Copy link
Copy Markdown
Contributor

Question from #1727 (comment):

How does it differ from the current int.thresh implementation?

Regards!

@jirogit

jirogit commented May 1, 2026

Copy link
Copy Markdown
Contributor Author

How does it differ from the current int.thresh implementation?

Thank you very much for your question!

This PR does not use int.thresh.

The int.thresh-based mechanism uses a relative noise floor threshold (noise_floor + threshold) as a voluntary interference avoidance heuristic (currently hardcoded to 0 in Companion firmware — see #2051).

ARIB STD-T108 LBT requires an absolute −80 dBm threshold, a minimum 5ms continuous RSSI sensing window before each TX, and a defined backoff protocol — these are legal requirements, not preferences. The getCADFailMaxDuration() override to UINT32_MAX is also needed to prevent the Dispatcher's default 4s forced-TX timeout from firing during a legitimate LBT backoff.

So the two mechanisms are complementary: int.thresh applies generally as a configurable courtesy; JP LBT applies when on Japan frequencies as a non-negotiable legal obligation with specified parameters.

@jirogit

jirogit commented May 2, 2026

Copy link
Copy Markdown
Contributor Author

One more note on the jitter mechanism in this PR, in context of the txdelay discussion in #1727:

The 0–500ms random jitter added on channel-free detection serves a similar purpose to txdelay, but targets a different point in the transmission chain:

txdelay JP LBT jitter
Purpose Stagger repeater relay TX after receiving a packet Stagger any TX attempt the moment a node detects the channel as free
Target Repeater relay behavior All nodes, every TX attempt
Value Proportional to airtime (default factor 0.5) Random (0–500ms)
Configuration set txdelay CLI Internal to JP LBT

Both mechanisms address the same root problem — synchronized transmission from multiple nodes — at different points in the chain. txdelay acts on the repeater receive side (after a packet arrives); JP LBT jitter acts on the transmit side (just before each TX attempt by any node, when the channel is free).

syssi's video in #1727 clearly demonstrates what happens without staggering: with txdelay 0, all repeaters synchronize and collide simultaneously. The same collision pattern occurs at TX origination without jitter.

@jirogit

jirogit commented May 3, 2026

Copy link
Copy Markdown
Contributor Author

Update: airtime-scaled jitter (f2aad64)

Replaced fixed random(0, 500) jitter with airtime-scaled random(0, airtime_ms / JP_LBT_JITTER_DIVISOR) (divisor=32).

Jitter upper bound by setting:
SF12/BW125/CR4/8 (JP standard): ~245ms (was 500ms)
SF7/BW62.5/CR4/5 (USA/Canada preset): ~12ms

Tested divisors 16 and 32 at SF12/BW125/CR4/8 — both 3/3 success with no measurable difference in delivery time. Divisor 32 adopted as it halves the unnecessary wait without observable downside.
The divisor is a named constant (JP_LBT_JITTER_DIVISOR) to make future tuning straightforward.

@jirogit

jirogit commented May 5, 2026

Copy link
Copy Markdown
Contributor Author

I've renamed isJapanMode() to isAS923_1_JP() to address @4np's concern about AS923-2 frequency overlap.
Rationale: LoRaMac-node uses the same sub-plan naming (AS923_1_JP) to distinguish Japan's ARIB STD-T108 LBT requirement from AS923 in general — AS923 itself has no LBT mandate; LBT here is Japan-specific. isAS923_1_JP() makes that explicit.

@jirogit

jirogit commented May 5, 2026

Copy link
Copy Markdown
Contributor Author

Added commit 4e09f6a: JP LBT txdelay suppression.

Problem

In JP LBT mode (SF12/BW125/CR4/8), the standard txdelay formula (random(0, 5 × airtime × factor)) produces delays up to ~8.9s with default tx_delay_factor=0.5. Urban Japan (Akihabara being the design target) has a high noise floor that makes SF7/BW125 ("LongFast") impractical — SF12/BW125 is required for reliable communication. At SF12, airtime is ~29× longer than SF7, which makes the standard txdelay formula produce multi-second delays. In this environment, LBT backoff is already doing the heavy lifting for channel access; txdelay on top is pure latency penalty.

Why txdelay has limited value in JP LBT

The purpose of txdelay is to stagger retransmit timing across multiple repeaters to reduce collisions. In JP LBT mode, this role is already covered by two mechanisms:

  • LBT backoff: exponential backoff (1–16s) when RSSI > −80 dBm — handles environmental noise and active transmissions
  • Jitter (added in f2aad64): random(0, airtime/32) applied after a clean LBT sense — handles the case where multiple nodes clear simultaneously

With very few MeshCore nodes currently deployed in Japan, repeater-to-repeater collision is not a practical concern.

Fix

When isAS923_1_JP() is true, getRetransmitDelay() and getDirectRetransmitDelay() now return random(0, jitter_max) instead of the full txdelay window — matching the jitter scale used in isChannelActive():

SF12/BW125/CR4/8:  jitter_max ≈ 111ms
  before: txdelay max ≈ 8,900ms, average ≈ 4,400ms
  after:  txdelay max ≈   111ms, average ≈    56ms

Collision probability analysis (4 simultaneous repeaters, 1ms detection threshold):

  • jitter alone: ~10.4% chance of true collision
  • jitter + txdelay at jitter-scale: ~7.0% (33% improvement)
  • average added latency: 56ms vs 4,400ms

This scales naturally as CR decreases over time (CR4/8 → CR4/5): jitter_max shrinks with airtime, so txdelay tracks it automatically without any config change.

JP_LBT_JITTER_DIVISOR is promoted from a local static const in RadioLibWrappers.cpp to a public static constexpr in RadioLibWrappers.h so the examples can reference it directly.

Applied to simple_repeater, simple_room_server, and simple_sensor. Also aligns simple_sensor's non-JP path to use nextInt(0, 5*t+1) (was nextInt(0, 6)*t).

@jirogit

jirogit commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto upstream/dev (includes #1727)

This branch has been completely rebuilt on top of the current upstream/dev (which includes the merged CAD LBT from #1727).

The previous history had #1727's CAD commits mixed in, causing conflicts across RadioLibWrappers.cpp/.h. Rather than resolving those conflict-by-conflict, I created a clean branch from upstream/dev and re-implemented the JP LBT changes on top.

isAS923_1_JP() LBT and CAD now operate independently:

JP mode: isAS923_1_JP() is active when frequency is set to 920.8 / 921.0 / 921.2 MHz, CAD optional — runs after RSSI clear if set cad on

Non-JP: isAS923_1_JP() not active, CAD optional, existing behavior unchanged

JP nodes run RSSI sensing first (ARIB requirement), then fall through to CAD if _cad_enabled. No existing behavior changes for non-JP nodes.

Also recovered two orphaned commits (f2aad64, 4e09f6a) that were not on any branch:

Airtime-scaled jitter (random(0, airtime/32)) instead of fixed 500ms
txdelay suppressed to jitter-scale in JP mode to avoid adding ~4400ms latency on top of LBT backoff

Hardware tested: RAK WisMesh Tag (SX1262) + LilyGo T1000-E (LR1110), companion firmware, SF12/BW125/CR4/8 at 920.8 MHz. DM and group messages at maximum text length delivered successfully.

@usrflo

usrflo commented Jul 15, 2026

Copy link
Copy Markdown

One thing I wanted to check on the 50 ms post-TX delay(50) in onSendFinished(): as far as I can see in RadioLib 7.7.1, SX126x::finishTransmit() ends in standby(), so the chip is in STANDBY — not RX — right after TX. RX is only re-armed from the main loop (recvRaw(), when state != STATE_RX), and that loop is blocked inside this delay(50). So the radio is effectively not receiving for 50 ms after every TX. ARIB STD-T108 §3.4.1 mandates a minimum spacing between transmissions; it doesn't appear to require the receiver to be muted during that gap. Is keeping RX closed during the wait intentional, or would re-arming RX immediately after finishTransmit() (letting the 50 ms spacing elapse while still receiving) be feasible?

Related: the busy-channel backoff seemts to block up to 16 s inside checkSend(), during which loop() can't service incoming packets - was a non-blocking, timestamp-based backoff considered so RX handling keeps running?

@jirogit

jirogit commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Good catch on both points — appreciate the close read of RadioLib's actual state transitions.

  1. You're right, and it's not intentional. finishTransmit() leaves the radio in STANDBY, recvRaw() only re-arms RX when state != STATE_RX, and the blocking delay(50) sits in between, so RX stays closed for the duration. Since ARIB STD-T108 §3.4.1 only requires spacing between transmissions and not RX muting, I'll switch this to re-arm RX (startReceive()) immediately after finishTransmit() and enforce the 50ms spacing as a non-blocking timestamp check before the next TX attempt instead.

  2. Also correct. The busy-channel backoff already avoids a hard delay() (that was changed earlier specifically to stop it blocking BLE/UI), but checkSend() still doesn't return until the backoff elapses, so packet handling is deferred for up to 16s. I'll convert this into a proper non-blocking check: store the backoff deadline and have checkSend() return immediately when busy, re-checking on the next loop iteration rather than spin-waiting inside the call.

Will have an updated version of both within the next few days.

@jirogit

jirogit commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review — both points below.

Post-TX 50ms delay(50): You're right, and confirmed by testing —
delay(50) does block RX from resuming for that window, since
finishTransmit() leaves the radio in STANDBY and recvRaw() only
re-arms RX on the next loop() pass. I looked into replacing this
with a non-blocking timestamp check, but decided against it for this
PR: the potential gain (avoiding a 50ms RX gap once per TX) is small
relative to the busy-channel backoff below, and getting a
timestamp-based implementation of the ARIB §3.4.1 spacing requirement
wrong would be a silent compliance issue rather than a visible bug.
delay(50) is simple and unambiguous, so I'd rather leave it as-is
unless there's a stronger case for changing it.

Busy-channel backoff blocking up to 16s: Converted to a
non-blocking state machine (_lbt_backoff_active / _lbt_deadline).
On busy detection, the backoff deadline is recorded and the function
returns immediately; Dispatcher::checkSend()'s existing retry
mechanism re-polls on the next loop() pass instead of spin-waiting.
loop() (and RX servicing) is no longer blocked during backoff.
The initial 5ms RSSI sense and the post-clear jitter wait are left
blocking, since both are short relative to the up-to-16s backoff this
targets.

Worth noting: 16s is the cap for a single backoff cycle, not the
total worst case. On repeated busy detections, _busy_count keeps
growing and each new cycle re-rolls a fresh 16s-capped window, so
under sustained channel activity the total wait is effectively
unbounded (by design -- getCADFailMaxDuration() returns
UINT32_MAX in JP mode, since ARIB STD-T108 prohibits forcing a
transmission through). In testing under real electrical interference
I saw a single message take ~4 minutes to clear; once the interference
source was removed, delivery was back to normal (single-digit
seconds). The important change here is that this multi-minute wait
no longer blocks the Dispatcher loop -- RX keeps working throughout.

Hardware-tested (Lilygo T3S3 sx1262 / Wio Tracker L1 Pro), including bidirectional
simultaneous-send collision scenarios -- backoff resolves correctly
and RX keeps working throughout.

jirogit added 10 commits July 20, 2026 23:57
Add getCodingRate() and getFreqMHz() virtual methods to RadioLibWrapper
base class, enabling subclasses to report their operating frequency.
isJapanMode() detects JP 920MHz band (CH25-27) for ARIB STD-T108 compliance.
getMaxTextLen() and getMaxGroupTextLen() enforce 4-second airtime limits
per SF12/BW125 measurements.
In JP 920MHz band (CH25-27): 5ms continuous RSSI sensing at -80dBm
absolute threshold, exponential backoff (2000-16000ms) on busy, jitter
(0-500ms) on free, then falls through to CAD if enabled.
Non-JP path: existing relative-RSSI threshold + CAD unchanged.
Add YIELD_TASK() macro and _busy_count field for backoff tracking.
Add isJapanMode() virtual to mesh::Radio (default false) so Dispatcher
can query frequency context without depending on RadioLibWrapper.
Dispatcher::getCADFailMaxDuration() returns UINT32_MAX for JP nodes,
eliminating the 4-second forced-TX that would violate ARIB STD-T108.
Non-JP nodes retain the original 4-second safety timeout unchanged.
getCodingRate() returns codingRate+4 (RadioLib stores 1-4 internally,
expose as CR4/5-8 matching the rest of the codebase).
CustomLR1110Wrapper overrides both getCodingRate() and getFreqMHz() so
isJapanMode() and getMaxTextLen() work correctly on T1000-E.
Enables isJapanMode() and getMaxTextLen() to work on SX1262-based targets
(WisMesh Tag, T-Echo Lite, T114, XIAO nRF52, etc.).
codingRate accessed as SX1262 base class member +4 (same pattern as LR1110).
Add getMaxTextLen() and getMaxGroupTextLen() virtual to mesh::Radio
(default 160 bytes). BaseChatMesh uses these instead of the hardcoded
MAX_TEXT_LEN macro for limit checks in composeMsgPacket(),
sendCommandData(), and sendGroupMessage(). Stack buffers remain sized
to MAX_TEXT_LEN as a safe upper bound.
isChannelActive() previously spin-waited (with YIELD_TASK()) for up to
16s after detecting RSSI busy, blocking Dispatcher::loop() from
servicing incoming packets for the duration of the backoff.

Replace the blocking wait with a state machine (_lbt_backoff_active /
_lbt_deadline): on busy detection, arm a deadline and return true
(busy) immediately. Dispatcher::checkSend()'s existing retry mechanism
(getCADFailRetryDelay()) re-polls isChannelActive() on the next
loop() pass; once the deadline has elapsed, re-sense (bounded,
single-level recursion) instead of assuming still-busy.

The initial 5ms RSSI sense and the post-clear jitter wait remain
blocking, as both are short (<=5ms and <=~1s respectively) relative
to the up-to-16s backoff this change targets.

Hardware-tested (LilyGo T3S3 sx1262 / Wio Tracker L1 Pro) in both quiet and
electrically noisy environments; confirmed both bidirectional and
unidirectional message delivery under legitimate busy-channel
conditions.
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.

4 participants