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.
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'pty'
5
+ require 'digest'
6
+ require 'timeout'
7
+ require 'tempfile'
8
+ require 'meshtastic/admin/firmware/serial_bootloader'
9
+
10
+ RSpec.describe Meshtastic::Admin::Firmware::SerialBootloader do # rubocop:disable Metrics/BlockLength
11
+ def image(chip_id = 0)
12
+ header = [0xe9, 1, 2, 0x20, 0x40000000].pack('C4V')
13
+ header << [0xee, 0, 0, 0, chip_id, 0, 0, 0].pack('C4vCvv') << "\0\0\0\0\1".b
14
+ data = ("\xc0\xdb".b * 600)
15
+ body = header + [0x3ffb0000, data.bytesize].pack('V2') + data
16
+ body << "\0" until body.bytesize % 16 == 15
17
+ body << [data.bytes.reduce(0xef, :^)].pack('C')
18
+ body + Digest::SHA256.digest(body)
19
+ end
20
+
21
+ def frame(packet)
22
+ "\xc0".b + packet.bytes.map { |b|
23
+ if b == 0xc0
24
+ "\xdb\xdc".b
25
+ else
26
+ b == 0xdb ? "\xdb\xdd".b : b.chr
27
+ end
28
+ }.join.b + "\xc0".b
29
+ end
30
+
31
+ def receive_packet(io)
32
+ bytes = +''.b
33
+ started = false
34
+ loop do
35
+ b = io.readpartial(1).getbyte(0)
36
+ if b == 0xc0
37
+ return bytes.gsub("\xdb\xdc".b, "\xc0".b).gsub("\xdb\xdd".b, "\xdb".b) if started && !bytes.empty?
38
+
39
+ started = true
40
+ elsif started
41
+ bytes << b
42
+ end
43
+ end
44
+ end
45
+
46
+ def emulate(bytes:, chip_id: 0, failure: nil)
47
+ PTY.open do |master, slave|
48
+ commands = []
49
+ flash = +''.b
50
+ worker = Thread.new do
51
+ Thread.current.report_on_exception = false
52
+ loop do
53
+ packet = receive_packet(master)
54
+ direction, op, length, checksum = packet.unpack('CCvV')
55
+ payload = packet.byteslice(8..)
56
+ raise 'bad request framing' unless direction.zero? && length == payload.bytesize
57
+
58
+ commands << op
59
+ value = 0
60
+ response = +''.b
61
+ case op
62
+ when 8
63
+ raise 'bad sync' unless payload == "\x07\x07\x12\x20".b + ("\x55" * 32)
64
+
65
+ value = failure == :stub ? 0 : 0x20100707
66
+ next if failure == :sync_once && commands.count(8) == 1
67
+ when 10
68
+ address = payload.unpack1('V')
69
+ value = address == 0x40001000 ? 0x00f01d83 : 0
70
+ value = 0 if failure == :wrong_chip
71
+ value = 0x30 if failure == :secure && address == 0x3ff5a018
72
+ when 0x14
73
+ response = ([failure == :secure ? 1 : 0] + ([0] * 8) + [failure == :wrong_chip ? 99 : chip_id, 0]).pack('VC8V2')
74
+ when 0x0d
75
+ raise 'bad attach' unless payload == [0, 0].pack('V2')
76
+ when 0x0b
77
+ raise 'bad geometry' unless payload == [0, 4 * 1024 * 1024, 65_536, 4096, 256, 65_535].pack('V6')
78
+ when 2
79
+ params = payload.unpack('V*')
80
+ expected = [bytes.bytesize, (bytes.bytesize + 1023) / 1024, 1024, 0x10000]
81
+ expected << 0 unless chip_id.zero?
82
+ raise "bad begin #{params}" unless params == expected
83
+ when 3
84
+ size, sequence, reserved1, reserved2 = payload.unpack('V4')
85
+ block = payload.byteslice(16..)
86
+ raise 'bad block' unless size == 1024 && block.bytesize == size && sequence * 1024 == flash.bytesize && reserved1.zero? && reserved2.zero?
87
+ raise 'bad checksum' unless checksum == block.bytes.reduce(0xef, :^)
88
+
89
+ flash << block
90
+ when 0x13
91
+ raise 'bad verify range' unless payload == [0x10000, bytes.bytesize, 0, 0].pack('V4')
92
+ raise 'different flashed bytes' unless flash.byteslice(0, bytes.bytesize) == bytes
93
+
94
+ response = failure == :digest ? '0' * 32 : Digest::MD5.hexdigest(flash.byteslice(0, bytes.bytesize))
95
+ when 4
96
+ raise 'bad end' unless payload == [0].pack('V')
97
+ else
98
+ raise "unexpected opcode #{op}"
99
+ end
100
+ status = failure == op ? [1, 5, 0, 0].pack('C4') : "\0" * 4
101
+ response += status
102
+ reply = frame([1, op, response.bytesize, value].pack('CCvV') + response)
103
+ if op == 3
104
+ next if failure == :silent
105
+
106
+ reply = "\xc0\xdb\x01\xc0".b if failure == :bad_escape
107
+ reply = frame([1, 99, 4, 0].pack('CCvV') + ("\0" * 4)) if failure == :wrong_opcode
108
+ reply = frame([1, op, 99, 0].pack('CCvV') + ("\0" * 4)) if failure == :bad_length
109
+ reply = frame('short') if failure == :short
110
+ end
111
+ (op == 8 ? 8 : 1).times { master.write(reply) }
112
+ break if op == 4 || failure == op || (op == 0x13 && failure == :digest)
113
+ end
114
+ rescue EOFError, Errno::EIO
115
+ nil
116
+ end
117
+ begin
118
+ yield slave.path, commands
119
+ ensure
120
+ worker.kill
121
+ worker.join
122
+ worker.value
123
+ end
124
+ end
125
+ end
126
+
127
+ let(:installer) { Meshtastic::Admin::Firmware::SerialBootloader }
128
+ let(:bytes) { image }
129
+
130
+ it 'rejects invalid chip, image, flash geometry and options before opening any port' do
131
+ base = { port: '/dev/never-open', chip: :esp32, bytes: bytes, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none }
132
+ corrupt = bytes.dup
133
+ corrupt.setbyte(35, corrupt.getbyte(35) ^ 1)
134
+ corrupt_hash = bytes.dup
135
+ corrupt_hash.setbyte(-1, corrupt_hash.getbyte(-1) ^ 1)
136
+ invalid = [
137
+ { chip: :esp8266 }, { bytes: 'bad' }, { bytes: image(9) }, { bytes: corrupt },
138
+ { bytes: corrupt_hash },
139
+ { bytes: "#{bytes}junk" }, { bytes: bytes.byteslice(0, 35) },
140
+ { offset: -1 }, { offset: 0x10001 }, { offset: 0 }, { offset: 4 * 1024 * 1024 },
141
+ { flash_size: 123 }, { timeout: 0 }, { timeout: Float::INFINITY },
142
+ { reset: :magic }, { protocol: :nordic_serial }, { firmware: '/tmp/ambiguous' },
143
+ { surprise: true }, { port: '' }
144
+ ]
145
+ expect(UART).not_to receive(:open)
146
+ invalid.each do |change|
147
+ expect { installer.install(base.merge(change)) }.to raise_error(ArgumentError), change.inspect
148
+ end
149
+ end
150
+
151
+ { esp32s3: 9, esp32c3: 5 }.each do |chip, id|
152
+ it "identifies #{chip} with GET_SECURITY_INFO and uses extended FLASH_BEGIN" do
153
+ firmware = image(id)
154
+ emulate(bytes: firmware, chip_id: id) do |port, commands|
155
+ result = installer.install(port: port, chip: chip, bytes: firmware, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none)
156
+ expect(result[:chip]).to eq(chip)
157
+ expect(commands).to eq([8, 20, 13, 11, 2, 3, 3, 19, 4])
158
+ end
159
+ end
160
+ end
161
+
162
+ it 'retries only synchronization when the ROM misses its first request' do
163
+ emulate(bytes: bytes, failure: :sync_once) do |port, commands|
164
+ result = installer.install(port: port, chip: :esp32, bytes: bytes, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none, timeout: 0.1)
165
+ expect(result[:status]).to eq(:verified)
166
+ expect(commands.count(8)).to eq(2)
167
+ expect(commands.count(2)).to eq(1)
168
+ end
169
+ end
170
+
171
+ it 'rejects an already running flasher stub before any flash command' do
172
+ emulate(bytes: bytes, failure: :stub) do |port, commands|
173
+ expect { installer.install(port: port, chip: :esp32, bytes: bytes, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none) }.to raise_error(IOError, /stub/)
174
+ expect(commands).to eq([8])
175
+ end
176
+ end
177
+
178
+ [3, :digest, :bad_escape, :wrong_opcode, :bad_length, :short, :silent].each do |failure|
179
+ it "fails closed on #{failure} without reboot or replaying FLASH_DATA and closes its UART" do
180
+ opened = nil
181
+ allow(UART).to(receive(:open).and_wrap_original { |original, *args| opened = original.call(*args) })
182
+ emulate(bytes: bytes, failure: failure) do |port, commands|
183
+ error = failure == :silent ? Timeout::Error : IOError
184
+ expect { installer.install(port: port, chip: :esp32, bytes: bytes, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none, timeout: 0.1) }.to raise_error(error)
185
+ expect(commands).not_to include(4)
186
+ expect(commands.count(3)).to eq(failure == :digest ? 2 : 1)
187
+ end
188
+ expect(opened).to be_closed
189
+ end
190
+ end
191
+
192
+ { esp32: 0, esp32s3: 9, esp32c3: 5 }.each do |chip, id|
193
+ %i[secure wrong_chip].each do |failure|
194
+ it "rejects #{failure} on #{chip} before erasing any flash" do
195
+ firmware = image(id)
196
+ emulate(bytes: firmware, chip_id: id, failure: failure) do |port, commands|
197
+ expect { installer.install(port: port, chip: chip, bytes: firmware, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none) }.to raise_error(IOError)
198
+ expect(commands).not_to include(2, 3)
199
+ end
200
+ end
201
+ end
202
+ end
203
+
204
+ it 'reads an image file and accepts the ESP format without an appended digest' do
205
+ firmware = bytes.byteslice(0, bytes.bytesize - 32)
206
+ firmware.setbyte(23, 0)
207
+ Tempfile.create(['esp-app', '.bin']) do |file|
208
+ file.binmode
209
+ file.write(firmware)
210
+ file.flush
211
+ emulate(bytes: firmware) do |port, _commands|
212
+ result = installer.install(port: port, chip: :esp32, firmware: file.path, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none)
213
+ expect(result[:md5]).to eq(Digest::MD5.hexdigest(firmware))
214
+ end
215
+ end
216
+ end
217
+
218
+ it 'clears inherited hardware flow control and hangup reset before writing ROM bytes' do
219
+ allow(UART).to receive(:open).and_wrap_original do |original, *args|
220
+ serial = original.call(*args)
221
+ attrs = Termios.tcgetattr(serial)
222
+ attrs.cflag |= Termios::CRTSCTS | Termios::HUPCL
223
+ Termios.tcsetattr(serial, Termios::TCSANOW, attrs)
224
+ allow(serial).to receive(:write).and_wrap_original do |write, *data|
225
+ expect(Termios.tcgetattr(serial).cflag & (Termios::CRTSCTS | Termios::HUPCL)).to eq(0)
226
+ write.call(*data)
227
+ end
228
+ serial
229
+ end
230
+ emulate(bytes: bytes) do |port, _commands|
231
+ installer.install(port: port, chip: :esp32, bytes: bytes, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none)
232
+ end
233
+ end
234
+
235
+ it 'uses classic DTR/RTS boot reset and a final EN pulse on a dedicated UART' do
236
+ controls = []
237
+ opened = nil
238
+ allow(UART).to receive(:open).and_wrap_original do |original, *args|
239
+ opened = original.call(*args)
240
+ allow(opened).to receive(:ioctl) { |op, bits| controls << [op, bits.unpack1('i')] }
241
+ opened
242
+ end
243
+ allow(installer).to receive(:sleep)
244
+ emulate(bytes: bytes) do |port, _commands|
245
+ installer.install(port: port, chip: :esp32, bytes: bytes, offset: 0x10000, flash_size: 4 * 1024 * 1024)
246
+ end
247
+ expect(controls).to eq([
248
+ [Termios::TIOCMBIC, Termios::TIOCM_DTR], [Termios::TIOCMBIS, Termios::TIOCM_RTS],
249
+ [Termios::TIOCMBIS, Termios::TIOCM_DTR], [Termios::TIOCMBIC, Termios::TIOCM_RTS],
250
+ [Termios::TIOCMBIC, Termios::TIOCM_DTR],
251
+ [Termios::TIOCMBIS, Termios::TIOCM_RTS], [Termios::TIOCMBIC, Termios::TIOCM_RTS]
252
+ ])
253
+ expect(opened).to be_closed
254
+ end
255
+
256
+ it 'opens a real UART PTY, syncs, flashes checksummed blocks and verifies the device MD5 before reboot' do
257
+ emulate(bytes: bytes) do |port, commands|
258
+ result = installer.install(port: port, chip: :esp32, bytes: bytes, offset: 0x10000, flash_size: 4 * 1024 * 1024, reset: :none)
259
+ expect(result).to include(status: :verified, chip: :esp32, bytes: bytes.bytesize, md5: Digest::MD5.hexdigest(bytes), reboot_requested: true, boot_verified: false)
260
+ expect(commands).to eq([8, 10, 10, 10, 13, 11, 2, 3, 3, 19, 4])
261
+ end
262
+ end
263
+ end
@@ -1,118 +1,315 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
- require 'tempfile'
4
+ require 'socket'
5
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
6
132
 
