meshtastic 0.0.184 → 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 (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 +124 -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
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'Meshtastic::Forwarder' do
6
+ it 'decodes a libcotshrink wire event rather than treating it as zlib XML' do
7
+ require 'meshtastic/forwarder'
8
+ # Hand-authored protobuf wire: uid="test", omitted type means PLI.
9
+ result = Meshtastic::Forwarder.decode(payload: "\x0a\x04test".b)
10
+ expect(result[:format]).to eq(:libcotshrink_protobuf)
11
+ expect(result[:event]).to include(uid: 'test', type: 'a-f-G-U-C', lat: 0.0, lon: 0.0)
12
+ end
13
+
14
+ it 'unwraps GZIP and decodes packed fields and nested detail' do
15
+ require 'meshtastic/forwarder'
16
+ require 'zlib'
17
+ packed = (123 << 39) | (60 << 16) | 3000
18
+ message = Meshtastic::ForwarderProtobuf::CotEvent.new(
19
+ uid: 'example', lat: 123_456_789, lon: -987_654_321,
20
+ customBytes: packed, customBytesExt: 2 << 61,
21
+ detail: { contact: { callsign: 'EXAMPLE' } }
22
+ )
23
+ result = Meshtastic::Forwarder.decode(payload: Zlib.gzip(message.to_proto), start_of_year: Time.utc(2025))
24
+ expect(result).to include(compression: :gzip)
25
+ expect(result[:protobuf][:detail][:contact][:callsign]).to eq('EXAMPLE')
26
+ expect(result[:event]).to include(lat: 12.3456789, lon: -98.7654321, hae: 100.0,
27
+ time: '2025-01-01T00:02:03Z', stale: '2025-01-01T00:03:03Z', how: 'm-g')
28
+ expect(result[:extensions]).to include(battery: 0, readiness: false, role: 'Team Member')
29
+ end
30
+
31
+ it 'bounds compressed and decompressed input and rejects damaged gzip' do
32
+ require 'meshtastic/forwarder'
33
+ require 'zlib'
34
+ expect { Meshtastic::Forwarder.decode(payload: Zlib.gzip('a' * 100_000), max_bytes: 100) }.to raise_error(ArgumentError, /limit/)
35
+ expect { Meshtastic::Forwarder.decode(payload: 'a' * 101, max_bytes: 100) }.to raise_error(ArgumentError, /limit/)
36
+ expect { Meshtastic::Forwarder.decode(payload: Zlib.gzip('abc')[0...-4]) }.to raise_error(ArgumentError, /GZIP/)
37
+ end
38
+
39
+ it 'recognizes EXI but explicitly reports the missing decoder' do
40
+ require 'meshtastic/forwarder'
41
+ ["\x80\x00".b, "$EXI\x80\x00".b].each do |payload|
42
+ expect { Meshtastic::Forwarder.decode(payload: payload) }.to raise_error(Meshtastic::Forwarder::UnsupportedFormat, /EXI/)
43
+ end
44
+ end
45
+
46
+ it 'does not invent a year and honors compact null sentinels' do
47
+ require 'meshtastic/forwarder'
48
+ message = Meshtastic::ForwarderProtobuf::CotEvent.new(uid: 'example', customBytesExt: 32 << 55)
49
+ result = Meshtastic::Forwarder.decode(payload: message.to_proto)
50
+ expect(result[:event]).to include(time_offset_seconds: 0, stale_after_seconds: 0)
51
+ expect(result[:event]).not_to have_key(:time)
52
+ expect(result[:extensions][:geopointsrc]).to be_nil
53
+ end
54
+
55
+ it 'decodes real Forwarder nibble headers and separates incomplete chunks' do
56
+ require 'meshtastic/forwarder'
57
+ result = Meshtastic::Forwarder.decode_packet(payload: "\x01\x0a\x04test".b)
58
+ expect(result[:event][:uid]).to eq('test')
59
+ expect(Meshtastic::Forwarder.decode_packet(payload: "\x02\x0a\x04".b)).to include(
60
+ format: :forwarder_fragment, index: 0, count: 2, body: "\x0a\x04".b
61
+ )
62
+ result = Meshtastic::Forwarder.decode_chunks(chunks: ["\x12test".b, "\x02\x0a\x04".b])
63
+ expect(result[:event][:uid]).to eq('test')
64
+ expect { Meshtastic::Forwarder.decode_chunks(chunks: ["\x02\x0a\x04".b]) }.to raise_error(ArgumentError, /missing/)
65
+ expect { Meshtastic::Forwarder.decode_packet(payload: "\x22bad".b) }.to raise_error(ArgumentError, /header/)
66
+ expect { Meshtastic::Forwarder.decode_chunks(chunks: ["\x02a".b, "\x02b".b]) }.to raise_error(ArgumentError, /duplicate/)
67
+ end
68
+
69
+ it 'returns discovery broadcasts separately from CoT' do
70
+ require 'meshtastic/forwarder'
71
+ result = Meshtastic::Forwarder.decode_packet(payload: "\x01ATAKBCAST,!aabbccdd,example,EXAMPLE,1".b)
72
+ expect(result).to include(format: :forwarder_discovery, mesh_id: '!aabbccdd', uid: 'example', callsign: 'EXAMPLE', initial: true)
73
+ end
74
+
75
+ it 'decodes independent protoc wire with the complete upstream detail graph' do
76
+ require 'meshtastic/forwarder'
77
+ # Produced by protoc --encode against pinned upstream .proto files, not this Ruby encoder.
78
+ wire = ['0a0e736368656d612d6669787475726518aab4de7520e1a2f3ad07282a300739b872c102006702004100000000000000404a1e0a090a074558414d504c452a0042008201008a01009201009a0100b20100'].pack('H*')
79
+ result = Meshtastic::Forwarder.decode(payload: wire)
80
+ expect(result[:event]).to include(uid: 'schema-fixture', lat: 12.3456789, lon: -98.7654321, ce: 42, le: 7)
81
+ expect(result[:protobuf][:detail].keys).to contain_exactly(:contact, :remarks, :chat, :route, :sensor, :video, :geoFence, :medevac)
82
+ end
83
+
84
+ it 'enforces packet limits even for discovery and incomplete fragments' do
85
+ require 'meshtastic/forwarder'
86
+ expect { Meshtastic::Forwarder.decode_packet(payload: "\x02abc".b, max_bytes: 2) }.to raise_error(ArgumentError, /limit/)
87
+ expect { Meshtastic::Forwarder.decode_packet(payload: "\x01ATAKBCAST,a,b,c,1".b, max_bytes: 2) }.to raise_error(ArgumentError, /limit/)
88
+ expect { Meshtastic::Forwarder.decode_chunks(chunks: ["\x02abc".b, "\x12def".b], max_bytes: 5) }.to raise_error(ArgumentError, /limit/)
89
+ end
90
+
91
+ it 'rejects invalid mapped values and malformed protobuf' do
92
+ require 'meshtastic/forwarder'
93
+ message = Meshtastic::ForwarderProtobuf::CotEvent.new(uid: 'example', customBytesExt: 7 << 61)
94
+ expect { Meshtastic::Forwarder.decode(payload: message.to_proto) }.to raise_error(ArgumentError, /mapping/)
95
+ expect { Meshtastic::Forwarder.decode(payload: "\x0a\x08x".b) }.to raise_error(ArgumentError, /protobuf/)
96
+ end
97
+ end
@@ -1,8 +1,177 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require_relative '../../support/payload_fixtures'
4
5
 
5
6
  describe Meshtastic::MeshInterface do
7
+ include PayloadFixtures
8
+
9
+ it 'inventories every bundled port exactly once and exercises each through transport fixtures' do
10
+ ports = described_class::PROTOBUF_PAYLOADS.keys + described_class::TEXT_PAYLOADS + described_class::BINARY_PAYLOADS + described_class::RAW_PAYLOADS + [:PRIVATE_APP]
11
+ expect(ports.sort).to eq(Meshtastic::PortNum.constants.sort)
12
+ expect(payload_cases.map(&:first).grep(Symbol).uniq.sort).to eq(ports.sort)
13
+ doc = File.read(File.expand_path('../../../documentation/mesh-interface.md', __dir__))
14
+ rows = doc.scan(/^\| (\d+) \| ([A-Z0-9_]+) \|/).to_h.transform_values(&:to_sym)
15
+ expect(rows).to eq(ports.to_h { |port| [Meshtastic::PortNum.resolve(port).to_s, port] })
16
+ end
17
+
18
+ it 'decodes default proto3 application messages even when Data omits payload bytes' do
19
+ data = Meshtastic::Data.new(portnum: :ROUTING_APP).to_h
20
+ expect(described_class.new.decode_payload(payload: data[:payload], msg_type: data[:portnum])).to eq({})
21
+ expect(described_class.new.decode_payload(payload: ''.b, msg_type: 5)).to eq({})
22
+ end
23
+
24
+ {
25
+ REMOTE_HARDWARE_APP: :HardwareMessage, POSITION_APP: :Position, NODEINFO_APP: :User,
26
+ ROUTING_APP: :Routing, ADMIN_APP: :AdminMessage, WAYPOINT_APP: :Waypoint,
27
+ KEY_VERIFICATION_APP: :KeyVerification, REMOTE_SHELL_APP: :RemoteShell,
28
+ PAXCOUNTER_APP: :Paxcount, STORE_FORWARD_PLUSPLUS_APP: :StoreForwardPlusPlus,
29
+ NODE_STATUS_APP: :StatusMessage, MESH_BEACON_APP: :MeshBeacon,
30
+ STORE_FORWARD_APP: :StoreAndForward, TELEMETRY_APP: :Telemetry,
31
+ SIMULATOR_APP: :Compressed, TRACEROUTE_APP: :RouteDiscovery,
32
+ NEIGHBORINFO_APP: :NeighborInfo, ATAK_PLUGIN: :TAKPacket, MAP_REPORT_APP: :MapReport,
33
+ POWERSTRESS_APP: :PowerStressMessage, LORAWAN_BRIDGE: :LoRaWANBridge, ATAK_PLUGIN_V2: :TAKPacketV2
34
+ }.each do |port, schema|
35
+ it "decodes empty #{port} using #{schema}, with symbolic and numeric portnums" do
36
+ [port, Meshtastic::PortNum.resolve(port)].each do |portnum|
37
+ [nil, ''.b].each do |bytes|
38
+ expect(described_class.new.decode_payload(payload: bytes, msg_type: portnum)).to eq(Meshtastic.const_get(schema).new.to_h)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ describe Meshtastic::MeshInterface, 'simulator and text decoding' do
46
+ it 'dispatches nested simulator data through its contained application port' do
47
+ position = Meshtastic::Position.new(latitude_i: 10_000_000).to_proto
48
+ inner = Meshtastic::Compressed.new(portnum: :POSITION_APP, data: position).to_proto
49
+ outer = Meshtastic::Compressed.new(portnum: :SIMULATOR_APP, data: inner).to_proto
50
+ result = described_class.new.decode_payload(payload: outer, msg_type: 69)
51
+ expect(result.dig(:data, :data)).to include(latitude_i: 10_000_000, latitude: 1.0)
52
+ end
53
+
54
+ it 'bounds simulator decoding to eight wrappers and preserves the remaining wire bytes' do
55
+ remaining = Meshtastic::Compressed.new(portnum: :TEXT_MESSAGE_APP, data: 'still encoded').to_proto
56
+ wire = remaining
57
+ 8.times { wire = Meshtastic::Compressed.new(portnum: :SIMULATOR_APP, data: wire).to_proto }
58
+ result = described_class.new.decode_payload(payload: wire, msg_type: :SIMULATOR_APP)
59
+ 8.times { result = result.fetch(:data) }
60
+ expect(result).to eq(remaining)
61
+ end
62
+
63
+ it 'uses unknown port zero when a simulator wrapper omits its portnum' do
64
+ wire = Meshtastic::Compressed.new(data: "\xff\x00".b).to_proto
65
+ expect(described_class.new.decode_payload(payload: wire, msg_type: :SIMULATOR_APP)).to eq(data: "\xff\x00".b)
66
+ expect(described_class.new.decode_payload(payload: nil, msg_type: :SIMULATOR_APP)).to eq({})
67
+ end
68
+
69
+ it 'decodes omitted simulator data using the known inner proto3 schema' do
70
+ wire = Meshtastic::Compressed.new(portnum: :ROUTING_APP).to_proto
71
+ expect(described_class.new.decode_payload(payload: wire, msg_type: :SIMULATOR_APP)).to eq(portnum: :ROUTING_APP, data: {})
72
+ end
73
+
74
+ it 'preserves malformed simulator wrappers and malformed inner data' do
75
+ mesh = described_class.new
76
+ raw = "\xff".b
77
+ expect(mesh.decode_payload(payload: raw, msg_type: :SIMULATOR_APP)).to eq(raw)
78
+ %i[POSITION_APP SIMULATOR_APP].each do |port|
79
+ wire = Meshtastic::Compressed.new(portnum: port, data: raw).to_proto
80
+ expect(mesh.decode_payload(payload: wire, msg_type: :SIMULATOR_APP)).to eq(portnum: port, data: raw)
81
+ end
82
+ end
83
+
84
+ it 'still dispatches a non-simulator leaf after eight wrappers' do
85
+ wire = Meshtastic::Compressed.new(portnum: :ROUTING_APP).to_proto
86
+ 7.times { wire = Meshtastic::Compressed.new(portnum: :SIMULATOR_APP, data: wire).to_proto }
87
+ result = described_class.new.decode_payload(payload: wire, msg_type: :SIMULATOR_APP)
88
+ 8.times { result = result.fetch(:data) }
89
+ expect(result).to eq({})
90
+ end
91
+
92
+ it 'scrubs text for display without mutating the original wire bytes' do
93
+ raw = "hello\xff\x00".b.freeze
94
+ result = described_class.new.decode_payload(payload: raw, msg_type: :TEXT_MESSAGE_APP)
95
+ expect(result).to eq("hello\uFFFD\x00")
96
+ expect(result.encoding).to eq(Encoding::UTF_8)
97
+ expect(raw.bytes).to eq([104, 101, 108, 108, 111, 255, 0])
98
+ expect(raw.encoding).to eq(Encoding::ASCII_8BIT)
99
+ end
100
+ end
101
+
102
+ describe Meshtastic::MeshInterface do
103
+ it 'treats sensor, alert, reply and range test payloads as text' do
104
+ %i[DETECTION_SENSOR_APP ALERT_APP REPLY_APP RANGE_TEST_APP].each do |port|
105
+ expect(described_class.new.decode_payload(payload: "\x08\x01", msg_type: port)).to eq("\x08\x01")
106
+ expect(described_class.new.decode_payload(payload: nil, msg_type: port)).to eq('')
107
+ end
108
+ end
109
+
110
+ it 'preserves opaque binary data rather than guessing a protobuf schema' do
111
+ %i[UNKNOWN_APP SERIAL_APP PRIVATE_APP].each do |port|
112
+ expect(described_class.new.decode_payload(payload: "\x08\x01".b, msg_type: port)).to eq("\x08\x01".b)
113
+ end
114
+ end
115
+
116
+ it 'preserves malformed protobuf bytes rather than terminating reception' do
117
+ expect(described_class.new.decode_payload(payload: "\xff".b, msg_type: :POSITION_APP)).to eq("\xff".b)
118
+ end
119
+
120
+ it 'preserves V2 wire bytes if the optional native library is unavailable' do
121
+ require 'meshtastic/payload_compression'
122
+ raw = "\x00compressed".b
123
+ allow(Meshtastic::PayloadCompression).to receive(:decode_v2).and_raise(Meshtastic::PayloadCompression::Unavailable, 'missing libzstd')
124
+ expect(described_class.new.decode_payload(payload: raw, msg_type: 78)).to eq(raw)
125
+ end
126
+
127
+ it 'uses the same codec dispatcher inside simulator wrappers' do
128
+ raw = ['8767c714bdeb7c74'].pack('H*')
129
+ wire = Meshtastic::Compressed.new(portnum: :TEXT_MESSAGE_COMPRESSED_APP, data: raw).to_proto
130
+ expect(described_class.new.decode_payload(payload: wire, msg_type: 69)).to eq(portnum: :TEXT_MESSAGE_COMPRESSED_APP, data: 'Hello world')
131
+ end
132
+
133
+ it 'selects radio ciphertext by channel hash and refuses ambiguous keys' do
134
+ key = 'k' * 32
135
+ name = 'Custom'
136
+ hash = (name.bytes + key.bytes).reduce(0, :^)
137
+ cipher = OpenSSL::Cipher.new('AES-256-CTR')
138
+ cipher.encrypt
139
+ cipher.key = key
140
+ cipher.iv = [42, 0, 123, 0].pack('V4')
141
+ data = Meshtastic::Data.new(portnum: :NODE_STATUS_APP, payload: Meshtastic::StatusMessage.new(status: 'ok').to_proto)
142
+ encrypted = cipher.update(data.to_proto) + cipher.final
143
+ packet = { id: 42, from: 123, channel: hash, encrypted: encrypted }
144
+ keys = { name => Base64.strict_encode64(key) }
145
+ result = described_class.new.decrypt_packet(message: packet.dup, psks: keys)
146
+ expect(result[:decoded]).to eq(data.to_h)
147
+ collision = keys.merge(name.reverse => Base64.strict_encode64(key))
148
+ result = described_class.new.decrypt_packet(message: packet.dup, psks: collision)
149
+ expect(result[:decoded]).to be_nil
150
+ expect(result[:encrypted]).to eq(encrypted)
151
+ end
152
+
153
+ it 'preserves already decoded PKI packets and ciphertext on invalid channel keys' do
154
+ decoded = { decoded: { portnum: :ROUTING_APP }, pki_encrypted: true }
155
+ expect(OpenSSL::Cipher).not_to receive(:new)
156
+ expect(described_class.new.decrypt_packet(message: decoded, psks: {}, channel: 'test')).to eq(decoded)
157
+ ['not base64!', Base64.strict_encode64('short')].each do |key|
158
+ packet = { encrypted: 'ciphertext' }
159
+ result = described_class.new.decrypt_packet(message: packet, psks: { test: key }, channel: 'test')
160
+ expect(result[:encrypted]).to eq('ciphertext')
161
+ expect(result[:decoded]).to be_nil
162
+ expect(result[:decryption_error]).to match(/Unable to decode/)
163
+ end
164
+ end
165
+
166
+ it 'preserves unsupported payloads without leaking them to diagnostic output' do
167
+ [:PRIVATE_APP, 400, :UNRECOGNIZED_APP].each do |port|
168
+ expect do
169
+ result = described_class.new.decode_payload(payload: 'secret payload', msg_type: port)
170
+ expect(result).to eq('secret payload')
171
+ end.not_to output.to_stdout
172
+ end
173
+ end
174
+
6
175
  it 'rejects PKI on MQTT instead of falling back to channel encryption' do
7
176
  expect do
8
177
  described_class.new.send_data(
@@ -1,8 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require_relative '../../support/payload_fixtures'
4
5
 
5
6
  describe Meshtastic::MQTT do # rubocop:disable Metrics/BlockLength
7
+ include PayloadFixtures
8
+
9
+ it 'decodes every fixture from already decoded and AES encrypted ServiceEnvelopes without losing subsequent packets' do
10
+ [false, true].each do |encrypted|
11
+ client = fake_mqtt_obj
12
+ payload_cases.each do |port, bytes, _expected|
13
+ packet = payload_radio(port, bytes).packet
14
+ if encrypted
15
+ cipher = OpenSSL::Cipher.new('AES-128-CTR')
16
+ cipher.encrypt
17
+ cipher.key = Base64.strict_decode64('1PG7OiApB1nwvP+rz05pAQ==')
18
+ cipher.iv = [42, 0, 123, 0].pack('V4')
19
+ data = packet.decoded.to_proto
20
+ packet.encrypted = (data.empty? ? ''.b : cipher.update(data)) + cipher.final
21
+ end
22
+ envelope = Meshtastic::ServiceEnvelope.new(packet: packet, channel_id: 'LongFast')
23
+ client.incoming << mqtt_packet(topic: 'msh/US/2/e/LongFast/!0000007b', payload: envelope.to_proto)
24
+ end
25
+ client.incoming << nil
26
+ received = []
27
+ described_class.subscribe(mqtt_obj: client) { |message| received << message.dig(:packet, :decoded, :payload) }
28
+ expect(received).to eq(payload_cases.map(&:last))
29
+ end
30
+ end
31
+
6
32
  def fake_mqtt_obj(client_id: '00000b0b')
7
33
  published = []
8
34
  subscribed = []
@@ -104,6 +130,36 @@ describe Meshtastic::MQTT do # rubocop:disable Metrics/BlockLength
104
130
  end
105
131
 
106
132
  describe 'mqtt reception' do
133
+ it 'does not apply a channel PSK to PKI ciphertext or fall back for an unknown channel' do
134
+ [true, false].each do |pki|
135
+ client = fake_mqtt_obj
136
+ packet = Meshtastic::MeshPacket.new(id: 42, from: 123, encrypted: 'ciphertext', pki_encrypted: pki)
137
+ envelope = Meshtastic::ServiceEnvelope.new(packet: packet, channel_id: 'Other')
138
+ client.incoming << mqtt_packet(topic: 'msh/US/2/e/Other/!0000007b', payload: envelope.to_proto)
139
+ client.incoming << nil
140
+ received = []
141
+ expect(OpenSSL::Cipher).not_to receive(:new)
142
+ described_class.subscribe(mqtt_obj: client) { |message| received << message }
143
+ expect(received.first.dig(:packet, :encrypted)).to eq('ciphertext')
144
+ expect(received.first.dig(:packet, :decoded)).to be_nil
145
+ expect(received.first.dig(:packet, :decryption_error)).to match(/PKI|No PSK/)
146
+ end
147
+ end
148
+
149
+ it 'does not substitute radio hash selection when MQTT channel identity is missing' do
150
+ client = fake_mqtt_obj
151
+ key = Base64.strict_decode64('1PG7OiApB1nwvP+rz05pAQ==')
152
+ hash = ('LongFast'.bytes + key.bytes).reduce(0, :^)
153
+ envelope = Meshtastic::ServiceEnvelope.new(packet: Meshtastic::MeshPacket.new(channel: hash, encrypted: 'ciphertext'))
154
+ client.incoming << mqtt_packet(topic: '', payload: envelope.to_proto)
155
+ client.incoming << nil
156
+ expect(OpenSSL::Cipher).not_to receive(:new)
157
+ described_class.subscribe(mqtt_obj: client) do |message|
158
+ expect(message.dig(:packet, :encrypted)).to eq('ciphertext')
159
+ expect(message.dig(:packet, :decryption_error)).to match(/No PSK/)
160
+ end
161
+ end
162
+
107
163
  it 'decrypts a published text message as UTF-8, never as a nested protobuf' do
108
164
  text = "\n\x02hi"
109
165
  _mqtt_obj, received = publish_and_subscribe(text: text, from: '!00000b0b', psks: { LongFast: 'AQ==' })
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'meshtastic/payload_compression'
5
+ require_relative '../../support/tak_codec_fixtures'
6
+
7
+ describe 'Meshtastic::PayloadCompression' do
8
+ it 'rejects oversized decoded frames without allocating their advertised size' do
9
+ # libzstd ZSTD_compress level 3, input: 4097 a bytes; magic stripped.
10
+ bomb = ['0060010f4d00001061610100fcf70116'].pack('H*')
11
+ expect { Meshtastic::PayloadCompression.decode_v2(payload: bomb) }.to raise_error(ArgumentError, /4096/)
12
+ expect { Meshtastic::PayloadCompression.decode_v2(payload: "\xff".b + ('a' * 4097)) }.to raise_error(ArgumentError, /4097/)
13
+ end
14
+
15
+ it 'ignores reserved flag bits and rejects trailing or truncated frames' do
16
+ _, hex, proto = TAK_CODEC_FIXTURES.first
17
+ wire = [hex].pack('H*')
18
+ wire.setbyte(0, wire.getbyte(0) | 0xc0)
19
+ expect(Meshtastic::PayloadCompression.decode_v2(payload: wire)).to eq([proto].pack('H*'))
20
+ ["#{wire}x", wire.byteslice(0...-1), "\x02".b, ''.b].each do |bad|
21
+ expect { Meshtastic::PayloadCompression.decode_v2(payload: bad) }.to raise_error(ArgumentError)
22
+ end
23
+ end
24
+
25
+ it 'does not require a native library for raw proto3 empty messages' do
26
+ expect(Meshtastic::PayloadCompression).not_to receive(:native)
27
+ expect(Meshtastic::PayloadCompression.decode_v2(payload: "\xff".b)).to eq(''.b)
28
+ end
29
+
30
+ it 'reports native availability failures explicitly' do
31
+ require 'fiddle'
32
+ allow(Fiddle).to receive(:dlopen).and_raise(Fiddle::DLError, 'not installed')
33
+ expect { Meshtastic::PayloadCompression.decode_v2(payload: [TAK_CODEC_FIXTURES.first[1]].pack('H*')) }
34
+ .to raise_error(Meshtastic::PayloadCompression::Unavailable, /libzstd/)
35
+ end
36
+
37
+ it 'decodes every official SDK golden frame to the exact protobuf bytes' do
38
+ TAK_CODEC_FIXTURES.each do |name, wire, protobuf|
39
+ expect(Meshtastic::PayloadCompression.decode_v2(payload: [wire].pack('H*')))
40
+ .to eq([protobuf].pack('H*')), name
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'meshtastic/payload_formats'
5
+
6
+ RSpec.describe 'Meshtastic::PayloadFormats' do
7
+ def decode(port, hex)
8
+ Meshtastic::PayloadFormats.decode(portnum: port, payload: [hex.delete(' ')].pack('H*'))
9
+ end
10
+
11
+ it 'parses an RFC 791 IPv4 header and preserves the upper-layer bytes' do
12
+ # RFC 791 figure 5, instantiated with documentation-only addresses/data.
13
+ result = decode(33, '45000015006f00007b012b77 0a000001 0a000002 00')
14
+ expect(result).to include(format: :ip, version: 4, header_length: 20, total_length: 21,
15
+ source: '10.0.0.1', destination: '10.0.0.2', protocol: 1, ttl: 123,
16
+ identification: 111, header_checksum_valid: true)
17
+ expect(result[:body].bytesize).to eq(1)
18
+ expect(decode(33, '45000029')[:status]).to eq(:malformed)
19
+ end
20
+
21
+ it 'parses an RFC 8200 IPv6 base header without inventing extension decoding' do
22
+ result = decode(33, '6000000000033b40 20010db8000000000000000000000001 20010db8000000000000000000000002 010203')
23
+ expect(result).to include(version: 6, source: '2001:db8::1', destination: '2001:db8::2', next_header: 59, hop_limit: 64)
24
+ expect(result[:body].unpack1('H*')).to eq('010203')
25
+ end
26
+
27
+ it 'leaves ZPS opaque unless its experimental ESP32 dialect is explicitly selected' do
28
+ # ZPSPlugin::outBufAdd + encodeBSS: uint64 timestamp, reserved word, packed scan.
29
+ bytes = ['0100000000000000 0000000000000000 ffeeddccbbaa063c'.delete(' ')].pack('H*')
30
+ expect(Meshtastic::PayloadFormats.decode(portnum: 68, payload: bytes)[:status]).to eq(:unsupported)
31
+ result = Meshtastic::PayloadFormats.decode(portnum: 68, payload: bytes, zps_profile: :esp32_legacy)
32
+ expect(result).to include(format: :zps, timestamp: 1, profile: :esp32_legacy)
33
+ expect(result[:records]).to eq([{ kind: :wifi, address: 'aa:bb:cc:dd:ee:ff', channel: 6, rssi: -60 }])
34
+ end
35
+
36
+ it 'optionally decodes real native Codec2 encoder output to signed PCM through Ruby Fiddle' do
37
+ require 'fiddle'
38
+ begin
39
+ library = Fiddle.dlopen('libcodec2.so')
40
+ rescue Fiddle::DLError
41
+ skip 'optional libcodec2 is not installed'
42
+ end
43
+ create = Fiddle::Function.new(library['codec2_create'], [Fiddle::TYPE_INT], Fiddle::TYPE_VOIDP)
44
+ destroy = Fiddle::Function.new(library['codec2_destroy'], [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
45
+ encode = Fiddle::Function.new(library['codec2_encode'], [Fiddle::TYPE_VOIDP] * 3, Fiddle::TYPE_VOID)
46
+ context = create.call(0)
47
+ frame = "\0".b * 8
48
+ encode.call(context, frame, Array.new(160, 0).pack('s*'))
49
+ destroy.call(context)
50
+ result = Meshtastic::PayloadFormats.decode(portnum: 9, payload: [0xc0, 0xde, 0xc2, 0].pack('C*') + frame, pcm: true)
51
+ expect(result).to include(pcm_decoded: true, pcm_encoding: :s16le, sample_rate: 8000)
52
+ expect(result[:pcm].bytesize).to eq(320)
53
+ expect(result[:frames]).to eq([frame])
54
+ end
55
+
56
+ it 'splits firmware Codec2 frames without mistaking compressed bytes for PCM' do
57
+ result = decode(9, 'c0dec200 0011223344556677 8899aabbccddeeff')
58
+ expect(result).to include(format: :codec2, mode: 0, bitrate: 3200, samples_per_frame: 160, pcm_decoded: false)
59
+ expect(result[:frames].map { |frame| frame.unpack1('H*') }).to eq(%w[0011223344556677 8899aabbccddeeff])
60
+ expect(decode(9, 'c0dec20011')[:status]).to eq(:malformed)
61
+ expect(decode(9, 'c0dec2ff')[:status]).to eq(:unsupported)
62
+ end
63
+
64
+ it 'decodes ota-common upstream header and start-info test vectors' do
65
+ expect(decode(79, '0307d204c8000004')).to include(format: :ota_common, type: :block, session: 7, index: 1234, offset: 200, total: 1024)
66
+ result = decode(79, '0107000000000000 0004320050c30000c0004000')
67
+ expect(result[:start]).to eq(block_size: 1024, block_count: 50, payload_length: 50_000, manifest_length: 192, signature_length: 64)
68
+ expect(result[:signature_verified]).to be false
69
+ end
70
+
71
+ it 'decodes published signed temperature and acceleration examples' do
72
+ expect(decode(:CAYENNE_APP, '0167ffd7')[:records].first[:value]).to eq(-4.1)
73
+ expect(decode(77, '067104d2fb2e0000')[:records].first[:value]).to eq(x: 1.234, y: -1.234, z: 0.0)
74
+ end
75
+
76
+ it 'decodes every original Cayenne scalar type and signed GPS coordinates' do
77
+ vectors = { 0 => ['01', 1], 1 => ['00', 0], 2 => ['ff9c', -1], 3 => ['007b', 1.23],
78
+ 101 => ['ffff', 65_535], 102 => ['01', 1], 104 => ['64', 50], 115 => ['279d', 1014.1] }
79
+ vectors.each do |type, (value, expected)|
80
+ expect(decode(77, format('01%<type>02x%<value>s', type: type, value: value))[:records].first[:value]).to eq(expected)
81
+ end
82
+ gps = decode(77, '0188 000001 ffffff 000064')[:records].first[:value]
83
+ expect(gps).to eq(latitude: 0.0001, longitude: -0.0001, altitude: 1.0)
84
+ expect(decode(77, '0186 0064 ff9c 0000')[:records].first[:value]).to eq(x: 1, y: -1, z: 0)
85
+ end
86
+
87
+ it 'never skips unknown LPP fields or loses malformed wire bytes' do
88
+ result = decode(77, '0167006402feabcd')
89
+ expect(result).to include(status: :unsupported, undecoded_offset: 4)
90
+ expect(result[:records].size).to eq(1)
91
+ expect(result[:remainder].unpack1('H*')).to eq('02feabcd')
92
+ %w[01 0167 016700].each do |hex|
93
+ expect(decode(77, hex)).to include(status: :malformed, raw: [hex].pack('H*'))
94
+ end
95
+ expect(decode(77, '')).to include(status: :decoded, records: [])
96
+ end
97
+
98
+ it 'checks IP lengths and keeps options and fragments uninterpreted' do
99
+ result = decode(33, '46000018106f2001800600000a0000010a00000201010000')
100
+ expect(result).to include(header_length: 24, fragment_offset: 8, flags: 1, body: ''.b)
101
+ expect(result[:options].unpack1('H*')).to eq('01010000')
102
+ expect(result[:header_checksum_valid]).to be false
103
+ %w[45000014106f0000800600000a0000010a00000200 44000014106f0000800600000a0000010a000002].each do |hex|
104
+ expect(decode(33, hex)[:status]).to eq(:malformed)
105
+ end
106
+ expect(decode(33, '')[:status]).to eq(:malformed)
107
+ expect(decode(33, 'f0')[:status]).to eq(:unsupported)
108
+ expect(decode(33, '60')[:status]).to eq(:malformed)
109
+ end
110
+
111
+ it 'validates all firmware Codec2 mode frame boundaries' do
112
+ [8, 6, 8, 7, 7, 6, 4, 4, 4].each_with_index do |width, mode|
113
+ result = decode(9, format('c0dec2%02x', mode) + ('00' * width))
114
+ expect(result).to include(status: :decoded, bytes_per_frame: width)
115
+ expect(result[:frames].length).to eq(1)
116
+ end
117
+ %w[00 c0dec2 00000000].each { |hex| expect(decode(9, hex)[:status]).to eq(:malformed) }
118
+ end
119
+
120
+ it 'decodes OTA load prefixes and controls while bounding fragments' do
121
+ result = decode(79, '0901000000000000 0400000001000000 aabb')
122
+ expect(result[:load]).to eq(total_length: 4, offset: 1, chunk: [0xaa, 0xbb].pack('C*'))
123
+ [5, 6, 7, 8, 10].each do |type|
124
+ expect(decode(79, format('%02x01ffff00000000', type))[:status]).to eq(:decoded)
125
+ end
126
+ expect(decode(79, '0401000000000000')[:status]).to eq(:decoded) # Single-leaf empty proof.
127
+ expect(decode(79, '0201ffff00000400aabb')[:body].unpack1('H*')).to eq('aabb')
128
+ %w[00 0101000000000000 0301000001000100ff 09010000000000000100000001000000ff].each do |hex|
129
+ expect(decode(79, hex)[:status]).to eq(:malformed)
130
+ end
131
+ expect(decode(79, '0b01000000000000')[:status]).to eq(:unsupported)
132
+ expect(decode(79, 'ff01000000000000')[:status]).to eq(:unsupported)
133
+ expect(decode(79, '00' * 234)[:status]).to eq(:malformed)
134
+ end
135
+
136
+ it 'decodes the source-defined ZPS position flag and BLE records only in the explicit profile' do
137
+ payload = [0x830000000001, 0xfffffffffffffffe, 0x3cffaabbccddeeff].pack('Q<*')
138
+ result = Meshtastic::PayloadFormats.decode(portnum: 68, payload: payload, zps_profile: :esp32_legacy)
139
+ expect(result[:position]).to eq(latitude_i: -1, longitude_i: -2, pdop: 3)
140
+ expect(result[:records].first).to include(kind: :ble, address: 'aa:bb:cc:dd:ee:ff', rssi: -60)
141
+ result = Meshtastic::PayloadFormats.decode(portnum: 68, payload: 'a', zps_profile: :esp32_legacy)
142
+ expect(result[:status]).to eq(:malformed)
143
+ end
144
+
145
+ it 'preserves unknown ports and rejects non-byte inputs' do
146
+ expect(decode(256, 'deadbeef')).to include(format: :opaque, status: :unsupported, raw: ['deadbeef'].pack('H*'))
147
+ expect { Meshtastic::PayloadFormats.decode(portnum: 77, payload: nil) }.to raise_error(ArgumentError)
148
+ end
149
+
150
+ it 'documents its public integration options at runtime' do
151
+ expect { Meshtastic::PayloadFormats.help }.to output(/portnum:.*\n.*payload:.*\n.*pcm:.*\n.*zps_profile:/).to_stdout
152
+ expect(Meshtastic::PayloadFormats.authors).not_to be_empty
153
+ end
154
+
155
+ it 'decodes the published myDevices two-temperature wire example' do
156
+ result = decode(77, '03 67 01 10 05 67 00 FF')
157
+ expect(result[:format]).to eq(:cayenne_lpp)
158
+ expect(result[:records].map { |r| r.values_at(:channel, :value) }).to eq([[3, 27.2], [5, 25.5]])
159
+ end
160
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+
6
+ RSpec.describe 'Meshtastic::Reticulum' do
7
+ let(:fixtures) { JSON.parse(File.read(File.expand_path('../../support/reticulum_fixtures.json', __dir__))) }
8
+ let(:chunks) { fixtures.fetch('chunks_hex').map { |hex| [hex].pack('H*') } }
9
+
10
+ it 'loads the tunnel codec from the gem entrypoint' do
11
+ expect(Meshtastic.const_defined?(:Reticulum)).to be(true)
12
+ end
13
+
14
+ it 'packages the upstream JSON fixture even without a Git file listing' do
15
+ root = File.expand_path('../../..', __dir__)
16
+ context = Object.new
17
+ allow(context).to receive(:`).with('git ls-files -z').and_return('')
18
+ path = File.join(root, 'meshtastic.gemspec')
19
+ specification = context.instance_eval(File.read(path), path)
20
+ expect(specification.files).to include('spec/support/reticulum_fixtures.json')
21
+ end
22
+
23
+ it 'recognizes upstream REQ control without feeding its bytes to an RNS decoder' do
24
+ require 'meshtastic/reticulum'
25
+ raw = [fixtures.fetch('request_hex')].pack('H*')
26
+ expect(Meshtastic::Reticulum.decode_packet(payload: raw)).to include(
27
+ format: :reticulum_request, message_index: 255, position: 2, index: 2, raw: raw, complete: false
28
+ )
29
+ end
30
+
31
+ it 'rejects malformed frames and bounds every wire input before copying' do
32
+ require 'meshtastic/reticulum'
33
+ [nil, 12, '', "\x01", "\x01\x00x", "\x01\xff", 'REQ', "REQ\x01", "REQ\x01\x00", "REQ\x01\x01x", "\x01\xff#{'x' * 201}"].each do |raw|
34
+ expect { Meshtastic::Reticulum.decode_packet(payload: raw) }.to raise_error(ArgumentError)
35
+ end
36
+ end
37
+
38
+ it 'retains an opaque complete single fragment and handles signed byte extremes' do
39
+ require 'meshtastic/reticulum'
40
+ raw = [fixtures.fetch('single_hex')].pack('H*')
41
+ expect(Meshtastic::Reticulum.decode_packet(payload: raw)).to include(complete: true, count: 1, message_index: 0, raw: raw)
42
+ expect(Meshtastic::Reticulum.decode_packet(payload: "\xff\x80x".b)).to include(position: -128, count: 128)
43
+ expect(Meshtastic::Reticulum.decode_packet(payload: "\x00\x7fx".b)).to include(position: 127, count: nil)
44
+ end
45
+
46
+ it 'reassembles a complete explicitly grouped upstream message final-first without inner decoding' do
47
+ require 'meshtastic/reticulum'
48
+ result = Meshtastic::Reticulum.decode_chunks(chunks: chunks.reverse)
49
+ expect(result).to include(format: :reticulum_packet, complete: true, message_index: 255,
50
+ count: 3, body: [fixtures.fetch('payload_hex')].pack('H*'), raw_chunks: chunks)
51
+ expect(result[:fragments].map { |frame| frame[:position] }).to eq([1, 2, -3])
52
+ end
53
+
54
+ it 'rejects incomplete, duplicate, conflicting, mixed-index and control-containing groups' do
55
+ require 'meshtastic/reticulum'
56
+ cases = [nil, [], chunks * 43, chunks.first(2), [chunks.last], chunks + [chunks.first],
57
+ [chunks.first, "\xff\x01different".b, chunks.last],
58
+ [chunks.first, "\xff\xfex".b, chunks.last],
59
+ [chunks.first, "\x00\x02x".b, chunks.last],
60
+ [chunks.first, "\xff\x04x".b, chunks.last],
61
+ ["REQ\xff\x02".b]]
62
+ cases.each do |group|
63
+ expect { Meshtastic::Reticulum.decode_chunks(chunks: group) }.to raise_error(ArgumentError)
64
+ end
65
+ end
66
+
67
+ it 'bounds aggregate output and rejects invalid caller limits' do
68
+ require 'meshtastic/reticulum'
69
+ [0, -1, 565, '564', 1.5].each do |limit|
70
+ expect { Meshtastic::Reticulum.decode_chunks(chunks: chunks, max_bytes: limit) }.to raise_error(ArgumentError)
71
+ end
72
+ expect { Meshtastic::Reticulum.decode_chunks(chunks: chunks, max_bytes: 563) }.to raise_error(ArgumentError, /limit/)
73
+ big = ["\x01\x01#{'x' * 200}", "\x01\x02#{'x' * 200}", "\x01\xfd#{'x' * 165}"]
74
+ expect { Meshtastic::Reticulum.decode_chunks(chunks: big) }.to raise_error(ArgumentError, /limit/)
75
+ end
76
+
77
+ it 'exposes usable runtime help and attribution' do
78
+ require 'meshtastic/reticulum'
79
+ expect { Meshtastic::Reticulum.help }.to output(/decode_packet.*payload:.*decode_chunks.*chunks:.*max_bytes:.*authors/m).to_stdout
80
+ expect(Meshtastic::Reticulum.authors).to include('landandair')
81
+ end
82
+
83
+ it 'decodes the two byte unsigned/signed upstream header and retains original bytes' do
84
+ require 'meshtastic/reticulum'
85
+ result = Meshtastic::Reticulum.decode_packet(payload: chunks.last)
86
+ expect(result).to include(format: :reticulum_fragment, message_index: 255, position: -3,
87
+ index: 3, count: 3, final: true, complete: false,
88
+ raw: chunks.last, body: chunks.last.byteslice(2..))
89
+ expect(result[:raw].encoding).to eq(Encoding::BINARY)
90
+ end
91
+ end