meshtastic 0.0.182 → 0.0.183
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/documentation/README.md +4 -0
- data/documentation/admin-channel.md +6 -6
- data/documentation/admin-config.md +5 -5
- data/documentation/admin-firmware-hex.md +57 -0
- data/documentation/admin-firmware-serial.md +1 -1
- data/documentation/admin-firmware-uf2.md +94 -0
- data/documentation/admin-firmware.md +52 -10
- data/documentation/admin.md +8 -2
- data/documentation/module-config.md +8 -4
- data/documentation/rtttl.md +8 -4
- data/lib/meshtastic/admin/channel.rb +6 -3
- data/lib/meshtastic/admin/config.rb +14 -14
- data/lib/meshtastic/admin/firmware/hex.rb +209 -0
- data/lib/meshtastic/admin/firmware/uf2.rb +166 -0
- data/lib/meshtastic/admin/firmware.rb +55 -13
- data/lib/meshtastic/admin.rb +58 -23
- data/lib/meshtastic/module_config.rb +22 -4
- data/lib/meshtastic/rtttl.rb +22 -4
- data/lib/meshtastic/version.rb +1 -1
- data/spec/lib/meshtastic/admin/channel_spec.rb +23 -9
- data/spec/lib/meshtastic/admin/config_spec.rb +28 -12
- data/spec/lib/meshtastic/admin/firmware/hex_spec.rb +160 -0
- data/spec/lib/meshtastic/admin/firmware/uf2_spec.rb +234 -0
- data/spec/lib/meshtastic/admin/firmware_spec.rb +106 -3
- data/spec/lib/meshtastic/admin_spec.rb +106 -40
- data/spec/lib/meshtastic/module_config_spec.rb +47 -0
- data/spec/lib/meshtastic/rtttl_spec.rb +47 -0
- metadata +7 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'tempfile'
|
|
6
|
+
require 'timeout'
|
|
7
|
+
|
|
8
|
+
module Meshtastic
|
|
9
|
+
module Admin
|
|
10
|
+
module Firmware
|
|
11
|
+
# Strict Intel HEX validation and explicitly selected nRF52840 SWD programming.
|
|
12
|
+
module Hex
|
|
13
|
+
MAX_IMAGE = 16 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
public_class_method def self.validate(opts = {})
|
|
16
|
+
raise ArgumentError, 'Only explicit expected_chip: :nrf52840 is supported' unless opts[:expected_chip] == :nrf52840
|
|
17
|
+
|
|
18
|
+
bytes = opts[:bytes]
|
|
19
|
+
raise ArgumentError, 'HEX bytes must be a nonempty String of at most 16 MiB' unless bytes.is_a?(String) && bytes.bytesize.between?(1, MAX_IMAGE)
|
|
20
|
+
|
|
21
|
+
base = 0
|
|
22
|
+
eof = false
|
|
23
|
+
start = nil
|
|
24
|
+
ranges = []
|
|
25
|
+
bytes.b.each_line do |raw|
|
|
26
|
+
line = raw.delete_suffix("\n").delete_suffix("\r")
|
|
27
|
+
raise ArgumentError, 'Invalid HEX record or data after EOF' if eof || !line.match?(/\A:(?:[0-9a-fA-F]{2}){5,260}\z/)
|
|
28
|
+
|
|
29
|
+
fields = [line[1..]].pack('H*').bytes
|
|
30
|
+
count, high, low, type = fields.first(4)
|
|
31
|
+
address = (high << 8) | low
|
|
32
|
+
data = fields[4, count]
|
|
33
|
+
raise ArgumentError, 'HEX length or checksum mismatch' unless fields.length == count + 5 && fields.sum.nobits?(255)
|
|
34
|
+
|
|
35
|
+
case type
|
|
36
|
+
when 0
|
|
37
|
+
raise ArgumentError, 'Empty data or record crosses 64 KiB boundary' unless count.positive? && address + count <= 0x10000
|
|
38
|
+
|
|
39
|
+
first = base + address
|
|
40
|
+
last = first + count
|
|
41
|
+
raise ArgumentError, 'HEX address outside nRF52840 flash/UICR' unless (first >= 0 && last <= 0x100000) || (first >= 0x10001000 && last <= 0x10002000)
|
|
42
|
+
|
|
43
|
+
ranges << [first, last]
|
|
44
|
+
when 1
|
|
45
|
+
raise ArgumentError, 'Malformed EOF record' unless count.zero? && address.zero?
|
|
46
|
+
|
|
47
|
+
eof = true
|
|
48
|
+
when 2, 4
|
|
49
|
+
raise ArgumentError, 'Malformed extended address record' unless count == 2 && address.zero?
|
|
50
|
+
|
|
51
|
+
base = data.pack('C*').unpack1('n') << (type == 2 ? 4 : 16)
|
|
52
|
+
when 3, 5
|
|
53
|
+
raise ArgumentError, 'Malformed or repeated start address record' unless count == 4 && address.zero? && start.nil?
|
|
54
|
+
|
|
55
|
+
start = if type == 3
|
|
56
|
+
segment, offset = data.pack('C*').unpack('n2')
|
|
57
|
+
(segment << 4) + offset
|
|
58
|
+
else
|
|
59
|
+
data.pack('C*').unpack1('N')
|
|
60
|
+
end
|
|
61
|
+
raise ArgumentError, 'Start address outside flash' unless start < 0x100000
|
|
62
|
+
else
|
|
63
|
+
raise ArgumentError, "Unsupported HEX record type #{type}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
raise ArgumentError, 'HEX requires EOF and nonempty data' unless eof && !ranges.empty?
|
|
67
|
+
|
|
68
|
+
ranges.sort_by!(&:first)
|
|
69
|
+
raise ArgumentError, 'Overlapping HEX data records' if ranges.each_cons(2).any? { |left, right| left.last > right.first }
|
|
70
|
+
|
|
71
|
+
{ data_bytes: ranges.sum { |first, last| last - first }, ranges: ranges, start_address: start }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
public_class_method def self.install(opts = {})
|
|
75
|
+
allowed = %i[protocol firmware bytes expected_chip expected_target openocd interface_config target_config timeout]
|
|
76
|
+
raise ArgumentError, 'Unsupported HEX install options' unless (opts.keys - allowed).empty?
|
|
77
|
+
raise ArgumentError, 'protocol must be explicitly :swd' unless opts[:protocol] == :swd
|
|
78
|
+
raise ArgumentError, 'Supply exactly one of firmware or bytes' unless opts.key?(:firmware) ^ opts.key?(:bytes)
|
|
79
|
+
|
|
80
|
+
bytes = image_bytes(opts.merge({}))
|
|
81
|
+
metadata = validate(bytes: bytes, expected_chip: opts[:expected_chip])
|
|
82
|
+
target = opts[:expected_target]
|
|
83
|
+
raise ArgumentError, 'expected_target must be an explicit OpenOCD target name' unless target.is_a?(String) && target.match?(/\A[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}\z/)
|
|
84
|
+
|
|
85
|
+
executable = trusted_file(path: opts[:openocd], executable: true)
|
|
86
|
+
interface = trusted_file(path: opts[:interface_config])
|
|
87
|
+
config = trusted_file(path: opts[:target_config])
|
|
88
|
+
timeout = opts.fetch(:timeout, 120)
|
|
89
|
+
raise ArgumentError, 'timeout must be finite and positive (at most 3600 seconds)' unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive? && timeout <= 3600
|
|
90
|
+
|
|
91
|
+
token = "MESHTASTIC_HEX_OK_#{SecureRandom.hex(16)}"
|
|
92
|
+
Tempfile.create(['meshtastic-', '.hex']) do |image|
|
|
93
|
+
image.binmode
|
|
94
|
+
image.write(bytes)
|
|
95
|
+
image.flush
|
|
96
|
+
path = tcl_word(value: image.path)
|
|
97
|
+
script = <<~TCL
|
|
98
|
+
if {[catch {
|
|
99
|
+
init
|
|
100
|
+
targets #{tcl_word(value: target)}
|
|
101
|
+
reset init
|
|
102
|
+
halt
|
|
103
|
+
if {[lindex [read_memory 0x10000100 32 1] 0] != 0x52840} {error "nRF52840 FICR PART mismatch"}
|
|
104
|
+
if {[lindex [read_memory 0x10000010 32 1] 0] != 4096 || [lindex [read_memory 0x10000014 32 1] 0] != 256} {error "nRF52840 flash geometry mismatch"}
|
|
105
|
+
flash write_image erase #{path} 0 ihex
|
|
106
|
+
verify_image #{path} 0 ihex
|
|
107
|
+
reset run
|
|
108
|
+
} failure]} {
|
|
109
|
+
echo $failure
|
|
110
|
+
shutdown error
|
|
111
|
+
} else {
|
|
112
|
+
echo #{token}
|
|
113
|
+
shutdown
|
|
114
|
+
}
|
|
115
|
+
TCL
|
|
116
|
+
args = [executable, '-c', 'gdb_port disabled; telnet_port disabled; tcl_port disabled',
|
|
117
|
+
'-f', interface, '-c', 'transport select swd', '-f', config, '-c', script]
|
|
118
|
+
run_programmer(args: args, timeout: timeout, token: token)
|
|
119
|
+
end
|
|
120
|
+
metadata.merge(status: :verified, protocol: :swd, format: :hex, expected_chip: opts[:expected_chip],
|
|
121
|
+
expected_target: target, bytes: bytes.bytesize, sha256: Digest::SHA256.hexdigest(bytes),
|
|
122
|
+
flash_verified: true, reboot_verified: false)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private_class_method def self.image_bytes(opts = {})
|
|
126
|
+
return opts[:bytes].b.dup.freeze if opts[:bytes].is_a?(String)
|
|
127
|
+
return opts[:bytes] unless opts.key?(:firmware)
|
|
128
|
+
|
|
129
|
+
path = opts[:firmware]
|
|
130
|
+
raise ArgumentError, 'firmware must name a regular HEX file' unless path.is_a?(String) && File.file?(path)
|
|
131
|
+
|
|
132
|
+
File.open(path, File::RDONLY | File::NONBLOCK) do |file|
|
|
133
|
+
raise ArgumentError, 'firmware must be a bounded regular file' unless file.stat.file? && file.stat.size <= MAX_IMAGE
|
|
134
|
+
|
|
135
|
+
file.binmode
|
|
136
|
+
file.read(MAX_IMAGE + 1).freeze
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private_class_method def self.trusted_file(opts = {})
|
|
141
|
+
path = opts[:path]
|
|
142
|
+
raise ArgumentError, 'Supply absolute paths to installed OpenOCD and trusted configuration files' unless path.is_a?(String) && path.start_with?('/') && !path.match?(/[\x00-\x1f\x7f]/) && File.file?(path) && File.readable?(path)
|
|
143
|
+
raise ArgumentError, 'openocd must be an executable file' if opts[:executable] && !File.executable?(path)
|
|
144
|
+
|
|
145
|
+
path
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private_class_method def self.tcl_word(opts = {})
|
|
149
|
+
escaped = opts.fetch(:value).gsub(/[\\"\[\]${}\r\n]/) { |character| { "\r" => '\r', "\n" => '\n' }.fetch(character) { "\\#{character}" } }
|
|
150
|
+
"\"#{escaped}\""
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private_class_method def self.run_programmer(opts = {})
|
|
154
|
+
Tempfile.create('meshtastic-openocd-log') do |log|
|
|
155
|
+
pid = Process.spawn(*opts.fetch(:args), in: File::NULL, out: log, err: log, pgroup: true)
|
|
156
|
+
begin
|
|
157
|
+
_, status = Timeout.timeout(opts.fetch(:timeout)) { Process.wait2(pid) }
|
|
158
|
+
rescue Timeout::Error
|
|
159
|
+
begin
|
|
160
|
+
Process.kill('KILL', -pid)
|
|
161
|
+
rescue Errno::ESRCH
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
begin
|
|
165
|
+
Process.wait(pid)
|
|
166
|
+
rescue Errno::ECHILD
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
raise IOError, 'OpenOCD timed out; flash state is uncertain; do not automatically retry'
|
|
170
|
+
end
|
|
171
|
+
log.rewind
|
|
172
|
+
completed = log.each_line.any? { |line| line.strip == opts.fetch(:token) }
|
|
173
|
+
raise IOError, "OpenOCD failed or did not complete verification/reset (exit #{status.exitstatus.inspect}); flash state may be partial" unless status.success? && completed
|
|
174
|
+
end
|
|
175
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
176
|
+
raise IOError, "Unable to execute installed OpenOCD: #{e.message}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
public_class_method def self.authors
|
|
180
|
+
'Meshtastic Ruby contributors'
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
public_class_method def self.help
|
|
184
|
+
puts <<~HELP
|
|
185
|
+
Validate Intel HEX without accessing hardware.
|
|
186
|
+
#{self}.validate(
|
|
187
|
+
bytes: 'required - complete Intel HEX content',
|
|
188
|
+
expected_chip: 'required - exact supported symbol :nrf52840'
|
|
189
|
+
)
|
|
190
|
+
Program and verify explicitly selected nRF52840 hardware (destructive).
|
|
191
|
+
#{self}.install(
|
|
192
|
+
protocol: 'required - explicitly :swd only',
|
|
193
|
+
firmware: 'optional - path to HEX file, exclusive with bytes',
|
|
194
|
+
bytes: 'optional - Intel HEX content, exclusive with firmware',
|
|
195
|
+
expected_chip: 'required - exact supported symbol :nrf52840',
|
|
196
|
+
expected_target: 'required - configured OpenOCD target name, e.g. nrf52.cpu',
|
|
197
|
+
openocd: 'required - absolute path to trusted installed executable',
|
|
198
|
+
interface_config: 'required - absolute path to trusted probe Tcl configuration',
|
|
199
|
+
target_config: 'required - absolute path to trusted nRF52 Tcl configuration',
|
|
200
|
+
timeout: 'optional - positive execution limit in seconds, default 120'
|
|
201
|
+
)
|
|
202
|
+
List the module contributors.
|
|
203
|
+
#{self}.authors
|
|
204
|
+
HELP
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Meshtastic
|
|
6
|
+
module Admin
|
|
7
|
+
module Firmware
|
|
8
|
+
# UF2 mass-storage submission is not a flash verification protocol.
|
|
9
|
+
module UF2
|
|
10
|
+
MAX_IMAGE = 32 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
public_class_method def self.install(opts = {})
|
|
13
|
+
raise ArgumentError, 'protocol must be explicitly :uf2' unless opts[:protocol] == :uf2
|
|
14
|
+
raise ArgumentError, 'Unsupported UF2 install options' unless (opts.keys - %i[protocol firmware bytes mount family_id board_id flash_size]).empty?
|
|
15
|
+
raise ArgumentError, 'Supply exactly one of firmware or bytes' unless opts.key?(:firmware) ^ opts.key?(:bytes)
|
|
16
|
+
|
|
17
|
+
bytes = image_bytes(opts.merge({}))
|
|
18
|
+
validate_image(opts.merge(bytes: bytes))
|
|
19
|
+
mount = opts[:mount]
|
|
20
|
+
raise ArgumentError, 'mount must be an explicit canonical absolute bootloader directory' unless mount.is_a?(String) && mount.start_with?('/') && mount != '/' && File.expand_path(mount) == mount && File.directory?(mount) && File.realpath(mount) == mount
|
|
21
|
+
raise NotImplementedError, 'Safe UF2 directory pinning requires Linux procfs and O_NOFOLLOW' unless File.directory?('/proc/self/fd') && File.const_defined?(:NOFOLLOW)
|
|
22
|
+
|
|
23
|
+
destination = File.join(mount, 'FIRMWARE.UF2')
|
|
24
|
+
File.open(mount, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |directory|
|
|
25
|
+
raise ArgumentError, 'mount must be a directory' unless directory.stat.directory?
|
|
26
|
+
|
|
27
|
+
# Use the opened directory, never re-resolve the mount path for a
|
|
28
|
+
# write: an unplug/unmount must not redirect data onto the host disk.
|
|
29
|
+
anchor = "/proc/self/fd/#{directory.fileno}"
|
|
30
|
+
validate_target(opts.merge(anchor: anchor))
|
|
31
|
+
current = File.stat(mount)
|
|
32
|
+
raise IOError, 'UF2 mount changed before submission' unless current.dev == directory.stat.dev && current.ino == directory.stat.ino && File.realpath(mount) == mount
|
|
33
|
+
|
|
34
|
+
File.open(File.join(anchor, 'FIRMWARE.UF2'), File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
|
|
35
|
+
file.binmode
|
|
36
|
+
written = file.write(bytes)
|
|
37
|
+
raise IOError, 'Incomplete UF2 submission; do not automatically retry' unless written == bytes.bytesize
|
|
38
|
+
|
|
39
|
+
file.flush
|
|
40
|
+
file.fsync
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
{ status: :copied, protocol: :uf2, bytes: bytes.bytesize, sha256: Digest::SHA256.hexdigest(bytes),
|
|
44
|
+
family_id: opts.fetch(:family_id), board_id: opts.fetch(:board_id), destination: destination,
|
|
45
|
+
flash_verified: false, reboot_verified: false }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private_class_method def self.validate_target(opts = {})
|
|
49
|
+
board = opts[:board_id]
|
|
50
|
+
raise ArgumentError, 'board_id must be the exact expected INFO_UF2.TXT Board-ID' unless board.is_a?(String) && board.match?(/\A[A-Za-z0-9][A-Za-z0-9_.-]{0,255}\z/)
|
|
51
|
+
|
|
52
|
+
anchor = opts.fetch(:anchor)
|
|
53
|
+
markers = Dir.children(anchor).select { |name| name.casecmp?('INFO_UF2.TXT') }
|
|
54
|
+
raise ArgumentError, 'Exactly one INFO_UF2.TXT is required on the selected mount' unless markers.length == 1
|
|
55
|
+
|
|
56
|
+
marker = File.join(anchor, markers.first)
|
|
57
|
+
raise ArgumentError, 'INFO_UF2.TXT must be a regular nonsymlink file' unless File.lstat(marker).file?
|
|
58
|
+
|
|
59
|
+
text = File.open(marker, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
60
|
+
raise ArgumentError, 'INFO_UF2.TXT must be a bounded regular file' unless file.stat.file? && file.stat.size <= 4096
|
|
61
|
+
|
|
62
|
+
file.binmode
|
|
63
|
+
file.read(4097)
|
|
64
|
+
end
|
|
65
|
+
raise ArgumentError, 'Invalid INFO_UF2.TXT bootloader identification' unless text.bytesize <= 4096 && text.match?(/\AUF2 Bootloader[^\r\n]*\r?\n/) && text.match?(/\A[\x09\x0a\x0d\x20-\x7e]*\z/)
|
|
66
|
+
|
|
67
|
+
boards = text.lines.filter_map { |line| line.chomp.delete_suffix("\r").match(/\ABoard-ID:[ \t]*(\S+)[ \t]*\z/)&.captures&.first }
|
|
68
|
+
raise ArgumentError, 'INFO_UF2.TXT Board-ID mismatch or ambiguous Board-ID' unless boards == [board]
|
|
69
|
+
|
|
70
|
+
family = if board.match?(/\AnRF52840-/i)
|
|
71
|
+
0xada52840
|
|
72
|
+
elsif board == 'RPI-RP2'
|
|
73
|
+
0xe48bff56
|
|
74
|
+
end
|
|
75
|
+
raise ArgumentError, 'INFO_UF2.TXT Board-ID does not identify the expected MCU family' unless family == opts.fetch(:family_id)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private_class_method def self.image_bytes(opts = {})
|
|
79
|
+
raw = if opts.key?(:firmware)
|
|
80
|
+
path = opts[:firmware]
|
|
81
|
+
raise ArgumentError, 'firmware must name a regular file' unless path.is_a?(String) && File.file?(path)
|
|
82
|
+
|
|
83
|
+
File.open(path, File::RDONLY | File::NONBLOCK) do |file|
|
|
84
|
+
raise ArgumentError, 'firmware must be a bounded regular file' unless file.stat.file? && file.stat.size <= MAX_IMAGE
|
|
85
|
+
|
|
86
|
+
file.binmode
|
|
87
|
+
file.read(MAX_IMAGE + 1)
|
|
88
|
+
end
|
|
89
|
+
else
|
|
90
|
+
opts[:bytes]
|
|
91
|
+
end
|
|
92
|
+
raise ArgumentError, 'UF2 image must be a binary String of at most 32 MiB' unless raw.is_a?(String) && raw.bytesize <= MAX_IMAGE
|
|
93
|
+
|
|
94
|
+
raw.b.freeze
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private_class_method def self.flash_bounds(opts = {})
|
|
98
|
+
raise ArgumentError, 'family_id must be an Integer' unless opts[:family_id].is_a?(Integer)
|
|
99
|
+
|
|
100
|
+
case opts[:family_id]
|
|
101
|
+
when 0xada52840
|
|
102
|
+
raise ArgumentError, 'flash_size applies only to RP2040' if opts.key?(:flash_size)
|
|
103
|
+
|
|
104
|
+
[0x27000, 0xf4000]
|
|
105
|
+
when 0xe48bff56
|
|
106
|
+
size = opts[:flash_size]
|
|
107
|
+
raise ArgumentError, 'RP2040 requires flash_size in bytes, 4096..16777216 and 4096 aligned' unless size.is_a?(Integer) && (4096..0x1000000).cover?(size) && (size % 4096).zero?
|
|
108
|
+
|
|
109
|
+
[0x10000000, 0x10000000 + size]
|
|
110
|
+
else
|
|
111
|
+
raise ArgumentError, 'Unsupported UF2 family_id; only NRF52840 and RP2040 application images are supported'
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private_class_method def self.validate_image(opts = {})
|
|
116
|
+
lower, upper = flash_bounds(opts.merge({}))
|
|
117
|
+
bytes = opts.fetch(:bytes)
|
|
118
|
+
raise ArgumentError, 'UF2 must contain complete nonempty 512-byte blocks' unless bytes.is_a?(String) && bytes.bytesize.positive? && (bytes.bytesize % 512).zero?
|
|
119
|
+
|
|
120
|
+
count = bytes.bytesize / 512
|
|
121
|
+
numbers = {}
|
|
122
|
+
regions = []
|
|
123
|
+
count.times do |index|
|
|
124
|
+
block = bytes.byteslice(index * 512, 512)
|
|
125
|
+
magic0, magic1, flags, address, size, number, total, family = block.unpack('V8')
|
|
126
|
+
raise ArgumentError, 'Invalid UF2 magic' unless magic0 == 0x0a324655 && magic1 == 0x9e5d5157 && block.byteslice(508, 4).unpack1('V') == 0x0ab16f30
|
|
127
|
+
raise ArgumentError, 'Only plain main-flash UF2 blocks with family IDs are supported' unless flags == 0x2000
|
|
128
|
+
raise ArgumentError, 'UF2 family mismatch or mixed families' unless family == opts.fetch(:family_id)
|
|
129
|
+
raise ArgumentError, 'Supported UF2 bootloaders require 256-byte payloads and target alignment' unless size == 256 && (address % 256).zero?
|
|
130
|
+
raise ArgumentError, 'Invalid UF2 block count or number' unless total == count && number < count
|
|
131
|
+
raise ArgumentError, 'Duplicate UF2 block number' if numbers[number]
|
|
132
|
+
raise ArgumentError, 'UF2 target address outside application flash' unless address >= lower && address + size <= upper
|
|
133
|
+
|
|
134
|
+
numbers[number] = true
|
|
135
|
+
regions << [address, address + size]
|
|
136
|
+
end
|
|
137
|
+
regions.sort.each_cons(2) do |left, right|
|
|
138
|
+
raise ArgumentError, 'Overlapping UF2 target addresses' if left[1] > right[0]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
public_class_method def self.authors
|
|
143
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n "
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
public_class_method def self.help
|
|
147
|
+
puts "USAGE:
|
|
148
|
+
# Validate and submit firmware through UF2 mass storage.
|
|
149
|
+
#{self}.install(
|
|
150
|
+
protocol: 'required - explicit :uf2 protocol selection',
|
|
151
|
+
bytes: 'optional - complete original UF2 image bytes instead of firmware',
|
|
152
|
+
firmware: 'optional - regular UF2 image file instead of bytes',
|
|
153
|
+
mount: 'required - absolute bootloader directory explicitly selected by the operator',
|
|
154
|
+
family_id: 'required - expected numeric UF2 MCU family identifier',
|
|
155
|
+
board_id: 'required - exact Board-ID from the selected bootloader INFO_UF2.TXT',
|
|
156
|
+
flash_size: 'optional - required for RP2040 only; installed flash capacity in bytes, 4096 aligned, at most 16 MiB'
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Return module author information.
|
|
160
|
+
#{self}.authors
|
|
161
|
+
"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -13,8 +13,10 @@ module Meshtastic
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
public_class_method def self.request_ota(opts = {})
|
|
16
|
-
|
|
17
|
-
raise ArgumentError, '
|
|
16
|
+
legacy = opts.keys & %i[serial_obj bluetooth_obj tcp_obj mqtt_obj]
|
|
17
|
+
raise ArgumentError, "#{legacy.join(', ')} are unsupported; use transport_obj" unless legacy.empty?
|
|
18
|
+
|
|
19
|
+
mode = ota_mode(opts.merge({}))
|
|
18
20
|
|
|
19
21
|
hash = opts[:ota_hash] || sha256(opts)
|
|
20
22
|
raise ArgumentError, 'ota_hash must be a raw 32-byte String' unless hash.is_a?(String) && hash.bytesize == 32
|
|
@@ -24,7 +26,23 @@ module Meshtastic
|
|
|
24
26
|
reboot_ota_mode: mode,
|
|
25
27
|
ota_hash: hash.b
|
|
26
28
|
)
|
|
27
|
-
Admin.send(opts.except(:bytes, :firmware, :ota_hash, :mode).merge(ota_request: event))
|
|
29
|
+
Admin.send(opts.except(:bytes, :firmware, :ota_hash, :mode, :transfer).merge(ota_request: event))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private_class_method def self.ota_mode(opts = {})
|
|
33
|
+
modes = { wifi: :OTA_WIFI, ble: :OTA_BLE }
|
|
34
|
+
raise ArgumentError, 'transfer must be :wifi or :ble' if opts.key?(:transfer) && !modes.key?(opts[:transfer])
|
|
35
|
+
raise ArgumentError, 'mode must be :OTA_BLE or :OTA_WIFI' if opts.key?(:mode) && !modes.value?(opts[:mode])
|
|
36
|
+
raise ArgumentError, 'transfer contradicts mode' if opts.key?(:transfer) && opts.key?(:mode) && modes[opts[:transfer]] != opts[:mode]
|
|
37
|
+
|
|
38
|
+
return modes[opts[:transfer]] if opts.key?(:transfer)
|
|
39
|
+
return opts[:mode] if opts.key?(:mode)
|
|
40
|
+
|
|
41
|
+
transport = Admin.transport_type(transport_obj: opts[:transport_obj]) if opts[:transport_obj]
|
|
42
|
+
return :OTA_BLE if transport == :bluetooth
|
|
43
|
+
return :OTA_WIFI if transport == :tcp
|
|
44
|
+
|
|
45
|
+
raise ArgumentError, 'Specify transfer: :wifi or :ble; the control transport does not select an OTA loader'
|
|
28
46
|
end
|
|
29
47
|
|
|
30
48
|
public_class_method def self.enter_dfu(opts = {})
|
|
@@ -48,19 +66,39 @@ module Meshtastic
|
|
|
48
66
|
|
|
49
67
|
public_class_method def self.install(opts = {})
|
|
50
68
|
validate_verification(opts[:verify]) if opts.key?(:verify)
|
|
51
|
-
|
|
69
|
+
formats = { unified_wifi: :bin, unified_ble: :bin, esp_rom: :bin, nordic_dfu: :zip, uf2: :uf2, swd: :hex }
|
|
70
|
+
expected_format = formats[opts[:protocol]]
|
|
71
|
+
raise ArgumentError, "format must be #{expected_format.inspect} for protocol #{opts[:protocol].inspect}" if opts.key?(:format) && opts[:format] != expected_format
|
|
72
|
+
|
|
73
|
+
options = opts.except(:verify, :format)
|
|
74
|
+
options = binary_options(options) if expected_format == :bin
|
|
52
75
|
result = case opts[:protocol]
|
|
76
|
+
when :swd then Hex.install(options)
|
|
77
|
+
when :uf2 then UF2.install(options)
|
|
53
78
|
when :unified_ble then BLE.install(options)
|
|
54
79
|
when :esp_rom then SerialBootloader.install(options)
|
|
55
80
|
when :nordic_dfu then NordicDFU.install(options)
|
|
56
81
|
when :unified_wifi then install_wifi(options)
|
|
57
|
-
else raise NotImplementedError, 'install requires explicit protocol: :unified_wifi, :unified_ble, :esp_rom or :
|
|
82
|
+
else raise NotImplementedError, 'install requires explicit protocol: :unified_wifi, :unified_ble, :esp_rom, :nordic_dfu, :uf2 or :swd'
|
|
58
83
|
end
|
|
59
84
|
return result unless opts[:verify]
|
|
60
85
|
|
|
61
86
|
result.merge(verify_reboot(opts[:verify])).merge(loader_status: result[:status], reboot_verified: true, boot_verified: true)
|
|
62
87
|
end
|
|
63
88
|
|
|
89
|
+
private_class_method def self.binary_options(opts = {})
|
|
90
|
+
extension = File.extname(opts[:firmware].to_s).downcase
|
|
91
|
+
raise ArgumentError, "#{extension} format cannot be streamed as a binary image" if %w[.uf2 .zip .hex .dfu].include?(extension)
|
|
92
|
+
|
|
93
|
+
bytes = firmware_bytes(opts.merge({}))
|
|
94
|
+
uf2 = bytes.start_with?([0x0a324655, 0x9e5d5157].pack('V2'))
|
|
95
|
+
zip = bytes.start_with?("PK\x03\x04".b, "PK\x05\x06".b, "PK\x07\x08".b)
|
|
96
|
+
hex = bytes.match?(/\A\s*:[0-9a-fA-F]{10}/)
|
|
97
|
+
raise ArgumentError, 'UF2, ZIP or Intel HEX format cannot be streamed as a binary image' if uf2 || zip || hex
|
|
98
|
+
|
|
99
|
+
opts.except(:firmware).merge(bytes: bytes)
|
|
100
|
+
end
|
|
101
|
+
|
|
64
102
|
private_class_method def self.install_wifi(opts = {})
|
|
65
103
|
validate_install(opts.merge({}))
|
|
66
104
|
bytes = firmware_bytes(opts.merge({}))
|
|
@@ -104,7 +142,7 @@ module Meshtastic
|
|
|
104
142
|
sleep 0.25
|
|
105
143
|
retry
|
|
106
144
|
end
|
|
107
|
-
reply = Admin.request(
|
|
145
|
+
reply = Admin.request(transport_obj: handle, get_device_metadata_request: true, timeout: opts.fetch(:timeout, 60))
|
|
108
146
|
metadata = reply.fetch(:value).to_h
|
|
109
147
|
raise IOError, "Firmware version mismatch: #{metadata[:firmware_version].inspect}" unless metadata[:firmware_version] == opts.fetch(:expected_version)
|
|
110
148
|
raise IOError, 'Post-reboot node identity mismatch' if opts[:expected_node] && handle[:my_node_num] != opts[:expected_node]
|
|
@@ -218,16 +256,17 @@ module Meshtastic
|
|
|
218
256
|
|
|
219
257
|
# Send Admin ota_request with the image hash and OTA mode.
|
|
220
258
|
#{self}.request_ota(
|
|
221
|
-
|
|
222
|
-
mqtt_obj: 'optional - MQTT client from Meshtastic::MQTT.connect',
|
|
259
|
+
transport_obj: 'required - connected Serial, Bluetooth, TCP handle or MQTT client',
|
|
223
260
|
firmware: 'optional - path to a firmware .bin on disk',
|
|
224
261
|
ota_hash: 'optional - 32-byte SHA-256 digest if not hashing firmware',
|
|
225
|
-
|
|
262
|
+
transfer: 'optional - :wifi or :ble; required for serial/MQTT unless mode is explicit',
|
|
263
|
+
mode: 'optional - explicit :OTA_BLE or :OTA_WIFI alias; must agree with transfer'
|
|
264
|
+
# Bluetooth transport infers :ble; TCP transport infers :wifi.
|
|
226
265
|
)
|
|
227
266
|
|
|
228
267
|
# Ask the node to enter DFU / UF2 bootloader mode.
|
|
229
268
|
#{self}.enter_dfu(
|
|
230
|
-
|
|
269
|
+
transport_obj: 'required - connected Serial, Bluetooth, TCP handle or MQTT client'
|
|
231
270
|
)
|
|
232
271
|
|
|
233
272
|
# Reject the obsolete unhandled legacy OTA reboot field.
|
|
@@ -241,7 +280,8 @@ module Meshtastic
|
|
|
241
280
|
|
|
242
281
|
# Upload using an explicitly selected native loader protocol.
|
|
243
282
|
#{self}.install(
|
|
244
|
-
protocol: 'required - :unified_wifi, :unified_ble, :esp_rom or :
|
|
283
|
+
protocol: 'required - :unified_wifi, :unified_ble, :esp_rom, :nordic_dfu, :uf2 or :swd; no protocol guessing',
|
|
284
|
+
format: 'optional - :bin, :zip, :uf2 or :hex; defaults to the selected protocol format; mismatches rejected',
|
|
245
285
|
verify: 'optional - verify_reboot options Hash; success becomes :boot_verified only after a fresh reply',
|
|
246
286
|
host: 'required - OTA loader IP address or hostname, not a mesh node ID',
|
|
247
287
|
port: 'optional - separate OTA TCP service port (default: 3232)',
|
|
@@ -251,9 +291,9 @@ module Meshtastic
|
|
|
251
291
|
retries: 'optional - connection refusal/timeout retries, 0..20 (default: 3)',
|
|
252
292
|
retry_delay: 'optional - nonnegative seconds between connection retries (default: 1)'
|
|
253
293
|
)
|
|
254
|
-
#
|
|
294
|
+
# For unified OTA first use request_ota with the matching transfer to pin the same image hash.
|
|
255
295
|
# install never sends preparation commands; :verified means loader OK, not boot confirmation.
|
|
256
|
-
# BLE.help, NordicDFU.help and
|
|
296
|
+
# BLE.help, NordicDFU.help, SerialBootloader.help, UF2.help and Hex.help document backend options.
|
|
257
297
|
|
|
258
298
|
# Reconnect and request fresh correlated application firmware metadata.
|
|
259
299
|
#{self}.verify_reboot(
|
|
@@ -286,3 +326,5 @@ end
|
|
|
286
326
|
require 'meshtastic/admin/firmware/ble'
|
|
287
327
|
require 'meshtastic/admin/firmware/serial_bootloader'
|
|
288
328
|
require 'meshtastic/admin/firmware/nordic_dfu'
|
|
329
|
+
require 'meshtastic/admin/firmware/uf2'
|
|
330
|
+
require 'meshtastic/admin/firmware/hex'
|