7
133
  describe Meshtastic::Admin::Firmware do
8
- def fake_serial_obj
9
- written = +''.b
10
- serial_conn = Object.new
11
- serial_conn.define_singleton_method(:write) do |b|
12
- written << b
13
- b.bytesize
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")
14
144
  end
15
- serial_conn.define_singleton_method(:flush) { true }
16
- serial_conn.define_singleton_method(:closed?) { false }
17
- serial_conn.define_singleton_method(:close) { true }
18
- { serial_conn: serial_conn, written: written, my_node_num: 0xb0b }
145
+ expect(result).to include(status: :verified, bytes: 3, sha256: Digest::SHA256.hexdigest('abc'))
19
146
  end
20
147
 
21
- def decode_frames(serial_obj)
22
- buf = serial_obj[:written].dup
23
- frames = []
24
- until buf.bytesize < 4
25
- break unless buf.getbyte(0) == Meshtastic::START1 && buf.getbyte(1) == Meshtastic::START2
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
26
153
 
27
- len = (buf.getbyte(2) << 8) + buf.getbyte(3)
28
- break if buf.bytesize < 4 + len
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
29
167
 
30
- frames << Meshtastic::ToRadio.decode(buf.byteslice(4, len))
31
- buf = buf.byteslice((4 + len)..)
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)
32
176
  end
