meshtastic 0.0.179 → 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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/README.md +3 -2
  4. data/documentation/admin-channel.md +64 -0
  5. data/documentation/admin-config.md +59 -0
  6. data/documentation/admin-firmware-nordic.md +86 -0
  7. data/documentation/admin-firmware-serial.md +180 -0
  8. data/documentation/admin-firmware.md +126 -0
  9. data/documentation/admin.md +88 -35
  10. data/documentation/atak.md +93 -10
  11. data/documentation/channel.md +1 -28
  12. data/documentation/config.md +1 -27
  13. data/documentation/mesh-interface.md +7 -0
  14. data/documentation/meshtastic.md +2 -2
  15. data/lib/meshtastic/admin/channel.rb +164 -0
  16. data/lib/meshtastic/admin/config.rb +213 -0
  17. data/lib/meshtastic/admin/firmware/ble.rb +207 -0
  18. data/lib/meshtastic/admin/firmware/nordic_dfu.rb +218 -0
  19. data/lib/meshtastic/admin/firmware/serial_bootloader.rb +233 -0
  20. data/lib/meshtastic/admin/firmware.rb +288 -0
  21. data/lib/meshtastic/admin.rb +688 -34
  22. data/lib/meshtastic/apponly.rb +19 -7
  23. data/lib/meshtastic/atak.rb +295 -17
  24. data/lib/meshtastic/bluetooth/bluez.rb +20 -1
  25. data/lib/meshtastic/bluetooth.rb +70 -59
  26. data/lib/meshtastic/cannedmessages.rb +13 -6
  27. data/lib/meshtastic/channel.rb +3 -25
  28. data/lib/meshtastic/clientonly.rb +19 -7
  29. data/lib/meshtastic/config.rb +3 -24
  30. data/lib/meshtastic/config_pb.rb +2 -1
  31. data/lib/meshtastic/connection_status.rb +12 -5
  32. data/lib/meshtastic/deviceonly.rb +19 -7
  33. data/lib/meshtastic/localonly.rb +19 -7
  34. data/lib/meshtastic/mesh_interface.rb +19 -0
  35. data/lib/meshtastic/module_config.rb +13 -7
  36. data/lib/meshtastic/mqtt.rb +72 -35
  37. data/lib/meshtastic/paxcount.rb +17 -6
  38. data/lib/meshtastic/portnums.rb +13 -6
  39. data/lib/meshtastic/position.rb +16 -6
  40. data/lib/meshtastic/remote_hardware.rb +27 -11
  41. data/lib/meshtastic/rtttl.rb +17 -8
  42. data/lib/meshtastic/serial.rb +81 -70
  43. data/lib/meshtastic/storeforward.rb +13 -6
  44. data/lib/meshtastic/storeforward_pb.rb +1 -1
  45. data/lib/meshtastic/stream_interface.rb +11 -0
  46. data/lib/meshtastic/tcp.rb +73 -22
  47. data/lib/meshtastic/telemetry.rb +15 -6
  48. data/lib/meshtastic/traceroute.rb +15 -6
  49. data/lib/meshtastic/util.rb +3 -1
  50. data/lib/meshtastic/version.rb +1 -1
  51. data/lib/meshtastic/xmodem.rb +12 -5
  52. data/lib/meshtastic.rb +22 -3
  53. data/spec/conventions_spec.rb +321 -0
  54. data/spec/lib/meshtastic/admin/channel_spec.rb +202 -0
  55. data/spec/lib/meshtastic/admin/config_spec.rb +114 -0
  56. data/spec/lib/meshtastic/admin/firmware/ble_spec.rb +170 -0
  57. data/spec/lib/meshtastic/admin/firmware/nordic_dfu_spec.rb +248 -0
  58. data/spec/lib/meshtastic/admin/firmware/serial_bootloader_spec.rb +263 -0
  59. data/spec/lib/meshtastic/admin/firmware_spec.rb +315 -0
  60. data/spec/lib/meshtastic/admin_spec.rb +412 -1
  61. data/spec/lib/meshtastic/apponly_spec.rb +1 -1
  62. data/spec/lib/meshtastic/atak_spec.rb +129 -4
  63. data/spec/lib/meshtastic/bluetooth_spec.rb +1 -1
  64. data/spec/lib/meshtastic/channel_spec.rb +2 -20
  65. data/spec/lib/meshtastic/config_spec.rb +2 -20
  66. data/spec/lib/meshtastic/connection_status_spec.rb +1 -1
  67. data/spec/lib/meshtastic/deviceonly_spec.rb +1 -1
  68. data/spec/lib/meshtastic/localonly_spec.rb +1 -1
  69. data/spec/lib/meshtastic/mesh_interface_spec.rb +31 -0
  70. data/spec/lib/meshtastic/portnums_spec.rb +2 -2
  71. metadata +24 -6
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'spec_helper'
4
4
 
