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
|
@@ -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 '
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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 '
|
|
116
|
-
|
|
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
|