meshtastic 0.0.180 → 0.0.181
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/Gemfile +2 -2
- data/documentation/admin-channel.md +51 -20
- data/documentation/admin-config.md +45 -14
- data/documentation/admin-firmware-nordic.md +86 -0
- data/documentation/admin-firmware-serial.md +180 -0
- data/documentation/admin-firmware.md +103 -46
- data/documentation/admin.md +88 -36
- data/documentation/mesh-interface.md +7 -0
- data/lib/meshtastic/admin/channel.rb +99 -25
- data/lib/meshtastic/admin/config.rb +70 -11
- data/lib/meshtastic/admin/firmware/ble.rb +207 -0
- data/lib/meshtastic/admin/firmware/nordic_dfu.rb +218 -0
- data/lib/meshtastic/admin/firmware/serial_bootloader.rb +233 -0
- data/lib/meshtastic/admin/firmware.rb +208 -93
- data/lib/meshtastic/admin.rb +251 -27
- data/lib/meshtastic/config_pb.rb +2 -1
- data/lib/meshtastic/mesh_interface.rb +8 -0
- data/lib/meshtastic/storeforward_pb.rb +1 -1
- data/lib/meshtastic/version.rb +1 -1
- data/spec/lib/meshtastic/admin/channel_spec.rb +159 -2
- data/spec/lib/meshtastic/admin/config_spec.rb +66 -0
- data/spec/lib/meshtastic/admin/firmware/ble_spec.rb +170 -0
- data/spec/lib/meshtastic/admin/firmware/nordic_dfu_spec.rb +248 -0
- data/spec/lib/meshtastic/admin/firmware/serial_bootloader_spec.rb +263 -0
- data/spec/lib/meshtastic/admin/firmware_spec.rb +292 -95
- data/spec/lib/meshtastic/admin_spec.rb +372 -1
- data/spec/lib/meshtastic/mesh_interface_spec.rb +31 -0
- metadata +14 -6
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
module AdminSpecHelpers
|
|
6
6
|
def fake_serial_obj
|
|
7
7
|
written = +''.b
|
|
8
8
|
serial_conn = Object.new
|
|
@@ -22,6 +22,10 @@ describe Meshtastic::Admin do
|
|
|
22
22
|
packet = Meshtastic::ToRadio.decode(body).packet
|
|
23
23
|
[packet, Meshtastic::AdminMessage.decode(packet.decoded.payload)]
|
|
24
24
|
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
describe Meshtastic::Admin do
|
|
28
|
+
include AdminSpecHelpers
|
|
25
29
|
|
|
26
30
|
it 'sends a reboot AdminMessage on ADMIN_APP' do
|
|
27
31
|
serial_obj = fake_serial_obj
|
|
@@ -78,6 +82,373 @@ describe Meshtastic::Admin do
|
|
|
78
82
|
described_class.set_favorite_node(serial_obj: serial_obj, node_num: 0xb0b)
|
|
79
83
|
expect(decode_admin(serial_obj).last.set_favorite_node).to eq(0xb0b)
|
|
80
84
|
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
module AdminSynchronousSpecHelpers
|
|
88
|
+
include AdminSpecHelpers
|
|
89
|
+
|
|
90
|
+
def responding_serial(&responder)
|
|
91
|
+
handle = fake_serial_obj.merge(from_radio_queue: Queue.new, sent: [])
|
|
92
|
+
handle[:serial_conn].define_singleton_method(:write) do |bytes|
|
|
93
|
+
packet = Meshtastic::ToRadio.decode(bytes.byteslice(4..)).packet
|
|
94
|
+
handle[:sent] << packet
|
|
95
|
+
responder.call(handle, packet)
|
|
96
|
+
bytes.bytesize
|
|
97
|
+
end
|
|
98
|
+
handle
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def admin_reply(packet, from: packet.to, request_id: packet.id, message: nil)
|
|
102
|
+
message ||= Meshtastic::AdminMessage.new(get_device_metadata_response: Meshtastic::DeviceMetadata.new(firmware_version: 'test-version'))
|
|
103
|
+
Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: from, decoded: Meshtastic::Data.new(
|
|
104
|
+
portnum: :ADMIN_APP, request_id: request_id, payload: message.to_proto
|
|
105
|
+
)))
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
describe Meshtastic::Admin, 'automatic sessions' do
|
|
110
|
+
include AdminSynchronousSpecHelpers
|
|
111
|
+
|
|
112
|
+
it 'automatically acquires and reuses a target-scoped session before remote writes' do
|
|
113
|
+
handle = responding_serial do |connection, packet|
|
|
114
|
+
message = Meshtastic::AdminMessage.decode(packet.decoded.payload)
|
|
115
|
+
next unless message.get_config_request == :SESSIONKEY_CONFIG && message.payload_variant == :get_config_request
|
|
116
|
+
|
|
117
|
+
reply = Meshtastic::AdminMessage.new(get_config_response: Meshtastic::Config.new, session_passkey: '12345678')
|
|
118
|
+
connection[:from_radio_queue] << admin_reply(packet, message: reply)
|
|
119
|
+
end
|
|
120
|
+
2.times { described_class.set_owner(serial_obj: handle, to: '!aabbccdd', long_name: 'Remote', timeout: 0.2) }
|
|
121
|
+
messages = handle[:sent].map { |packet| Meshtastic::AdminMessage.decode(packet.decoded.payload) }
|
|
122
|
+
expect(messages.map(&:payload_variant)).to eq(%i[get_config_request set_owner set_owner])
|
|
123
|
+
expect(messages.drop(1).map(&:session_passkey)).to eq(%w[12345678 12345678])
|
|
124
|
+
described_class.set_owner(serial_obj: handle, to: '!aabbccee', long_name: 'Other', timeout: 0.2)
|
|
125
|
+
expect(handle[:sent].length).to eq(5)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'returns a target ACK for a write and invalidates rejected sessions without replaying the write' do
|
|
129
|
+
reason = :NONE
|
|
130
|
+
handle = responding_serial do |connection, packet|
|
|
131
|
+
message = Meshtastic::AdminMessage.decode(packet.decoded.payload)
|
|
132
|
+
if message.payload_variant == :get_config_request
|
|
133
|
+
reply = Meshtastic::AdminMessage.new(get_config_response: Meshtastic::Config.new, session_passkey: '12345678')
|
|
134
|
+
connection[:from_radio_queue] << admin_reply(packet, message: reply)
|
|
135
|
+
else
|
|
136
|
+
connection[:from_radio_queue] << Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: packet.to,
|
|
137
|
+
decoded: Meshtastic::Data.new(portnum: :ROUTING_APP, request_id: packet.id, payload: Meshtastic::Routing.new(error_reason: reason).to_proto)))
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
options = { serial_obj: handle, to: '!aabbccdd', reboot_seconds: 5, timeout: 0.2 }
|
|
141
|
+
expect(described_class.request(options)).to include(variant: :routing, value: :NONE)
|
|
142
|
+
reason = :ADMIN_BAD_SESSION_KEY
|
|
143
|
+
expect { described_class.request(options) }.to raise_error(Meshtastic::Admin::RoutingError)
|
|
144
|
+
expect(handle[:sent].length).to eq(3)
|
|
145
|
+
reason = :NONE
|
|
146
|
+
described_class.request(options)
|
|
147
|
+
expect(handle[:sent].length).to eq(5)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'caches the newest readback passkey, expires conservatively and supports explicit refresh' do
|
|
151
|
+
key = 'freshkey'
|
|
152
|
+
handle = responding_serial do |connection, packet|
|
|
153
|
+
message = Meshtastic::AdminMessage.decode(packet.decoded.payload)
|
|
154
|
+
next unless message.payload_variant.to_s.start_with?('get_')
|
|
155
|
+
|
|
156
|
+
reply = Meshtastic::AdminMessage.new(get_config_response: Meshtastic::Config.new, session_passkey: key)
|
|
157
|
+
connection[:from_radio_queue] << admin_reply(packet, message: reply)
|
|
158
|
+
end
|
|
159
|
+
options = { serial_obj: handle, to: '!aabbccdd', timeout: 0.5 }
|
|
160
|
+
described_class.request(options.merge(get_config_request: :SESSIONKEY_CONFIG))
|
|
161
|
+
described_class.reboot(options)
|
|
162
|
+
expect(handle[:sent].length).to eq(2)
|
|
163
|
+
handle[:admin_sessions][0xaabbccdd][:expires_at] = 0
|
|
164
|
+
key = 'newerkey'
|
|
165
|
+
described_class.reboot(options)
|
|
166
|
+
expect(handle[:sent].length).to eq(4)
|
|
167
|
+
expect(Meshtastic::AdminMessage.decode(handle[:sent].last.decoded.payload).session_passkey).to eq(key)
|
|
168
|
+
described_class.reboot(options.merge(refresh_session: true))
|
|
169
|
+
expect(handle[:sent].length).to eq(6)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it 'discards the old cache before a forced refresh which fails' do
|
|
173
|
+
handle = responding_serial do |connection, packet|
|
|
174
|
+
connection[:from_radio_queue] << admin_reply(packet, message: Meshtastic::AdminMessage.new(get_config_response: Meshtastic::Config.new))
|
|
175
|
+
end
|
|
176
|
+
handle[:admin_sessions] = { 0xaabbccdd => { key: 'old-key!', expires_at: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 100 } }
|
|
177
|
+
options = { serial_obj: handle, to: '!aabbccdd', timeout: 0.2 }
|
|
178
|
+
expect { described_class.reboot(options.merge(refresh_session: true)) }.to raise_error(ArgumentError, /eight-byte/)
|
|
179
|
+
expect { described_class.reboot(options) }.to raise_error(ArgumentError, /eight-byte/)
|
|
180
|
+
expect(handle[:sent].map { |packet| Meshtastic::AdminMessage.decode(packet.decoded.payload).payload_variant }).to eq(%i[get_config_request get_config_request])
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'honors the session timeout even when only submission is requested' do
|
|
184
|
+
handle = responding_serial { |_connection, _packet| nil }
|
|
185
|
+
expect do
|
|
186
|
+
Timeout.timeout(0.5, RuntimeError, 'outer deadline exceeded') do
|
|
187
|
+
described_class.request(serial_obj: handle, to: '!aabbccdd', reboot_seconds: 5, wait: false, timeout: 0.15)
|
|
188
|
+
end
|
|
189
|
+
end.to raise_error(Timeout::Error, /Admin response/)
|
|
190
|
+
expect(handle[:sent].length).to eq(1)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it 'rejects overlapping synchronous consumers on one handle without sending a second request' do
|
|
194
|
+
entered = Queue.new
|
|
195
|
+
release = Queue.new
|
|
196
|
+
handle = responding_serial do |connection, packet|
|
|
197
|
+
entered << true
|
|
198
|
+
release.pop
|
|
199
|
+
connection[:from_radio_queue] << admin_reply(packet)
|
|
200
|
+
end
|
|
201
|
+
worker = Thread.new { described_class.request(serial_obj: handle, get_device_metadata_request: true, timeout: 1) }
|
|
202
|
+
entered.pop
|
|
203
|
+
begin
|
|
204
|
+
expect do
|
|
205
|
+
Timeout.timeout(0.3) { described_class.request(serial_obj: handle, get_device_metadata_request: true, timeout: 0.1) }
|
|
206
|
+
end.to raise_error(IOError, /already active/)
|
|
207
|
+
ensure
|
|
208
|
+
release << true
|
|
209
|
+
release << true
|
|
210
|
+
worker.value
|
|
211
|
+
end
|
|
212
|
+
expect(handle[:sent].length).to eq(1)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
describe Meshtastic::Admin, 'synchronous requests' do
|
|
217
|
+
include AdminSynchronousSpecHelpers
|
|
218
|
+
|
|
219
|
+
it 'ignores malformed routing payloads from unrelated senders' do
|
|
220
|
+
unrelated = nil
|
|
221
|
+
handle = responding_serial do |connection, packet|
|
|
222
|
+
unrelated = Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: 77,
|
|
223
|
+
decoded: Meshtastic::Data.new(portnum: :ROUTING_APP, request_id: packet.id, payload: "\xff".b)))
|
|
224
|
+
connection[:from_radio_queue] << unrelated
|
|
225
|
+
connection[:from_radio_queue] << admin_reply(packet)
|
|
226
|
+
end
|
|
227
|
+
expect(described_class.request(serial_obj: handle, get_device_metadata_request: true, timeout: 0.2)[:value].firmware_version).to eq('test-version')
|
|
228
|
+
expect(handle[:from_radio_queue].pop(true)).to eq(unrelated)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
%i[tcp_obj bluetooth_obj].each do |transport|
|
|
232
|
+
it "reads correlated replies through the real #{transport} send path" do
|
|
233
|
+
handle = if transport == :tcp_obj
|
|
234
|
+
responding_serial { |connection, packet| connection[:from_radio_queue] << admin_reply(packet) }
|
|
235
|
+
else
|
|
236
|
+
{ my_node_num: 0xb0b, from_radio_queue: Queue.new, tx_mutex: Mutex.new, bluetooth_conn: Object.new }
|
|
237
|
+
end
|
|
238
|
+
if transport == :bluetooth_obj
|
|
239
|
+
make_reply = method(:admin_reply)
|
|
240
|
+
handle[:bluetooth_conn].define_singleton_method(:write) do |bytes|
|
|
241
|
+
packet = Meshtastic::ToRadio.decode(bytes).packet
|
|
242
|
+
handle[:from_radio_queue] << make_reply.call(packet)
|
|
243
|
+
bytes.bytesize
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
result = described_class.request(transport => handle, get_device_metadata_request: true, timeout: 0.5)
|
|
247
|
+
expect(result[:value].firmware_version).to eq('test-version')
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it 'bounds a silent receive and retains wrong-variant replies and text traffic on timeout' do
|
|
252
|
+
preserved = []
|
|
253
|
+
handle = responding_serial do |connection, packet|
|
|
254
|
+
preserved << admin_reply(packet, message: Meshtastic::AdminMessage.new(get_owner_response: Meshtastic::User.new))
|
|
255
|
+
preserved << Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: packet.to,
|
|
256
|
+
decoded: Meshtastic::Data.new(portnum: :TEXT_MESSAGE_APP, payload: 'keep')))
|
|
257
|
+
preserved.each { |message| connection[:from_radio_queue] << message }
|
|
258
|
+
end
|
|
259
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
260
|
+
expect { described_class.request(serial_obj: handle, get_device_metadata_request: true, timeout: 0.15) }.to raise_error(Timeout::Error)
|
|
261
|
+
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - start).to be < 0.6
|
|
262
|
+
expect(preserved.map { handle[:from_radio_queue].pop(true) }).to eq(preserved)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
it 'fails closed before writing when automatic session acquisition has no valid passkey' do
|
|
266
|
+
handle = responding_serial do |connection, packet|
|
|
267
|
+
connection[:from_radio_queue] << admin_reply(packet, message: Meshtastic::AdminMessage.new(get_config_response: Meshtastic::Config.new))
|
|
268
|
+
end
|
|
269
|
+
expect do
|
|
270
|
+
described_class.reboot(serial_obj: handle, to: '!aabbccdd', timeout: 0.2)
|
|
271
|
+
end.to raise_error(ArgumentError, /eight-byte passkey/)
|
|
272
|
+
expect(handle[:sent].map { |packet| Meshtastic::AdminMessage.decode(packet.decoded.payload).payload_variant }).to eq([:get_config_request])
|
|
273
|
+
expect do
|
|
274
|
+
described_class.reboot(mqtt_obj: Object.new, to: '!aabbccdd')
|
|
275
|
+
end.to raise_error(ArgumentError, /supply session_passkey/)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
it 'rejects invalid timeouts before submitting any data' do
|
|
279
|
+
handle = responding_serial { |_connection, _packet| raise 'must not send' }
|
|
280
|
+
[0, -1, nil, Float::INFINITY, Float::NAN, '1'].each do |timeout|
|
|
281
|
+
expect { described_class.request(serial_obj: handle, get_owner_request: true, timeout: timeout) }.to raise_error(ArgumentError, /timeout/)
|
|
282
|
+
end
|
|
283
|
+
expect(handle[:sent]).to be_empty
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
it 'keeps unrelated packets readable after the transport closes during a request' do
|
|
287
|
+
unrelated = Meshtastic::FromRadio.new(log_record: Meshtastic::LogRecord.new(message: 'keep'))
|
|
288
|
+
handle = responding_serial do |connection, _packet|
|
|
289
|
+
connection[:from_radio_queue] << unrelated
|
|
290
|
+
connection[:from_radio_queue].close
|
|
291
|
+
end
|
|
292
|
+
expect do
|
|
293
|
+
described_class.request(serial_obj: handle, get_owner_request: true, timeout: 0.2)
|
|
294
|
+
end.to raise_error(Timeout::Error)
|
|
295
|
+
expect(handle[:from_radio_queue].pop(true)).to eq(unrelated)
|
|
296
|
+
expect(handle[:from_radio_queue]).to be_closed
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
it 'reports correlated routing failures without mistaking a local ACK for remote readback' do
|
|
300
|
+
handle = responding_serial do |connection, packet|
|
|
301
|
+
[connection[:my_node_num], packet.to].each do |sender|
|
|
302
|
+
routing = Meshtastic::Routing.new(error_reason: sender == packet.to ? :NOT_AUTHORIZED : :NONE)
|
|
303
|
+
connection[:from_radio_queue] << Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(
|
|
304
|
+
from: sender, decoded: Meshtastic::Data.new(portnum: :ROUTING_APP, request_id: packet.id, payload: routing.to_proto)
|
|
305
|
+
))
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
expect do
|
|
309
|
+
described_class.request(serial_obj: handle, to: '!aabbccdd', get_device_metadata_request: true, timeout: 0.1)
|
|
310
|
+
end.to raise_error(Meshtastic::Admin::RoutingError, /NOT_AUTHORIZED/)
|
|
311
|
+
expect(handle[:from_radio_queue].size).to eq(1)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
it 'waits for the target-correlated metadata response and preserves unrelated packets' do
|
|
315
|
+
unrelated = []
|
|
316
|
+
handle = responding_serial do |connection, packet|
|
|
317
|
+
unrelated << admin_reply(packet, from: 123)
|
|
318
|
+
unrelated << admin_reply(packet, request_id: packet.id + 1)
|
|
319
|
+
unrelated << Meshtastic::FromRadio.new(log_record: Meshtastic::LogRecord.new(message: 'other traffic'))
|
|
320
|
+
unrelated.each { |message| connection[:from_radio_queue] << message }
|
|
321
|
+
connection[:from_radio_queue] << admin_reply(packet)
|
|
322
|
+
end
|
|
323
|
+
result = described_class.request(serial_obj: handle, message: Meshtastic::AdminMessage.new(get_device_metadata_request: true), timeout: 0.2)
|
|
324
|
+
expect(result[:value].firmware_version).to eq('test-version')
|
|
325
|
+
expect(result[:request_id]).to eq(handle[:sent].last.id)
|
|
326
|
+
expect(unrelated.map { handle[:from_radio_queue].pop(true) }).to eq(unrelated)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
describe Meshtastic::Admin, 'generated schema coverage' do
|
|
331
|
+
Meshtastic::AdminMessage.descriptor.lookup_oneof('payload_variant').each do |field|
|
|
332
|
+
it "round-trips the #{field.name} payload without changing its oneof selection" do
|
|
333
|
+
value = case field.type
|
|
334
|
+
when :message then field.subtype.msgclass.new
|
|
335
|
+
when :bool then false
|
|
336
|
+
when :string then ''
|
|
337
|
+
when :enum then 0
|
|
338
|
+
else 1
|
|
339
|
+
end
|
|
340
|
+
message = described_class.encode(field.name.to_sym => value)
|
|
341
|
+
decoded = described_class.decode(payload: message.to_proto)
|
|
342
|
+
expect(decoded.payload_variant).to eq(field.name.to_sym)
|
|
343
|
+
expect(decoded.public_send(field.name)).to eq(message.public_send(field.name))
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
describe Meshtastic::Admin, 'validation and response handling' do
|
|
349
|
+
include AdminSpecHelpers
|
|
350
|
+
|
|
351
|
+
it 'converts a zero-based channel index exactly once and rejects invalid indexes' do
|
|
352
|
+
serial_obj = fake_serial_obj
|
|
353
|
+
[0, 7].each do |index|
|
|
354
|
+
serial_obj[:written].clear
|
|
355
|
+
described_class.get_channel(serial_obj: serial_obj, index: index)
|
|
356
|
+
expect(decode_admin(serial_obj).last.get_channel_request).to eq(index + 1)
|
|
357
|
+
end
|
|
358
|
+
[-1, 8, '1', 1.5].each do |index|
|
|
359
|
+
expect { described_class.get_channel(serial_obj: serial_obj, index: index) }.to raise_error(ArgumentError)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
it 'rejects conflicting, empty and unknown payloads without mutating caller messages' do
|
|
364
|
+
original = Meshtastic::AdminMessage.new(get_owner_request: true)
|
|
365
|
+
expect { described_class.encode(message: original, reboot_seconds: 5) }.to raise_error(ArgumentError, /one payload/)
|
|
366
|
+
expect(original.payload_variant).to eq(:get_owner_request)
|
|
367
|
+
expect { described_class.encode(get_owner_request: true, sensor_config: Meshtastic::SensorConfig.new) }.to raise_error(ArgumentError, /one payload/)
|
|
368
|
+
expect { described_class.encode(set_owner: nil) }.to raise_error(ArgumentError)
|
|
369
|
+
expect { described_class.encode(rebot_seconds: 5) }.to raise_error(ArgumentError)
|
|
370
|
+
encoded = described_class.encode(message: original, session_passkey: '12345678')
|
|
371
|
+
expect(encoded.session_passkey).to eq('12345678')
|
|
372
|
+
expect(original.session_passkey).to eq('')
|
|
373
|
+
expect(described_class.encode(get_config_request: :DEVICE_CONFIG).payload_variant).to eq(:get_config_request)
|
|
374
|
+
expect(described_class.encode(nodedb_reset: false).payload_variant).to eq(:nodedb_reset)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
it 'targets the connected node locally and requests replies only for getters by default' do
|
|
378
|
+
serial_obj = fake_serial_obj
|
|
379
|
+
described_class.reboot(serial_obj: serial_obj)
|
|
380
|
+
packet, = decode_admin(serial_obj)
|
|
381
|
+
expect(packet.to).to eq(0xb0b)
|
|
382
|
+
expect(packet.from).to eq(0)
|
|
383
|
+
expect(packet.decoded.want_response).to be(false)
|
|
384
|
+
serial_obj[:written].clear
|
|
385
|
+
described_class.get_owner(serial_obj: serial_obj)
|
|
386
|
+
expect(decode_admin(serial_obj).first.decoded.want_response).to be(true)
|
|
387
|
+
serial_obj[:written].clear
|
|
388
|
+
described_class.reboot(serial_obj: serial_obj, to: '!aabbccdd', want_response: true, auto_session: false)
|
|
389
|
+
expect(decode_admin(serial_obj).first.to).to eq(0xaabbccdd)
|
|
390
|
+
expect(decode_admin(serial_obj).first.decoded.want_response).to be(true)
|
|
391
|
+
expect { described_class.get_owner(mqtt_obj: Object.new) }.to raise_error(ArgumentError, /destination/)
|
|
392
|
+
expect { described_class.get_owner(serial_obj: serial_obj, to: '!ffffffff') }.to raise_error(ArgumentError, /destination/)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
it 'decodes admin responses and correlates without consuming transport queues' do
|
|
396
|
+
admin = Meshtastic::AdminMessage.new(get_owner_response: Meshtastic::User.new(long_name: 'Remote'), session_passkey: '12345678')
|
|
397
|
+
packet = Meshtastic::MeshPacket.new(from: 0xaabbccdd, decoded: Meshtastic::Data.new(portnum: :ADMIN_APP, payload: admin.to_proto, request_id: 42))
|
|
398
|
+
frame = Meshtastic::FromRadio.new(packet: packet)
|
|
399
|
+
expect(described_class.decode(payload: admin.to_proto)).to eq(admin)
|
|
400
|
+
expect(described_class.decode(packet: frame)).to eq(admin)
|
|
401
|
+
result = described_class.response(packet: frame, request_id: 42, from: 0xaabbccdd)
|
|
402
|
+
expect(result).to include(variant: :get_owner_response, value: admin.get_owner_response, session_passkey: '12345678', request_id: 42, from: 0xaabbccdd)
|
|
403
|
+
expect(described_class.response(packet: frame, request_id: 43)).to be_nil
|
|
404
|
+
expect(described_class.response(packet: frame, from: 123)).to be_nil
|
|
405
|
+
packet.decoded.portnum = :TEXT_MESSAGE_APP
|
|
406
|
+
expect(described_class.response(packet: packet)).to be_nil
|
|
407
|
+
expect { described_class.decode(packet: packet) }.to raise_error(ArgumentError, /ADMIN_APP/)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
it 'supports OTA wire operations and resetting all nodes explicitly' do
|
|
411
|
+
serial_obj = fake_serial_obj
|
|
412
|
+
event = Meshtastic::AdminMessage::OTAEvent.new(reboot_ota_mode: :OTA_BLE, ota_hash: 'x' * 32)
|
|
413
|
+
described_class.ota_request(serial_obj: serial_obj, event: event)
|
|
414
|
+
expect(decode_admin(serial_obj).last.ota_request).to eq(event)
|
|
415
|
+
serial_obj[:written].clear
|
|
416
|
+
described_class.reboot_ota(serial_obj: serial_obj, seconds: -1)
|
|
417
|
+
expect(decode_admin(serial_obj).last.reboot_ota_seconds).to eq(-1)
|
|
418
|
+
serial_obj[:written].clear
|
|
419
|
+
described_class.nodedb_reset(serial_obj: serial_obj, preserve_favorites: false)
|
|
420
|
+
expect(decode_admin(serial_obj).last.payload_variant).to eq(:nodedb_reset)
|
|
421
|
+
expect(decode_admin(serial_obj).last.nodedb_reset).to be(false)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
it 'does not turn omitted required values into zero or empty destructive commands' do
|
|
425
|
+
serial_obj = fake_serial_obj
|
|
426
|
+
%i[delete_file set_scale set_time set_canned_messages set_ringtone remove_by_nodenum set_favorite_node remove_favorite_node set_ignored_node remove_ignored_node toggle_muted_node].each do |method|
|
|
427
|
+
expect { described_class.public_send(method, serial_obj: serial_obj) }.to raise_error(ArgumentError), method.to_s
|
|
428
|
+
end
|
|
429
|
+
expect(serial_obj[:written]).to be_empty
|
|
430
|
+
described_class.set_canned_messages(serial_obj: serial_obj, messages: '')
|
|
431
|
+
expect(decode_admin(serial_obj).last.payload_variant).to eq(:set_canned_message_module_messages)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
it 'returns the actual transmitted request ID for later response correlation' do
|
|
435
|
+
serial_obj = fake_serial_obj
|
|
436
|
+
result = described_class.request(serial_obj: serial_obj, get_owner_request: true, request_id: 1234, wait: false)
|
|
437
|
+
expect(result[:request_id]).to eq(1234)
|
|
438
|
+
expect(decode_admin(serial_obj).first.id).to eq(1234)
|
|
439
|
+
expect { described_class.request(serial_obj: serial_obj, get_owner_request: true, request_id: 0) }.to raise_error(ArgumentError)
|
|
440
|
+
serial_obj[:written].clear
|
|
441
|
+
result = described_class.request(serial_obj: serial_obj, get_config_request: :SESSIONKEY_CONFIG, wait: false)
|
|
442
|
+
expect(result[:request_id]).to eq(decode_admin(serial_obj).first.id)
|
|
443
|
+
expect(result[:request_id]).to be_positive
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
it 'requires an eight-byte session passkey when explicitly supplied' do
|
|
447
|
+
[nil, '', 'short', 'x' * 9].each do |key|
|
|
448
|
+
expect { described_class.encode(get_owner_request: true, session_passkey: key) }.to raise_error(ArgumentError, /eight bytes/)
|
|
449
|
+
end
|
|
450
|
+
expect(described_class.encode(get_owner_request: true, session_passkey: "\x00" * 8).session_passkey.bytesize).to eq(8)
|
|
451
|
+
end
|
|
81
452
|
|
|
82
453
|
it 'prints usage without raising' do
|
|
83
454
|
expect { described_class.help }.to output(/USAGE/).to_stdout
|
|
@@ -3,4 +3,35 @@
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
5
|
describe Meshtastic::MeshInterface do
|
|
6
|
+
it 'rejects PKI on MQTT instead of falling back to channel encryption' do
|
|
7
|
+
expect do
|
|
8
|
+
described_class.new.send_data(
|
|
9
|
+
data: Meshtastic::Data.new(portnum: :ADMIN_APP, payload: 'request'),
|
|
10
|
+
port_num: Meshtastic::PortNum::ADMIN_APP,
|
|
11
|
+
from: 1, to: 2, via: :mqtt, pki_encrypted: true, public_key: 'k' * 32
|
|
12
|
+
)
|
|
13
|
+
end.to raise_error(ArgumentError, /PKI.*radio/)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'rejects malformed recipient public keys' do
|
|
17
|
+
expect do
|
|
18
|
+
described_class.new.send_data(
|
|
19
|
+
data: Meshtastic::Data.new(payload: 'request'),
|
|
20
|
+
from: 1, to: 2, psks: nil, pki_encrypted: true, public_key: 'short'
|
|
21
|
+
)
|
|
22
|
+
end.to raise_error(ArgumentError, /32 bytes/)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'preserves explicit radio PKI parameters for remote administrative packets' do
|
|
26
|
+
wire = described_class.new.send_data(
|
|
27
|
+
data: Meshtastic::Data.new(portnum: :ADMIN_APP, payload: 'request'),
|
|
28
|
+
port_num: Meshtastic::PortNum::ADMIN_APP,
|
|
29
|
+
from: 1, to: 2, via: :radio, psks: nil,
|
|
30
|
+
pki_encrypted: true, public_key: 'k' * 32
|
|
31
|
+
)
|
|
32
|
+
packet = Meshtastic::ToRadio.decode(wire).packet
|
|
33
|
+
expect(packet.pki_encrypted).to be true
|
|
34
|
+
expect(packet.public_key).to eq('k' * 32)
|
|
35
|
+
expect(packet.decoded.payload).to eq('request')
|
|
36
|
+
end
|
|
6
37
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: meshtastic
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.181
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- 0day Inc.
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 4.0.
|
|
18
|
+
version: 4.0.21
|
|
19
19
|
type: :development
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 4.0.
|
|
25
|
+
version: 4.0.21
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: bundler-audit
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -99,14 +99,14 @@ dependencies:
|
|
|
99
99
|
requirements:
|
|
100
100
|
- - '='
|
|
101
101
|
- !ruby/object:Gem::Version
|
|
102
|
-
version: 1.
|
|
102
|
+
version: 1.84.0
|
|
103
103
|
type: :runtime
|
|
104
104
|
prerelease: false
|
|
105
105
|
version_requirements: !ruby/object:Gem::Requirement
|
|
106
106
|
requirements:
|
|
107
107
|
- - '='
|
|
108
108
|
- !ruby/object:Gem::Version
|
|
109
|
-
version: 1.
|
|
109
|
+
version: 1.84.0
|
|
110
110
|
- !ruby/object:Gem::Dependency
|
|
111
111
|
name: mqtt
|
|
112
112
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -287,6 +287,8 @@ files:
|
|
|
287
287
|
- documentation/README.md
|
|
288
288
|
- documentation/admin-channel.md
|
|
289
289
|
- documentation/admin-config.md
|
|
290
|
+
- documentation/admin-firmware-nordic.md
|
|
291
|
+
- documentation/admin-firmware-serial.md
|
|
290
292
|
- documentation/admin-firmware.md
|
|
291
293
|
- documentation/admin.md
|
|
292
294
|
- documentation/apponly.md
|
|
@@ -324,6 +326,9 @@ files:
|
|
|
324
326
|
- lib/meshtastic/admin/channel.rb
|
|
325
327
|
- lib/meshtastic/admin/config.rb
|
|
326
328
|
- lib/meshtastic/admin/firmware.rb
|
|
329
|
+
- lib/meshtastic/admin/firmware/ble.rb
|
|
330
|
+
- lib/meshtastic/admin/firmware/nordic_dfu.rb
|
|
331
|
+
- lib/meshtastic/admin/firmware/serial_bootloader.rb
|
|
327
332
|
- lib/meshtastic/admin_pb.rb
|
|
328
333
|
- lib/meshtastic/apponly.rb
|
|
329
334
|
- lib/meshtastic/apponly_pb.rb
|
|
@@ -386,6 +391,9 @@ files:
|
|
|
386
391
|
- spec/conventions_spec.rb
|
|
387
392
|
- spec/lib/meshtastic/admin/channel_spec.rb
|
|
388
393
|
- spec/lib/meshtastic/admin/config_spec.rb
|
|
394
|
+
- spec/lib/meshtastic/admin/firmware/ble_spec.rb
|
|
395
|
+
- spec/lib/meshtastic/admin/firmware/nordic_dfu_spec.rb
|
|
396
|
+
- spec/lib/meshtastic/admin/firmware/serial_bootloader_spec.rb
|
|
389
397
|
- spec/lib/meshtastic/admin/firmware_spec.rb
|
|
390
398
|
- spec/lib/meshtastic/admin_pb_spec.rb
|
|
391
399
|
- spec/lib/meshtastic/admin_spec.rb
|
|
@@ -469,7 +477,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
469
477
|
- !ruby/object:Gem::Version
|
|
470
478
|
version: '0'
|
|
471
479
|
requirements: []
|
|
472
|
-
rubygems_version: 4.0.
|
|
480
|
+
rubygems_version: 4.0.21
|
|
473
481
|
specification_version: 4
|
|
474
482
|
summary: Ruby gem for Meshtastic
|
|
475
483
|
test_files: []
|