Status (2026-06-16): code-complete + build-green on dev-ABP-lorawan. Tier A/B core PASSED on
hardware — A1, A2, A5, B1, B2, B5. Results + methods (auditable) in BENCH-RESULTS.md. Open:
A4/B3/B4/B6/B7 (owner, WiFi config page), A3 (needs a >242 B raw transmitter), C1–C4 (colleague's
ChirpStack). Each test below is self-contained — this is the full procedure.
| Tier | Needs | Who runs it |
|---|---|---|
| A — DUT board only | one bridge + USB serial | you |
| B — Synthetic LNS | DUT bridge + a 2nd "sniffer" bridge, no ChirpStack | you |
| C — ChirpStack LNS | DUT bridge + ChirpStack + gateway | colleague |
- Boards / ports: DUT-A = COM13 · SNIFFER = COM6 · DUT-B/spare = COM14.
- Run from the project root (PowerShell):
cd "C:\Users\6r4yh\workspace\Platformio\Projects\Xiao-esp32s3-lora-repeater - main dev-ABP-lorawan" - Every board = 3 commands:
erase→upload→monitor(spelled out in each test). ⚠️ Erase before every env/config change. Autosave + the portal only act on an unconfigured board, so re-flashing without erase keeps the OLD config. Exception: the FCnt tests (B2/B3) erase once, then reboot with the RST button (re-erasing wipes the counter under test).- Bench creds (baked into
bench_lw_enc/_dwell): DevAddr01000001· NwkSKey2B7E151628AED2A6ABF7158809CF4F3C· AppSKeyD41420B7F5A3C96E1D8204F7B3A65C90· FPort 13. - Sync / proto legend:
0x2B=Meshtastic(1) ·0x12=MeshCore(2) ·0x42=Reticulum(3) ·0x34=LoRaWAN(5) · else Custom(4). DevAddr01000001= NwkID 0 (matches ChirpStack default NetID0x000000; a mismatched NwkID is silently dropped). - Config check: open the monitor, press RST, and confirm the
Confirm at boot:line each test names. A wrong value almost always means you skipped-t erase.
Board: DUT-A COM13 · env: bench_lw_enc
pio run -e bench_lw_enc -t erase --upload-port COM13
pio run -e bench_lw_enc -t upload --upload-port COM13
pio device monitor --port COM13 --baud 115200Confirm at boot: [lw-selftest] overall : PASS · [lw-enc] … ready=1 DevAddr=0x01000001 FPort=13 · radio2 RF … sync=0x34 903.900 … SF7 · region=1.
Do: just watch the boot output.
PASS: 4× RFC4493 CMAC … PASS, FRMPayload A_1 … PASS, frame … PASS, overall : PASS.
FAIL: any FAIL — the encoder then refuses to emit (drop=lw-selftest-fail).
Board: DUT-A COM13 · env: bench_lw_enc_dwell
pio run -e bench_lw_enc_dwell -t erase --upload-port COM13
pio run -e bench_lw_enc_dwell -t upload --upload-port COM13
pio device monitor --port COM13 --baud 115200Confirm at boot: radio2 RF … sync=0x34 903.900 BW125.0 SF12 · region=1.
Do: send any MT text to R1 (or let a routed frame reach R2).
PASS: evt=DROP radio=… drop=dwell toa=1811 limit=400 — frame NOT transmitted (exact toa scales with payload; any value > limit=400 passes).
FAIL: an evt=TX_START / on-air frame at SF12 (dwell not enforced).
Board: DUT-A COM13 · env: xiao_esp32s3_lwabp (portal)
pio run -e xiao_esp32s3_lwabp -t erase --upload-port COM13
pio run -e xiao_esp32s3_lwabp -t upload --upload-port COM13
pio device monitor --port COM13 --baud 115200Portal config: set a radio to Custom (your raw-LoRa freq/BW/SF/sync); add an ABP device (Any source). Save & reboot.
Confirm at boot: [LoRaWANConfig] … anyConfigured=1 + a dev0 … line · the Custom radioN RF line.
Do: transmit a raw frame > 242 B (or > 237 B if the source tag is on).
PASS: evt=DROP … drop=lw-payload-overflow len=… cap=…; no QUEUE for it.
FAIL: a truncated frame is queued/sent.
Board: DUT-A COM13 · env: xiao_esp32s3_lwabp (portal)
pio run -e xiao_esp32s3_lwabp -t erase --upload-port COM13
pio run -e xiao_esp32s3_lwabp -t upload --upload-port COM13
pio device monitor --port COM13 --baud 115200Portal config: "LoRaWAN ABP devices" → Device 0: Enabled, Any source, DevAddr + NwkSKey + AppSKey + FPort. Set a radio to LoRaWAN. Save & reboot.
Confirm at boot: [LoRaWANConfig] loaded … anyConfigured=1 + dev0 … devaddr=… fport=… · radioN RF … sync=0x34 ….
PASS: the saved DevAddr/FPort appear in the boot dump AND re-opening the portal shows them.
FAIL: anyConfigured=0 / values blank (didn't persist).
Board: DUT-A COM13 · env: xiao_esp32s3
pio run -e xiao_esp32s3 -t erase --upload-port COM13
pio run -e xiao_esp32s3 -t upload --upload-port COM13
pio device monitor --port COM13 --baud 115200Confirm at boot: NO [lw-selftest] and NO [lw-enc] lines · radio2 RF … sync=0x12 910.525 BW62.5 SF7 (MeshCore).
Do: run normal MT↔MC traffic.
PASS: normal routing; no ABP evt=QUEUE; MT/MC→LW still drop=no-lw-encoder.
FAIL: any ABP output (encoder leaked into a stock build).
Flash the SNIFFER once (COM6) — it stays put for all of Tier B:
pio run -e bench_lw_sniffer -t erase --upload-port COM6
pio run -e bench_lw_sniffer -t upload --upload-port COM6
pio device monitor --port COM6 --baud 115200Confirm at boot (sniffer): radio2 RF … sync=0x34 903.900 BW125.0 SF7 (no [lw-enc]/[lw-selftest] — encoder off). When the DUT emits, the sniffer prints evt=RX proto=LW … devaddr=… fcnt=… fport=… + evt=LWRAW raw=<hex>. Keep this terminal open for every B test.
Board: DUT-A COM13 · env: bench_lw_enc
pio run -e bench_lw_enc -t erase --upload-port COM13
pio run -e bench_lw_enc -t upload --upload-port COM13
pio device monitor --port COM13 --baud 115200Confirm at boot (DUT): [lw-selftest] overall : PASS · [lw-enc] … ready=1 DevAddr=0x01000001 FPort=13 · radio2 RF … sync=0x34 903.900 … SF7.
Do: send one MT text from a node on the DUT's R1 (Meshtastic US LongFast 906.875/BW250/SF11).
PASS: SNIFFER prints evt=RX proto=LW mtype=Data(Up,Unconf) devaddr=0x01000001 fport=13 len=….
FAIL: nothing on the sniffer — check the DUT shows evt=QUEUE … dstproto=LW and both radios share 903.9/BW125/SF7.
Board: DUT-A COM13 (bench_lw_enc, from B1). Erase once, then power-cycle — do NOT re-flash.
# (only if not already on bench_lw_enc:)
pio run -e bench_lw_enc -t erase --upload-port COM13
pio run -e bench_lw_enc -t upload --upload-port COM13
pio device monitor --port COM13 --baud 115200Do: send several texts (note the SNIFFER fcnt= values) → press RST on the DUT → send more.
PASS: post-reboot fcnt= resumes ahead of the last pre-reboot value (may jump up to 32; never repeats/decreases).
FAIL: fcnt restarts low (would replay at a real LNS).
Board: DUT-A COM13 · env: xiao_esp32s3_lwabp (portal)
pio run -e xiao_esp32s3_lwabp -t erase --upload-port COM13
pio run -e xiao_esp32s3_lwabp -t upload --upload-port COM13
pio device monitor --port COM13 --baud 115200Portal config: Device 0 = DevAddr X (+ keys/FPort), Any source; set a radio to LoRaWAN. Save.
Do: send N texts (note SNIFFER fcnt=) → portal: clear Device 0, add the same DevAddr X in Device 1 → reboot → send again.
PASS: the first post-move fcnt= is ≥ the last value seen for X.
FAIL: it resumes at slot 1's stale/zero counter.
Board: DUT-A COM13 · env: xiao_esp32s3_lwabp, source radio = Meshtastic
pio run -e xiao_esp32s3_lwabp -t erase --upload-port COM13
pio run -e xiao_esp32s3_lwabp -t upload --upload-port COM13
pio device monitor --port COM13 --baud 115200Portal config (distinct DevAddrs): Device 0 = MT node id = node A → W · Device 1 = Source protocol = Meshtastic → X · Device 3 = Any source → Z. Save.
Confirm at boot: three dev0/1/3 … lines with sel/devaddr as set.
Do: send from MT node A, then MT node B (different id), then any other source.
PASS (SNIFFER): A → devaddr=…W · B → …X · other → …Z.
FAIL: wrong DevAddr per source (resolve order broken).
MC note: MeshCore matches only Source protocol/Any (no MC-node selector). For an MC pass, set the source radio to MeshCore + add a Source protocol = MeshCore device.
Board: none (host PC). Uses a frame captured by the SNIFFER in any B test.
pip install cryptography
python tools/lw-verify.py <raw-hex-from-evt=LWRAW> 2B7E151628AED2A6ABF7158809CF4F3C D41420B7F5A3C96E1D8204F7B3A65C90PASS: MIC … -> PASS and Decrypted … as ASCII : '<your text>' → VERDICT: MIC valid — ChirpStack would ACCEPT.
FAIL: MIC … -> FAIL (recheck keys / the copied hex). A PASS predicts a Tier-C PASS.
Board: DUT-A COM13 · env: xiao_esp32s3_lwabp
pio run -e xiao_esp32s3_lwabp -t erase --upload-port COM13
pio run -e xiao_esp32s3_lwabp -t upload --upload-port COM13
pio device monitor --port COM13 --baud 115200Portal config: one ABP device with "Prepend source tag" ON; set the source radio to MT (then repeat with MC).
Do: send a text; copy the SNIFFER evt=LWRAW raw=…; run
python tools/lw-verify.py <raw-hex> <NwkSKey> <AppSKey> --tagged.
PASS: src tag : proto=1(meshtastic) (MT) and proto=2(meshcore) (MC) + the payload.
FAIL: proto=43/"?" (raw sync word — the pre-#5 bug).
Board: DUT-A COM13 · env: xiao_esp32s3_lwabp, source radio = MeshCore
pio run -e xiao_esp32s3_lwabp -t erase --upload-port COM13
pio run -e xiao_esp32s3_lwabp -t upload --upload-port COM13
pio device monitor --port COM13 --baud 115200Portal config: source radio = MeshCore; one ABP device (Any or Source protocol = MeshCore). Save.
Do: send an MC text from an MC node.
PASS: DUT evt=QUEUE … dstproto=LW … cred=nvs; SNIFFER evt=RX proto=LW devaddr=…; lw-verify.py decrypts the MC body.
FAIL: drop=no-lw-abp-dest (no LoRaWAN dest radio) or no sniffer RX.
Provisioning (every C test): ChirpStack v4 + a gateway in range. Device-profile =
your region · MAC 1.0.x · ABP · Class A · ADR off; device = DevAddr + NwkSKey +
AppSKey + FPort matching the DUT; tick "Disable frame-counter validation" (or rely
on persisted FCnt); paste tools/chirpstack-codec.js into the codec. DevAddr NwkID
must match the LNS NetID. Stick to C1 + C2 — Tier B already proved the resolve
matrix + crypto off-box.
Board: DUT COM13 · env: bench_lw_enc
pio run -e bench_lw_enc -t erase --upload-port COM13
pio run -e bench_lw_enc -t upload --upload-port COM13
pio device monitor --port COM13 --baud 115200ChirpStack: provision DevAddr 01000001 + the bench keys + FPort 13.
Confirm at boot (DUT): [lw-selftest] overall : PASS · [lw-enc] … ready=1 DevAddr=0x01000001.
Do: send an MT text to the DUT's R1.
PASS: DUT evt=QUEUE … cred=flag; ChirpStack shows the decoded uplink for 01000001 (FCnt increasing).
FAIL: no LNS event — wrong MIC / MAC version / DevAddr NwkID vs NetID (run B5 first to rule the crypto out).
Board: DUT COM13 · env: xiao_esp32s3_lwabp
pio run -e xiao_esp32s3_lwabp -t erase --upload-port COM13
pio run -e xiao_esp32s3_lwabp -t upload --upload-port COM13
pio device monitor --port COM13 --baud 115200Portal config: set a radio to Custom (station's exact freq/BW/SF/CR/sync); add an ABP device (Any or Source protocol = Custom). Edit decodeStation() in the codec to your real layout first.
ChirpStack: provision that device's DevAddr/keys/FPort.
Do: transmit a raw frame from the station (or a 2nd board on that RF).
PASS: DUT evt=QUEUE … cred=nvs … msg="custom-raw"; ChirpStack shows the uplink + codec readings.
FAIL: drop=no-lw-abp-dest / drop=loop-dup / drop=lw-payload-overflow.
Same board/flash/portal as C2, but the device has "Prepend source tag" ON and the codec has HAS_SOURCE_TAG=true.
PASS: ChirpStack decodes source:{proto,srcId} with the correct proto name + payload.
FAIL: proto wrong / not split.
Same board/flash as C1, but provision the device with frame-counter validation enabled. PASS: consecutive uplinks accepted; after a DUT RST the LNS still accepts (resumed-ahead FCnt); a forced low/replayed FCnt is rejected. FAIL: valid uplinks dropped, or a replay accepted.
Offline verifier — tools/lw-verify.py (mirrors src/LoRaWANCrypto.h):
python tools/lw-verify.py <PHYPayload-hex> <NwkSKey-hex32> <AppSKey-hex32> [--fcnt-msb N] [--tagged]Prints MHDR / DevAddr(+NwkID) / FCnt / FPort, MIC PASS/FAIL, decrypted FRMPayload.
Serial events: evt=QUEUE … cred=flag|nvs tag=0|1 (queued) · evt=RX proto=LW … devaddr=… fcnt=… fport=… (sniffer) · evt=LWRAW … raw=<hex> (sniffer, full frame) · drop=lw-selftest-fail · drop=lw-fcnt-fail / FCNT_PERSIST_FAIL · drop=lw-payload-overflow · drop=dwell · [lw-warn] … DevAddr … collision.
Pre-bench fixes (folded in): #1 FCnt-by-DevAddr (B3) · #2 self-test gate (A1) · #3 fail-closed FCnt (B2) · #4 reboot-safe build-flag FCnt (B2) · #5 proto-enum tag (B6/C3) · #6 over-cap drop (A3). Commits d98da4b,1496717,9aaf423 on dev-ABP-lorawan.
Notes: a wrong MIC is silently dropped by ChirpStack — suspect keys / MAC version / DevAddr NwkID before the bridge. Migration: FCnt moved to fc_<addr> NVS keys; old fc<N> keys orphaned → a device resumes at FCnt 0 on first boot (fine with "Disable frame-counter validation"). Encoder is uplink-only (Class A, ADR off). Deferred (not v8.4): ABP/OTAA decode, dual-LNS, MT/MC→RNS.