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.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/documentation/README.md +3 -2
- data/documentation/admin-channel.md +64 -0
- data/documentation/admin-config.md +59 -0
- data/documentation/admin-firmware-nordic.md +86 -0
- data/documentation/admin-firmware-serial.md +180 -0
- data/documentation/admin-firmware.md +126 -0
- data/documentation/admin.md +88 -35
- data/documentation/atak.md +93 -10
- data/documentation/channel.md +1 -28
- data/documentation/config.md +1 -27
- data/documentation/mesh-interface.md +7 -0
- data/documentation/meshtastic.md +2 -2
- data/lib/meshtastic/admin/channel.rb +164 -0
- data/lib/meshtastic/admin/config.rb +213 -0
- 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 +288 -0
- data/lib/meshtastic/admin.rb +688 -34
- data/lib/meshtastic/apponly.rb +19 -7
- data/lib/meshtastic/atak.rb +295 -17
- data/lib/meshtastic/bluetooth/bluez.rb +20 -1
- data/lib/meshtastic/bluetooth.rb +70 -59
- data/lib/meshtastic/cannedmessages.rb +13 -6
- data/lib/meshtastic/channel.rb +3 -25
- data/lib/meshtastic/clientonly.rb +19 -7
- data/lib/meshtastic/config.rb +3 -24
- data/lib/meshtastic/config_pb.rb +2 -1
- data/lib/meshtastic/connection_status.rb +12 -5
- data/lib/meshtastic/deviceonly.rb +19 -7
- data/lib/meshtastic/localonly.rb +19 -7
- data/lib/meshtastic/mesh_interface.rb +19 -0
- data/lib/meshtastic/module_config.rb +13 -7
- data/lib/meshtastic/mqtt.rb +72 -35
- data/lib/meshtastic/paxcount.rb +17 -6
- data/lib/meshtastic/portnums.rb +13 -6
- data/lib/meshtastic/position.rb +16 -6
- data/lib/meshtastic/remote_hardware.rb +27 -11
- data/lib/meshtastic/rtttl.rb +17 -8
- data/lib/meshtastic/serial.rb +81 -70
- data/lib/meshtastic/storeforward.rb +13 -6
- data/lib/meshtastic/storeforward_pb.rb +1 -1
- data/lib/meshtastic/stream_interface.rb +11 -0
- data/lib/meshtastic/tcp.rb +73 -22
- data/lib/meshtastic/telemetry.rb +15 -6
- data/lib/meshtastic/traceroute.rb +15 -6
- data/lib/meshtastic/util.rb +3 -1
- data/lib/meshtastic/version.rb +1 -1
- data/lib/meshtastic/xmodem.rb +12 -5
- data/lib/meshtastic.rb +22 -3
- data/spec/conventions_spec.rb +321 -0
- data/spec/lib/meshtastic/admin/channel_spec.rb +202 -0
- data/spec/lib/meshtastic/admin/config_spec.rb +114 -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 +315 -0
- data/spec/lib/meshtastic/admin_spec.rb +412 -1
- data/spec/lib/meshtastic/apponly_spec.rb +1 -1
- data/spec/lib/meshtastic/atak_spec.rb +129 -4
- data/spec/lib/meshtastic/bluetooth_spec.rb +1 -1
- data/spec/lib/meshtastic/channel_spec.rb +2 -20
- data/spec/lib/meshtastic/config_spec.rb +2 -20
- data/spec/lib/meshtastic/connection_status_spec.rb +1 -1
- data/spec/lib/meshtastic/deviceonly_spec.rb +1 -1
- data/spec/lib/meshtastic/localonly_spec.rb +1 -1
- data/spec/lib/meshtastic/mesh_interface_spec.rb +31 -0
- data/spec/lib/meshtastic/portnums_spec.rb +2 -2
- metadata +24 -6
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'zlib'
|
|
6
|
+
require 'meshtastic/admin/firmware/nordic_dfu' if File.exist?(File.expand_path('../../../../../lib/meshtastic/admin/firmware/nordic_dfu.rb', __dir__))
|
|
7
|
+
|
|
8
|
+
# A stateful byte-level peer, not canned success responses. It enforces the
|
|
9
|
+
# SDK11 command order, image sizing, PRN cadence, and independent image CRC.
|
|
10
|
+
class NordicDFUPeer
|
|
11
|
+
CONTROL = '00001531-1212-efde-1523-785feabcd123'
|
|
12
|
+
PACKET = '00001532-1212-efde-1523-785feabcd123'
|
|
13
|
+
attr_reader :image, :activated, :closed, :writes
|
|
14
|
+
attr_accessor :fault
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@state = :idle
|
|
18
|
+
@events = []
|
|
19
|
+
@image = ''.b
|
|
20
|
+
@init = ''.b
|
|
21
|
+
@writes = []
|
|
22
|
+
@count = 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def subscribe(uuid:)
|
|
26
|
+
raise 'wrong subscription' unless uuid == CONTROL
|
|
27
|
+
|
|
28
|
+
@subscribed = true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def write(uuid:, bytes:, response:)
|
|
32
|
+
raise 'not subscribed' unless @subscribed
|
|
33
|
+
|
|
34
|
+
@writes << [uuid, bytes, response]
|
|
35
|
+
if uuid == CONTROL
|
|
36
|
+
raise 'control requires write response' unless response
|
|
37
|
+
|
|
38
|
+
control(bytes)
|
|
39
|
+
else
|
|
40
|
+
raise 'invalid packet write' unless uuid == PACKET && !response && bytes.bytesize <= 20
|
|
41
|
+
|
|
42
|
+
packet(bytes)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def control(bytes)
|
|
47
|
+
case [@state, bytes.bytes]
|
|
48
|
+
when [:idle, [1, 4]] then @state = :size
|
|
49
|
+
when [:started, [2, 0]] then @state = :init
|
|
50
|
+
when [:init, [2, 1]]
|
|
51
|
+
raise 'bad init' unless @init.bytesize == 14 && @init.unpack1('v') == 0x52
|
|
52
|
+
|
|
53
|
+
reply(2)
|
|
54
|
+
@state = :initialized
|
|
55
|
+
when [:ready, [3]] then @state = :image
|
|
56
|
+
when [:received, [4]]
|
|
57
|
+
@image.setbyte(0, @image.getbyte(0) ^ 1) if @fault == :flash_corruption
|
|
58
|
+
reply(4, crc(@image) == @init.byteslice(-2, 2).unpack1('v') ? 1 : 5)
|
|
59
|
+
@state = :validated
|
|
60
|
+
when [:validated, [5]] then @activated = true
|
|
61
|
+
else
|
|
62
|
+
raise "unexpected command #{@state}: #{bytes.bytes}" unless @state == :initialized && bytes.getbyte(0) == 8 && bytes.bytesize == 3
|
|
63
|
+
|
|
64
|
+
@interval = bytes.byteslice(1, 2).unpack1('v')
|
|
65
|
+
raise 'receipts disabled' unless @interval.positive?
|
|
66
|
+
|
|
67
|
+
@state = :ready
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def packet(bytes)
|
|
72
|
+
case @state
|
|
73
|
+
when :size
|
|
74
|
+
sd, bl, @size = bytes.unpack('V3')
|
|
75
|
+
raise 'bad image sizes' unless bytes.bytesize == 12 && sd.zero? && bl.zero? && @size.positive?
|
|
76
|
+
|
|
77
|
+
@state = :started
|
|
78
|
+
reply(1)
|
|
79
|
+
when :init then @init << bytes
|
|
80
|
+
when :image
|
|
81
|
+
raise 'unaligned image packet' unless (bytes.bytesize % 4).zero?
|
|
82
|
+
|
|
83
|
+
@image << bytes
|
|
84
|
+
raise 'image overflow' if @image.bytesize > @size
|
|
85
|
+
|
|
86
|
+
@count += 1
|
|
87
|
+
if @image.bytesize == @size
|
|
88
|
+
@state = :received
|
|
89
|
+
reply(3)
|
|
90
|
+
elsif (@count % @interval).zero?
|
|
91
|
+
receipt = @image.bytesize + (@fault == :receipt ? 4 : 0)
|
|
92
|
+
@events << [17, receipt].pack('CV')
|
|
93
|
+
end
|
|
94
|
+
else raise "packet in #{@state}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def reply(opcode, status = 1)
|
|
99
|
+
status = 6 if @fault == :reject && opcode == 2
|
|
100
|
+
opcode = 4 if @fault == :wrong_opcode && opcode == 1
|
|
101
|
+
@events << [16, opcode, status].pack('C*')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def notification(timeout:)
|
|
105
|
+
raise 'unbounded wait' unless timeout.positive?
|
|
106
|
+
return nil if @fault == :timeout
|
|
107
|
+
|
|
108
|
+
bytes = @events.shift
|
|
109
|
+
raise 'client waited when peer owes no response' unless bytes
|
|
110
|
+
|
|
111
|
+
{ uuid: CONTROL, bytes: bytes }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def close
|
|
115
|
+
@closed = true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def crc(bytes)
|
|
119
|
+
crc = 0xffff
|
|
120
|
+
bytes.each_byte do |byte|
|
|
121
|
+
crc ^= byte << 8
|
|
122
|
+
8.times { crc = ((crc << 1) ^ (crc.anybits?(0x8000) ? 0x1021 : 0)) & 0xffff }
|
|
123
|
+
end
|
|
124
|
+
crc
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
RSpec.describe 'Meshtastic::Admin::Firmware::NordicDFU' do
|
|
129
|
+
let(:installer) { Meshtastic::Admin::Firmware::NordicDFU }
|
|
130
|
+
let(:peer) { NordicDFUPeer.new }
|
|
131
|
+
let(:image) { (0...244).to_a.pack('C*') }
|
|
132
|
+
let(:init) { [0x52, 0xffff, 1, 1, 0xfffe, peer.crc(image)].pack('vvVvvv') }
|
|
133
|
+
let(:manifest) { { manifest: { application: { bin_file: 'app.bin', dat_file: 'app.dat' } } } }
|
|
134
|
+
let(:entries) { { 'manifest.json' => JSON.generate(manifest), 'app.bin' => image, 'app.dat' => init } }
|
|
135
|
+
|
|
136
|
+
def zip(entries, compression: 0)
|
|
137
|
+
local = ''.b
|
|
138
|
+
central = ''.b
|
|
139
|
+
entries.each do |name, bytes|
|
|
140
|
+
raw = if compression == 8
|
|
141
|
+
deflater = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -Zlib::MAX_WBITS)
|
|
142
|
+
begin
|
|
143
|
+
deflater.deflate(bytes, Zlib::FINISH)
|
|
144
|
+
ensure
|
|
145
|
+
deflater.close
|
|
146
|
+
end
|
|
147
|
+
else
|
|
148
|
+
bytes
|
|
149
|
+
end
|
|
150
|
+
crc = Zlib.crc32(bytes)
|
|
151
|
+
central << [0x02014b50, 20, 20, 0, compression, 0, 0, crc, raw.bytesize, bytes.bytesize,
|
|
152
|
+
name.bytesize, 0, 0, 0, 0, 0, local.bytesize].pack('VvvvvvvVVVvvvvvVV') << name
|
|
153
|
+
local << [0x04034b50, 20, 0, compression, 0, 0, crc, raw.bytesize, bytes.bytesize,
|
|
154
|
+
name.bytesize, 0].pack('VvvvvvVVVvv') << name << raw
|
|
155
|
+
end
|
|
156
|
+
local + central + [0x06054b50, 0, 0, entries.length, entries.length, central.bytesize, local.bytesize, 0].pack('VvvvvVVv')
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
{
|
|
160
|
+
'image CRC mismatch' => ->(entries) { entries.merge('app.bin' => 'x' * 244) },
|
|
161
|
+
'unaligned image' => ->(entries) { entries.merge('app.bin' => 'x') },
|
|
162
|
+
'empty image' => ->(entries) { entries.merge('app.bin' => '') },
|
|
163
|
+
'signed extension' => ->(entries) { entries.merge('app.dat' => entries.fetch('app.dat').byteslice(0, 12) + [2, 244].pack('V2') + ('x' * 96)) },
|
|
164
|
+
'secure protobuf init' => ->(entries) { entries.merge('app.dat' => "\x12\x08\x0a\x06secure".b) },
|
|
165
|
+
'bootloader update' => ->(entries) { entries.merge('manifest.json' => JSON.generate(manifest: { bootloader: { bin_file: 'app.bin', dat_file: 'app.dat' } })) },
|
|
166
|
+
'traversal entry' => ->(entries) { entries.merge('../outside' => 'unsafe') },
|
|
167
|
+
'missing init' => ->(entries) { entries.except('app.dat') }
|
|
168
|
+
}.each do |description, mutate|
|
|
169
|
+
it "rejects #{description} before any bootloader writes" do
|
|
170
|
+
expect { installer.install(package_bytes: zip(mutate.call(entries)), gatt: peer) }.to raise_error(ArgumentError)
|
|
171
|
+
expect(peer.writes).to be_empty
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it 'rejects truncated ZIP data without writing to the bootloader' do
|
|
176
|
+
expect { installer.install(package_bytes: zip(entries).byteslice(0, 100), gatt: peer) }.to raise_error(ArgumentError)
|
|
177
|
+
expect(peer.writes).to be_empty
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it 'reads a package file without requiring any external unpacker' do
|
|
181
|
+
require 'tempfile'
|
|
182
|
+
Tempfile.create(['nordic', '.zip']) do |file|
|
|
183
|
+
file.binmode
|
|
184
|
+
file.write(zip(entries))
|
|
185
|
+
file.flush
|
|
186
|
+
installer.install(package: file.path, gatt: peer)
|
|
187
|
+
expect(peer.activated).to be(true)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'rejects ambiguous package sources before touching GATT' do
|
|
192
|
+
expect { installer.install(package_bytes: zip(entries), package: '/unused.zip', gatt: peer) }.to raise_error(ArgumentError, /exactly one/)
|
|
193
|
+
expect(peer.writes).to be_empty
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it 'rejects unknown options rather than ignoring secure or retry requests' do
|
|
197
|
+
expect { installer.install(package_bytes: zip(entries), gatt: peer, secure: true) }.to raise_error(ArgumentError, /Unsupported.*secure/)
|
|
198
|
+
expect(peer.writes).to be_empty
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it 'rejects a JSON scalar manifest with an operator-usable error' do
|
|
202
|
+
malformed = entries.merge('manifest.json' => JSON.generate('not a manifest'))
|
|
203
|
+
expect { installer.install(package_bytes: zip(malformed), gatt: peer) }.to raise_error(ArgumentError, /manifest/)
|
|
204
|
+
expect(peer.writes).to be_empty
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
it 'rejects malformed manifest application types clearly before I/O' do
|
|
208
|
+
malformed = entries.merge('manifest.json' => JSON.generate(manifest: { application: 'oops' }))
|
|
209
|
+
expect { installer.install(package_bytes: zip(malformed), gatt: peer) }.to raise_error(ArgumentError, /application/)
|
|
210
|
+
expect(peer.writes).to be_empty
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
%i[receipt reject wrong_opcode timeout flash_corruption].each do |fault|
|
|
214
|
+
it "fails closed on #{fault} without replaying unsequenced packets" do
|
|
215
|
+
peer.fault = fault
|
|
216
|
+
expect { installer.install(package_bytes: zip(entries), gatt: peer) }.to raise_error(fault == :timeout ? Timeout::Error : IOError)
|
|
217
|
+
expect(peer.activated).not_to be(true)
|
|
218
|
+
expect(peer.closed).to be(true)
|
|
219
|
+
expect(peer.writes.count { |_uuid, bytes, _response| bytes == [1, 4].pack('C*') }).to eq(1)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it 'connects the shared production BlueZ backend to the legacy service by default' do
|
|
224
|
+
require 'meshtastic/admin/firmware/ble'
|
|
225
|
+
expect(Meshtastic::Admin::Firmware::BLE::BlueZ).to receive(:new).with(
|
|
226
|
+
address: 'AA:BB:CC:DD:EE:FF', adapter: 'hci0', timeout: 30,
|
|
227
|
+
service_uuid: '00001530-1212-efde-1523-785feabcd123'
|
|
228
|
+
).and_return(peer)
|
|
229
|
+
expect(peer).to receive(:connect).and_return(peer)
|
|
230
|
+
installer.install(package_bytes: zip(entries), address: 'AA:BB:CC:DD:EE:FF')
|
|
231
|
+
expect(peer.activated).to be(true)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it 'reads a deflated ZIP generated by normal DFU package tools' do
|
|
235
|
+
installer.install(package_bytes: zip(entries, compression: 8), gatt: peer)
|
|
236
|
+
expect(peer.image).to eq(image)
|
|
237
|
+
expect(peer.activated).to be(true)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it 'installs a legacy application ZIP through real command and receipt states' do
|
|
241
|
+
expect(defined?(Meshtastic::Admin::Firmware::NordicDFU)).to eq('constant')
|
|
242
|
+
result = installer.install(package_bytes: zip(entries), gatt: peer)
|
|
243
|
+
expect(peer.image).to eq(image)
|
|
244
|
+
expect(peer.activated).to be(true)
|
|
245
|
+
expect(peer.closed).to be(true)
|
|
246
|
+
expect(result).to include(status: :verified, bytes: image.bytesize, protocol: :nordic_dfu, reboot_verified: false)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -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
|