5
- describe Meshtastic::Admin do
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
@@ -39,6 +43,413 @@ describe Meshtastic::Admin do
39
43
  expect(admin.set_owner.short_name).to eq('TN')
40
44
  end
41
45
 
46
+ it 'covers remaining AdminMessage request fields' do
47
+ serial_obj = fake_serial_obj
48
+ examples = {
49
+ get_module_config: %i[get_module_config_request MQTT_CONFIG],
50
+ get_canned_messages: [:get_canned_message_module_messages_request, true],
51
+ get_device_metadata: [:get_device_metadata_request, true],
52
+ get_ringtone: [:get_ringtone_request, true],
53
+ get_device_connection_status: [:get_device_connection_status_request, true],
54
+ get_node_remote_hardware_pins: [:get_node_remote_hardware_pins_request, true],
55
+ get_ui_config: [:get_ui_config_request, true],
56
+ enter_dfu: [:enter_dfu_mode_request, true],
57
+ begin_edit: [:begin_edit_settings, true],
58
+ commit_edit: [:commit_edit_settings, true],
59
+ remove_fixed_position: [:remove_fixed_position, true],
60
+ exit_simulator: [:exit_simulator, true],
61
+ nodedb_reset: [:nodedb_reset, true]
62
+ }
63
+ examples.each do |meth, (field, expected)|
64
+ serial_obj[:written].clear
65
+ described_class.public_send(meth, serial_obj: serial_obj)
66
+ _packet, admin = decode_admin(serial_obj)
67
+ expect(admin.public_send(field)).to eq(expected), meth.to_s
68
+ end
69
+ end
70
+
71
+ it 'sends factory resets, file delete, favorites, and edit transactions' do
72
+ serial_obj = fake_serial_obj
73
+ described_class.factory_reset_config(serial_obj: serial_obj)
74
+ expect(decode_admin(serial_obj).last.factory_reset_config).to eq(1)
75
+ serial_obj[:written].clear
76
+ described_class.factory_reset_device(serial_obj: serial_obj)
77
+ expect(decode_admin(serial_obj).last.factory_reset_device).to eq(1)
78
+ serial_obj[:written].clear
79
+ described_class.delete_file(serial_obj: serial_obj, path: '/prefs.json')
80
+ expect(decode_admin(serial_obj).last.delete_file_request).to eq('/prefs.json')
81
+ serial_obj[:written].clear
82
+ described_class.set_favorite_node(serial_obj: serial_obj, node_num: 0xb0b)
83
+ expect(decode_admin(serial_obj).last.set_favorite_node).to eq(0xb0b)
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
452
+
42
453
  it 'prints usage without raising' do
43
454
  expect { described_class.help }.to output(/USAGE/).to_stdout
44
455
  end
@@ -5,6 +5,6 @@ require 'spec_helper'
5
5
  describe Meshtastic::Apponly do
6
6
  it 'round-trips a ChannelSet' do
7
7
  set = described_class.encode
8
- expect(described_class.decode(set.to_proto)).to be_a(Meshtastic::ChannelSet)
8
+ expect(described_class.decode(bytes: set.to_proto)).to be_a(Meshtastic::ChannelSet)
9
9
  end
10
10
  end
@@ -16,13 +16,138 @@ describe Meshtastic::ATAK do
16
16
  { serial_conn: serial_conn, written: written, my_node_num: 0xb0b }
17
17
  end
18
18
 
