meshtastic 0.0.183 → 0.0.185

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +1 -0
  3. data/Gemfile +2 -1
  4. data/documentation/README.md +6 -0
  5. data/documentation/admin-backup.md +369 -0
  6. data/documentation/admin.md +4 -0
  7. data/documentation/forwarder.md +149 -0
  8. data/documentation/mesh-interface.md +157 -1
  9. data/documentation/payload-formats.md +187 -0
  10. data/documentation/reticulum.md +89 -0
  11. data/lib/meshtastic/admin/backup.rb +560 -0
  12. data/lib/meshtastic/admin.rb +2 -0
  13. data/lib/meshtastic/atak.rb +98 -13
  14. data/lib/meshtastic/atak_pb.rb +3 -1
  15. data/lib/meshtastic/config_pb.rb +1 -1
  16. data/lib/meshtastic/field_metadata_pb.rb +1 -1
  17. data/lib/meshtastic/forwarder.rb +190 -0
  18. data/lib/meshtastic/forwarder_pb.rb +77 -0
  19. data/lib/meshtastic/mesh_beacon_pb.rb +1 -1
  20. data/lib/meshtastic/mesh_interface.rb +115 -65
  21. data/lib/meshtastic/mesh_pb.rb +2 -1
  22. data/lib/meshtastic/module_config_pb.rb +2 -1
  23. data/lib/meshtastic/mqtt.rb +4 -34
  24. data/lib/meshtastic/payload_compression.rb +90 -0
  25. data/lib/meshtastic/payload_formats.rb +248 -0
  26. data/lib/meshtastic/reticulum.rb +71 -0
  27. data/lib/meshtastic/serial.rb +1 -19
  28. data/lib/meshtastic/telemetry_pb.rb +2 -2
  29. data/lib/meshtastic/unishox2.rb +467 -0
  30. data/lib/meshtastic/version.rb +1 -1
  31. data/lib/meshtastic.rb +4 -0
  32. data/meshtastic.gemspec +2 -0
  33. data/spec/lib/meshtastic/admin/backup_spec.rb +704 -0
  34. data/spec/lib/meshtastic/atak_spec.rb +50 -0
  35. data/spec/lib/meshtastic/bluetooth_spec.rb +14 -0
  36. data/spec/lib/meshtastic/forwarder_pb_spec.rb +11 -0
  37. data/spec/lib/meshtastic/forwarder_spec.rb +97 -0
  38. data/spec/lib/meshtastic/mesh_interface_spec.rb +169 -0
  39. data/spec/lib/meshtastic/mqtt_spec.rb +56 -0
  40. data/spec/lib/meshtastic/payload_compression_spec.rb +43 -0
  41. data/spec/lib/meshtastic/payload_formats_spec.rb +160 -0
  42. data/spec/lib/meshtastic/reticulum_spec.rb +91 -0
  43. data/spec/lib/meshtastic/serial_spec.rb +46 -0
  44. data/spec/lib/meshtastic/tcp_spec.rb +15 -0
  45. data/spec/lib/meshtastic/unishox2_spec.rb +34 -0
  46. data/spec/support/payload_fixtures.rb +124 -0
  47. data/spec/support/reticulum_fixtures.json +12 -0
  48. data/spec/support/tak_codec_fixtures.rb +4 -0
  49. data/spec/support/unishox_fixtures.rb +4 -0
  50. metadata +39 -3
@@ -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.