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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +1 -0
- data/Gemfile +2 -1
- data/documentation/README.md +5 -0
- data/documentation/forwarder.md +149 -0
- data/documentation/mesh-interface.md +157 -1
- data/documentation/payload-formats.md +187 -0
- data/documentation/reticulum.md +89 -0
- data/lib/meshtastic/atak.rb +98 -13
- data/lib/meshtastic/atak_pb.rb +3 -1
- data/lib/meshtastic/config_pb.rb +1 -1
- data/lib/meshtastic/field_metadata_pb.rb +1 -1
- data/lib/meshtastic/forwarder.rb +190 -0
- data/lib/meshtastic/forwarder_pb.rb +77 -0
- data/lib/meshtastic/mesh_beacon_pb.rb +1 -1
- data/lib/meshtastic/mesh_interface.rb +115 -65
- data/lib/meshtastic/mesh_pb.rb +2 -1
- data/lib/meshtastic/module_config_pb.rb +1 -1
- data/lib/meshtastic/mqtt.rb +4 -34
- data/lib/meshtastic/payload_compression.rb +90 -0
- data/lib/meshtastic/payload_formats.rb +248 -0
- data/lib/meshtastic/reticulum.rb +71 -0
- data/lib/meshtastic/serial.rb +1 -19
- data/lib/meshtastic/telemetry_pb.rb +2 -2
- data/lib/meshtastic/unishox2.rb +467 -0
- data/lib/meshtastic/version.rb +1 -1
- data/lib/meshtastic.rb +4 -0
- data/meshtastic.gemspec +2 -0
- data/spec/lib/meshtastic/atak_spec.rb +50 -0
- data/spec/lib/meshtastic/bluetooth_spec.rb +14 -0
- data/spec/lib/meshtastic/forwarder_pb_spec.rb +11 -0
- data/spec/lib/meshtastic/forwarder_spec.rb +97 -0
- data/spec/lib/meshtastic/mesh_interface_spec.rb +169 -0
- data/spec/lib/meshtastic/mqtt_spec.rb +56 -0
- data/spec/lib/meshtastic/payload_compression_spec.rb +43 -0
- data/spec/lib/meshtastic/payload_formats_spec.rb +160 -0
- data/spec/lib/meshtastic/reticulum_spec.rb +91 -0
- data/spec/lib/meshtastic/serial_spec.rb +46 -0
- data/spec/lib/meshtastic/tcp_spec.rb +15 -0
- data/spec/lib/meshtastic/unishox2_spec.rb +34 -0
- data/spec/support/payload_fixtures.rb +123 -0
- data/spec/support/reticulum_fixtures.json +12 -0
- data/spec/support/tak_codec_fixtures.rb +4 -0
- data/spec/support/unishox_fixtures.rb +4 -0
- metadata +36 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3e07fc03dd4ac92690b8209db83c3878cedaeeccae799f64d5a66ebcd77c661f
|
|
4
|
+
data.tar.gz: b084165dc768b1446b4977a1ab8be30657773b2549c6ef42f699ecb336516890
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 981f4b515e1820458ddfa5655aeb9d568e72fa92e05b7458b9069dc2b3baeb6bd7ab6c7a61c91e6d74fdd6f34e77c3aa4ae25f8b47c4f202667a8d0aaa147bc3
|
|
7
|
+
data.tar.gz: 54c9de46786ccf0052ede5e441cc526a3294eabb098a8c717d477edd46cf28a47d4e4cfa67349a97f067780ff7a056e3d80a6be598495754d967a69729b49135
|
data/.rubocop_todo.yml
CHANGED
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.
|
|
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'
|
data/documentation/README.md
CHANGED
|
@@ -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` —
|
|
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.
|