meshtastic 0.0.182 → 0.0.184

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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/documentation/README.md +5 -0
  3. data/documentation/admin-backup.md +369 -0
  4. data/documentation/admin-channel.md +6 -6
  5. data/documentation/admin-config.md +5 -5
  6. data/documentation/admin-firmware-hex.md +57 -0
  7. data/documentation/admin-firmware-serial.md +1 -1
  8. data/documentation/admin-firmware-uf2.md +94 -0
  9. data/documentation/admin-firmware.md +52 -10
  10. data/documentation/admin.md +12 -2
  11. data/documentation/module-config.md +8 -4
  12. data/documentation/rtttl.md +8 -4
  13. data/lib/meshtastic/admin/backup.rb +560 -0
  14. data/lib/meshtastic/admin/channel.rb +6 -3
  15. data/lib/meshtastic/admin/config.rb +14 -14
  16. data/lib/meshtastic/admin/firmware/hex.rb +209 -0
  17. data/lib/meshtastic/admin/firmware/uf2.rb +166 -0
  18. data/lib/meshtastic/admin/firmware.rb +55 -13
  19. data/lib/meshtastic/admin.rb +60 -23
  20. data/lib/meshtastic/config_pb.rb +1 -1
  21. data/lib/meshtastic/mesh_pb.rb +1 -1
  22. data/lib/meshtastic/module_config.rb +22 -4
  23. data/lib/meshtastic/module_config_pb.rb +2 -1
  24. data/lib/meshtastic/rtttl.rb +22 -4
  25. data/lib/meshtastic/version.rb +1 -1
  26. data/spec/lib/meshtastic/admin/backup_spec.rb +704 -0
  27. data/spec/lib/meshtastic/admin/channel_spec.rb +23 -9
  28. data/spec/lib/meshtastic/admin/config_spec.rb +28 -12
  29. data/spec/lib/meshtastic/admin/firmware/hex_spec.rb +160 -0
  30. data/spec/lib/meshtastic/admin/firmware/uf2_spec.rb +234 -0
  31. data/spec/lib/meshtastic/admin/firmware_spec.rb +106 -3
  32. data/spec/lib/meshtastic/admin_spec.rb +106 -40
  33. data/spec/lib/meshtastic/module_config_spec.rb +47 -0
  34. data/spec/lib/meshtastic/rtttl_spec.rb +47 -0
  35. metadata +10 -1
@@ -2,6 +2,13 @@
2
2
 
3
3
  require 'spec_helper'
4
4
 
5
+ RSpec.describe Meshtastic::Admin::Channel 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
11
+
5
12
  shared_context 'channel serial framing' do
6
13
  def fake_serial_obj
7
14
  written = +''.b
@@ -26,16 +33,23 @@ end
26
33
  describe Meshtastic::Admin::Channel do
27
34
  include_context 'channel serial framing'
28
35
 
