meshtastic 0.0.182 → 0.0.183

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
- mode = opts.fetch(:mode, :OTA_BLE)
17
- raise ArgumentError, 'mode must be :OTA_BLE or :OTA_WIFI' unless %i[OTA_BLE OTA_WIFI].include?(mode)
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
- options = opts.except(:verify)
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 :nordic_dfu'
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(key => handle, get_device_metadata_request: true, timeout: opts.fetch(:timeout, 60))
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
- serial_obj: 'optional - serial handle from Meshtastic::Serial.connect',
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
- mode: 'optional - :OTA_BLE or :OTA_WIFI (default: :OTA_BLE)'
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
- serial_obj: 'optional - serial handle from Meshtastic::Serial.connect'
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 :nordic_dfu; no protocol guessing',
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
- # First use request_ota with the matching mode to pin the same image hash.
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 SerialBootloader.help document protocol-specific options.
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'