33
- frames
34
177
  end
35
178
 
36
- def firmware_file(bytes = "hello firmware\n")
37
- file = Tempfile.new(['fw', '.bin'])
38
- file.binmode
39
- file.write(bytes)
40
- file.flush
41
- file
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
42
185
  end
186
+ end
187
+
188
+ describe Meshtastic::Admin::Firmware do
189
+ include_context 'a unified OTA TCP loader'
43
190
 
44
- it 'sends ota_request with SHA-256 and BLE mode' do
45
- serial_obj = fake_serial_obj
46
- file = firmware_file('abc')
47
- described_class.request_ota(serial_obj: serial_obj, firmware: file.path, mode: :OTA_BLE)
48
- packet = decode_frames(serial_obj).last.packet
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
49
203
  admin = Meshtastic::AdminMessage.decode(packet.decoded.payload)
50
204
  expect(packet.decoded.portnum).to eq(:ADMIN_APP)
51
- expect(admin.ota_request.reboot_ota_mode).to eq(:OTA_BLE)
52
- expect(admin.ota_request.ota_hash.bytesize).to eq(32)
53
- expect(admin.ota_request.ota_hash).to eq(Digest::SHA256.digest('abc'))
54
- ensure
55
- file.close!
56
- end
57
-
58
- it 'sends enter_dfu_mode_request' do
59
- serial_obj = fake_serial_obj
60
- described_class.enter_dfu(serial_obj: serial_obj)
61
- admin = Meshtastic::AdminMessage.decode(decode_frames(serial_obj).last.packet.decoded.payload)
62
- expect(admin.enter_dfu_mode_request).to be true
63
- end
64
-
65
- it 'builds XModem SOH blocks with CRC16 and an EOT' do
66
- blocks = described_class.xmodem_blocks(bytes: 'A' * 130)
67
- expect(blocks.first.control).to eq(:SOH)
68
- expect(blocks.first.seq).to eq(1)
69
- expect(blocks.first.buffer.bytesize).to eq(128)
70
- expect(blocks[1].seq).to eq(2)
71
- expect(blocks.last.control).to eq(:EOT)
72
- end
73
-
74
- it 'streams XModem ToRadio frames after ota_request on serial' do
75
- serial_obj = fake_serial_obj
76
- file = firmware_file('A' * 10)
77
- described_class.install(serial_obj: serial_obj, firmware: file.path, mode: :OTA_BLE)
78
- frames = decode_frames(serial_obj)
79
- admin = Meshtastic::AdminMessage.decode(frames.first.packet.decoded.payload)
80
- expect(admin.ota_request.ota_hash.bytesize).to eq(32)
81
- xmodem = frames[1..]
82
- expect(xmodem.first.xmodemPacket.control).to eq(:SOH)
83
- expect(xmodem.last.xmodemPacket.control).to eq(:EOT)
84
- ensure
85
- file.close!
86
- end
87
-
88
- it 'publishes ota_request over MQTT without XModem PhoneAPI frames' do
89
- published = []
90
- mqtt_obj = Object.new
91
- mqtt_obj.define_singleton_method(:client_id) { '00000b0b' }
92
- mqtt_obj.define_singleton_method(:publish) do |topic, payload|
93
- published << { topic: topic, payload: payload }
94
- payload.bytesize
95
- end
96
- file = firmware_file('xyz')
97
- described_class.install(mqtt_obj: mqtt_obj, firmware: file.path, mode: :OTA_WIFI, to: '!aabbccdd')
98
- expect(published.size).to eq(1)
99
- envelope = Meshtastic::ServiceEnvelope.decode(published.first[:payload])
100
- packet = envelope.packet
101
- nonce = [packet.id].pack('V').ljust(8, "\x00") + [packet.from].pack('V').ljust(8, "\x00")
102
- psk = Base64.strict_decode64('1PG7OiApB1nwvP+rz05pAQ==')
103
- cipher = OpenSSL::Cipher.new('AES-128-CTR')
104
- cipher.decrypt
105
- cipher.key = psk
106
- cipher.iv = nonce
107
- data = Meshtastic::Data.decode(cipher.update(packet.encrypted) + cipher.final)
108
- expect(data.portnum).to eq(:ADMIN_APP)
109
- admin = Meshtastic::AdminMessage.decode(data.payload)
110
205
  expect(admin.ota_request.reboot_ota_mode).to eq(:OTA_WIFI)
111
- ensure
112
- file.close!
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)
113
275
  end
114
276
 
115
- it 'prints usage without raising' do
116
- expect { described_class.help }.to output(/firmware/).to_stdout
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
117
314
  end
118
315
  end