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.
- checksums.yaml +4 -4
- data/documentation/README.md +5 -0
- data/documentation/admin-backup.md +369 -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 +12 -2
- data/documentation/module-config.md +8 -4
- data/documentation/rtttl.md +8 -4
- data/lib/meshtastic/admin/backup.rb +560 -0
- 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 +60 -23
- data/lib/meshtastic/config_pb.rb +1 -1
- data/lib/meshtastic/mesh_pb.rb +1 -1
- data/lib/meshtastic/module_config.rb +22 -4
- data/lib/meshtastic/module_config_pb.rb +2 -1
- data/lib/meshtastic/rtttl.rb +22 -4
- data/lib/meshtastic/version.rb +1 -1
- data/spec/lib/meshtastic/admin/backup_spec.rb +704 -0
- 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 +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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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}",
|
|
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(
|
|
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}",
|
|
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}",
|
|
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(
|
|
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(
|
|
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(
|
|
93
|
-
expect { described_class.set_sessionkey(
|
|
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}",
|
|
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
|