36
+ it 'rejects legacy connection keys before filtering or building channel options' do
37
+ %i[serial_obj bluetooth_obj tcp_obj mqtt_obj].each do |key|
38
+ expect { described_class.set(transport_obj: fake_serial_obj, key => nil, index: -1) }
39
+ .to raise_error(ArgumentError, /#{key}.*transport_obj/)
40
+ end
41
+ end
42
+
29
43
  it 'requests a channel by index via Admin' do
30
44
  serial_obj = fake_serial_obj
31
- described_class.get(serial_obj: serial_obj, index: 1)
45
+ described_class.get(transport_obj: serial_obj, index: 1)
32
46
  expect(decode_admin(serial_obj).get_channel_request).to eq(2)
33
47
  end
34
48
 
35
49
  [0, 7].each do |index|
36
50
  it "requests zero-based slot #{index} with its one-based wire index" do
37
51
  serial_obj = fake_serial_obj
38
- described_class.get(serial_obj: serial_obj, index: index)
52
+ described_class.get(transport_obj: serial_obj, index: index)
39
53
  expect(decode_admin(serial_obj).get_channel_request).to eq(index + 1)
40
54
  end
41
55
  end
@@ -43,7 +57,7 @@ describe Meshtastic::Admin::Channel do
43
57
  it 'sets a Channel protobuf including settings and role' do
44
58
  serial_obj = fake_serial_obj
45
59
  settings = described_class.build_settings(name: 'LongFast', uplink_enabled: true)
46
- described_class.set(serial_obj: serial_obj, index: 0, role: :PRIMARY, settings: settings)
60
+ described_class.set(transport_obj: serial_obj, index: 0, role: :PRIMARY, settings: settings)
47
61
  channel = decode_admin(serial_obj).set_channel
48
62
  expect(channel.index).to eq(0)
49
63
  expect(channel.role).to eq(:PRIMARY)
@@ -124,7 +138,7 @@ describe Meshtastic::Admin::Channel, 'channel URL support' do
124
138
  it 'rejects invalid settings lengths and slot indexes before any write' do
125
139
  [-1, 8, 0.5, 'junk'].each do |index|
126
140
  serial_obj = fake_serial_obj
127
- expect { described_class.set(serial_obj: serial_obj, index: index, role: :PRIMARY) }.to raise_error(ArgumentError)
141
+ expect { described_class.set(transport_obj: serial_obj, index: index, role: :PRIMARY) }.to raise_error(ArgumentError)
128
142
  expect(serial_obj[:written]).to be_empty
129
143
  end
130
144
  expect { described_class.build_settings(psk: 'bad') }.to raise_error(ArgumentError)
@@ -137,7 +151,7 @@ describe Meshtastic::Admin::Channel, 'channel URL support' do
137
151
  it 'validates and overlays supplied channel/settings before transmission' do
138
152
  serial_obj = fake_serial_obj
139
153
  original = Meshtastic::Channel.new(index: 2, role: :SECONDARY, settings: { name: 'before', uplink_enabled: true })
140
- described_class.set(serial_obj: serial_obj, channel: original, name: 'after', uplink_enabled: false)
154
+ described_class.set(transport_obj: serial_obj, channel: original, name: 'after', uplink_enabled: false)
141
155
  written = decode_admin(serial_obj).set_channel
142
156
  expect(written.index).to eq(2)
143
157
  expect(written.settings.name).to eq('after')
@@ -145,7 +159,7 @@ describe Meshtastic::Admin::Channel, 'channel URL support' do
145
159
  expect(original.settings.name).to eq('before')
146
160
  expect(described_class.build(settings: { name: 'hash' }).settings.name).to eq('hash')
147
161
  invalid = Meshtastic::Channel.new(index: 8)
148
- expect { described_class.set(serial_obj: fake_serial_obj, channel: invalid) }.to raise_error(ArgumentError)
162
+ expect { described_class.set(transport_obj: fake_serial_obj, channel: invalid) }.to raise_error(ArgumentError)
149
163
  expect { described_class.build(settings: { psk: 'bad' }) }.to raise_error(ArgumentError)
150
164
  end
151
165
 
@@ -153,7 +167,7 @@ describe Meshtastic::Admin::Channel, 'channel URL support' do
153
167
  serial_obj = fake_serial_obj
154
168
  channel_set = Meshtastic::ChannelSet.new(settings: [{ name: 'main' }, { name: 'other' }], lora_config: { region: :US })
155
169
  url = "https://meshtastic.org/e/##{Base64.urlsafe_encode64(channel_set.to_proto, padding: false)}"
156
- results = described_class.apply_url(serial_obj: serial_obj, url: url, session_passkey: 'test-key')
170
+ results = described_class.apply_url(transport_obj: serial_obj, url: url, session_passkey: 'test-key')
157
171
  frames = serial_obj[:written].dup
158
172
  messages = []
159
173
  until frames.empty?
@@ -176,11 +190,11 @@ describe Meshtastic::Admin::Channel, 'channel URL support' do
176
190
  serial_obj = fake_serial_obj
177
191
  invalid = Meshtastic::ChannelSet.new(settings: [{ name: 'good' }, { psk: 'bad' }])
178
192
  url = "https://meshtastic.org/e/##{Base64.urlsafe_encode64(invalid.to_proto, padding: false)}"
179
- expect { described_class.apply_url(serial_obj: serial_obj, url: url) }.to raise_error(ArgumentError)
193
+ expect { described_class.apply_url(transport_obj: serial_obj, url: url) }.to raise_error(ArgumentError)
180
194
  expect(serial_obj[:written]).to be_empty
181
195
  valid = Meshtastic::ChannelSet.new(settings: [{ name: 'test' }])
182
196
  url = "https://meshtastic.org/e/?add=true##{Base64.urlsafe_encode64(valid.to_proto, padding: false)}"
183
- expect { described_class.apply_url(serial_obj: serial_obj, url: url) }.to raise_error(ArgumentError, /add-only/)
197
+ expect { described_class.apply_url(transport_obj: serial_obj, url: url) }.to raise_error(ArgumentError, /add-only/)
184
198
  expect(serial_obj[:written]).to be_empty
185
199
  end
186
200
 
@@ -2,6 +2,13 @@
2
2
 
3
3
  require 'spec_helper'
4
4
 
5
+ RSpec.describe Meshtastic::Admin::Config 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
11
+
5
12
  describe Meshtastic::Admin::Config do
6
13
  def fake_serial_obj
7
14
  written = +''.b
@@ -22,9 +29,18 @@ describe Meshtastic::Admin::Config do
22
29
  Meshtastic::AdminMessage.decode(Meshtastic::ToRadio.decode(body).packet.decoded.payload)
23
30
  end
24
31
 
32
+ it 'rejects legacy connection keys rather than silently filtering them' do
33
+ %i[serial_obj bluetooth_obj tcp_obj mqtt_obj].each do |key|
34
+ handle = fake_serial_obj
35
+ expect { described_class.set_device_ui(transport_obj: handle, key => nil, device_ui: {}) }
36
+ .to raise_error(ArgumentError, /#{key}/)
37
+ expect(handle[:written]).to be_empty
38
+ end
39
+ end
40
+
25
41
  it 'requests LoRa config via Admin' do
26
42
  serial_obj = fake_serial_obj
27
- described_class.get(serial_obj: serial_obj, config_type: :LORA_CONFIG)
43
+ described_class.get(transport_obj: serial_obj, config_type: :LORA_CONFIG)
28
44
  expect(decode_admin(serial_obj).get_config_request).to eq(:LORA_CONFIG)
29
45
  end
30
46
 
@@ -32,13 +48,13 @@ describe Meshtastic::Admin::Config do
32
48
  serial_obj = fake_serial_obj
33
49
  config = Meshtastic::Config.new
34
50
  config.device = Meshtastic::Config::DeviceConfig.new(role: :CLIENT)
35
- described_class.set(serial_obj: serial_obj, config: config)
51
+ described_class.set(transport_obj: serial_obj, config: config)
36
52
  expect(decode_admin(serial_obj).set_config.device.role).to eq(:CLIENT)
37
53
  end
38
54
 
39
55
  it 'requests each ConfigType via named getters' do
40
56
  serial_obj = fake_serial_obj
41
- described_class.get_lora(serial_obj: serial_obj)
57
+ described_class.get_lora(transport_obj: serial_obj)
42
58
  expect(decode_admin(serial_obj).get_config_request).to eq(:LORA_CONFIG)
43
59
  end
44
60
 
@@ -48,7 +64,7 @@ describe Meshtastic::Admin::Config do
48
64
  it "writes the #{field.name} section through real serial framing" do
49
65
  serial_obj = fake_serial_obj
50
66
  section = field.subtype.msgclass.new
51
- described_class.public_send("set_#{field.name}", serial_obj: serial_obj, field.name.to_sym => section)
67
+ described_class.public_send("set_#{field.name}", transport_obj: serial_obj, field.name.to_sym => section)
52
68
  config = decode_admin(serial_obj).set_config
53
69
  expect(config.payload_variant).to eq(field.name.to_sym)
54
70
  expect(config[field.name]).to eq(section)
@@ -57,7 +73,7 @@ describe Meshtastic::Admin::Config do
57
73
 
58
74
  it 'rejects an empty config before writing bytes' do
59
75
  serial_obj = fake_serial_obj
60
- expect { described_class.set(serial_obj: serial_obj, config: Meshtastic::Config.new) }.to raise_error(ArgumentError)
76
+ expect { described_class.set(transport_obj: serial_obj, config: Meshtastic::Config.new) }.to raise_error(ArgumentError)
61
77
  expect(serial_obj[:written]).to be_empty
62
78
  end
63
79
 
@@ -66,31 +82,31 @@ describe Meshtastic::Admin::Config do
66
82
 
67
83
  it "accepts a field hash for #{field.name}" do
68
84
  serial_obj = fake_serial_obj
69
- described_class.public_send("set_#{field.name}", serial_obj: serial_obj, field.name.to_sym => {})
85
+ described_class.public_send("set_#{field.name}", transport_obj: serial_obj, field.name.to_sym => {})
70
86
  expect(decode_admin(serial_obj).set_config.payload_variant).to eq(field.name.to_sym)
71
87
  end
72
88
 
73
89
  it "rejects missing #{field.name} before writing bytes" do
74
90
  serial_obj = fake_serial_obj
75
- expect { described_class.public_send("set_#{field.name}", serial_obj: serial_obj) }.to(raise_error { |error| expect([ArgumentError, KeyError]).to include(error.class) })
91
+ expect { described_class.public_send("set_#{field.name}", transport_obj: serial_obj) }.to(raise_error { |error| expect([ArgumentError, KeyError]).to include(error.class) })
76
92
  expect(serial_obj[:written]).to be_empty
77
93
  end
78
94
  end
79
95
 
80
96
  it 'uses dedicated device UI requests and stores instead of firmware no-op Config fields' do
81
97
  serial_obj = fake_serial_obj
82
- described_class.get_device_ui(serial_obj: serial_obj)
98
+ described_class.get_device_ui(transport_obj: serial_obj)
83
99
  expect(decode_admin(serial_obj).payload_variant).to eq(:get_ui_config_request)
84
100
  serial_obj = fake_serial_obj
85
- described_class.set_device_ui(serial_obj: serial_obj, device_ui: {})
101
+ described_class.set_device_ui(transport_obj: serial_obj, device_ui: {})
86
102
  expect(decode_admin(serial_obj).payload_variant).to eq(:store_ui_config)
87
103
  end
88
104
 
89
105
  it 'rejects the read-only session-key placeholder without transmitting' do
90
106
  serial_obj = fake_serial_obj
91
107
  config = Meshtastic::Config.new(sessionkey: {})
92
- expect { described_class.set(serial_obj: serial_obj, config: config) }.to raise_error(ArgumentError, /request-only/)
93
- expect { described_class.set_sessionkey(serial_obj: serial_obj, sessionkey: {}) }.to raise_error(ArgumentError, /request-only/)
108
+ expect { described_class.set(transport_obj: serial_obj, config: config) }.to raise_error(ArgumentError, /request-only/)
109
+ expect { described_class.set_sessionkey(transport_obj: serial_obj, sessionkey: {}) }.to raise_error(ArgumentError, /request-only/)
94
110
  expect(serial_obj[:written]).to be_empty
95
111
  end
96
112
 
@@ -101,7 +117,7 @@ describe Meshtastic::Admin::Config do
101
117
  }.each do |section, config_type|
102
118
  it "requests #{section} using its protocol ConfigType" do
103
119
  serial_obj = fake_serial_obj
104
- described_class.public_send("get_#{section}", serial_obj: serial_obj)
120
+ described_class.public_send("get_#{section}", transport_obj: serial_obj)
105
121
  message = decode_admin(serial_obj)
106
122
  expect(message.payload_variant).to eq(:get_config_request)
107
123
  expect(message.get_config_request).to eq(config_type)
@@ -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