ABP-LORAWAN-SPEC — LoRaWAN ABP encoder · universal ChirpStack ingestion (branch dev-ABP-lorawan; MT/MC→RNS deferred)
Status: DRAFT (scoping). Branch
dev-ABP-lorawanoffmain(pushed). Supersedes nothing yet — additive on top of shipped v8.3. Release version: targeted for v8.4 (decided 2026-06-15; earlier de-versioned while TBD). This feature was tentatively labelled "v8.3.1" while in scoping; that slot was reassigned to a v8.3 patch (Radio2 pin-defines fix, separate session). Thedev-ABP-lorawanbranch now targets the v8.4 release (the spec filename stays de-versioned). Do NOT tag this branch v8.3.1. Scope steer (owner): LoRaWAN ABP is REQUIRED; OTAA is OPTIONAL and may be dropped if hard/complicated (see §10). The encoder is opt-in and keyed — v8.3's keyless LoRaWAN capture/relay stays intact as a separate mode (see §8). Some §§ (exact code-seam file:line, final phase effort) will be tightened from the in-flight scope-research synthesis.
Owner decisions (the three §1.1/§3/§9 forks):
- Weather station = raw-LoRa (Fork B) → build the transcoder. The encoder is the work.
- Delivery = B1 (RF re-emit) → mint the ABP frame and push it to
g_routeQfor re-transmit on a LoRaWAN channel; an external gateway hears it. No WiFi/packet-forwarder in P1 (B2 stays a P5 option). - Device model = M1 (per-source) → one ABP identity per source (DevAddr + keys + its own 32-bit FCnt). Heavier NVS/portal surface than M2, accepted. (Note: spec §3 recommends M2; owner overrode for per-node device semantics in ChirpStack.)
P1 — IMPLEMENTED (code-complete, build-green, crypto-verified) 2026-06-15:
src/LoRaWANCrypto.h— self-contained RFC 4493 AES-CMAC overmbedtls_aes_*(CMAC is compiled OUT of the prebuilt lib, finding A4) +encodeUplink()(AES-CTR FRMPayload + CMAC MIC, ADR=0, Unconfirmed) +selfTest()(RFC4493 + A_1 keystream + frame round-trip KATs).src/main.cpp— replaced theno-lw-encoderdrop (the seam) with a keyed transcode →g_routeQRF re-emit (B1), gated byBRIDGE_LW_ENCODE+ parsed creds so a stock build keeps v8.3's do-no-harm drop;BRIDGE_LW_ENC_*flags; build-flag credential resolver (P2 replaces with schema-v5 per-source store + NVS FCnt); boot self-test underBRIDGE_LW_ENC_SELFTEST.platformio.ini—[env:bench_lw_enc](R2=LoRaWAN 903.9, encoder+self-test on, throwaway ABP creds).- Verified: stock build green (24.6%, do-no-harm),
bench_lw_encgreen (24.7%) and links (nombedtls_cipher_cmac); crypto cross-checked against an independentcryptography-lib CMAC — RFC4493 vectors match + a minted frame is MIC-valid and round-trips. - P1 acceptance still open (owner bench): on-air
raw/MT source → ABP uplink → real gateway → ChirpStack decoded payload. Provision DevAddr0x01000001+ the bench keys (MAC 1.0.x, ABP, Class A, ADR off, disable-FCnt-validation or persist). (The old0x26011B22was NwkID 19 → silently dropped by a default LNS; the live bench DevAddr0x01000001is NwkID 0, matching ChirpStack's default NetID0x000000.selfTest()retains0x26011B22as an internal crypto KAT only — never on air.)
- LoRaWAN ABP uplink encoder (
encodeLoRaWANUplink) — the bridge mints valid LoRaWAN uplinks a LoRaWAN LNS (ChirpStack) accepts. §2. - Universal source → ABP — Meshtastic / MeshCore / Reticulum / Custom (raw-LoRa) traffic becomes LoRaWAN uplinks ingestible by ChirpStack. §3, scenarios §1.
MT/MC → RNS encoder— DEFERRED this cycle (owner): no RNS coding in the ABP cycle. Design retained in §6 for later.
Non-goals / deferred: the entire MT/MC → RNS encoder (§6) — RNS coding deferred per owner; OTAA join (impractical for an uplink-only emitter — §10); the bidirectional dual-LNS downlink crosslink (§5 documents the constraints; implementation is a later stretch).
Goal: a weather station that transmits over LoRa has its readings ingested into a ChirpStack LNS as a normal device.
Decisive fork — is the station LoRaWAN or raw-LoRa? (LoRa = the PHY modulation; LoRaWAN = the MAC: sync 0x34, DevAddr, FCnt, MIC, encryption.)
- (Fork A) Station already speaks LoRaWAN (has DevEUI/keys, sends MIC'd uplinks): out of scope for the encoder — this is a gateway problem. Use a real LoRaWAN gateway + provision the device in ChirpStack with the manufacturer's keys. The bridge adds no value (it can't decrypt without the device's keys; a normal gateway won't even demodulate a non-
0x34device). - (Fork B) Station speaks raw LoRa (proprietary framing, no LoRaWAN MAC): the encoder's sweet spot. The bridge transcodes raw-LoRa → LoRaWAN-ABP:
- A bridge radio runs Custom protocol matched to the station's RF (freq / BW / SF / CR / sync) and receives the raw packet.
- The bridge wraps the payload into a LoRaWAN ABP uplink under a bridge-held identity that represents the station —
DevAddr+NwkSKey+AppSKey(also provisioned in ChirpStack), with valid MIC, AES-CTR-encrypted FRMPayload, monotonic FCnt, on an FPort (§2, §3). - Delivery to ChirpStack — two architectures:
- (B1) RF re-emit — transmit the ABP frame over the air on a LoRaWAN channel; an existing LoRaWAN gateway hears it → ChirpStack. Fits the current dual-radio design; needs a gateway in range; costs extra airtime/duty-cycle (re-transmission).
- (B2) Bridge-as-gateway (WiFi) — the ESP32-S3 forwards the frame straight to ChirpStack via the Semtech UDP packet-forwarder / ChirpStack Gateway Bridge (MQTT); no second gateway. Cleaner end-to-end; larger build (a packet-forwarder/MQTT path + gateway provisioning in ChirpStack).
- ChirpStack validates the MIC + decrypts with the provisioned keys → the station appears as a normal device uplink, decoded by a ChirpStack payload codec.
Outcome: the bridge "LoRaWAN-izes" a non-LoRaWAN sensor. This is the same machinery as §3, with a Custom-protocol source.
Open design Qs (owner) — RESOLVED 2026-06-15 (§0.0): (i) raw-LoRa (Fork B); (ii) delivery B1 (RF re-emit + gateway); (iii) device model M1 (per-source).
General case of §1.1: any decoded bridge source is mapped to a LoRaWAN ABP uplink (one virtual device per source identity, or one multiplexed device with a source tag — §3). Delivery B1 or B2 as above.
The bridge carries payloads between two LoRaWAN networks by being a separate ABP endpoint on each (radio1 holds ABP1 creds for LNS1; radio2 holds ABP2 creds for LNS2). The "crosslink" is the bridge's app-layer routing between the two endpoints (re-encode/re-MIC per side). Constraints in §5. Implementation = later stretch (documented here so the ABP cycle is built without foreclosing it).
Mints a class-A uplink PHYPayload (LoRaWAN 1.0.x):
PHYPayload = MHDR(1) | MACPayload | MIC(4)
MACPayload = FHDR | FPort(1) | FRMPayload
FHDR = DevAddr(4, LE) | FCtrl(1) | FCnt(2, LE = low 16 of the 32-bit counter) | FOpts(0)
MHDR = 0x40 (Unconfirmed Data Up, Major=0) ; 0x80 = Confirmed (avoid — needs ACK)
FCtrl(up) = ADR(0) ADRACKReq(0) ACK(0) ClassB(0) FOptsLen(0) => 0x00 (see §4: ADR=0)
- FRMPayload encryption (LoRaWAN counter mode): for each 16-byte block
i,A_i = 0x01 | 0x00000000 | Dir(0) | DevAddr(4 LE) | FCnt(4 LE) | 0x00 | i; keystream =AES-128-ECB(K, A_i); ciphertext = plaintext XOR keystream. K = AppSKey forFPort>0(application data), NwkSKey forFPort=0(MAC-only). - MIC = first 4 bytes of
AES-128-CMAC(NwkSKey, B0 || MHDR|FHDR|FPort|FRMPayload), whereB0 = 0x49 | 0x00000000 | Dir(0) | DevAddr(4 LE) | FCntUp(4 LE) | 0x00 | len(msg). - Keys held by the bridge per virtual device:
NwkSKey(MIC) +AppSKey(payload). Emitting LoRaWAN is fundamentally keyed — not keyless (§7). - FCnt must be monotonic and persisted across reboots (§4, §8).
Crypto: MeshCore already uses mbedTLS AES-128-ECB (so ECB + the CTR-mode keystream is in hand). AES-CMAC for the MIC: verify MBEDTLS_CMAC_C / mbedtls_cipher_cmac is enabled in the ESP-IDF build; if not, add a small CMAC-over-AES-ECB helper (~30 lines). (Confirmed by scope-research A4: it is OFF; helper added in src/LoRaWANCrypto.h.)
Hook point: the dispatcher's enqueueTextForDest currently log-and-drops when the dest protocol is LoRaWAN (no-lw-encoder, main.cpp ~557/565). The encoder replaces that drop, building a PHYPayload and pushing it to the dest radio's RouteQueue (RF re-emit, B1) or to the WiFi forwarder (B2). Mirrors the encodeMeshtasticText / encodeMeshCoreGrpTxt pattern in MeshEncoderDebug.h.
Map every source (MT / MC / RNS / Custom) to an ABP uplink. Two device models:
- (M1) Per-source virtual ABP device — one
DevAddr+keys per mesh sender / per source. ChirpStack shows each as a distinct device. Cleanest semantics; heaviest provisioning (one ABP device per source in ChirpStack) and key/FCnt state per device in NVS. - (M2) Single multiplexed bridge ABP device — one
DevAddr+keys; the source identity is carried in the FRMPayload (a header byte / tag) and split out by a ChirpStack payload codec. Minimal provisioning; the LNS sees "one device" carrying many sources.
Recommendation: support M2 first (one device, low friction — ideal for the weather-station scenario where it is one source anyway), with M1 as an option for deployments that want per-node devices. (Owner chose M1 — see §0.0.)
FPort allocation (so a ChirpStack codec can demux the source protocol): e.g. FPort 10 = MT, 11 = MC, 12 = RNS, 13 = Custom/weather (final numbers TBD). FRMPayload schema: [srcTag][srcId?][len][payload…] — documented so a JS/codec on ChirpStack decodes back to the original message. (For the weather station: payload = the raw sensor bytes, decoded by a station-specific codec.)
- MIC — computed with
NwkSKey(§2). Wrong MIC ⇒ ChirpStack silently drops. Non-negotiable: the bridge must holdNwkSKey. - NONCE — ABP has no nonce. Anti-replay/freshness is the monotonic FCnt. (OTAA's
DevNonce/JoinNonceapply only to the join handshake we are not doing — §10.) Therefore FCntUp must never go backwards: persist it in NVS (write-throttled to spare flash), or enable ChirpStack "disable frame-counter validation" / relaxed counters (weaker; accepts resets). Default plan: NVS-persisted FCnt + relax-counters as the fallback knob. - ADR — set ADR = 0 and use Unconfirmed uplinks. The bridge is effectively uplink-only; it can't service ADR downlinks/MAC commands or ACK confirmed downlinks, so ADR=1 would risk
ADRACKReqstalls and unfulfillable downlink expectations. - Timing / duty-cycle — respect the regional limit (EU868 ~1%/sub-band; US915 400 ms dwell) on the LoRaWAN side. Reuse the existing v8.2 airtime throttle but bound it to LoRaWAN duty/dwell, not just the bridge's self-TX cap. Class-A RX1/RX2 windows are ignored for an uplink-only emitter (no downlinks consumed) — acceptable for ingestion; revisit only for §5.
Verdict (from cross-LNS research): "uplink to LNS1, downlink from LNS2" is not a single-identity LoRaWAN capability — a device session (keys, FCntUp/FCntDown, downlink timing) is owned by exactly one Network Server. The only spec path for one session across networks is operator Roaming (LoRaWAN Backend Interfaces / TS002), which is LNS↔LNS, device-transparent — not a device-side trick. Achieve it instead as TWO independent ABP sessions the bridge owns: radio1=ABP1 in LNS1, radio2=ABP2 in LNS2; the bridge routes payloads between them at the application layer (re-encode/re-MIC per side).
Hard constraints:
- Two independent sessions — each radio: own
DevAddr+NwkSKey+AppSKey, ownFCntUp/FCntDown, provisioned in its LNS. No key sharing across LNS; no MIC forging. - Async downlink from LNS2 ⇒ radio2 must be Class C — continuous RX2 on LNS2's RX2 freq + DR, so LNS2 can push downlinks (unicast or multicast — shared addr/keys/FCnt) anytime, untied to an uplink. Class-A alternative: radio2 polls by sending periodic uplinks to LNS2 and reading queued downlinks in RX1/RX2 — simpler, but downlink latency is bounded by the poll interval and duty-cycle.
- A reachable LNS2 gateway + the downlink enqueued (ChirpStack device or multicast queue via API/UI). ChirpStack v4 supports Class C (reuses the device downlink queue) and multicast, but has no built-in roaming and no TS011-relay — the crosslink is your app-layer routing.
- It works only for identities the bridge OWNS (ABP1/ABP2 it provisions). It cannot transparently relay a third-party device across networks — LoRaWAN security blocks forging the MIC or reading the encrypted payload without that LNS's keys.
- TS011 "Relay" does NOT apply — that spec is a same-network, battery-powered coverage extender (Wake-on-Radio), not a cross-LNS bridge.
- Per-radio regulatory budgets — duty-cycle/dwell/EIRP on each RF domain independently; RX2 DR/freq matched per LNS; continuous RX (Class C) power is fine on USB.
Status: documented; implementation deferred (later). The ABP cycle builds the one-way ABP uplink encoder without foreclosing this (keep keys/FCnt state per-radio-capable in the config schema, §8).
RNS coding is deferred this cycle (owner decision). Retained below as the design of record for whenever it's picked up (later); there is no implementation in the ABP cycle.
- (Phase 1 — tractable) Round-trip passthrough. Reassemble RNS bytes previously tunnelled as
[rns <seq> <x>/<y>] <base64>(fill inreassembleReticulumFragment()), then raw re-emit the reconstructed frame on an RNS radio. No addressing/crypto needed — the bytes are already a valid RNS frame. Closes theRNS → MT/MC → RNSround trip across two bridges. - (Phase 2 — optional, harder) Arbitrary MT/MC → new RNS packet. Requires a configured destination Identity/hash + encryption to that destination (Reticulum addresses are destination-hash + Identity-keyed). Needs a new addressing-model config; assess feasibility before committing. Today
encodeReticulum()(MeshEncoderDebug.h~325) returnsfalseand the dest-RNS path is ano-rns-encoderdrop (main.cpp~557).
v8.3 LoRaWAN is keyless (cleartext-header capture/relay only). The ABP encoder is keyed (needs NwkSKey+AppSKey to mint valid uplinks). Keep both: keyless capture/relay remains the default for a radio set to "LoRaWAN" with no keys; the encoder activates only when a radio/dest is configured with ABP credentials. The two modes are mutually exclusive per radio and selected by presence of keys.
- Per virtual device (M1) or per bridge device (M2):
DevAddr(4 B),NwkSKey(16 B),AppSKey(16 B), persistedFCntUp(32-bit, write-throttled), FPort map, source→device mapping. For §5: a second set keyed per-radio +FCntDown. BridgeConfigschema version bump (current v4) with forward migration; new captive-portal section ("LoRaWAN device / ABP credentials"); a build-flag fallback for source builds.- Delivery selector: B1 RF re-emit vs B2 WiFi forwarder (the latter adds gateway/MQTT config — host, credentials, gateway EUI).
- (M) AES-CMAC helper +
encodeLoRaWANUplink— ✅ IMPLEMENTED 2026-06-15 (§0.0). RFC-4493 AES-CMAC + build/encrypt/MIC of a valid ABP uplink at theno-lw-encoderseam → push tog_routeQ(B1). Crypto unit-verified vs RFC4493 + an independent CMAC; both builds green. Remaining = the on-air bench (Custom/MT source → ABP uplink → real gateway → ChirpStack shows the decoded payload). - ✅ IMPLEMENTED (P2) 2026-06-15 — Config/NVS + portal. Per-source ABP creds + persisted FCnt + portal section, in a dedicated
LoRaWANConfigmodule + its own NVS namespace (lwabp) — NOT a BridgeConfig schema v4→v5 bump (cleaner / isolated / no migration). Reboot-safe FCnt via block reservation. Build-flag creds stay as a fallback. - ✅ IMPLEMENTED (P3) 2026-06-15 — Universal mapping. M1 per-source devices (owner's choice) + an optional per-device source tag for a multiplexed device; Custom raw-LoRa → ABP (the weather-station path: raw RX bytes → FRMPayload);
tools/chirpstack-codec.jssample codec. On-air weather-station run = the acceptance test (BENCH-v8.4.md).- ✅ Regional timing (P4) 2026-06-15 — US915 per-TX dwell cap (400 ms ToA) in the TX scheduler; EU868 duty via
BRIDGE_TX_DUTY_PERCENT.
- ✅ Regional timing (P4) 2026-06-15 — US915 per-TX dwell cap (400 ms ToA) in the TX scheduler; EU868 duty via
- (L, optional) B2 bridge-as-gateway — Semtech UDP / ChirpStack Gateway Bridge MQTT forwarder over WiFi (removes the separate-gateway requirement).
MT/MC→RNS passthrough— DEFERRED → later (RNS coding deferred this cycle, owner). §6 holds the design.- (L, stretch → later) Dual-LNS crosslink (§5) — second ABP identity per radio + Class-C (or Class-A poll) downlink path + cross-routing.
- (optional) M1 per-source devices; arbitrary MT/MC→RNS (§6 Phase 2); OTAA — only if specifically wanted. (Owner chose M1 for the device model — §0.0.)
- OTAA dropped (owner-approved): OTAA-encode needs the JoinAccept downlink in a Class-A RX window +
DevNonceuniqueness/state — impractical for an uplink-only emitter; OTAA-decode needs join-derived keys. ABP only. (Revisit only if a join-capable bidirectional mode is ever built.) - FCnt persistence vs flash wear — write-throttle / store coarsely; relax-counters in ChirpStack as fallback.
- Duty-cycle — B1 (RF re-emit) doubles airtime; ensure the throttle enforces LoRaWAN regional limits.
- CMAC availability — confirmed OFF in the prebuilt lib; bundled the RFC-4493 helper (
src/LoRaWANCrypto.h). - Per-source provisioning blow-up (M1) — many devices to register; M2 avoids it. (Owner accepted M1.)
- Weather-station fork — confirmed raw-LoRa (Fork B); if LoRaWAN (Fork A), it's a gateway task, not this.
- Delivery model — B1 vs B2 is the biggest scope lever; owner chose B1.
- Repo:
CLAUDE.md,V8.3-SPEC.md,BENCH-v8.3.md; seamssrc/main.cpp(no-rns-encoder/no-lw-encoder~557/565),src/MeshEncoderDebug.h(encodeReticulum~325, encode patterns),src/MeshDecoderDebug.h(extractLoRaWANMeta~978-1012),src/BridgeConfig.*. - External (cross-LNS research): LoRaWAN 1.0.4 MAC + Backend-Interfaces/Roaming (TS002), TS011 Relay, ChirpStack v4 docs (device classes / multicast / configuration / API). Cited in the ABP scope research + memory.
The ABP scope research (workflow wf_815177cd-9aa; LoRaWAN 1.0.4 spec + ChirpStack v4 docs + repo) is complete. Refinements to the sections above:
Crypto — the one real gotcha (A4):
- mbedTLS AES-128 ECB + CTR are present & proven in-tree (
MeshEncoderDebug.h— MeshCore ECB ~73-81, Meshtastic CTR ~192-202/309-319); HMAC-SHA256, SHA-256, base64 too. ⚠️ AES-CMAC is NOT linkable — the prebuilt esp32s3 mbedTLS (arduino-esp32 2.0.17) was built withCONFIG_MBEDTLS_CMAC_COFF (tools/sdk/esp32s3/sdkconfig:1644);mbedtls_cipher_cmaclink-errors. Added a self-contained RFC-4493 AES-CMAC helper (src/LoRaWANCrypto.h) onmbedtls_aes_setkey_enc+mbedtls_aes_crypt_ecb(both in the prebuilt lib). Only new crypto primitive required.
Exact code seams (A2/A4):
- Encoder hook:
main.cpp:565-569— theMT/MC → LoRaWANdrop=no-lw-encodersite insideenqueueTextForDest()(main.cpp:551-669), withdstChan/srcId/srcTag/bodyin hand. Replace the drop →encodeLoRaWANUplink()→outPkt[256]→g_routeQ[destIdx].push()→evt=QUEUE; addDedupCache::record(...)for loop-safety (mirror MT ~610 / MC ~636 / dedup ~654-656). (Done in P1.) - Encoder template:
encodeMeshtasticText(MeshEncoderDebug.h:121-206). Deferral pattern:encodeReticulumstub (:340). - Decoder (inverse ref):
extractLoRaWANMeta(MeshDecoderDebug.h:978-1012) — cleartext header only; itsFCntisuint16(low 16) — the encoder needs the full 32-bit FCnt in B0/A_i.
Config / NVS (A4):
SCHEMA_VERSION=4(BridgeConfig.cpp:89);RadioChannel.key[32](RadioChannel.h:19) holds ONE AES key → too small for NwkSKey+AppSKey ⇒ schema v4→v5 with a per-radio LoRaWAN credential struct (DevAddr/NwkSKey/AppSKey/FPort) + migration arm + a newLoRaWANConfig::resolve()(mirrorMeshCoreConfig::resolve:36-60). (P2.)- FCnt: a separate small NVS key (
prefs.putUInt), updated per-uplink — NOT the main blobsave()(BridgeConfig.cpp:337-348) → avoids flash wear. (P2.) - Portal:
CaptivePortal.cpp:519-523currently forces sync0x34and clears the key (keyless); add DevAddr/NwkSKey/AppSKey/FPort inputs gated to thelwclass + save-handler validation. (P2.)
ChirpStack v4 (A3):
- Provision: device-profile (region; pin MAC 1.0.x; ABP; Class A) → device with DevAddr (private prefix, NetID 0x000000/0x000001) + NwkSKey + AppSKey + FPort/codec.
- FCnt: persist 32-bit in bridge or tick "Disable frame-counter validation" (drops replay protection; a v4 quirk where
skipFCntCheckdidn't persist was reported — verify it sticks). - Wrong MIC ⇒ silent drop (no app event). ADR off + Unconfirmed (uplink-only).
- Timing: EU868 ~1%/sub-band (single rolling
BRIDGE_TX_DUTY_PERCENTonly approximates, not per-sub-band ETSI); US915 400 ms per-packet dwell — the rolling throttle does NOT enforce a per-TX ToA cap; add one (estimateAirtimeMsatmain.cpp:971). (P4.)
RNS (A1 — deferred §6): keyless origination confirmed impossible (single-dest addr = SHA-256 over name + destination public key; payload = per-packet ephemeral-X25519 + Fernet AES-CBC/HMAC). Only keyless path = passthrough egress (reassemble [rns ..] fragments → rawRepeatForDest); stubs MeshDecoderDebug.h:930 / MeshEncoderDebug.h:340; hook the marker on the MT/MC RX path (~main.cpp:957-964) before text decode. Deferred per owner.