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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +1 -0
- data/Gemfile +2 -1
- data/documentation/README.md +6 -0
- data/documentation/admin-backup.md +369 -0
- data/documentation/admin.md +4 -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/admin/backup.rb +560 -0
- data/lib/meshtastic/admin.rb +2 -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 +2 -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/admin/backup_spec.rb +704 -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 +124 -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 +39 -3
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require_relative '../../support/tak_codec_fixtures'
|
|
5
|
+
require_relative '../../support/unishox_fixtures'
|
|
4
6
|
|
|
5
7
|
describe Meshtastic::ATAK do
|
|
6
8
|
def fake_serial_obj
|
|
@@ -146,6 +148,54 @@ describe Meshtastic::ATAK do
|
|
|
146
148
|
packet = decode_to_radio(serial_obj)
|
|
147
149
|
expect(packet.decoded.portnum).to eq(:ATAK_PLUGIN)
|
|
148
150
|
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
describe Meshtastic::ATAK do
|
|
154
|
+
it 'handles official SDK malformed cases and reserved flag bits' do
|
|
155
|
+
TAK_MALFORMED_FIXTURES.each do |name, hex|
|
|
156
|
+
if name == 'reserved_bits_set'
|
|
157
|
+
expect(described_class.decode_v2(payload: [hex].pack('H*'))).to be_a(Meshtastic::TAKPacketV2)
|
|
158
|
+
else
|
|
159
|
+
expect { described_class.decode_v2(payload: [hex].pack('H*')) }.to raise_error(StandardError), name
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it 'rejects malformed V1 framing and excessive input before protobuf parsing' do
|
|
165
|
+
["\x08\x01\x12\x80", "\x08\x01\x00", "\x08\x01\x12\x20", 'a' * 4097].each do |wire|
|
|
166
|
+
expect { described_class.decode(portnum: :ATAK_PLUGIN, payload: wire.b) }.to raise_error(ArgumentError)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it 'preserves V1 raw detail and accepts empty proto3 packets' do
|
|
171
|
+
wire = Meshtastic::TAKPacket.new(is_compressed: true, detail: "\xff\x00".b).to_proto
|
|
172
|
+
decoded = described_class.decode(portnum: :ATAK_PLUGIN, payload: wire)
|
|
173
|
+
expect(decoded.detail).to eq("\xff\x00".b)
|
|
174
|
+
expect(decoded.is_compressed).to be(false)
|
|
175
|
+
expect(described_class.decode(portnum: :ATAK_PLUGIN, payload: '').to_h).to eq({})
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it 'decodes SDK compressed V2 golden protobufs' do
|
|
179
|
+
TAK_CODEC_FIXTURES.each do |name, wire, proto|
|
|
180
|
+
expect(described_class.decode_v2(payload: [wire].pack('H*')).to_h)
|
|
181
|
+
.to eq(Meshtastic::TAKPacketV2.decode([proto].pack('H*')).to_h), name
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it 'decompresses V1 binary string fields before UTF-8 protobuf parsing' do
|
|
186
|
+
field = ->(tag, data) { [(tag * 8) + 2, data.bytesize].pack('C*') + data }
|
|
187
|
+
compressed = ->(text) { [UNISHOX_FIXTURES.assoc(text).last].pack('H*') }
|
|
188
|
+
contact = field.call(1, compressed.call('ALPHA')) + field.call(2, compressed.call('RADIO-1'))
|
|
189
|
+
chat = field.call(1, compressed.call('ATAK chat')) + field.call(2, compressed.call('ANDROID-aabbccdd')) + field.call(3, compressed.call('ALPHA'))
|
|
190
|
+
wire = "\x08\x01".b + field.call(2, contact) + field.call(6, chat)
|
|
191
|
+
packet = described_class.decode(portnum: :ATAK_PLUGIN, payload: wire)
|
|
192
|
+
expect(packet.is_compressed).to be(false)
|
|
193
|
+
expect(packet.contact.callsign).to eq('ALPHA')
|
|
194
|
+
expect(packet.contact.device_callsign).to eq('RADIO-1')
|
|
195
|
+
expect(packet.chat.message).to eq('ATAK chat')
|
|
196
|
+
expect(packet.chat.to).to eq('ANDROID-aabbccdd')
|
|
197
|
+
expect(packet.chat.to_callsign).to eq('ALPHA')
|
|
198
|
+
end
|
|
149
199
|
|
|
150
200
|
it 'prints usage without raising' do
|
|
151
201
|
expect { described_class.help }.to output(/ATAK_PLUGIN_V2/).to_stdout
|
|
@@ -1,8 +1,22 @@
|
|
|
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::Bluetooth do
|
|
7
|
+
include PayloadFixtures
|
|
8
|
+
|
|
9
|
+
it 'decodes every application fixture received from GATT including malformed and default messages' do
|
|
10
|
+
handle = connect
|
|
11
|
+
payload_cases.each do |port, bytes, expected|
|
|
12
|
+
connection.incoming << payload_radio(port, bytes).to_proto
|
|
13
|
+
received = Timeout.timeout(2) do
|
|
14
|
+
described_class.subscribe(bluetooth_obj: handle) { |message| break message }
|
|
15
|
+
end
|
|
16
|
+
expect(received.dig(:packet, :decoded, :payload)).to eq(expected), "port #{port} bytes #{bytes.inspect}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
6
20
|
let(:connection) do
|
|
7
21
|
Class.new do
|
|
8
22
|
attr_reader :writes, :incoming
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'meshtastic/forwarder_pb'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Meshtastic::ForwarderProtobuf::CotEvent do
|
|
7
|
+
it 'loads the complete pinned upstream descriptor graph' do
|
|
8
|
+
expect(described_class.decode("\x0a\x04test".b).uid).to eq('test')
|
|
9
|
+
expect(described_class.descriptor.lookup('detail').subtype.count).to eq(22)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -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
|