19
- it 'sends a GeoChat TAKPacket' do
20
- serial_obj = fake_serial_obj
21
- described_class.send(serial_obj: serial_obj, message: 'ATAK chat')
19
+ def decode_to_radio(serial_obj)
22
20
  frame = serial_obj[:written]
23
- packet = Meshtastic::ToRadio.decode(frame.byteslice(4, (frame.getbyte(2) << 8) + frame.getbyte(3))).packet
21
+ body = frame.byteslice(4, (frame.getbyte(2) << 8) + frame.getbyte(3))
22
+ Meshtastic::ToRadio.decode(body).packet
23
+ end
24
+
25
+ it 'encodes V1 GeoChat with contact, group, and status' do
26
+ packet = described_class.encode_v1(
27
+ message: 'ATAK chat',
28
+ to: 'ANDROID-aabbccdd',
29
+ callsign: 'ALPHA',
30
+ device_callsign: 'RADIO-1',
31
+ team: :Cyan,
32
+ role: :TeamMember,
33
+ battery: 87
34
+ )
35
+ expect(packet).to be_a(Meshtastic::TAKPacket)
36
+ expect(packet.chat.message).to eq('ATAK chat')
37
+ expect(packet.chat.to).to eq('ANDROID-aabbccdd')
38
+ expect(packet.contact.callsign).to eq('ALPHA')
39
+ expect(packet.group.team).to eq(:Cyan)
40
+ expect(packet.group.role).to eq(:TeamMember)
41
+ expect(packet.status.battery).to eq(87)
42
+ end
43
+
44
+ it 'encodes V1 PLI with 1e7-scaled coordinates' do
45
+ packet = described_class.encode_v1(
46
+ lat: 37.7749,
47
+ lon: -122.4194,
48
+ altitude: 12,
49
+ speed: 3,
50
+ course: 90
51
+ )
52
+ expect(packet.pli.latitude_i).to eq(377_749_000)
53
+ expect(packet.pli.longitude_i).to eq(-1_224_194_000)
54
+ expect(packet.pli.altitude).to eq(12)
55
+ expect(packet.pli.speed).to eq(3)
56
+ expect(packet.pli.course).to eq(90)
57
+ end
58
+
59
+ it 'sends V1 GeoChat on ATAK_PLUGIN' do
60
+ serial_obj = fake_serial_obj
61
+ described_class.send_chat(serial_obj: serial_obj, message: 'ATAK chat')
62
+ packet = decode_to_radio(serial_obj)
24
63
  expect(packet.decoded.portnum).to eq(:ATAK_PLUGIN)
25
64
  tak = Meshtastic::TAKPacket.decode(packet.decoded.payload)
26
65
  expect(tak.chat.message).to eq('ATAK chat')
27
66
  end
