How a MeshPRS implementation is shown to work, from byte-level vectors on a desk to a controlled field trial on the SEQ mesh and APRS-IS. The trial’s pass criteria are the proposed answer to OPEN-QUESTIONS Q12. Nothing here is decided until Alec signs it off through a decision record.
The models and numbers behind this plan are in reliability.md. Frame layouts belong to ../spec/MeshPRS-Spec.md. The frame fields this plan relies on (seq, retry, the gating field’s test value, the acked seq) are in spec §9.3; reliability.md §5 maps them (C1-C6).
Tests are run in stages, and each stage gates the next:
| Stage | What | Where | Gate to next stage |
|---|---|---|---|
| T1 | Encode/decode and gate logic against shared vectors | desk, no radio | every vector passes in every implementation |
| T2 | Simulated mesh, fault injection | desk (Kelly mesh sim) | retry/dedupe/fallback logic behaves |
| T3 | RF bench, attenuated, 1-2 hops | bench | per-hop delivery ≥ 99 % at good SNR |
| T4 | Hop chain 1-8 hops | bench or controlled outdoor chain | curve measured; ≤ 3-hop target met |
| T5 | Multi-iGate and APRS-IS, private server | bench + private aprsc | 0 duplicates after the server’s dupe filter |
| T6 | Field trial on the live mesh + private aprsc | SEQ | Q12 criteria (§7) |
| T7 | Controlled live APRS-IS test | SEQ + live APRS-IS | short run, clean, Alec signs off |
Layout. The spec (§15) owns the file names and the drop-reason strings; this plan uses them as written there. Spec §15.1 names four files: frames.json (encode and decode, all subtypes), gateway.json (frame → APRS line, or drop reason; also the dedupe, retry and sequence cases below), inject.json (APRS-IS → mesh) and text.json (compact text profile).
The model is kelly-page/bridge/igate_vectors.json. Every implementation (js/igate.js via tests/igate-tests.html, bridge/kelly_igate.py via test_kelly_igate.py) loads the same file and must pass every entry. Its conventions, kept:
"_" string says what the file is and which test runners must pass it.[input…, expected]. For example "passcode": [["VK4AZ", 19432], …] and "lat": [[-27.4545, 0, "2727.27S"], …]. null as the expected value means “rejected”."packet": [[{src, igate, lat, lon, sym, comment}, "VK4AZ-9>APZKPG,qAO,VK4AZ-10:!…"]]).MeshPRS adds the record form already given in ../test-vectors/README.md: id, spec (section), about, input, config, expect ({ "out": … } or { "drop": "<reason>" }, with the reason exactly as spec §9.5 / §10.1 names it).
frames.json (byte layouts come from the spec; these are the kinds of vector, not the bytes):
"encode": [{subtype, fields…}, "hex blob"], one or more per subtype."decode": ["hex blob", {fields…}], and ["hex blob", null] for every malformed case. Malformed cases to cover:
short frame)unknown version)unknown subtype)experimental subtype)unknown flag); non-ASCII text (bad text)"onair": [blob_len, path_hops, hash_size, on_air_bytes], checking the findings §1.4 formula. For example [28, 0, 2, 37], [30, 0, 2, 53], [28, 6, 2, 49], [83, 0, 2, 101]."grp_data": full GRP_DATA packets (header, path_len, path, channel hash, MAC, ciphertext) under the MeshPRS channel key, so the AES-ECB, MAC and padding path is tested end to end. Keys must be the published test-channel key, never a private one.gateway.json, reliability cases:
{src, seq, retry, rxAt} in, and accept or { "drop": "replayed" } per row out, including the resync after two.{ "drop": "duplicate" }.seq: expect two outputs.retry: expect one output, then duplicate (spec §10.2 ignores the retry field).igate call, different rxAt). Expect byte-identical info fields (spec §10.3); only the qAO,IGATE part differs.{ "drop": "test" } for a live-APRS-IS config and { "out": … } for a test-server config. Gating = 1 or 3: nogate.interval, queue full and rate where spec §10.1 says. Over the reliability.md §4.4 airtime budget, expect airtime (IS→mesh, spec §6.5); a frame held past 15 s expects expired (spec §10.1 step 9).Pass: 100 % of vectors in every implementation. Any change to a vector file needs the spec section it tests to change in the same commit.
kelly-page/bridge/kelly_mesh_sim.py (and its JS twin js/meshcoresim.js) is a companion-protocol simulator with fault injection: ackloss=P, acklossfirst=N, acklatefirst=N, hang=S and others. Extend it with a MeshPRS channel and GRP_DATA frames, then run:
acklatefirst=1 with acklatems=18700 (the delay measured 2026-10-24). Expect the late ack to be accepted for the earlier attempt, and no extra retry once it arrives.ackloss = 0.1 / 0.3 / 0.5. Expect at most 2 retries, at 30 s and 60 s, each retry with a changed retry field, and a fallback to flood after one miss on a direct route (reliability.md §4.2)..moved repeater. Expect the direct route to fail once, a flood to follow, and the path to be re-learned.Pass: the expected sequence of sends, byte for byte, in each case. These are logic tests only; the sim has no RF model.
Kit:
path.hash.mode 1).Link design.
GET_NEIGHBOURS before trusting a run.Tests:
rx_delay_base: repeat the collision test at flood factor 0.5 / 1.0 / 2.0, and with rx_delay_base > 0. These are the bench-only experiments from reliability.md §4.3.Pass: ≥ 99 % per hop (95 % lower bound ≥ 98 %) at SNR ≥ +5 dB. The SNR-vs-delivery curve and the collision results are recorded for reliability.md, replacing the model’s values.
Three ways, in order of fidelity:
| Method | How | Tests | Limits |
|---|---|---|---|
| A. Forced direct path on an open bench | All nodes in one room. The sender uses CMD_SEND_CHANNEL_DATA with an explicit path of 1-8 repeater hashes; only the named next hop forwards a direct packet [source §2.2] |
Hop-count logic, path growth, latency, ack return, direct-route fallback | Every node hears every hop, so no hidden-node or weak-link effects, and floods short-circuit to 1 hop. Logic only, not RF reliability |
| B. Attenuated chain | Nodes in boxes, adjacent pairs linked through attenuators/splitters, non-adjacent pairs isolated (§3). A flood can only travel the chain | Real flood hop count, real per-hop loss, collision effects (add a parallel branch) | Needs 8 repeaters + 2 endpoints and enough isolation; the most work |
| C. Outdoor chain | Low-power repeaters spaced out along a line (a road or a park). Then the real mesh, using known 3-6 hop routes such as VK4AZ ↔ VK4AB | Real terrain and real background traffic | Not controlled; background traffic varies; uses the live channel (keep within the reliability.md §4.4 budget) |
TRACE_ROUTE_PLAN.md; memory note Kelly Trace Route).path_len and path hashes (the raw packet logger scripts/meshcore_rx_logger.py does this). The hop count of a run is the received path length, not the intended one.Output: a measured delivery-vs-hops table to set against the reliability.md §2.1 model, per frame class and route type.
Pass: the ≤ 3-hop one-way delivery target in §7 is met on method B or C.
(src, seq, retry, rxAt, path, snr, rssi) for every copy it hears. A gateway’s diagnostic log (spec §10.4) has these fields plus the verdict, so each iGate in a trial is also a sink.(src, seq) received ÷ seq values sent, taken from the originator’s own send log. Don’t infer the sent count from gaps in the received seq: a lost run at the end looks like no loss.n = 1.96² · p(1−p) / E².| True p | ±5 % | ±3 % | ±2 % |
|---|---|---|---|
| 0.5 (worst case) | 385 | 1 068 | 2 401 |
| 0.8 | 246 | 683 | 1 537 |
| 0.9 | 139 | 385 | 865 |
| 0.95 | 73 | 203 | 457 |
wilson.py with the observed proportion held exactly at its value. An observed 90 % needs n ≥ 196 for the lower bound to reach 85 %; an observed 95 % needs n ≥ 139 to reach 90 %. (A one-sided 95 % bound, z = 1.645, would need 138 and 98; this plan uses the two-sided figures.) With zero failures, n = ln 0.05 / ln p gives 29 frames to show p ≥ 0.90, 59 for 0.95 and 299 for 0.99.(src, seq) at each listener. This is not a failure, just flood redundancy, but it is reported, together with the path of each copy.(src, seq). Must be 0; this is the iGate’s own dedupe.(src, seq), after its 30 s dupe filter. Must be 1. Packets submitted for it equals the number of iGates that heard it; this is reported, not failed.chronyc tracking).(src, seq) arriving at the APRS-IS side via mesh → APRS → mesh → APRS, or as a frame re-entering the mesh from an iGate.GET_STATUS (tx/rx airtime, flood/direct counters, duplicate counters [source §3.3]), read before and after each run on every repeater we can log in to.At each iGate and trial repeater, log the channel for ≥ 2 h before the trial, as on 2026-10-25 (meshcore_rx_logger.py). That measures G for reliability.md §2.3 and gives the baseline the trial’s added airtime is compared against.
Why it needs care:
pass -1 (unverified) login cannot send: aprsc drops its packets [source §4.3], so it is not a sandbox.srvr.aprs-is.net:8080 is “for testing” but feeds the live network.aprsc-test service, server ID MPRSTEST, with no uplink configured, so nothing it accepts can leave (installed by MeshPRS/tools/install-test-aprsc.sh). Clients: 127.0.0.1:14580 on RiverReach, or RiverReach’s Tailscale address; status page on port 14501 at the same addresses.*.aprs2.net host. Before each run, check that the config has no uplink section.T5 multi-iGate dedupe test:
(src, seq) is passed by the server exactly once; the info fields from all iGates are byte-identical (spec §10.3); the submitted count equals the iGate count.Only after T5 and T6 pass.
APZKPG (experimental range, unallocated [source §4.4]), qAO.Measured over T6 (the live mesh with a private aprsc), with at least 2 iGates and at least 1 mobile and 2 static originators, over ≥ 7 days.
| # | Metric | Proposed criterion | Sample |
|---|---|---|---|
| P1 | Originator → any iGate, one-way, ≤ 3 hops (the received path) | ≥ 90 % observed, 95 % lower bound ≥ 85 % | ≥ 196 frames per originator |
| P2 | Same, 4-6 hops | Reported, not failed; compared with the model | ≥ 140 |
| P3 | Message round trip (frame + ack, at most 2 retries at 30 s and 60 s), ≤ 3 hops | ≥ 95 % observed, lower bound ≥ 90 % | ≥ 139 messages |
| P4 | Duplicates passed by APRS-IS per (src, seq) |
exactly 1, in 100 % of frames | all |
| P5 | Duplicate submissions by one iGate | 0 | all |
| P6 | Loops | 0 | all |
| P7 | MeshPRS airtime | within the reliability.md §4.4 budget per node and per iGate; ≤ 2 % added occupancy at each logged repeater, from GET_STATUS deltas |
daily |
| P8 | Latency, mesh → APRS-IS, ≤ 3 hops | p95 ≤ 10 s | all delivered |
| P9 | No harm to other mesh traffic | logged repeaters’ flood, dupe and error counters show no step change beyond normal day-to-day variation (baseline from §5.6) | before vs during |
| P10 | Vectors | the trial build passes 100 % of the T1 vectors | — |
If P1 or P3 fails, the report states the hop count and per-hop SNR (from TRACE) of the failing originators before anyone changes the protocol. The usual cause in SEQ is a weak link: in the 2026-10-24 test it was a repeater’s power, not the protocol.