meshtastic 0.0.184 → 0.0.186

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +1 -0
  3. data/Gemfile +2 -1
  4. data/documentation/README.md +5 -0
  5. data/documentation/forwarder.md +149 -0
  6. data/documentation/mesh-interface.md +157 -1
  7. data/documentation/payload-formats.md +187 -0
  8. data/documentation/reticulum.md +89 -0
  9. data/lib/meshtastic/atak.rb +98 -13
  10. data/lib/meshtastic/atak_pb.rb +3 -1
  11. data/lib/meshtastic/config_pb.rb +1 -1
  12. data/lib/meshtastic/field_metadata_pb.rb +1 -1
  13. data/lib/meshtastic/forwarder.rb +190 -0
  14. data/lib/meshtastic/forwarder_pb.rb +77 -0
  15. data/lib/meshtastic/mesh_beacon_pb.rb +1 -1
  16. data/lib/meshtastic/mesh_interface.rb +115 -65
  17. data/lib/meshtastic/mesh_pb.rb +2 -1
  18. data/lib/meshtastic/module_config_pb.rb +1 -1
  19. data/lib/meshtastic/mqtt.rb +4 -34
  20. data/lib/meshtastic/payload_compression.rb +90 -0
  21. data/lib/meshtastic/payload_formats.rb +248 -0
  22. data/lib/meshtastic/reticulum.rb +71 -0
  23. data/lib/meshtastic/serial.rb +1 -19
  24. data/lib/meshtastic/telemetry_pb.rb +2 -2
  25. data/lib/meshtastic/unishox2.rb +467 -0
  26. data/lib/meshtastic/version.rb +1 -1
  27. data/lib/meshtastic.rb +4 -0
  28. data/meshtastic.gemspec +2 -0
  29. data/spec/lib/meshtastic/atak_spec.rb +50 -0
  30. data/spec/lib/meshtastic/bluetooth_spec.rb +14 -0
  31. data/spec/lib/meshtastic/forwarder_pb_spec.rb +11 -0
  32. data/spec/lib/meshtastic/forwarder_spec.rb +97 -0
  33. data/spec/lib/meshtastic/mesh_interface_spec.rb +169 -0
  34. data/spec/lib/meshtastic/mqtt_spec.rb +56 -0
  35. data/spec/lib/meshtastic/payload_compression_spec.rb +43 -0
  36. data/spec/lib/meshtastic/payload_formats_spec.rb +160 -0
  37. data/spec/lib/meshtastic/reticulum_spec.rb +91 -0
  38. data/spec/lib/meshtastic/serial_spec.rb +46 -0
  39. data/spec/lib/meshtastic/tcp_spec.rb +15 -0
  40. data/spec/lib/meshtastic/unishox2_spec.rb +34 -0
  41. data/spec/support/payload_fixtures.rb +123 -0
  42. data/spec/support/reticulum_fixtures.json +12 -0
  43. data/spec/support/tak_codec_fixtures.rb +4 -0
  44. data/spec/support/unishox_fixtures.rb +4 -0
  45. metadata +36 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 763ef1042cfde175868eab3896f7b53eaa75daf8984e64295b1b6db22a39a31e
4
- data.tar.gz: eb3ba83188a23a19013c769fd8181962c8d8e0ca9e35c98a8c87efa600bef281
3
+ metadata.gz: 3e07fc03dd4ac92690b8209db83c3878cedaeeccae799f64d5a66ebcd77c661f
4
+ data.tar.gz: b084165dc768b1446b4977a1ab8be30657773b2549c6ef42f699ecb336516890
5
5
  SHA512:
6
- metadata.gz: 03e7f64573c6baaa1a52624a184dd6c119f3e56fa0dd39dae82ed3540451fd835e6783fc59a6dd7cc2af2a3b0fe965c70c64a6049e0626f225762e6185141765
7
- data.tar.gz: 394b62b327b1ca44b89d961f2896d9e1c1389a677353314cd7319a589e009372ab56868fb5ece662d84d6f2e0a1a3e29d9b13f622e89d9c85c417acbb7f2d19c
6
+ metadata.gz: 981f4b515e1820458ddfa5655aeb9d568e72fa92e05b7458b9069dc2b3baeb6bd7ab6c7a61c91e6d74fdd6f34e77c3aa4ae25f8b47c4f202667a8d0aaa147bc3
7
+ data.tar.gz: 54c9de46786ccf0052ede5e441cc526a3294eabb098a8c717d477edd46cf28a47d4e4cfa67349a97f067780ff7a056e3d80a6be598495754d967a69729b49135
data/.rubocop_todo.yml CHANGED
@@ -94,6 +94,7 @@ Style/RedundantStringEscape:
94
94
  Exclude:
95
95
  - 'lib/meshtastic/admin_pb.rb'
96
96
  - 'lib/meshtastic/atak_pb.rb'
97
+ - 'lib/meshtastic/config_pb.rb'
97
98
  - 'lib/meshtastic/deviceonly_pb.rb'
98
99
  - 'lib/meshtastic/mesh_pb.rb'
99
100
  - 'lib/meshtastic/module_config_pb.rb'
data/Gemfile CHANGED
@@ -9,9 +9,10 @@ gemspec
9
9
  gem 'bundler', '>=4.0.21'
10
10
  gem 'bundler-audit', '>=0.9.3'
11
11
  gem 'executable-hooks', '1.7.1'
12
+ gem 'fiddle', '1.1.8'
12
13
  gem 'gem-wrappers', '1.4.0'
13
14
  gem 'geocoder', '1.8.6'
14
- gem 'google-protobuf', '4.36.1'
15
+ gem 'google-protobuf', '4.36.2'
15
16
  gem 'grpc-tools', '1.84.0'
16
17
  gem 'mqtt', '0.7.0'
17
18
  gem 'rake', '13.4.2'
@@ -23,6 +23,8 @@ Examples use placeholder addresses only:
23
23
 
24
24
  Feature modules send protobufs over Serial, Bluetooth, or TCP via `Meshtastic.deliver_data`. Pass `serial_obj:`, `bluetooth_obj:`, or `tcp_obj:`. MQTT encrypts on the host; the radio encrypts on the other three.
25
25
 