67
+
68
+ it 'sends V1 PLI on ATAK_PLUGIN' do
69
+ serial_obj = fake_serial_obj
70
+ described_class.send_pli(serial_obj: serial_obj, lat: 37.7749, lon: -122.4194)
71
+ packet = decode_to_radio(serial_obj)
72
+ expect(packet.decoded.portnum).to eq(:ATAK_PLUGIN)
73
+ tak = Meshtastic::TAKPacket.decode(packet.decoded.payload)
74
+ expect(tak.pli.latitude_i).to eq(377_749_000)
75
+ end
76
+
77
+ it 'encodes uncompressed V2 GeoChat wire frames with flags 0xFF' do
78
+ wire = described_class.encode_v2(
79
+ callsign: 'ALPHA',
80
+ team: :Cyan,
81
+ role: :TeamMember,
82
+ lat: 37.7749,
83
+ lon: -122.4194,
84
+ message: 'v2 chat'
85
+ )
86
+ expect(wire.getbyte(0)).to eq(0xFF)
87
+ v2 = Meshtastic::TAKPacketV2.decode(wire.byteslice(1..))
88
+ expect(v2.chat.message).to eq('v2 chat')
89
+ expect(v2.callsign).to eq('ALPHA')
90
+ expect(v2.latitude_i).to eq(377_749_000)
91
+ end
92
+
93
+ it 'round-trips V2 typed payloads' do
94
+ {
95
+ aircraft: Meshtastic::AircraftTrack.new(icao: 'ABC123', flight: 'N1'),
96
+ shape: Meshtastic::DrawnShape.new(kind: :Kind_Circle, major_cm: 1000),
97
+ marker: Meshtastic::Marker.new(kind: :Kind_Spot),
98
+ route: Meshtastic::Route.new(prefix: 'R1'),
99
+ casevac: Meshtastic::CasevacReport.new(title: 'CASEVAC'),
100
+ emergency: Meshtastic::EmergencyAlert.new(type: :Type_Alert911),
101
+ task: Meshtastic::TaskRequest.new(task_type: 'recon', note: 'look'),
102
+ taktalk: Meshtastic::TakTalkMessage.new(text: 'voice', chatroom_id: 'room1'),
103
+ taktalk_room: Meshtastic::TakTalkRoomData.new(room_id: 'room1', room_name: 'Ops'),
104
+ rab: Meshtastic::RangeAndBearing.new(range_cm: 500)
105
+ }.each do |field, value|
106
+ v2 = described_class.build_v2(field => value, callsign: 'ALPHA')
107
+ expect(v2.public_send(field)).to eq(value)
108
+ decoded = described_class.decode_v2(wire: described_class.wrap_v2(packet: v2))
109
+ expect(decoded.public_send(field).to_h).to eq(value.to_h)
110
+ end
111
+ end
112
+
113
+ it 'sends V2 packets on ATAK_PLUGIN_V2' do
114
+ serial_obj = fake_serial_obj
115
+ described_class.send_v2(serial_obj: serial_obj, message: 'v2 chat', callsign: 'ALPHA')
116
+ packet = decode_to_radio(serial_obj)
117
+ expect(packet.decoded.portnum).to eq(:ATAK_PLUGIN_V2)
118
+ decoded = described_class.decode_v2(payload: packet.decoded.payload)
119
+ expect(decoded.chat.message).to eq('v2 chat')
120
+ end
121
+
122
+ it 'zlib-compresses CoT XML on ATAK_FORWARDER' do
123
+ serial_obj = fake_serial_obj
124
+ cot = '<event type="b-m-p-s-m" uid="marker-1"><point lat="37.77" lon="-122.41"/></event>'
125
+ described_class.send_cot(serial_obj: serial_obj, cot: cot)
126
+ packet = decode_to_radio(serial_obj)
127
+ expect(packet.decoded.portnum).to eq(:ATAK_FORWARDER)
128
+ xml = described_class.decompress_cot(payload: packet.decoded.payload)
129
+ expect(xml).to include('marker-1')
130
+ end
131
+
132
+ it 'decodes inbound payloads by portnum' do
133
+ v1 = described_class.encode_v1(message: 'hi')
134
+ expect(described_class.decode(payload: v1.to_proto, portnum: :ATAK_PLUGIN).chat.message).to eq('hi')
135
+
136
+ v2 = described_class.encode_v2(message: 'v2')
137
+ expect(described_class.decode(payload: v2, portnum: :ATAK_PLUGIN_V2).chat.message).to eq('v2')
138
+
139
+ cot = described_class.compress_cot(cot: '<event uid="x"/>')
140
+ expect(described_class.decode(payload: cot, portnum: :ATAK_FORWARDER)).to include('uid="x"')
141
+ end
142
+
143
+ it 'keeps send as a V1 GeoChat alias' do
144
+ serial_obj = fake_serial_obj
145
+ described_class.send(serial_obj: serial_obj, message: 'ATAK chat')
146
+ packet = decode_to_radio(serial_obj)
147
+ expect(packet.decoded.portnum).to eq(:ATAK_PLUGIN)
148
+ end
149
+
150
+ it 'prints usage without raising' do
151
+ expect { described_class.help }.to output(/ATAK_PLUGIN_V2/).to_stdout
152
+ end
28
153
  end
@@ -136,7 +136,7 @@ describe Meshtastic::Bluetooth do
136
136
 
137
137
  it 'scans with the selected adapter and bounded discovery timeout' do
