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.
@@ -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(serial_obj: serial, bytes: 'abc', mode: :OTA_WIFI)
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(serial_obj: serial)
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/)