26
+ All four subscriptions share [payload decoding and the complete port inventory](mesh-interface.md#receive-payload-coverage), including empty proto3 messages and explicit raw/unsupported codec boundaries.
27
+
26
28
  Do not open Serial and Bluetooth to the same radio at once. Always `disconnect` in `ensure`.
27
29
 
28
30
  ## Feature modules
@@ -43,6 +45,9 @@ Do not open Serial and Bluetooth to the same radio at once. Always `disconnect`
43
45
  - [Meshtastic::RemoteHardware](remote-hardware.md)
44
46
  - [Meshtastic::Storeforward](storeforward.md)
45
47
  - [Meshtastic::ATAK](atak.md)
48
+ - [Meshtastic::Forwarder](forwarder.md)
49
+ - [Meshtastic::Reticulum](reticulum.md)
50
+ - [Meshtastic::PayloadFormats](payload-formats.md)
46
51
  - [Meshtastic::Paxcount](paxcount.md)
47
52
  - [Meshtastic::Cannedmessages](cannedmessages.md)
48
53
  - [Meshtastic::RTTTL](rtttl.md)
@@ -0,0 +1,149 @@
1
+ # ATAK Forwarder (port 257)
2
+
3
+ `require 'meshtastic/forwarder'` loads a Ruby decoder independent of ATAK V1/V2.
4
+ The caller must preserve the original payload when decoding fails.
5
+
6
+ ```ruby
7
+ # One actual port-257 packet, including the chunk header:
8
+ result = Meshtastic::Forwarder.decode_packet(payload: bytes)
9
+
10
+ # A complete explicitly grouped set of header-bearing chunks (any order):
11
+ result = Meshtastic::Forwarder.decode_chunks(chunks: chunks)
12
+
13
+ # Already reassembled libcotshrink bytes, without the chunk header:
14
+ result = Meshtastic::Forwarder.decode(payload: bytes)
15
+ ```
16
+
17
+ ## Implemented support and precise limits
18
+
19
+ * `:libcotshrink_protobuf` results contain `:protobuf`, a schema-level hash
20
+ covering the complete 43-file upstream CoT descriptor graph, including all
21
+ 22 Detail fields, nested shapes, routes, GeoChat, video, sensors and medevac.
22
+ This is **not** Meshtastic TAKPacket or standard TAK Protocol CotEvent.
23
+ * `:event` additionally interprets UID, omitted PLI type, scaled latitude and
24
+ longitude, CE/LE, packed HAE, time offset and stale interval. `:extensions`
25
+ interprets all 16 customBytesExt fields, including nullable values and mapped
26
+ strings. These conversions follow libcotshrink's own converters.
27
+ * Nested Detail values remain **schema-level encoded values**: this module does
28
+ not implement all per-detail scaling, string substitutions, defaults or CoT
29
+ XML reconstruction. A decoded protobuf hash is not a full reconstructed CoT
30
+ event. The packed source values remain in `:protobuf`.
31
+ * Optional GZIP wrapping is detected by its magic bytes. Both compressed input
32
+ and decompressed output are bounded by `max_bytes` (default and hard ceiling
33
+ 1,048,576). Malformed/truncated GZIP and trailing bytes are rejected. Plain
34
+ zlib/DEFLATE is not the libcotshrink wrapper.
35
+ * Year and timezone are **not transmitted**. Upstream derives its epoch using
36
+ local `Calendar`, January 1 of its startup year. Without context the decoder
37
+ returns `time_offset_seconds` and `stale_after_seconds`; it does not guess.
38
+ Supply `start_of_year: Time.new(2025, 1, 1, 0, 0, 0, '+00:00')` only when the
39
+ sender's epoch is known. Then `time`, `start`, and `stale` are emitted as UTC
40
+ ISO 8601 strings. This matters for archived messages and New Year boundaries.
41
+ * Invalid mapped values, malformed protobuf, and size violations raise
42
+ `ArgumentError`. EXI raises `Meshtastic::Forwarder::UnsupportedFormat`, a
43
+ subclass of `ArgumentError`. Empty reassembled bytes remain a valid default
44
+ proto3 message; an empty radio packet lacks its mandatory chunk header.
45
+
46
+ ### Framing is not optional
47
+
48
+ Upstream MeshSender prepends **one byte**: high nibble is zero-based chunk
49
+ index, low nibble is total chunk count (1–15). Its normal chunk body is at most
50
+ 200 bytes. Even an unfragmented message begins with `0x01`, **not `0x00`**.
51
+ `decode_packet` strips that byte and decodes a single-chunk message. For a
52
+ multi-chunk message it returns `format: :forwarder_fragment`, `index`, `count`,
53
+ and binary `body`; it never treats a fragment as a complete protobuf.
54
+
55
+ `decode_chunks` requires every index exactly once with a consistent count,
56
+ rejects missing/duplicate chunks, orders the chunks, bounds aggregate bytes,
57
+ and decodes only after reassembly. It is deliberately stateless. Integration
58
+ must group by connection/channel/sender and impose a short lifetime and a
59
+ bounded queue. The upstream header has **no message ID**, so concurrent
60
+ same-sender messages cannot be disambiguated reliably from payload bytes.
61
+ Do not silently merge unrelated generations. A Meshtastic packet ID identifies
62
+ one radio packet, not all chunks of an application message.
63
+
64
+ `ATAKBCAST,mesh-id,uid,callsign,initial` discovery broadcasts are returned as
65
+ `:forwarder_discovery`, not sent through a CoT decoder.
66
+
67
+ ## EXI blocker — full Forwarder decoding is not complete
68
+
69
+ libcotshrink's lossless mode uses EXIficient `DefaultEXIFactory.newInstance()`;
70
+ its lossy mode falls back to that mode for unsupported CoT details. Either can
71
+ be GZIP wrapped. No XSD is supplied: the required format is schema-less EXI,
72
+ not a fixed CoT field table. Real support needs EXI header/options handling,
73
+ bit-level event codes, dynamically learned XML grammars, QName/string-table
74
+ partitions and EXI datatype decoding. A zlib inflater or protobuf schema cannot
75
+ replace this engine.
76
+
77
+ This implementation recognizes the EXI distinguishing bits/cookie and fails
78
+ explicitly. No working Ruby EXI engine or captured EXI wire fixture was found
79
+ in this research. That is an implementation/dependency gap, not a claim that
80
+ EXI is mathematically impossible in Ruby. The superficially named `xi_parser`
81
+ gem is a personal-wiki-to-HTML parser, not EXI. No Java or Python subprocess is
82
+ used; full EXI support remains unimplemented.
83
+
84
+ ## Provenance and verification
85
+
86
+ The generated `forwarder_pb.rb` embeds FileDescriptorProto bytes from
87
+ `paulmandal/libcotshrink` revision
88
+ `7d818b0d119a8df291770923b463fbb734b7e1b0`. They were produced with `protoc
89
+ --include_imports --descriptor_set_out=... cotevent.proto` and registered in a
90
+ private Ruby DescriptorPool in dependency order. The upstream MIT license is
91
+ included in that generated file. Runtime requires neither protoc nor the
92
+ Android libraries.
93
+
94
+ Specs include an independent `protoc --encode` wire fixture, direct
95
+ hand-authored wire, packed-bit/null handling, nested schema decoding, actual
96
+ nibble framing, shuffled reassembly, missing/duplicate fragments, discovery,
97
+ GZIP and output limits. These are schema-conformance fixtures, **not captured
98
+ radio packets**. Upstream `HackyTests.java` supplies CoT XML test inputs and
99
+ Android runtime round-trip tests, not checked-in binary fixtures. The Android
100
+ implementation was inspected but not executed, and interoperability with live
101
+ hardware is not claimed.
102
+
103
+ Primary implementation sources:
104
+
105
+ * [CotShrinker format selection and GZIP](https://github.com/paulmandal/libcotshrink/blob/7d818b0d119a8df291770923b463fbb734b7e1b0/src/main/java/com/paulmandal/atak/libcotshrink/pub/api/CotShrinker.java)
106
+ * [CoT schema](https://github.com/paulmandal/libcotshrink/blob/7d818b0d119a8df291770923b463fbb734b7e1b0/src/main/proto/cotevent.proto)
107
+ * [Detail schema graph](https://github.com/paulmandal/libcotshrink/blob/7d818b0d119a8df291770923b463fbb734b7e1b0/src/main/proto/detail/detail.proto)
108
+ * [Packed time/height](https://github.com/paulmandal/libcotshrink/blob/7d818b0d119a8df291770923b463fbb734b7e1b0/src/main/java/com/paulmandal/atak/libcotshrink/protobuf/CustomBytesConverter.java)
109
+ * [Packed extensions/mappings](https://github.com/paulmandal/libcotshrink/blob/7d818b0d119a8df291770923b463fbb734b7e1b0/src/main/java/com/paulmandal/atak/libcotshrink/protobuf/CustomBytesExtConverter.java)
110
+ * [Nullable bit semantics](https://github.com/paulmandal/libcotshrink/blob/7d818b0d119a8df291770923b463fbb734b7e1b0/src/main/java/com/paulmandal/atak/libcotshrink/protobuf/utils/BitUtils.java)
111
+ * [Local-year epoch](https://github.com/paulmandal/libcotshrink/blob/7d818b0d119a8df291770923b463fbb734b7e1b0/src/main/java/com/paulmandal/atak/libcotshrink/protobuf/cotevent/CotEventProtobufConverterFactory.java)
112
+ * [EXI configuration](https://github.com/paulmandal/libcotshrink/blob/7d818b0d119a8df291770923b463fbb734b7e1b0/src/main/java/com/paulmandal/atak/libcotshrink/exi/ExiConverterFactory.java), [EXI standard](https://www.w3.org/TR/exi/)
113
+ * [Forwarder chunk construction](https://github.com/paulmandal/atak-forwarder/blob/ad47f2c69e45785eb6029945be232bc52acd695c/app/src/main/java/com/paulmandal/atak/forwarder/comm/meshtastic/MeshSender.java)
114
+ * [Forwarder chunk reception](https://github.com/paulmandal/atak-forwarder/blob/ad47f2c69e45785eb6029945be232bc52acd695c/app/src/main/java/com/paulmandal/atak/forwarder/comm/meshtastic/InboundMeshMessageHandler.java)
115
+ * [Upstream XML tests](https://github.com/paulmandal/libcotshrink/blob/7d818b0d119a8df291770923b463fbb734b7e1b0/src/main/java/com/paulmandal/atak/libcotshrink/hackytests/HackyTests.java)
116
+
117
+ ## Read-only fragmentation assessment: ports 76 and 75
118
+
119
+ ### Reticulum tunnel (76)
120
+
121
+ The inspected [RNS Meshtastic interface, revision e5eb5d23](https://github.com/landandair/RNS_Over_Meshtastic/blob/e5eb5d23a619958ae0556c88fcbc4bf1d867ab0a/Interface/Meshtastic_Interface.py)
122
+ uses a two-byte `Bb` header: unsigned message index and signed fragment
123
+ position. Positions start at 1; a negative position marks the final fragment
124
+ and its absolute value is the total count. `REQ` followed by the same two-byte
125
+ metadata requests retransmission; it is not RNS packet data. Reassembly must
126
+ key by sender and message index (plus connection/channel isolation), wait for
127
+ every position through the known last index, strip headers, and only then
128
+ parse the RNS packet. Out-of-order final-first delivery, duplicate conflicts,
129
+ index wrap/reuse, timeouts, maximum packet bytes and bounded state all need
130
+ coverage. An encrypted RNS inner payload cannot be made plaintext by tunnel
131
+ reassembly. This assessment does not assert every third-party port-76 sender
132
+ uses this identical protocol. [Reticulum](reticulum.md) implements this pinned
133
+ tunnel framing and explicitly grouped, bounded reassembly; the receive dispatcher
134
+ decodes individual frames only. Inner RNS parsing and decryption remain unsupported.
135
+
136
+ ### LoRaWAN bridge (75)
137
+
138
+ The [pinned authoritative bridge protobuf](https://github.com/meshtastic/protobufs/blob/51028ca5a6945c76d3977c2bb803f9947d319ac5/meshtastic/lorawan_bridge.proto)
139
+ explicitly defines fragmentation: Uplink/Downlink `chunk_count == 0` is a full
140
+ PHY frame; `chunk_count == 2` means `payload` is its first part and `payload_id`
141
+ is 1–255. A separate `PayloadChunk` has the same ID, `chunk_index == 1` and the
142
+ remaining `payload_chunk`. Reassembly keys on sender and payload ID; retain
143
+ head RF metadata, append exactly one continuation and reject other counts or
144
+ indexes. Isolate connections/channels/directions, expire incomplete entries,
145
+ handle continuation-before-head, and reject conflicting duplicates. This is
146
+ not the RNS signed-position format. Only the reassembled LoRaWAN PHYPayload
147
+ should reach a LoRaWAN frame parser; application plaintext also needs the
148
+ correct LoRaWAN security context. TxResult correlates by `request_id`, not
149
+ `tmst`. No LoRaWAN transport/reassembly code was changed.
@@ -13,7 +13,8 @@ Packet builder used by Serial, Bluetooth, TCP, and MQTT. Instantiated internally
13
13
  - `send_packet` — encrypt when `psks` present; `via: :radio` → `ToRadio`, `via: :mqtt` → `ServiceEnvelope`
14
14
  - `send_data` / `send_text`
15
15
  - `send_to_radio` / `send_to_mqtt` — serialize only
16
- - `decode_payload` — TEXT_MESSAGE_APP as UTF-8; other portnums as nested protobufs when known
16
+ - `decode_payload` — shared protobuf/text/binary decoding; [complete port inventory](#receive-payload-coverage)
17
+ - `decrypt_packet` — selected channel AES-CTR only; ciphertext preserved on failure
17
18
  - `help` / `authors`
18
19
 
19
20
  On Serial/Bluetooth/TCP, transports pass `psks: nil` so the radio owns channel crypto. MQTT must pass `psks`.
@@ -52,6 +53,161 @@ envelope = mesh.send_text(
52
53
  )
53
54
  ```
54
55
 
56
+ ## Receive payload coverage
57
+
58
+ All four subscriptions use the same `MeshInterface#decode_payload` registry:
59
+
60
+ - Serial reads `0x94 0xC3` length-prefixed `FromRadio`, then `Serial.enrich_packet`.
61
+ - TCP delegates its framed stream and subscription to Serial.
62
+ - Bluetooth reads raw `FromRadio` from GATT, then calls `Serial.enrich_packet` (no UART framing).
63
+ - MQTT reads `ServiceEnvelope`, decrypts only if needed, then calls `decode_payload`.
64
+
65
+ `recv_from_radio`, `drain_from_radio`, and raw buffer APIs intentionally return the
66
+ original protobufs rather than enriched subscription hashes. Non-packet `FromRadio`
67
+ variants remain decoded by their generated outer schema.
68
+
69
+ Known protobuf payloads return a Hash, including `{}` for valid empty/default
70
+ messages. This applies when `Data#to_h` omits the payload entirely; nil is **not** a
71
+ reason to skip a known schema. Both numeric and symbolic portnums work. Unknown or
72
+ opaque bytes remain a binary String (an omitted unknown payload stays nil). Text
73
+ is UTF-8 with invalid sequences scrubbed for display: that returned text is not
74
+ lossless, but decoding does not mutate the input bytes. A malformed known protobuf returns its
75
+ original bytes rather than killing the subscription; a String at a protobuf port
76
+ therefore means parsing failed. Existing position/MAC/public-key/time enrichment
77
+ remains. Use `include_raw: true` when exact original wire bytes are needed, including
78
+ unknown protobuf fields, which `to_h` does not expose.
79
+
80
+ The inventory below covers **all 41 bundled enum entries**, verified against
81
+ [protobufs at 51028ca5](https://github.com/meshtastic/protobufs/blob/51028ca5a6945c76d3977c2bb803f9947d319ac5/meshtastic/portnums.proto)
82
+ and [firmware at 8ef996f5](https://github.com/meshtastic/firmware/blob/8ef996f5b474300c9f06128beff5ce36369f812e/src/mesh/generated/meshtastic/portnums.pb.h).
83
+ Schema selection was also checked against firmware module handlers (including
84
+ KeyVerification, StatusMessage, PowerStress and the simulator `Compressed` wrapper).
85
+ Several existing bundled application schemas were missing from the receive decoder.
86
+ The separate Forwarder decoder adds the pinned libcotshrink protobuf graph rather
87
+ than guessing that protocol from Meshtastic's similarly named TAK messages.
88
+
89
+ | ID | Port | Subscription payload / limitation |
90
+ |---:|---|---|
91
+ | 0 | UNKNOWN_APP | Raw, undefined binary; **not** `Data` |
92
+ | 1 | TEXT_MESSAGE_APP | UTF-8 text |
93
+ | 2 | REMOTE_HARDWARE_APP | `HardwareMessage` |
94
+ | 3 | POSITION_APP | `Position` |
95
+ | 4 | NODEINFO_APP | `User` |
96
+ | 5 | ROUTING_APP | `Routing` (empty/default ACK supported) |
97
+ | 6 | ADMIN_APP | `AdminMessage` |
98
+ | 7 | TEXT_MESSAGE_COMPRESSED_APP | Unishox2 default-preset text; malformed bytes preserved |
99
+ | 8 | WAYPOINT_APP | `Waypoint` |
100
+ | 9 | AUDIO_APP | Codec2 mode/header/frame Hash; PCM is opt-in via `PayloadFormats` |
101
+ | 10 | DETECTION_SENSOR_APP | UTF-8 text; **not** `DeviceState` |
102
+ | 11 | ALERT_APP | UTF-8 text |
103
+ | 12 | KEY_VERIFICATION_APP | `KeyVerification` |
104
+ | 13 | REMOTE_SHELL_APP | `RemoteShell`; stream body remains bytes |
105
+ | 32 | REPLY_APP | Text (ASCII wire convention) |
106
+ | 33 | IP_TUNNEL_APP | IPv4/IPv6 base headers and raw body; no network injection |
107
+ | 34 | PAXCOUNTER_APP | `Paxcount` |
108
+ | 35 | STORE_FORWARD_PLUSPLUS_APP | `StoreForwardPlusPlus`; encrypted/fragmented body remains bytes |
109
+ | 36 | NODE_STATUS_APP | `StatusMessage` |
110
+ | 37 | MESH_BEACON_APP | `MeshBeacon` |
111
+ | 38 | PAGING_APP | Raw: upstream enum names `PagingPacket`, but neither audited source tree defines that schema |
112
+ | 64 | SERIAL_APP | Raw serial bytes; **not** `SerialConnectionStatus` |
113
+ | 65 | STORE_FORWARD_APP | `StoreAndForward` |
114
+ | 66 | RANGE_TEST_APP | Text (ASCII wire convention); **not** `FromRadio` |
115
+ | 67 | TELEMETRY_APP | `Telemetry` and its nested metrics |
116
+ | 68 | ZPS_APP | Unsupported status + raw bytes by default; explicit legacy profile via `PayloadFormats` |
117
+ | 69 | SIMULATOR_APP | `Compressed` wrapper; `data` recursively decoded by its `portnum`, up to eight wrappers |
118
+ | 70 | TRACEROUTE_APP | `RouteDiscovery` |
119
+ | 71 | NEIGHBORINFO_APP | `NeighborInfo` |
120
+ | 72 | ATAK_PLUGIN | `TAKPacket`; Unishox2 Contact/GeoChat fields decompressed before UTF-8 parsing; detail remains bytes |
121
+ | 73 | MAP_REPORT_APP | `MapReport` |
122
+ | 74 | POWERSTRESS_APP | `PowerStressMessage` |
123
+ | 75 | LORAWAN_BRIDGE | `LoRaWANBridge`; embedded radio frames remain bytes |
124
+ | 76 | RETICULUM_TUNNEL_APP | Reticulum tunnel fragment/REQ metadata and raw bytes; opaque body, explicit reassembly only |
125
+ | 77 | CAYENNE_APP | Cayenne LPP typed records; unknown type stops parsing and retains remainder |
126
+ | 78 | ATAK_PLUGIN_V2 | Flags-prefixed `TAKPacketV2`; raw `0xFF` or official zstd dictionaries 0/1 (requires libzstd) |
127
+ | 79 | LORA_OTA_APP | ota-common header, START/LOAD bodies; no signature verification or firmware installation |
128
+ | 112 | GROUPALARM_APP | Raw external protocol; no bundled schema |
129
+ | 256 | PRIVATE_APP | Raw application-owned bytes |
130
+ | 257 | ATAK_FORWARDER | Chunk header, discovery, protobuf/GZIP libcotshrink; EXI unsupported; no automatic grouping |
131
+ | 511 | MAX | Enum limit, not an application schema; raw |
132
+
133
+ Other numeric/private ports preserve bytes without attempting arbitrary protobuf
134
+ detection. Unsupported private/unknown payloads are returned without warnings
135
+ or payload dumps. Decoding does not implement application state machines, automatic
136
+ fragment grouping, shell execution, paging acknowledgement, or inner decryption.
137
+
138
+ ### Binary codecs and limits
139
+
140
+ `PayloadFormats` ports return Hashes with `:raw`, `:status` (`:decoded`,
141
+ `:unsupported`, or `:malformed`) and available format fields. A malformed packet
142
+ never discards its original bytes. Subscriptions use `pcm: false` and no ZPS
143
+ profile: call `Meshtastic::PayloadFormats.decode(portnum:, payload:, pcm: true)`
144
+ for optional libcodec2 PCM, or pass `zps_profile: :esp32_legacy` only for that
145
+ experimental sender dialect. Neither native audio playback nor packet-stream
146
+ synthesis is automatic. See [PayloadFormats](payload-formats.md).
147
+
148
+ Unishox2 and ATAK decoding are bounded to 4096 decoded bytes. ATAK V2 uses
149
+ flags-prefixed wire frames, not naked protobuf bytes; `0xFF` denotes uncompressed
150
+ protobuf. For compatibility, absent/empty V2 application payloads still yield
151
+ `{}`; direct `ATAK.decode_v2` requires a flags byte. Invalid compressed bytes or
152
+ unavailable libzstd return original wire bytes in subscriptions. The `fiddle` gem
153
+ is a packaged runtime dependency, not assumed bundled with Ruby.
154
+
155
+ [Forwarder](forwarder.md) consumes the port-257 chunk header, including `0x01`
156
+ for single-packet events. Multi-chunk packets yield fragment metadata; callers
157
+ must explicitly group a complete message before `Forwarder.decode_chunks`.
158
+ There is no application message ID and no safe automatic concurrent grouping.
159
+ GZIP protobuf events return schema-level detail, not reconstructed CoT XML;
160
+ EXI stays raw because its grammar decoder is unsupported. Packed timestamps
161
+ retain sender-relative offsets unless explicit year/timezone context is supplied
162
+ to the direct Forwarder API. Legacy `ATAK.compress_cot`/`decompress_cot` zlib
163
+ helpers are not the upstream Forwarder framing protocol.
164
+
165
+ ### Simulator wrapper decoding
166
+
167
+ [Reticulum](reticulum.md) decodes the pinned port-76 tunnel header and `REQ`
168
+ control frames on all four receive paths, including inside simulator wrappers.
169
+ Malformed frames retain their original bytes. Fragment bodies remain opaque;
170
+ call `Reticulum.decode_chunks` only with an explicitly isolated complete group.
171
+ There is no automatic grouping, retransmission, inner RNS decoding or decryption.
172
+
173
+ `SIMULATOR_APP` retains the `Compressed` wrapper Hash and decodes its `:data`
174
+ through the same application dispatcher using the contained `:portnum`. Omitted
175
+ portnums mean zero (`UNKNOWN_APP`), so their data stays raw. Omitted data for a
176
+ known protobuf port decodes as the default message; an entirely empty wrapper
177
+ remains `{}`. Existing enrichment and `gps_metadata` also apply to inner messages.
178
+
179
+ At most eight simulator wrappers are decoded per call. A further simulator
180
+ wrapper remains its original binary bytes; a non-simulator leaf at that boundary
181
+ still decodes normally. Malformed outer wrappers remain raw, and malformed inner
182
+ messages remain raw in `:data`, without discarding successfully decoded wrappers.
183
+ This is schema dispatch, not decompression of arbitrary bytes.
184
+
185
+ ### Encryption boundary
186
+
187
+ `decrypt_packet(message:, psks:, channel:)` preserves already-decoded packets,
188
+ even if PKI metadata is present. MQTT selects the exact `ServiceEnvelope.channel_id`
189
+ (string or symbol PSK key), using the topic channel only when the envelope omits it.
190
+ It never falls back from an unknown channel to LongFast. Radio ciphertext carries
191
+ an eight-bit XOR hash of channel name and key, not a local channel slot; only a
192
+ unique matching configured name/key is selected. Hash collisions are refused.
193
+ Supply the actual firmware channel name and full Base64 16- or 32-byte key.
194
+ Subscription wrappers also accept the existing `LongFast: 'AQ=='` shorthand.
195
+
196
+ PKI ciphertext is never fed to an AES channel cipher. Missing/ambiguous/invalid keys
197
+ and malformed decrypted `Data` preserve ciphertext with `:decryption_error`.
198
+ Successful channel decoding retains the existing `encrypted: :decrypted` marker.
199
+ AES-CTR is unauthenticated: valid protobuf parsing is not proof the key was correct
200
+ or the sender authentic. Device-owned private-key decryption remains the radio's
201
+ responsibility. No private-key discovery or downgrade fallback is performed.
202
+
203
+ ### Verification
204
+
205
+ Shared independent fixtures exercise every enum through real PTY UART framing,
206
+ TCP stream framing, fake GATT reads, and both already-decoded and AES-encrypted
207
+ MQTT envelopes. Every mapped protobuf is tested with populated, empty/default,
208
+ and malformed payloads followed by further packets. These are hardware-free host
209
+ protocol tests, not claims of live radio/broker interoperability.
210
+
55
211
  ## Related
56
212
 
57
213
  - [Meshtastic::Serial](serial.md)
@@ -0,0 +1,187 @@
1
+ # Binary application payload formats
2
+
3
+ `Meshtastic::PayloadFormats` is a Ruby, transport-independent decoder. It does not
4
+ open radios, send packets, install firmware, or alter the original payload.
5
+
6
+ ```ruby
7
+ require 'meshtastic/payload_formats'
8
+
9
+ result = Meshtastic::PayloadFormats.decode(
10
+ portnum: :CAYENNE_APP, # alternatively integer 77
11
+ payload: ['03670110056700ff'].pack('H*')
12
+ )
13
+ # result[:records] contains channel 3 / 27.2 C and channel 5 / 25.5 C.
14
+ ```
15
+
16
+ ## Integration contract
17
+
18
+ Call `decode(portnum:, payload:, pcm: false, zps_profile: nil)` using a Ruby options
19
+ Hash. `payload` must be a String of actual binary bytes, **not base64 or hex**.
20
+ Numeric ports, enum symbols, and enum-name strings are supported. `PORTS` maps
21
+ these enum names to numbers. No protobuf or shared dispatcher changes are required
22
+ inside this module; callers must explicitly require and invoke it.
23
+
24
+ Every result retains `:raw` (binary String) and `:portnum`. `:status` is:
25
+
26
+ - `:decoded`: the documented format layer was decoded, not necessarily its nested
27
+ application content, cryptographic identity, or audio samples.
28
+ - `:unsupported`: an unknown schema/version/type remains opaque; `:error` explains
29
+ why. Known partial fields may be present.
30
+ - `:malformed`: a known format failed bounds/framing validation; original bytes
31
+ and an error remain available. No partial success is claimed.
32
+
33
+ Invalid Ruby input types raise `ArgumentError`. Malformed on-wire input returns a
34
+ Hash instead of disrupting a receive callback. A dispatcher should preserve these
35
+ statuses and raw bytes rather than displaying unsupported bytes as text. This
36
+ module does not decrypt encrypted MeshPackets.
37
+
38
+ | Port | Name | Decoded layer |
39
+ |---|---|---|
40
+ | 77 | `CAYENNE_APP` | Original myDevices Cayenne LPP sensor records |
41
+ | 33 | `IP_TUNNEL_APP` | IPv4 header/options or IPv6 base header, remaining bytes |
42
+ | 9 | `AUDIO_APP` | Firmware Codec2 magic/mode/framing; optional native PCM |
43
+ | 79 | `LORA_OTA_APP` | Pinned ota-common transport header and verified body layouts |
44
+ | 68 | `ZPS_APP` | Opaque by default; explicit experimental ESP32 dialect available |
45
+
46
+ Other ports remain opaque. Do not apply the ZPS dialect to arbitrary private-port
47
+ traffic simply because the historical implementation used `PRIVATE_APP`.
48
+
49
+ ## Cayenne LPP
50
+
51
+ Records preserve wire order and repeated channels. Each contains `channel`,
52
+ `type`, `name`, `value`, and `unit`. Supported original type IDs are 0, 1, 2, 3,
53
+ 101, 102, 103, 104, 113, 115, 134, 136. Signed big-endian values use the source
54
+ resolutions, including 24-bit signed GPS latitude/longitude/altitude. Acceleration
55
+ and gyrometer values use `{x:, y:, z:}`; GPS uses
56
+ `{latitude:, longitude:, altitude:}` in degrees/degrees/metres.
57
+
58
+ An unknown type stops decoding at its channel byte: because LPP has no generic
59
+ length field, skipping it would invent a layout. Earlier valid records,
60
+ `undecoded_offset`, and `remainder` are returned. Fork-specific extended LPP types
61
+ are not inferred. An empty LPP payload is an empty record list.
62
+
63
+ ## IP tunnel
64
+
65
+ The official tunnel sends raw IP datagrams, without an Ethernet or TUN prefix.
66
+ IPv4 decoding validates IHL and exact total length, extracts addresses, protocol,
67
+ TTL, DSCP/ECN byte, identification, flags, fragment offset **in bytes**, checksum,
68
+ raw options and `body`. `header_checksum_valid` reports the checksum separately:
69
+ a bad checksum does not erase an otherwise readable header.
70
+
71
+ IPv6 decoding validates its fixed header and exact payload length, extracts
72
+ addresses, traffic class, flow label, next-header and hop-limit, and preserves
73
+ `body`. IPv6 jumbograms remain unsupported. The Python tunnel implementation is
74
+ IPv4-oriented; parsing an IPv6 header here does not claim IPv6 forwarding support
75
+ in that client. Neither parser reassembles fragments, decodes upper-layer
76
+ TCP/UDP/ICMP, walks IPv6 extension headers, or validates transport checksums.
77
+
78
+ ## Audio and optional native Codec2
79
+
80
+ The firmware payload begins `C0 DE C2`, followed by a **Codec2 mode ID**, not a
81
+ literal bitrate. IDs 0–7 correspond to 3200, 2400, 1600, 1400, 1300, 1200, 700,
82
+ 700B. Mode 8 is the documented modern Codec2 700C extension. Each compressed
83
+ frame occupies `ceil(bits_per_frame / 8)` bytes: 8, 6, 8, 7, 7, 6, 4, 4, 4.
84
+ A final partial frame is malformed. Padding bits in 52-/28-bit modes are not
85
+ reinterpreted as another frame.
86
+
87
+ By default, `frames` and `encoded` contain compressed bytes. `pcm_decoded: false`
88
+ means **no waveform has been produced**, even though framing is decoded.
89
+
90
+ ```ruby
91
+ result = Meshtastic::PayloadFormats.decode(
92
+ portnum: 9,
93
+ payload: audio_payload,
94
+ pcm: true
95
+ )
96
+ # On success: result[:pcm] is mono signed 16-bit little-endian, 8000 Hz.
97
+ # Check result[:pcm_decoded], not merely result[:status].
98
+ ```
99
+
100
+ Optional PCM conversion uses Ruby Fiddle calling an installed `libcodec2.so`;
101
+ there are no shell commands, Python dependencies, playback or file writes.
102
+ Frame geometry is checked against the native library before entering its decoder.
103
+ Unavailable Fiddle/library/mode produces `pcm_error` without losing framing or
104
+ raw bytes. Modern libcodec2 often lacks the historical 700/700B modes. The binding
105
+ creates a fresh decoder per packet and retains state **within** its frames, not
106
+ across successive mesh packets. Therefore this is packet-local waveform decoding,
107
+ not a continuous-stream audio player or a guarantee of bit-identical firmware
108
+ post-filter output. Library loading currently uses the Linux shared-object name;
109
+ other platforms can still use pure-Ruby framing.
110
+
111
+ ## OTA: decoding is not signature verification
112
+
113
+ This implementation targets `caveman99/ota-common` at
114
+ `e0d0e37d23a21df093981b40a09b454b9ce1c327`, not a fictional protobuf or a different
115
+ ESP32 Wi-Fi/BLE loader protocol. Frames contain an 8-byte header:
116
+ `type:u8, session:u8, index:u16le, offset:u16le, total:u16le`; maximum frame size is
117
+ 233 bytes. `index == 65535` denotes the manifest unit where that frame type uses
118
+ an index.
119
+
120
+ - START (1): 12-byte geometry body, exposed as `start` with `block_size`,
121
+ `block_count`, `payload_length`, `manifest_length`, `signature_length`.
122
+ - MANIFEST (2), BLOCK (3), PROOF (4): `body` is a fragment of the unit. Bounds are
123
+ checked against `offset`/`total`. Empty single-leaf proofs are valid.
124
+ - REQUEST (5), ACK (6), DONE (7), ABORT (8), LOAD_COMMIT (10): header-only control
125
+ frames according to the verified sender conventions.
126
+ - LOAD (9): 8-byte little-endian `total_length:u32, offset:u32` prefix followed by
127
+ `chunk`, exposed as `load`; package bounds are checked.
128
+ - ANNOUNCE (11): its enum/header is known, but its body layout is not defined in
129
+ the pinned transport codec. Its body remains opaque with `:unsupported`.
130
+ Unknown future frame types receive the same treatment.
131
+
132
+ `signature_verified` is always false. These are **source-verified layouts**, not
133
+ cryptographically verified messages. No cross-frame reassembly, manifest parsing,
134
+ Merkle-proof checking, XEdDSA signature verification, flash state or OTA session
135
+ completion is claimed. START geometry is an unsigned hint; even an ACK/DONE
136
+ packet is not independent evidence that an image was installed.
137
+
138
+ ## ZPS: explicit experimental profile only
139
+
140
+ The registry says “arrays of int64 fields,” which is not enough to establish a
141
+ portable schema. The linked original project has a concrete ESP32 implementation
142
+ but still sends on `PRIVATE_APP`; it uses native-memory `uint64_t` copies, has no
143
+ version marker, and documents unfinished behavior. Accordingly port 68 remains
144
+ opaque unless the caller knows the sender and explicitly supplies
145
+ `zps_profile: :esp32_legacy`.
146
+
147
+ That profile follows commit `8d56d8e29f24e6f5eeeae20e9daa04d1d5cd3fc3`:
148
+ little-endian timestamp/header word, position/reserved word, followed by up to
149
+ 20 packed scan words. It returns the timestamp, raw header words, Wi-Fi/BLE
150
+ records (address, channel marker and negative RSSI), and optional raw signed
151
+ latitude/longitude integers plus PDOP when bit 47 indicates a position. No
152
+ geolocation service, units normalization for PDOP, or universal port-68
153
+ compatibility is implied.
154
+
155
+ ## Primary sources and fixture provenance
156
+
157
+ - [myDevices CayenneLPP README](https://github.com/myDevicesIoT/CayenneLPP/blob/master/README.md):
158
+ original type table and literal temperature/acceleration wire examples used in
159
+ specs. Additional edge vectors follow that table.
160
+ - [Meshtastic port registry](https://github.com/meshtastic/protobufs/blob/master/meshtastic/portnums.proto):
161
+ port assignments and encoding descriptions; the registry alone is not treated
162
+ as a complete schema.
163
+ - [Official tunnel implementation](https://github.com/meshtastic/python/blob/master/meshtastic/tunnel.py),
164
+ [RFC 791](https://www.rfc-editor.org/rfc/rfc791),
165
+ [RFC 8200](https://www.rfc-editor.org/rfc/rfc8200): raw IP payload and headers.
166
+ IPv4 fixture instantiates RFC 791 figure 5 with test addresses/data and a
167
+ computed valid checksum; IPv6 fixture is a constructed RFC-format datagram.
168
+ - [Firmware AudioModule](https://github.com/meshtastic/firmware/blob/6d41e279f1f51bd59f687b9d441c1bf47b1594fc/src/modules/esp32/AudioModule.cpp),
169
+ [historical Codec2 header/source](https://github.com/deulis/ESP32_Codec2/tree/a2bb5afb0c3f28f49bb77bcde65abbf9416be99d/codec2),
170
+ [modern Codec2 API](https://github.com/drowe67/codec2/blob/main/src/codec2.h):
171
+ magic, mode IDs, frame sizes and native calls. Specs exercise native
172
+ encoder-produced bytes, not a mocked PCM decoder. Pattern-byte framing vectors
173
+ test boundaries, not speech fidelity.
174
+ - [ota-common transport header](https://github.com/caveman99/ota-common/blob/e0d0e37d23a21df093981b40a09b454b9ce1c327/include/ota_common/transport.h),
175
+ [codec/senders](https://github.com/caveman99/ota-common/blob/e0d0e37d23a21df093981b40a09b454b9ce1c327/src/transport.cpp),
176
+ [upstream tests](https://github.com/caveman99/ota-common/blob/e0d0e37d23a21df093981b40a09b454b9ce1c327/test/test_transport/test_transport.cpp):
177
+ header vector uses upstream `{Block,7,1234,200,1024}`; START geometry uses its
178
+ `{1024,50,50000,192,64}` example, serialized to literal little-endian bytes.
179
+ - [ZPS original source](https://github.com/a-f-G-U-C/Meshtastic-ZPS/tree/8d56d8e29f24e6f5eeeae20e9daa04d1d5cd3fc3):
180
+ `outBufAdd`, `encodeBSS`, `encodeBLE`, `allocReply`, and `handleReceived` define
181
+ the explicitly selected dialect. Tests instantiate those layouts with dummy
182
+ addresses; they are not captured port-68 packets.
183
+
184
+ Tests do not require hardware or network access. They distinguish published
185
+ vectors, independently produced native Codec2 output, and constructed
186
+ source-conformant boundary fixtures. No claim of live transport/firmware testing
187
+ is made by this module's isolated specs.
@@ -0,0 +1,89 @@
1
+ # Reticulum tunnel (port 76)
2
+
3
+ ```ruby
4
+ require 'meshtastic'
5
+ frame = Meshtastic::Reticulum.decode_packet(payload: bytes)
6
+ packet = Meshtastic::Reticulum.decode_chunks(chunks: explicitly_grouped_wire_strings)
7
+ ```
8
+
9
+ This is a **tunnel framing decoder**, not a full Reticulum packet decoder.
10
+ No transport is opened, no retransmission is sent, and no state is retained.
11
+ The shared receive dispatcher calls `decode_packet` for port 76 on Serial, TCP,
12
+ Bluetooth and MQTT, including recursively within simulator wrappers. Malformed
13
+ frames preserve their original payload bytes; the direct API raises ArgumentError.
14
+
15
+ ## Verified wire dialect
16
+
17
+ Primary source: [landandair/RNS_Over_Meshtastic Meshtastic_Interface.py](https://github.com/landandair/RNS_Over_Meshtastic/blob/e5eb5d23a619958ae0556c88fcbc4bf1d867ab0a/Interface/Meshtastic_Interface.py),
18
+ revision `e5eb5d23a619958ae0556c88fcbc4bf1d867ab0a`.
19
+
20
+ * `PacketHandler.struct_format` (line 333) is Python `Bb`: **one unsigned byte
21
+ message index followed by one signed byte position**, exactly two bytes.
22
+ Python's default native struct mode adds no padding between these byte fields;
23
+ byte order has no effect on either single-byte field. Ruby uses `Cc`.
24
+ * `split_data` (345–358) starts positions at 1 and negates the last position.
25
+ `-1` is a complete single-fragment message; `-3` is fragment 3 of 3.
26
+ A positive position provides no total count. Zero is invalid.
27
+ * `process_message` (239–243, 257 onward) reserves the prefix `REQ`, followed
28
+ by those same two metadata bytes, for retransmission control. Exactly five
29
+ bytes are accepted here. Signed request positions are preserved, with `index`
30
+ exposing their absolute value; upstream lookup accepts either sign.
31
+ * The message index wraps modulo 256 (`calc_index`, 419–420).
32
+ * Supported bounds follow this pinned interface's default 200-byte fragment
33
+ body and 564-byte hardware MTU (line 102). Wire data is 3–202 bytes: a header
34
+ plus a nonempty body. Reassembly accepts 1–128 fragments and at most 564 body
35
+ bytes. `max_bytes:` on `decode_chunks` can lower, not increase, that ceiling.
36
+ Customized upstream fragment sizes and other port-76 dialects are not claimed.
37
+
38
+ ## Returned values
39
+
40
+ `decode_packet` returns `format: :reticulum_fragment`, `message_index`, signed
41
+ `position`, one-based absolute `index`, `final`, `count` (nil until final),
42
+ `complete` (true only for `-1`), `body`, and original binary `raw`.
43
+ Even a complete single fragment remains a tunnel frame; body bytes are opaque.
44
+ `REQ` returns `format: :reticulum_request`, `message_index`, `position`, `index`,
45
+ `complete: false`, and `raw`; it is never treated as packet data.
46
+
47
+ `decode_chunks` takes original header-bearing String payloads in any order,
48
+ including final-first order. It returns `format: :reticulum_packet`,
49
+ `complete: true`, `message_index`, `count`, concatenated binary `body`, and
50
+ position-sorted `fragments` plus position-sorted original `raw_chunks`.
51
+ It rejects missing positions/final markers, multiple final markers, out-of-range
52
+ positions, mixed message indexes, requests, all duplicates (including byte-identical
53
+ retransmissions), conflicting duplicates, invalid types and size violations with
54
+ `ArgumentError`. The caller can explicitly deduplicate identical retransmissions
55
+ before submission; this decoder never silently resolves conflicts.
56
+
57
+ ## Grouping and security limits
58
+
59
+ The caller must isolate connection, channel, sender, message index **and message
60
+ generation**, impose expiration and bound its pending queue. Index reuse/wrap is
61
+ not detectable from these bytes; same-index fragments from different generations
62
+ can be indistinguishable. There is deliberately no global auto-reassembler and no
63
+ claim that a consistent index authenticates a group. A missing final fragment
64
+ cannot be inferred from arrival order. The reserved `REQ` prefix is ambiguous
65
+ with data whose header/body happen to start with those bytes; this implementation
66
+ follows upstream's control-prefix precedence and rejects malformed control lengths.
67
+
68
+ No inner RNS header parsing, decryption, signature verification, destination
69
+ interpretation or plaintext claims are implemented. Reassembled bytes are not
70
+ necessarily a valid RNS packet. No hardware or installed-gem validation was done.
71
+
72
+ ## Fixtures and tests
73
+
74
+ `spec/support/reticulum_fixtures.json` contains actual output from executing only
75
+ `PacketHandler` extracted via Python AST from the pinned source (no imports of
76
+ RNS/meshtastic, hardware access or rewritten splitting algorithm). It records the
77
+ source SHA-256. Inputs were opaque `bytes(range(256))*2 + bytes(range(52))` with
78
+ index 255, and `b'\x00\xffRNS opaque'` with index 0. Request bytes were generated
79
+ with the upstream `struct_format` and `b'REQ' + struct.pack(..., 255, 2)`.
80
+ These are real upstream-encoder framing fixtures, **not captured radio traffic or
81
+ valid-RNS-packet fixtures**. Python is not a runtime dependency of the Ruby module.
82
+
83
+ Specs exercise these fixtures, signed extrema, strict malformed inputs,
84
+ final-first grouping, duplicate/conflict/missing/control rejection, and aggregate
85
+ limits. Shared transport fixtures cover an upstream single fragment, REQ control,
86
+ malformed byte preservation and a simulator-wrapped fragment over all four receive
87
+ boundaries (MQTT both decoded and AES-encrypted). The JSON vectors are included in
88
+ the gem manifest even without a Git file listing. New behavior was exercised with
89
+ failing tests before implementation.