138
138
  devices = [{ address: 'AA:BB:CC:DD:EE:FF', name: 'Meshtastic_test', paired: true }]
139
- expect(described_class::BlueZ).to receive(:scan).with(adapter: 'hci1', timeout: 2).and_return(devices)
139
+ expect(described_class::BlueZ).to receive(:scan).with({ adapter: 'hci1', timeout: 2 }).and_return(devices)
140
140
  expect(described_class.scan(adapter: 'hci1', timeout: 2)).to eq(devices)
141
141
  end
142
142
 
@@ -3,25 +3,7 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  describe Meshtastic::Channel do
6
- def fake_serial_obj
7
- written = +''.b
8
- serial_conn = Object.new
9
- serial_conn.define_singleton_method(:write) do |b|
10
- written << b
11
- b.bytesize
12
- end
13
- serial_conn.define_singleton_method(:flush) { true }
14
- serial_conn.define_singleton_method(:closed?) { false }
15
- serial_conn.define_singleton_method(:close) { true }
16
- { serial_conn: serial_conn, written: written, my_node_num: 0xb0b }
17
- end
18
-
19
- it 'requests a channel via Admin' do
20
- serial_obj = fake_serial_obj
21
- described_class.get(serial_obj: serial_obj, index: 1)
22
- frame = serial_obj[:written]
23
- packet = Meshtastic::ToRadio.decode(frame.byteslice(4, (frame.getbyte(2) << 8) + frame.getbyte(3))).packet
24
- admin = Meshtastic::AdminMessage.decode(packet.decoded.payload)
25
- expect(admin.get_channel_request).to eq(1)
6
+ it 'is the generated Channel protobuf' do
7
+ expect(described_class.new).to be_a(Google::Protobuf::MessageExts)
26
8
  end
27
9
  end
@@ -3,25 +3,7 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  describe Meshtastic::Config do
6
- def fake_serial_obj
7
- written = +''.b
8
- serial_conn = Object.new
9
- serial_conn.define_singleton_method(:write) do |b|
10
- written << b
11
- b.bytesize
12
- end
13
- serial_conn.define_singleton_method(:flush) { true }
14
- serial_conn.define_singleton_method(:closed?) { false }
15
- serial_conn.define_singleton_method(:close) { true }
16
- { serial_conn: serial_conn, written: written, my_node_num: 0xb0b }
17
- end
18
-
19
- it 'requests LoRa config via Admin' do
20
- serial_obj = fake_serial_obj
21
- described_class.get(serial_obj: serial_obj, config_type: :LORA_CONFIG)
22
- frame = serial_obj[:written]
23
- packet = Meshtastic::ToRadio.decode(frame.byteslice(4, (frame.getbyte(2) << 8) + frame.getbyte(3))).packet
24
- admin = Meshtastic::AdminMessage.decode(packet.decoded.payload)
25
- expect(admin.get_config_request).to eq(:LORA_CONFIG)
6
+ it 'is the generated Config protobuf' do
7
+ expect(described_class.new).to be_a(Google::Protobuf::MessageExts)
26
8
  end
27
9
  end
@@ -4,6 +4,6 @@ require 'spec_helper'
4
4
 
5
5
  describe Meshtastic::ConnectionStatus do
6
6
  it 'decodes DeviceConnectionStatus bytes' do
7
- expect(described_class.decode(Meshtastic::DeviceConnectionStatus.new.to_proto)).to be_a(Meshtastic::DeviceConnectionStatus)
7
+ expect(described_class.decode(bytes: Meshtastic::DeviceConnectionStatus.new.to_proto)).to be_a(Meshtastic::DeviceConnectionStatus)
8
8
  end
9
9
  end
@@ -4,6 +4,6 @@ require 'spec_helper'
4
4
 
5
5
  describe Meshtastic::Deviceonly do
6
6
  it 'decodes an empty DeviceState' do
7
- expect(described_class.decode_state(Meshtastic::DeviceState.new.to_proto)).to be_a(Meshtastic::DeviceState)
7
+ expect(described_class.decode_state(bytes: Meshtastic::DeviceState.new.to_proto)).to be_a(Meshtastic::DeviceState)
8
8
  end
9
9
  end