Skip to content

Latest commit

 

History

History
200 lines (136 loc) · 22.8 KB

File metadata and controls

200 lines (136 loc) · 22.8 KB

ABP-LORAWAN-SPEC — LoRaWAN ABP encoder · universal ChirpStack ingestion (branch dev-ABP-lorawan; MT/MC→RNS deferred)

Status: DRAFT (scoping). Branch dev-ABP-lorawan off main (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). The dev-ABP-lorawan branch 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.

0.0 Status & owner decisions — LOCKED 2026-06-15

Owner decisions (the three §1.1/§3/§9 forks):

  1. Weather station = raw-LoRa (Fork B) → build the transcoder. The encoder is the work.
  2. Delivery = B1 (RF re-emit) → mint the ABP frame and push it to g_routeQ for re-transmit on a LoRaWAN channel; an external gateway hears it. No WiFi/packet-forwarder in P1 (B2 stays a P5 option).
  3. 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 over mbedtls_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 the no-lw-encoder drop (the seam) with a keyed transcode → g_routeQ RF re-emit (B1), gated by BRIDGE_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 under BRIDGE_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_enc green (24.7%) and links (no mbedtls_cipher_cmac); crypto cross-checked against an independent cryptography-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 DevAddr 0x01000001 + the bench keys (MAC 1.0.x, ABP, Class A, ADR off, disable-FCnt-validation or persist). (The old 0x26011B22 was NwkID 19 → silently dropped by a default LNS; the live bench DevAddr 0x01000001 is NwkID 0, matching ChirpStack's default NetID 0x000000. selfTest() retains 0x26011B22 as an internal crypto KAT only — never on air.)

0. Goals

  1. LoRaWAN ABP uplink encoder (encodeLoRaWANUplink) — the bridge mints valid LoRaWAN uplinks a LoRaWAN LNS (ChirpStack) accepts. §2.
  2. Universal source → ABP — Meshtastic / MeshCore / Reticulum / Custom (raw-LoRa) traffic becomes LoRaWAN uplinks ingestible by ChirpStack. §3, scenarios §1.
  3. MT/MC → RNS encoderDEFERRED 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).


1. Scenarios

1.1 LoRa weather-station → LNS uplink (CANONICAL driving scenario)

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-0x34 device).
  • (Fork B) Station speaks raw LoRa (proprietary framing, no LoRaWAN MAC): the encoder's sweet spot. The bridge transcodes raw-LoRa → LoRaWAN-ABP:
    1. A bridge radio runs Custom protocol matched to the station's RF (freq / BW / SF / CR / sync) and receives the raw packet.
    2. The bridge wraps the payload into a LoRaWAN ABP uplink under a bridge-held identity that represents the stationDevAddr + NwkSKey + AppSKey (also provisioned in ChirpStack), with valid MIC, AES-CTR-encrypted FRMPayload, monotonic FCnt, on an FPort (§2, §3).
    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).
    4. 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).

1.2 Mesh → LNS (MT / MC / RNS / Custom → ABP)

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.

1.3 Dual-repeating-LNS crosslink relay (radio1 = ABP1/LNS1 ↑, radio2 = ABP2/LNS2 ↓)

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


2. LoRaWAN ABP uplink encoder (encodeLoRaWANUplink)

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 for FPort>0 (application data), NwkSKey for FPort=0 (MAC-only).
  • MIC = first 4 bytes of AES-128-CMAC(NwkSKey, B0 || MHDR|FHDR|FPort|FRMPayload), where B0 = 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.


3. Universal encoder → ChirpStack

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


4. MIC / NONCE / ADR / timing — how each is satisfied

  • MIC — computed with NwkSKey (§2). Wrong MIC ⇒ ChirpStack silently drops. Non-negotiable: the bridge must hold NwkSKey.
  • NONCEABP has no nonce. Anti-replay/freshness is the monotonic FCnt. (OTAA's DevNonce/JoinNonce apply 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 ADRACKReq stalls 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.

5. Dual-repeating-LNS constraints (uplink LNS1 / downlink LNS2)

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:

  1. Two independent sessions — each radio: own DevAddr + NwkSKey + AppSKey, own FCntUp/FCntDown, provisioned in its LNS. No key sharing across LNS; no MIC forging.
  2. 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.
  3. 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.
  4. 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.
  5. TS011 "Relay" does NOT apply — that spec is a same-network, battery-powered coverage extender (Wake-on-Radio), not a cross-LNS bridge.
  6. 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).


