meshtastic 0.0.182 → 0.0.183
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/documentation/README.md +4 -0
- data/documentation/admin-channel.md +6 -6
- data/documentation/admin-config.md +5 -5
- data/documentation/admin-firmware-hex.md +57 -0
- data/documentation/admin-firmware-serial.md +1 -1
- data/documentation/admin-firmware-uf2.md +94 -0
- data/documentation/admin-firmware.md +52 -10
- data/documentation/admin.md +8 -2
- data/documentation/module-config.md +8 -4
- data/documentation/rtttl.md +8 -4
- data/lib/meshtastic/admin/channel.rb +6 -3
- data/lib/meshtastic/admin/config.rb +14 -14
- data/lib/meshtastic/admin/firmware/hex.rb +209 -0
- data/lib/meshtastic/admin/firmware/uf2.rb +166 -0
- data/lib/meshtastic/admin/firmware.rb +55 -13
- data/lib/meshtastic/admin.rb +58 -23
- data/lib/meshtastic/module_config.rb +22 -4
- data/lib/meshtastic/rtttl.rb +22 -4
- data/lib/meshtastic/version.rb +1 -1
- data/spec/lib/meshtastic/admin/channel_spec.rb +23 -9
- data/spec/lib/meshtastic/admin/config_spec.rb +28 -12
- data/spec/lib/meshtastic/admin/firmware/hex_spec.rb +160 -0
- data/spec/lib/meshtastic/admin/firmware/uf2_spec.rb +234 -0
- data/spec/lib/meshtastic/admin/firmware_spec.rb +106 -3
- data/spec/lib/meshtastic/admin_spec.rb +106 -40
- data/spec/lib/meshtastic/module_config_spec.rb +47 -0
- data/spec/lib/meshtastic/rtttl_spec.rb +47 -0
- metadata +7 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'rbconfig'
|
|
7
|
+
require 'open3'
|
|
8
|
+
require 'meshtastic/admin/firmware/hex' if File.file?(File.expand_path('../../../../../lib/meshtastic/admin/firmware/hex.rb', __dir__))
|
|
9
|
+
|
|
10
|
+
describe 'Meshtastic::Admin::Firmware::Hex' do
|
|
11
|
+
def record(type, address = 0, data = [])
|
|
12
|
+
bytes = [data.length, address >> 8, address & 255, type] + data
|
|
13
|
+
":#{(bytes + [(-bytes.sum) & 255]).pack('C*').unpack1('H*').upcase}\n"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def backend
|
|
17
|
+
Meshtastic::Admin::Firmware.const_get(:Hex)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
let(:image) { record(0, 0, [1, 2, 3, 4]) + record(1) }
|
|
21
|
+
|
|
22
|
+
it 'runs a real fake executable with guarded write, verify and reset commands' do
|
|
23
|
+
Dir.mktmpdir('hex test ;[]$') do |dir|
|
|
24
|
+
executable = File.join(dir, 'fake openocd')
|
|
25
|
+
log = File.join(dir, 'argv.json')
|
|
26
|
+
File.write(executable, <<~RUBY)
|
|
27
|
+
#!#{RbConfig.ruby}
|
|
28
|
+
require 'json'
|
|
29
|
+
File.write(#{log.inspect}, JSON.generate(ARGV))
|
|
30
|
+
script = ARGV.last
|
|
31
|
+
puts script[/MESHTASTIC_HEX_OK_[a-f0-9]+/]
|
|
32
|
+
RUBY
|
|
33
|
+
File.chmod(0o700, executable)
|
|
34
|
+
config = File.join(dir, 'trusted.cfg')
|
|
35
|
+
File.write(config, '# trusted test configuration')
|
|
36
|
+
firmware = File.join(dir, 'input ;[$].hex')
|
|
37
|
+
File.binwrite(firmware, image)
|
|
38
|
+
result = backend.install(protocol: :swd, firmware: firmware, expected_chip: :nrf52840,
|
|
39
|
+
expected_target: 'nrf52.cpu', openocd: executable,
|
|
40
|
+
interface_config: config, target_config: config)
|
|
41
|
+
expect(result).to include(status: :verified, flash_verified: true, reboot_verified: false)
|
|
42
|
+
args = JSON.parse(File.read(log))
|
|
43
|
+
expect(args).to include(config, 'transport select swd')
|
|
44
|
+
script = args.last
|
|
45
|
+
expect(script).to include('0x10000100', '0x52840', 'flash write_image erase', 'verify_image', 'reset run', 'shutdown error')
|
|
46
|
+
expect(script.index('0x10000100')).to be < script.index('flash write_image erase')
|
|
47
|
+
expect(script.index('verify_image')).to be < script.index('reset run')
|
|
48
|
+
expect(script).not_to include('mass_erase')
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'quotes braces and substitution characters even inside a braced Tcl catch' do
|
|
53
|
+
value = '/tmp/a}b{c\\d"e[$x];file.hex'
|
|
54
|
+
word = backend.send(:tcl_word, value: value)
|
|
55
|
+
output, error, status = Open3.capture3('tclsh', stdin_data: "if {[catch {set path #{word}; puts $path} failure]} {puts stderr $failure; exit 1}\n")
|
|
56
|
+
expect(status.success?).to be(true), error
|
|
57
|
+
expect(output.chomp).to eq(value)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'rejects malformed records, unknown types, missing EOF and overlaps' do
|
|
61
|
+
invalid = [image.sub('01020304', '01020305'), image.sub(':04', ':05'), image + record(1),
|
|
62
|
+
record(0, 0, [1]), record(1), "\n#{image}", "#{image}\n", ':zz',
|
|
63
|
+
record(6) + image, record(1, 1), record(1, 0, [1]),
|
|
64
|
+
record(2, 1, [0, 0]) + image, record(4, 0, [0]) + image,
|
|
65
|
+
record(0, 0, []) + record(1), record(0, 65_535, [1, 2]) + record(1),
|
|
66
|
+
record(0, 0, [1, 2]) + record(0, 1, [2]) + record(1),
|
|
67
|
+
record(4, 0, [255, 255]) + record(0, 65_535, [1]) + record(1),
|
|
68
|
+
(record(5, 0, [0, 0, 0, 1]) * 2) + image,
|
|
69
|
+
record(5, 0, [0, 16, 0, 0]) + image]
|
|
70
|
+
invalid.each do |bytes|
|
|
71
|
+
expect { backend.validate(bytes: bytes, expected_chip: :nrf52840) }.to raise_error(ArgumentError)
|
|
72
|
+
end
|
|
73
|
+
expect { backend.validate(bytes: image, expected_chip: :nrf52832) }.to raise_error(ArgumentError)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'handles segment and linear bases, UICR, CRLF and start records' do
|
|
77
|
+
bytes = record(2, 0, [0x10, 0]) + record(0, 0, [1]) + record(4, 0, [0x10, 0]) +
|
|
78
|
+
record(0, 0x1000, [2]) + record(5, 0, [0, 1, 0, 1]) + record(1)
|
|
79
|
+
expect(backend.validate(bytes: bytes.gsub("\n", "\r\n"), expected_chip: :nrf52840)).to include(ranges: [[0x10000, 0x10001], [0x10001000, 0x10001001]], start_address: 0x10001)
|
|
80
|
+
bytes = record(3, 0, [0x10, 0, 0, 1]) + image
|
|
81
|
+
expect(backend.validate(bytes: bytes, expected_chip: :nrf52840)[:start_address]).to eq(0x10001)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'requires actual subprocess success and a completion marker and bounds runtime' do
|
|
85
|
+
Dir.mktmpdir do |dir|
|
|
86
|
+
executable = File.join(dir, 'openocd')
|
|
87
|
+
config = File.join(dir, 'config')
|
|
88
|
+
File.write(config, '# test')
|
|
89
|
+
options = { protocol: :swd, bytes: image, expected_chip: :nrf52840, expected_target: 'nrf52.cpu',
|
|
90
|
+
openocd: executable, interface_config: config, target_config: config }
|
|
91
|
+
['exit 0', 'puts ARGV.last[/MESHTASTIC_HEX_OK_[a-f0-9]+/]; exit 1', 'sleep 10'].each do |body|
|
|
92
|
+
File.write(executable, "#!#{RbConfig.ruby}\n#{body}\n")
|
|
93
|
+
File.chmod(0o700, executable)
|
|
94
|
+
expect { backend.install(options.merge(timeout: 0.2)) }.to raise_error(IOError)
|
|
95
|
+
end
|
|
96
|
+
File.write(executable, "#!#{RbConfig.ruby}\nabort 'must not run'\n")
|
|
97
|
+
[{ bytes: image.sub('01020304', '01020305') }, { expected_target: 'x;exit' },
|
|
98
|
+
{ expected_chip: :esp32 }, { protocol: :serial }, { firmware: '/tmp/also.hex' },
|
|
99
|
+
{ timeout: 0 }, { target_config: nil }, { openocd: 'openocd' }, { surprise: true }].each do |change|
|
|
100
|
+
expect { backend.install(options.merge(change)) }.to raise_error(ArgumentError)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'executes Tcl guards before erase and refuses failed verification or reset' do
|
|
106
|
+
Dir.mktmpdir do |dir|
|
|
107
|
+
executable = File.join(dir, 'openocd')
|
|
108
|
+
config = File.join(dir, 'config')
|
|
109
|
+
trace = File.join(dir, 'trace')
|
|
110
|
+
File.write(config, '# test')
|
|
111
|
+
options = { protocol: :swd, bytes: image, expected_chip: :nrf52840, expected_target: 'nrf52.cpu',
|
|
112
|
+
openocd: executable, interface_config: config, target_config: config }
|
|
113
|
+
%w[success chip verify reset].each do |mode|
|
|
114
|
+
simulator = <<~TCL
|
|
115
|
+
set trace [open #{backend.send(:tcl_word, value: trace)} w]
|
|
116
|
+
proc init {} {}
|
|
117
|
+
proc targets {name} {if {$name ne "nrf52.cpu"} {error "wrong target"}}
|
|
118
|
+
proc reset {mode} {
|
|
119
|
+
if {$mode eq "run" && "#{mode}" eq "reset"} {error "reset failed"}
|
|
120
|
+
puts $::trace "reset $mode"
|
|
121
|
+
}
|
|
122
|
+
proc halt {} {}
|
|
123
|
+
proc read_memory {address width count} {
|
|
124
|
+
if {$address == 0x10000100} {return #{mode == 'chip' ? '0x52832' : '0x52840'}}
|
|
125
|
+
if {$address == 0x10000010} {return 4096}
|
|
126
|
+
return 256
|
|
127
|
+
}
|
|
128
|
+
proc flash {args} {puts $::trace flash}
|
|
129
|
+
proc verify_image {args} {
|
|
130
|
+
puts $::trace verify
|
|
131
|
+
if {"#{mode}" eq "verify"} {error "verification failed"}
|
|
132
|
+
}
|
|
133
|
+
proc echo {text} {puts $text}
|
|
134
|
+
proc shutdown {args} {close $::trace; if {[llength $args]} {exit 1}; exit 0}
|
|
135
|
+
TCL
|
|
136
|
+
File.write(executable, <<~RUBY)
|
|
137
|
+
#!#{RbConfig.ruby}
|
|
138
|
+
require 'open3'
|
|
139
|
+
output, error, status = Open3.capture3('tclsh', stdin_data: #{simulator.inspect} + ARGV.last)
|
|
140
|
+
print output
|
|
141
|
+
warn error unless error.empty?
|
|
142
|
+
exit status.exitstatus
|
|
143
|
+
RUBY
|
|
144
|
+
File.chmod(0o700, executable)
|
|
145
|
+
if mode == 'success'
|
|
146
|
+
expect(backend.install(options)[:flash_verified]).to be(true)
|
|
147
|
+
expect(File.read(trace)).to eq("reset init\nflash\nverify\nreset run\n")
|
|
148
|
+
else
|
|
149
|
+
expect { backend.install(options) }.to raise_error(IOError)
|
|
150
|
+
expect(File.read(trace)).not_to include('reset run')
|
|
151
|
+
expect(File.read(trace)).not_to include('flash') if mode == 'chip'
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it 'validates Intel HEX bytes without a programmer or hardware' do
|
|
158
|
+
expect(backend.validate(bytes: image, expected_chip: :nrf52840)).to include(data_bytes: 4, ranges: [[0, 4]])
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'meshtastic/admin/firmware/uf2' if File.exist?(File.expand_path('../../../../../lib/meshtastic/admin/firmware/uf2.rb', __dir__))
|
|
6
|
+
|
|
7
|
+
shared_context 'UF2 bootloader files' do
|
|
8
|
+
def block(opts = {})
|
|
9
|
+
defaults = { number: 0, count: 1, address: 0x27000, family: 0xada52840, flags: 0x2000, size: 256 }.merge(opts)
|
|
10
|
+
[0x0a324655, 0x9e5d5157, defaults[:flags], defaults[:address], defaults[:size], defaults[:number], defaults[:count], defaults[:family]].pack('V8') + ('x' * 256).ljust(476, "\0") + [0x0ab16f30].pack('V')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
around do |example|
|
|
14
|
+
Dir.mktmpdir('meshtastic-uf2-') do |dir|
|
|
15
|
+
@mount = File.join(dir, 'boot')
|
|
16
|
+
Dir.mkdir(@mount)
|
|
17
|
+
File.write(File.join(@mount, 'INFO_UF2.TXT'), "UF2 Bootloader 0.9.2\nModel: Test board\nBoard-ID: nRF52840-Test-v1\n")
|
|
18
|
+
@options = { protocol: :uf2, bytes: block, mount: @mount, board_id: 'nRF52840-Test-v1', family_id: 0xada52840 }
|
|
19
|
+
example.run
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
describe 'Meshtastic::Admin::Firmware::UF2 image validation' do
|
|
25
|
+
include_context 'UF2 bootloader files'
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
empty: -> { ''.b },
|
|
29
|
+
truncated: -> { block.byteslice(0, 511) },
|
|
30
|
+
trailing: -> { "#{block}x" },
|
|
31
|
+
mixed_families: -> { block(count: 2) + block(number: 1, count: 2, address: 0x27100, family: 0xe48bff56) },
|
|
32
|
+
duplicate_blocks: -> { block(count: 2) * 2 },
|
|
33
|
+
missing_block: -> { block(count: 2) },
|
|
34
|
+
inconsistent_count: -> { block(count: 2) + block(number: 1, address: 0x27100) },
|
|
35
|
+
out_of_range_number: -> { block(number: 1) },
|
|
36
|
+
no_family: -> { block(flags: 0) },
|
|
37
|
+
not_main_flash: -> { block(flags: 0x2001) },
|
|
38
|
+
file_container: -> { block(flags: 0x3000) },
|
|
39
|
+
md5_extension: -> { block(flags: 0x6000) },
|
|
40
|
+
extension_tags: -> { block(flags: 0xa000) },
|
|
41
|
+
unknown_flags: -> { block(flags: 0x12000) },
|
|
42
|
+
zero_payload: -> { block(size: 0) },
|
|
43
|
+
unsupported_page_size: -> { block(size: 128) },
|
|
44
|
+
unsupported_page_alignment: -> { block(address: 0x27004) },
|
|
45
|
+
oversized_payload: -> { block(size: 480) },
|
|
46
|
+
unaligned_payload: -> { block(size: 255) },
|
|
47
|
+
unaligned_address: -> { block(address: 0x27001) },
|
|
48
|
+
bootloader_address: -> { block(address: 0xf4000) },
|
|
49
|
+
softdevice_address: -> { block(address: 0) },
|
|
50
|
+
crossing_boundary: -> { block(address: 0xf3ffc) },
|
|
51
|
+
overflow_address: -> { block(address: 0xfffffffc) },
|
|
52
|
+
overlap: -> { block(count: 2) + block(number: 1, count: 2, address: 0x27000) }
|
|
53
|
+
}.each do |name, image|
|
|
54
|
+
it "rejects #{name} before creating the destination" do
|
|
55
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options.merge(bytes: instance_exec(&image))) }.to raise_error(ArgumentError)
|
|
56
|
+
expect(Dir.children(@mount)).to eq(['INFO_UF2.TXT'])
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'rejects numeric lookalikes for family identifiers' do
|
|
61
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options.merge(family_id: 0xada52840.to_f)) }.to raise_error(ArgumentError)
|
|
62
|
+
expect(Dir.children(@mount)).to eq(['INFO_UF2.TXT'])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'checks every magic word in every block' do
|
|
66
|
+
[0, 4, 508].each do |offset|
|
|
67
|
+
bytes = block(count: 2) + block(number: 1, count: 2, address: 0x27100)
|
|
68
|
+
bytes.setbyte(512 + offset, 0)
|
|
69
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options.merge(bytes: bytes)) }.to raise_error(ArgumentError, /magic/)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'requires explicit :uf2 protocol and rejects unknown options' do
|
|
74
|
+
[@options.except(:protocol), @options.merge(protocol: :esp_rom), @options.merge(offset: 0)].each do |options|
|
|
75
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(options) }.to raise_error(ArgumentError)
|
|
76
|
+
end
|
|
77
|
+
expect(Dir.children(@mount)).to eq(['INFO_UF2.TXT'])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'validates the source file and accepts exactly one immutable snapshot' do
|
|
81
|
+
source = File.join(File.dirname(@mount), 'input.uf2')
|
|
82
|
+
File.binwrite(source, block)
|
|
83
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options.merge(firmware: source)) }.to raise_error(ArgumentError)
|
|
84
|
+
result = Meshtastic::Admin::Firmware::UF2.install(@options.except(:bytes).merge(firmware: source))
|
|
85
|
+
expect(result[:sha256]).to eq(Digest::SHA256.hexdigest(File.binread(source)))
|
|
86
|
+
expect(File.binread(File.join(@mount, 'FIRMWARE.UF2'))).to eq(File.binread(source))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'rejects missing or non-string bytes and unknown families before writing' do
|
|
90
|
+
[@options.except(:bytes), @options.merge(bytes: nil), @options.merge(bytes: 42), @options.merge(family_id: 123)].each do |options|
|
|
91
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(options) }.to raise_error(ArgumentError)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'submits an RP2040 flash image only inside the explicitly supplied flash capacity' do
|
|
96
|
+
File.write(File.join(@mount, 'INFO_UF2.TXT'), "UF2 Bootloader v3.0\nModel: Raspberry Pi RP2\nBoard-ID: RPI-RP2\n")
|
|
97
|
+
options = @options.merge(bytes: block(address: 0x10000000, family: 0xe48bff56), board_id: 'RPI-RP2', family_id: 0xe48bff56)
|
|
98
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(options) }.to raise_error(ArgumentError, /flash_size/)
|
|
99
|
+
result = Meshtastic::Admin::Firmware::UF2.install(options.merge(flash_size: 2 * 1024 * 1024))
|
|
100
|
+
expect(result[:status]).to eq(:copied)
|
|
101
|
+
expect(File.binread(result[:destination])).to eq(options[:bytes])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'rejects RP2040 RAM, flash overflow and invalid capacities' do
|
|
105
|
+
File.write(File.join(@mount, 'INFO_UF2.TXT'), "UF2 Bootloader v3.0\nBoard-ID: RPI-RP2\n")
|
|
106
|
+
options = @options.merge(board_id: 'RPI-RP2', family_id: 0xe48bff56, flash_size: 2 * 1024 * 1024)
|
|
107
|
+
[0x20000000, 0x10200000, 0x101ffffc].each do |address|
|
|
108
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(options.merge(bytes: block(address: address, family: 0xe48bff56))) }.to raise_error(ArgumentError)
|
|
109
|
+
end
|
|
110
|
+
[0, -1, '2097152', 32 * 1024 * 1024].each do |size|
|
|
111
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(options.merge(flash_size: size)) }.to raise_error(ArgumentError)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
describe 'Meshtastic::Admin::Firmware::UF2 mount safety' do
|
|
117
|
+
include_context 'UF2 bootloader files'
|
|
118
|
+
|
|
119
|
+
it 'refuses a directory without INFO_UF2.TXT' do
|
|
120
|
+
File.unlink(File.join(@mount, 'INFO_UF2.TXT'))
|
|
121
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options) }.to raise_error(ArgumentError, /INFO_UF2/)
|
|
122
|
+
expect(Dir.children(@mount)).to be_empty
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'requires a canonical absolute directory selected by the caller' do
|
|
126
|
+
[nil, '', '.', '/', File.join(@mount, '..', 'boot')].each do |mount|
|
|
127
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options.merge(mount: mount)) }.to raise_error(ArgumentError)
|
|
128
|
+
end
|
|
129
|
+
expect(Dir.children(@mount)).to eq(['INFO_UF2.TXT'])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'refuses a symlinked directory or parent directory' do
|
|
133
|
+
link = File.join(File.dirname(@mount), 'link')
|
|
134
|
+
File.symlink(@mount, link)
|
|
135
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options.merge(mount: link)) }.to raise_error(ArgumentError)
|
|
136
|
+
File.unlink(link)
|
|
137
|
+
File.symlink(File.dirname(@mount), link)
|
|
138
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options.merge(mount: File.join(link, 'boot'))) }.to raise_error(ArgumentError)
|
|
139
|
+
expect(Dir.children(@mount)).to eq(['INFO_UF2.TXT'])
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'refuses a symlinked or nonregular marker' do
|
|
143
|
+
marker = File.join(@mount, 'INFO_UF2.TXT')
|
|
144
|
+
saved = File.join(File.dirname(@mount), 'saved')
|
|
145
|
+
File.rename(marker, saved)
|
|
146
|
+
File.symlink(saved, marker)
|
|
147
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options) }.to raise_error(ArgumentError)
|
|
148
|
+
File.unlink(marker)
|
|
149
|
+
File.mkfifo(marker)
|
|
150
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options) }.to raise_error(ArgumentError)
|
|
151
|
+
expect(Dir.children(@mount)).to eq(['INFO_UF2.TXT'])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it 'checks the exact expected Board-ID and its MCU family' do
|
|
155
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options.merge(board_id: 'nRF52840-Other-v1')) }.to raise_error(ArgumentError)
|
|
156
|
+
File.write(File.join(@mount, 'INFO_UF2.TXT'), "UF2 Bootloader v3.0\nBoard-ID: RPI-RP2\n")
|
|
157
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options.merge(board_id: 'RPI-RP2')) }.to raise_error(ArgumentError)
|
|
158
|
+
expect(Dir.children(@mount)).to eq(['INFO_UF2.TXT'])
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'rejects malformed, oversized, missing and duplicate board metadata' do
|
|
162
|
+
["Board-ID: nRF52840-Test-v1\n", "UF2 Bootloader\n", "UF2 Bootloader\nBoard-ID: nRF52840-Test-v1\nBoard-ID: nRF52840-Test-v1\n", 'x' * 4097].each do |text|
|
|
163
|
+
File.write(File.join(@mount, 'INFO_UF2.TXT'), text)
|
|
164
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options) }.to raise_error(ArgumentError)
|
|
165
|
+
expect(Dir.children(@mount)).to eq(['INFO_UF2.TXT'])
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it 'recognizes the lowercase standard marker filename' do
|
|
170
|
+
File.rename(File.join(@mount, 'INFO_UF2.TXT'), File.join(@mount, 'info_uf2.txt'))
|
|
171
|
+
expect(Meshtastic::Admin::Firmware::UF2.install(@options)[:status]).to eq(:copied)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it 'never overwrites existing destinations or follows destination symlinks' do
|
|
175
|
+
destination = File.join(@mount, 'FIRMWARE.UF2')
|
|
176
|
+
File.write(destination, 'existing')
|
|
177
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options) }.to raise_error(Errno::EEXIST)
|
|
178
|
+
expect(File.read(destination)).to eq('existing')
|
|
179
|
+
File.unlink(destination)
|
|
180
|
+
outside = File.join(File.dirname(@mount), 'outside')
|
|
181
|
+
File.write(outside, 'untouched')
|
|
182
|
+
File.symlink(outside, destination)
|
|
183
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options) }.to raise_error(Errno::EEXIST)
|
|
184
|
+
expect(File.read(outside)).to eq('untouched')
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'does not redirect writes when the chosen mount is replaced during validation' do
|
|
188
|
+
original_mount = "#{@mount}-old"
|
|
189
|
+
allow(File).to receive(:stat).and_call_original
|
|
190
|
+
allow(File).to receive(:stat).with(@mount).and_wrap_original do |method, path|
|
|
191
|
+
File.rename(@mount, original_mount)
|
|
192
|
+
Dir.mkdir(@mount)
|
|
193
|
+
method.call(path)
|
|
194
|
+
end
|
|
195
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options) }.to raise_error(IOError, /mount changed/)
|
|
196
|
+
expect(Dir.children(@mount)).to be_empty
|
|
197
|
+
expect(Dir.children(original_mount)).to eq(['INFO_UF2.TXT'])
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it 'propagates disconnect or fsync failure without claiming success or retrying' do
|
|
201
|
+
attempts = 0
|
|
202
|
+
allow(File).to receive(:open).and_wrap_original do |method, path, *args, &callback|
|
|
203
|
+
if path.end_with?('/FIRMWARE.UF2')
|
|
204
|
+
attempts += 1
|
|
205
|
+
method.call(path, *args) do |file|
|
|
206
|
+
allow(file).to receive(:fsync).and_raise(Errno::ENODEV)
|
|
207
|
+
callback.call(file)
|
|
208
|
+
end
|
|
209
|
+
else
|
|
210
|
+
method.call(path, *args, &callback)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options) }.to raise_error(Errno::ENODEV)
|
|
214
|
+
expect(attempts).to eq(1)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'allows shuffled blocks and holes without reinterpreting addresses or reordering bytes' do
|
|
218
|
+
bytes = block(number: 1, count: 2, address: 0x28000) + block(count: 2)
|
|
219
|
+
Meshtastic::Admin::Firmware::UF2.install(@options.merge(bytes: bytes))
|
|
220
|
+
expect(File.binread(File.join(@mount, 'FIRMWARE.UF2'))).to eq(bytes)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it 'rejects a raw binary image without writing anything' do
|
|
224
|
+
expect { Meshtastic::Admin::Firmware::UF2.install(@options.merge(bytes: 'x' * 512)) }.to raise_error(ArgumentError, /magic/)
|
|
225
|
+
expect(Dir.children(@mount)).to eq(['INFO_UF2.TXT'])
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
it 'copies intact UF2 bytes to an explicitly selected bootloader without claiming flash verification' do
|
|
229
|
+
expect(Meshtastic::Admin::Firmware.const_defined?(:UF2)).to be true
|
|
230
|
+
result = Meshtastic::Admin::Firmware::UF2.install(@options)
|
|
231
|
+
expect(File.binread(File.join(@mount, 'FIRMWARE.UF2'))).to eq(block)
|
|
232
|
+
expect(result).to include(status: :copied, protocol: :uf2, bytes: 512, family_id: 0xada52840, board_id: 'nRF52840-Test-v1', flash_verified: false, reboot_verified: false)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -1,10 +1,101 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Meshtastic::Admin::Firmware do
|
|
6
|
+
it 'documents transport_obj rather than legacy connection keywords' do
|
|
7
|
+
expect { described_class.help }.to output(/transport_obj:/).to_stdout
|
|
8
|
+
expect { described_class.help }.not_to output(/serial_obj:|bluetooth_obj:|tcp_obj:|mqtt_obj:/).to_stdout
|
|
9
|
+
end
|
|
10
|
+
end
|
|
4
11
|
require 'socket'
|
|
5
12
|
require 'digest'
|
|
6
13
|
require 'tempfile'
|
|
7
14
|
|
|
15
|
+
RSpec.describe Meshtastic::Admin::Firmware do
|
|
16
|
+
it 'dispatches Intel HEX to the explicit SWD programmer' do
|
|
17
|
+
expect(described_class.const_defined?(:Hex, false)).to be true
|
|
18
|
+
options = { protocol: :swd, bytes: ':00000001FF', expected_chip: :nrf52840 }
|
|
19
|
+
expect(described_class::Hex).to receive(:install).with(options).and_return(status: :verified)
|
|
20
|
+
expect(described_class.install(options.merge(format: :hex))).to eq(status: :verified)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'rejects legacy OTA connection keys explicitly before inference or file access' do
|
|
24
|
+
%i[serial_obj bluetooth_obj tcp_obj mqtt_obj].each do |key|
|
|
25
|
+
expect(Meshtastic::Admin).not_to receive(:send)
|
|
26
|
+
expect { described_class.request_ota(key => nil, firmware: '/missing') }
|
|
27
|
+
.to raise_error(ArgumentError, /#{key}.*transport_obj/)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'separates explicit OTA transfer selection from the Admin control handle' do
|
|
32
|
+
handle = { serial_conn: Object.new }
|
|
33
|
+
expect(Meshtastic::Admin).to receive(:send) do |options|
|
|
34
|
+
expect(options[:transport_obj]).to equal(handle)
|
|
35
|
+
expect(options).not_to have_key(:transfer)
|
|
36
|
+
expect(options[:ota_request].reboot_ota_mode).to eq(:OTA_WIFI)
|
|
37
|
+
end
|
|
38
|
+
described_class.request_ota(transport_obj: handle, transfer: :wifi, bytes: 'abc')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'requires an unambiguous supported transfer choice before sending OTA' do
|
|
42
|
+
invalid = [{ transport_obj: { serial_conn: Object.new } }, { transport_obj: MQTT::Client.new }, {},
|
|
43
|
+
{ transfer: :mqtt }, { transfer: nil }, { transfer: :wifi, mode: :OTA_BLE },
|
|
44
|
+
{ transfer: :ble, mode: :UNKNOWN }]
|
|
45
|
+
expect(Meshtastic::Admin).not_to receive(:send)
|
|
46
|
+
invalid.each do |options|
|
|
47
|
+
expect { described_class.request_ota(options.merge(bytes: 'abc')) }.to raise_error(ArgumentError, /transfer|mode/)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'infers OTA transfer only from a single Bluetooth or TCP control handle' do
|
|
52
|
+
tcp_socket = Object.new
|
|
53
|
+
[{ transport_obj: { bluetooth_conn: Object.new } },
|
|
54
|
+
{ transport_obj: { tcp_socket: tcp_socket, serial_conn: tcp_socket } }].zip(%i[OTA_BLE OTA_WIFI]).each do |options, mode|
|
|
55
|
+
expect(Meshtastic::Admin).to receive(:send) { |request| expect(request[:ota_request].reboot_ota_mode).to eq(mode) }
|
|
56
|
+
described_class.request_ota(options.merge(bytes: 'abc'))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'dispatches explicit UF2 format to the mounted-volume installer' do
|
|
61
|
+
expect(described_class.const_defined?(:UF2, false)).to be true
|
|
62
|
+
options = { protocol: :uf2, bytes: 'UF2 fixture', mount: '/selected/volume' }
|
|
63
|
+
expect(described_class::UF2).to receive(:install).with(options).and_return(status: :copied)
|
|
64
|
+
expect(described_class.install(options.merge(format: :uf2))).to eq(status: :copied)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'rejects incompatible declared formats before invoking an installer' do
|
|
68
|
+
expect(described_class::BLE).not_to receive(:install)
|
|
69
|
+
expect(described_class::SerialBootloader).not_to receive(:install)
|
|
70
|
+
expect(described_class::NordicDFU).not_to receive(:install)
|
|
71
|
+
expect(Socket).not_to receive(:tcp)
|
|
72
|
+
{ unified_wifi: :uf2, unified_ble: :zip, esp_rom: :hex, nordic_dfu: :bin }.each do |protocol, format|
|
|
73
|
+
expect { described_class.install(protocol: protocol, format: format, bytes: 'abc') }.to raise_error(ArgumentError, /format/)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'rejects UF2 ZIP and Intel HEX content before opening binary loaders' do
|
|
78
|
+
expect(described_class::BLE).not_to receive(:install)
|
|
79
|
+
expect(described_class::SerialBootloader).not_to receive(:install)
|
|
80
|
+
expect(Socket).not_to receive(:tcp)
|
|
81
|
+
images = ["#{[0x0a324655, 0x9e5d5157].pack('V2')}payload", "PK\x03\x04archive", ':020000040000FA\n']
|
|
82
|
+
%i[unified_wifi unified_ble esp_rom].each do |protocol|
|
|
83
|
+
images.each do |bytes|
|
|
84
|
+
expect { described_class.install(protocol: protocol, bytes: bytes) }.to raise_error(ArgumentError, /format/)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'rejects nonbinary filename formats even when their contents look binary' do
|
|
90
|
+
expect(described_class::BLE).not_to receive(:install)
|
|
91
|
+
Tempfile.create(['image', '.uf2']) do |file|
|
|
92
|
+
file.write('abc')
|
|
93
|
+
file.flush
|
|
94
|
+
expect { described_class.install(protocol: :unified_ble, format: :bin, firmware: file.path) }.to raise_error(ArgumentError, /format/)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
8
99
|
RSpec.describe Meshtastic::Admin::Firmware do
|
|
9
100
|
it 'dispatches explicitly to independent native bootloader protocols' do
|
|
10
101
|
%i[esp_rom nordic_dfu].each do |protocol|
|
|
@@ -39,6 +130,17 @@ RSpec.describe Meshtastic::Admin::Firmware do
|
|
|
39
130
|
end
|
|
40
131
|
end
|
|
41
132
|
|
|
133
|
+
it 'uses transport_obj for reboot metadata but named keys for low-level lifecycle calls' do
|
|
134
|
+
handle = { serial_conn: Object.new, my_node_num: 123 }
|
|
135
|
+
expect(Meshtastic::Serial).to receive(:wait_for_config).with(serial_obj: handle, timeout: 1).and_return(handle)
|
|
136
|
+
expect(Meshtastic::Serial).to receive(:disconnect).with(serial_obj: handle)
|
|
137
|
+
expect(Meshtastic::Admin).to receive(:request).with(transport_obj: handle, get_device_metadata_request: true, timeout: 1)
|
|
138
|
+
.and_return(value: Meshtastic::DeviceMetadata.new(firmware_version: 'expected'))
|
|
139
|
+
result = described_class.verify_reboot(transport: :serial, reconnect: ->(_options) { handle },
|
|
140
|
+
expected_version: 'expected', reboot_delay: 0, timeout: 1)
|
|
141
|
+
expect(result).to include(status: :boot_verified, node_num: 123)
|
|
142
|
+
end
|
|
143
|
+
|
|
42
144
|
it 'ignores cached metadata and checks a fresh correlated Admin reply after callback reconnect' do
|
|
43
145
|
queue = Queue.new
|
|
44
146
|
writer = Object.new
|
|
@@ -170,6 +272,7 @@ describe Meshtastic::Admin::Firmware do
|
|
|
170
272
|
[{ bytes: '' }, { bytes: 123 }, { bytes: nil }, { firmware: '/missing', bytes: 'abc' },
|
|
171
273
|
{ host: '' }, { port: 0 }, { timeout: 0 }, { timeout: Float::INFINITY },
|
|
172
274
|
{ retries: -1 }, { retry_delay: -1 }, { mode: :OTA_BLE }, { tcp_obj: Object.new },
|
|
275
|
+
{ transport_obj: { tcp_socket: Object.new, serial_conn: Object.new } },
|
|
173
276
|
{ to: '!aabbccdd' }, { protocol: :unified_wifi, bluetooth_obj: Object.new }].each do |invalid|
|
|
174
277
|
expect(Socket).not_to receive(:tcp)
|
|
175
278
|
expect { described_class.install(defaults.merge(invalid)) }.to raise_error(ArgumentError)
|
|
@@ -197,7 +300,7 @@ describe Meshtastic::Admin::Firmware do
|
|
|
197
300
|
end
|
|
198
301
|
connection.define_singleton_method(:flush) { true }
|
|
199
302
|
serial = { serial_conn: connection, my_node_num: 0xb0b }
|
|
200
|
-
described_class.request_ota(
|
|
303
|
+
described_class.request_ota(transport_obj: serial, bytes: 'abc', mode: :OTA_WIFI)
|
|
201
304
|
length = written.byteslice(2, 2).unpack1('n')
|
|
202
305
|
packet = Meshtastic::ToRadio.decode(written.byteslice(4, length)).packet
|
|
203
306
|
admin = Meshtastic::AdminMessage.decode(packet.decoded.payload)
|
|
@@ -205,7 +308,7 @@ describe Meshtastic::Admin::Firmware do
|
|
|
205
308
|
expect(admin.ota_request.reboot_ota_mode).to eq(:OTA_WIFI)
|
|
206
309
|
expect(admin.ota_request.ota_hash).to eq(Digest::SHA256.digest('abc'))
|
|
207
310
|
written.clear
|
|
208
|
-
described_class.enter_dfu(
|
|
311
|
+
described_class.enter_dfu(transport_obj: serial)
|
|
209
312
|
length = written.byteslice(2, 2).unpack1('n')
|
|
210
313
|
packet = Meshtastic::ToRadio.decode(written.byteslice(4, length)).packet
|
|
211
314
|
expect(Meshtastic::AdminMessage.decode(packet.decoded.payload).enter_dfu_mode_request).to be true
|
|
@@ -306,7 +409,7 @@ describe Meshtastic::Admin::Firmware do
|
|
|
306
409
|
end
|
|
307
410
|
|
|
308
411
|
it 'rejects PhoneAPI and MQTT installation without sending any commands' do
|
|
309
|
-
%i[serial_obj tcp_obj bluetooth_obj mqtt_obj].each do |transport|
|
|
412
|
+
%i[transport_obj serial_obj tcp_obj bluetooth_obj mqtt_obj].each do |transport|
|
|
310
413
|
expect(Meshtastic::Admin).not_to receive(:send)
|
|
311
414
|
expect { described_class.install(transport => Object.new, bytes: 'abc') }
|
|
312
415
|
.to raise_error(NotImplementedError, /unified_wifi/)
|