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
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'socket'
5
+ require 'digest'
6
+ require 'tempfile'
7
+
8
+ RSpec.describe Meshtastic::Admin::Firmware do
9
+ it 'dispatches explicitly to independent native bootloader protocols' do
10
+ %i[esp_rom nordic_dfu].each do |protocol|
11
+ name = protocol == :esp_rom ? :SerialBootloader : :NordicDFU
12
+ implementation = described_class.const_get(name)
13
+ options = { protocol: protocol, bytes: 'abc' }
14
+ expect(implementation).to receive(:install).with(options).and_return(status: :verified)
15
+ expect(described_class.install(options)).to eq(status: :verified)
16
+ end
17
+ end
18
+
19
+ it 'validates reboot verification before upload and only verifies after loader success' do
20
+ backend = double('BLE backend')
21
+ verify = { transport: :tcp, connection: { host: '192.0.2.1' }, expected_version: '2.7.1' }
22
+ options = { protocol: :unified_ble, backend: backend, bytes: 'abc' }
23
+ expect(described_class::BLE).to receive(:install).with(options).ordered.and_return(status: :verified, bytes: 3, reboot_verified: false, boot_verified: false)
24
+ expect(described_class).to receive(:verify_reboot).with(verify).ordered.and_return(status: :boot_verified, firmware_version: '2.7.1')
25
+ expect(described_class.install(options.merge(verify: verify))).to include(status: :boot_verified, loader_status: :verified, bytes: 3, reboot_verified: true, boot_verified: true)
26
+ end
27
+
28
+ it 'rejects invalid verification settings before any destructive transfer' do
29
+ base = { transport: :tcp, connection: { host: '192.0.2.1' }, expected_version: '2.7.1' }
30
+ invalid = [true, {}, base.merge(timeout: 0), base.merge(timeout: Float::INFINITY),
31
+ base.merge(reboot_delay: -1), base.merge(expected_version: ''), base.merge(transport: :mqtt),
32
+ base.merge(connection: {}), base.merge(reconnect: true), base.merge(expected_node: '!bad'),
33
+ base.merge(connection: { host: '192.0.2.1', socket: Object.new }),
34
+ base.merge(connection: { host: '192.0.2.1', port: 0 }),
35
+ base.merge(transport: :bluetooth, connection: { address: 'not-a-mac' })]
36
+ invalid.each do |verify|
37
+ expect(described_class::BLE).not_to receive(:install)
38
+ expect { described_class.install(protocol: :unified_ble, bytes: 'abc', verify: verify) }.to raise_error(ArgumentError)
39
+ end
40
+ end
41
+
42
+ it 'ignores cached metadata and checks a fresh correlated Admin reply after callback reconnect' do
43
+ queue = Queue.new
44
+ writer = Object.new
45
+ writer.define_singleton_method(:write) do |bytes|
46
+ request = Meshtastic::ToRadio.decode(bytes.byteslice(4..)).packet
47
+ payload = Meshtastic::AdminMessage.new(get_device_metadata_response: Meshtastic::DeviceMetadata.new(firmware_version: 'wrong')).to_proto
48
+ queue << Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: 123, decoded: Meshtastic::Data.new(portnum: :ADMIN_APP, request_id: request.id, payload: payload)))
49
+ bytes.bytesize
50
+ end
51
+ writer.define_singleton_method(:flush) { true }
52
+ handle = { serial_conn: writer, from_radio_queue: queue, my_node_num: 123, metadata: { firmware_version: 'expected' } }
53
+ attempts = 0
54
+ reconnect = lambda do |_options|
55
+ attempts += 1
56
+ raise Errno::ECONNREFUSED if attempts == 1
57
+
58
+ handle
59
+ end
60
+ expect(Meshtastic::Serial).to receive(:wait_for_config).with(serial_obj: handle, timeout: 1).and_return(handle)
61
+ expect(Meshtastic::Serial).to receive(:disconnect).with(serial_obj: handle)
62
+ expect do
63
+ described_class.verify_reboot(transport: :serial, reconnect: reconnect, expected_version: 'expected', reboot_delay: 0, timeout: 1)
64
+ end.to raise_error(IOError, /version mismatch/)
65
+ end
66
+
67
+ it 'verifies fresh post-reboot metadata over a new production TCP PhoneAPI connection' do
68
+ server = TCPServer.new('127.0.0.1', 0)
69
+ worker = Thread.new do
70
+ socket = server.accept
71
+ buffer = +''.b
72
+ loop do
73
+ buffer << socket.read(1)
74
+ next unless buffer.end_with?("\x94\xC3".b)
75
+
76
+ length = socket.read(2).unpack1('n')
77
+ request = Meshtastic::ToRadio.decode(socket.read(length))
78
+ replies = if request.want_config_id.positive?
79
+ [Meshtastic::FromRadio.new(my_info: Meshtastic::MyNodeInfo.new(my_node_num: 123)),
80
+ Meshtastic::FromRadio.new(metadata: Meshtastic::DeviceMetadata.new(firmware_version: 'STALE')),
81
+ Meshtastic::FromRadio.new(config_complete_id: request.want_config_id)]
82
+ elsif request.packet
83
+ admin = Meshtastic::AdminMessage.decode(request.packet.decoded.payload)
84
+ expect(admin.get_device_metadata_request).to be true
85
+ payload = Meshtastic::AdminMessage.new(get_device_metadata_response: Meshtastic::DeviceMetadata.new(firmware_version: '2.7.1', hw_model: :HELTEC_V3)).to_proto
86
+ [Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: 123, decoded: Meshtastic::Data.new(portnum: :ADMIN_APP, request_id: request.packet.id, payload: payload)))]
87
+ else
88
+ []
89
+ end
90
+ replies.each do |reply|
91
+ bytes = reply.to_proto
92
+ socket.write("\x94\xC3".b + [bytes.bytesize].pack('n') + bytes)
93
+ end
94
+ break if request.packet
95
+ end
96
+ ensure
97
+ socket&.close
98
+ end
99
+ worker.report_on_exception = false
100
+ result = described_class.verify_reboot(transport: :tcp, connection: { host: '127.0.0.1', port: server.addr[1] }, expected_version: '2.7.1', expected_node: 123, reboot_delay: 0, timeout: 2)
101
+ expect(result).to include(status: :boot_verified, firmware_version: '2.7.1', node_num: 123)
102
+ worker.value
103
+ ensure
104
+ worker&.kill
105
+ worker&.join
106
+ server&.close
107
+ end
108
+ end
109
+
110
+ RSpec.shared_context 'a unified OTA TCP loader' do
111
+ def with_loader(options = {}, &handler)
112
+ server = TCPServer.new('127.0.0.1', 0)
113
+ worker = Thread.new do
114
+ client = server.accept
115
+ handler.call(client)
116
+ ensure
117
+ client&.close
118
+ end
119
+ worker.report_on_exception = false
120
+ yield_options = { protocol: :unified_wifi, host: '127.0.0.1', port: server.addr[1], bytes: 'abc', timeout: 0.3 }
121
+ result = described_class.install(yield_options.merge(options))
122
+ raise 'loader handshake did not finish' unless worker.join(1)
123
+
124
+ worker.value
125
+ result
126
+ ensure
127
+ worker&.kill
128
+ worker&.join
129
+ server&.close
130
+ end
131
+ end
132
+
133
+ describe Meshtastic::Admin::Firmware do
134
+ include_context 'a unified OTA TCP loader'
135
+
136
+ it 'performs the unified WiFi handshake and waits for final verified OK' do
137
+ result = with_loader do |client|
138
+ expect(client.gets).to eq("VERSION\n")
139
+ client.write("OK 1 2.7.0 3 v1.0\n")
140
+ expect(client.gets).to eq("OTA 3 #{Digest::SHA256.hexdigest('abc')}\n")
141
+ client.write("ERASING\nOK\n")
142
+ expect(client.read(3)).to eq('abc')
143
+ client.write("ACK\nOK\n")
144
+ end
145
+ expect(result).to include(status: :verified, bytes: 3, sha256: Digest::SHA256.hexdigest('abc'))
146
+ end
147
+
148
+ it 'retries refused loader connections before issuing the OTA command' do
149
+ attempts = 0
150
+ allow(Socket).to receive(:tcp).and_wrap_original do |original, *args, **keywords|
151
+ attempts += 1
152
+ raise Errno::ECONNREFUSED if attempts == 1
153
+
154
+ original.call(*args, **keywords)
155
+ end
156
+ result = with_loader do |client|
157
+ expect(client.gets).to eq("VERSION\n")
158
+ client.write("OK 1 2.7.0 3 v1.0\n")
159
+ client.gets
160
+ client.write("OK\n")
161
+ client.read(3)
162
+ client.write("OK\n")
163
+ end
164
+ expect(result[:status]).to eq(:verified)
165
+ expect(attempts).to eq(2)
166
+ end
167
+
168
+ it 'validates all install options before connecting or reading files' do
169
+ defaults = { protocol: :unified_wifi, host: '127.0.0.1', bytes: 'abc' }
170
+ [{ bytes: '' }, { bytes: 123 }, { bytes: nil }, { firmware: '/missing', bytes: 'abc' },
171
+ { host: '' }, { port: 0 }, { timeout: 0 }, { timeout: Float::INFINITY },
172
+ { retries: -1 }, { retry_delay: -1 }, { mode: :OTA_BLE }, { tcp_obj: Object.new },
173
+ { to: '!aabbccdd' }, { protocol: :unified_wifi, bluetooth_obj: Object.new }].each do |invalid|
174
+ expect(Socket).not_to receive(:tcp)
175
+ expect { described_class.install(defaults.merge(invalid)) }.to raise_error(ArgumentError)
176
+ end
177
+ end
178
+
179
+ it 'rejects invalid OTA modes and inconsistent or nonbinary supplied hashes before sending' do
180
+ [{ mode: :UNKNOWN, bytes: 'abc' }, { ota_hash: Object.new },
181
+ { ota_hash: 'a' * 32, bytes: 'abc' }, { ota_hash: 'a' * 64 }].each do |invalid|
182
+ expect(Meshtastic::Admin).not_to receive(:send)
183
+ expect { described_class.request_ota(invalid) }.to raise_error(ArgumentError)
184
+ end
185
+ end
186
+ end
187
+
188
+ describe Meshtastic::Admin::Firmware do
189
+ include_context 'a unified OTA TCP loader'
190
+
191
+ it 'hashes files and sends the real OTA and DFU Admin protobuf fields' do
192
+ written = +''.b
193
+ connection = Object.new
194
+ connection.define_singleton_method(:write) do |data|
195
+ written << data
196
+ data.bytesize
197
+ end
198
+ connection.define_singleton_method(:flush) { true }
199
+ serial = { serial_conn: connection, my_node_num: 0xb0b }
200
+ described_class.request_ota(serial_obj: serial, bytes: 'abc', mode: :OTA_WIFI)
201
+ length = written.byteslice(2, 2).unpack1('n')
202
+ packet = Meshtastic::ToRadio.decode(written.byteslice(4, length)).packet
203
+ admin = Meshtastic::AdminMessage.decode(packet.decoded.payload)
204
+ expect(packet.decoded.portnum).to eq(:ADMIN_APP)
205
+ expect(admin.ota_request.reboot_ota_mode).to eq(:OTA_WIFI)
206
+ expect(admin.ota_request.ota_hash).to eq(Digest::SHA256.digest('abc'))
207
+ written.clear
208
+ described_class.enter_dfu(serial_obj: serial)
209
+ length = written.byteslice(2, 2).unpack1('n')
210
+ packet = Meshtastic::ToRadio.decode(written.byteslice(4, length)).packet
211
+ expect(Meshtastic::AdminMessage.decode(packet.decoded.payload).enter_dfu_mode_request).to be true
212
+ Tempfile.create('firmware') do |file|
213
+ file.write('abc')
214
+ file.flush
215
+ expect(described_class.sha256(firmware: file.path)).to eq(Digest::SHA256.digest('abc'))
216
+ end
217
+ end
218
+
219
+ it 'rejects malformed VERSION responses before sending OTA' do
220
+ ["ERR Unknown Command\n", "OK\n", 'x' * 513, "OK 1 fw 2 loader"].each do |reply|
221
+ expect do
222
+ with_loader do |client|
223
+ client.gets
224
+ client.write(reply)
225
+ end
226
+ end.to raise_error(IOError)
227
+ end
228
+ end
229
+
230
+ it 'surfaces loader handshake and final integrity errors without retrying OTA' do
231
+ [false, true].each do |after_upload|
232
+ expect do
233
+ with_loader do |client|
234
+ client.gets
235
+ client.write("OK 1 fw 2 loader\n")
236
+ client.gets
237
+ if after_upload
238
+ client.write("OK\n")
239
+ client.read(3)
240
+ end
241
+ client.write("ERR Hash Mismatch\n")
242
+ end
243
+ end.to raise_error(IOError, /ERR Hash Mismatch/)
244
+ end
245
+ end
246
+
247
+ it 'does not treat disconnect or ACK as final verification' do
248
+ expect do
249
+ with_loader do |client|
250
+ client.gets
251
+ client.write("OK 1 fw 2 loader\n")
252
+ client.gets
253
+ client.write("OK\n")
254
+ client.read(3)
255
+ client.write("ACK\n")
256
+ end
257
+ end.to raise_error(IOError, /closed before confirmation/)
258
+ end
259
+
260
+ it 'bounds a silent loader handshake and closes its connection' do
261
+ expect do
262
+ with_loader do |client|
263
+ client.gets
264
+ expect(client.read).to eq('')
265
+ end
266
+ end.to raise_error(Timeout::Error)
267
+ end
268
+
269
+ it 'exhausts bounded connection retries without sending any admin commands' do
270
+ expect(Meshtastic::Admin).not_to receive(:send)
271
+ expect(Socket).to receive(:tcp).exactly(3).times.and_raise(Errno::ECONNREFUSED)
272
+ expect do
273
+ described_class.install(protocol: :unified_wifi, host: '127.0.0.1', bytes: 'abc', retries: 2, retry_delay: 0)
274
+ end.to raise_error(Errno::ECONNREFUSED)
275
+ end
276
+
277
+ it 'drains TCP ACKs during upload rather than deadlocking on backpressure' do
278
+ allow(Socket).to receive(:tcp).and_wrap_original do |original, *args, **keywords|
279
+ socket = original.call(*args, **keywords)
280
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, 1024)
281
+ socket
282
+ end
283
+ bytes = 'a' * 1_048_576
284
+ result = with_loader(bytes: bytes, timeout: 5) do |client|
285
+ client.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, 1024)
286
+ client.gets
287
+ client.write("OK 1 fw 2 loader\n")
288
+ client.gets
289
+ client.write("OK\n")
290
+ received = +''
291
+ while received.bytesize < bytes.bytesize
292
+ received << client.read(1024)
293
+ client.write("ACK\n" * 1024)
294
+ end
295
+ expect(received).to eq(bytes)
296
+ client.write("OK\n")
297
+ end
298
+ expect(result[:bytes]).to eq(bytes.bytesize)
299
+ end
300
+
301
+ it 'rejects obsolete firmware XModem helpers and unhandled legacy OTA reboot' do
302
+ %i[xmodem_blocks send_xmodem reboot_ota].each do |method|
303
+ expect { described_class.public_send(method, bytes: 'abc') }
304
+ .to raise_error(NotImplementedError)
305
+ end
306
+ end
307
+
308
+ it 'rejects PhoneAPI and MQTT installation without sending any commands' do
309
+ %i[serial_obj tcp_obj bluetooth_obj mqtt_obj].each do |transport|
310
+ expect(Meshtastic::Admin).not_to receive(:send)
311
+ expect { described_class.install(transport => Object.new, bytes: 'abc') }
312
+ .to raise_error(NotImplementedError, /unified_wifi/)
313
+ end
314
+ end
315
+ end