6. MT/MC → RNS encoder (carry-over) — ⏸️ DEFERRED (not in the ABP cycle)

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 in reassembleReticulumFragment()), then raw re-emit the reconstructed frame on an RNS radio. No addressing/crypto needed — the bytes are already a valid RNS frame. Closes the RNS → MT/MC → RNS round 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) returns false and the dest-RNS path is a no-rns-encoder drop (main.cpp ~557).

7. Keyed vs keyless (architectural note)

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.


8. Config / NVS schema needs

  • Per virtual device (M1) or per bridge device (M2): DevAddr (4 B), NwkSKey (16 B), AppSKey (16 B), persisted FCntUp (32-bit, write-throttled), FPort map, source→device mapping. For §5: a second set keyed per-radio + FCntDown.
  • BridgeConfig schema 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).

9. Prioritized phases (effort S/M/L — refine from scope synthesis)

  1. (M) AES-CMAC helper + encodeLoRaWANUplink — ✅ IMPLEMENTED 2026-06-15 (§0.0). RFC-4493 AES-CMAC + build/encrypt/MIC of a valid ABP uplink at the no-lw-encoder seam → push to g_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).
  2. IMPLEMENTED (P2) 2026-06-15 — Config/NVS + portal. Per-source ABP creds + persisted FCnt + portal section, in a dedicated LoRaWANConfig module + 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.
  3. 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.js sample 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.
  4. (L, optional) B2 bridge-as-gateway — Semtech UDP / ChirpStack Gateway Bridge MQTT forwarder over WiFi (removes the separate-gateway requirement).
  5. MT/MC→RNS passthroughDEFERRED → later (RNS coding deferred this cycle, owner). §6 holds the design.
  6. (L, stretch → later) Dual-LNS crosslink (§5) — second ABP identity per radio + Class-C (or Class-A poll) downlink path + cross-routing.
  7. (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.)

10. Risks & open questions

  • OTAA dropped (owner-approved): OTAA-encode needs the JoinAccept downlink in a Class-A RX window + DevNonce uniqueness/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.

11. References

  • Repo: CLAUDE.md, V8.3-SPEC.md, BENCH-v8.3.md; seams src/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.

12. Scope-research findings (recovered 2026-06-15) — code-grounded

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 with CONFIG_MBEDTLS_CMAC_C OFF (tools/sdk/esp32s3/sdkconfig:1644); mbedtls_cipher_cmac link-errors. Added a self-contained RFC-4493 AES-CMAC helper (src/LoRaWANCrypto.h) on mbedtls_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 — the MT/MC → LoRaWAN drop=no-lw-encoder site inside enqueueTextForDest() (main.cpp:551-669), with dstChan/srcId/srcTag/body in hand. Replace the drop → encodeLoRaWANUplink()outPkt[256]g_routeQ[destIdx].push()evt=QUEUE; add DedupCache::record(...) for loop-safety (mirror MT ~610 / MC ~636 / dedup ~654-656). (Done in P1.)
  • Encoder template: encodeMeshtasticText (MeshEncoderDebug.h:121-206). Deferral pattern: encodeReticulum stub (:340).
  • Decoder (inverse ref): extractLoRaWANMeta (MeshDecoderDebug.h:978-1012) — cleartext header only; its FCnt is uint16 (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+AppSKeyschema v4→v5 with a per-radio LoRaWAN credential struct (DevAddr/NwkSKey/AppSKey/FPort) + migration arm + a new LoRaWANConfig::resolve() (mirror MeshCoreConfig::resolve :36-60). (P2.)
  • FCnt: a separate small NVS key (prefs.putUInt), updated per-uplink — NOT the main blob save() (BridgeConfig.cpp:337-348) → avoids flash wear. (P2.)
  • Portal: CaptivePortal.cpp:519-523 currently forces sync 0x34 and clears the key (keyless); add DevAddr/NwkSKey/AppSKey/FPort inputs gated to the lw class + 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 skipFCntCheck didn'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_PERCENT only 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 (estimateAirtimeMs at main.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.