meshtastic 0.0.180 → 0.0.181

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,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'timeout'
5
+ require 'uart'
6
+
7
+ module Meshtastic
8
+ module Admin
9
+ module Firmware
10
+ # Native ROM protocol only: never uploads an executable flasher stub.
11
+ module SerialBootloader
12
+ CHIP_IDS = { esp32: 0, esp32s3: 9, esp32c3: 5 }.freeze
13
+
14
+ public_class_method def self.install(opts = {})
15
+ bytes = validate(opts.merge({}))
16
+ chip = opts.fetch(:chip)
17
+ offset = opts.fetch(:offset)
18
+ port = UART.open(opts.fetch(:port), 115_200, '8N1')
19
+ attrs = Termios.tcgetattr(port)
20
+ attrs.cflag &= ~(Termios::CRTSCTS | Termios::HUPCL)
21
+ Termios.tcsetattr(port, Termios::TCSANOW, attrs)
22
+ reset_lines(io: port, bootloader: true) if opts.fetch(:reset, :classic) == :classic
23
+ context = { io: port, timeout: opts.fetch(:timeout, 120) }
24
+ synchronize(context.merge({}))
25
+ identify(context.merge(chip: chip))
26
+
27
+ command(context.merge(op: 13, payload: [0, 0].pack('V2')))
28
+ command(context.merge(op: 11, payload: [0, opts.fetch(:flash_size), 65_536, 4096, 256, 65_535].pack('V6')))
29
+ blocks = (bytes.bytesize + 1023) / 1024
30
+ params = [bytes.bytesize, blocks, 1024, offset]
31
+ params << 0 unless chip == :esp32
32
+ command(context.merge(op: 2, payload: params.pack('V*')))
33
+ blocks.times do |sequence|
34
+ block = bytes.byteslice(sequence * 1024, 1024).ljust(1024, "\xff".b)
35
+ payload = [1024, sequence, 0, 0].pack('V4') + block
36
+ command(context.merge(op: 3, payload: payload, checksum: block.bytes.reduce(0xef, :^)))
37
+ end
38
+ md5 = command(context.merge(op: 19, payload: [offset, bytes.bytesize, 0, 0].pack('V4'), response_size: 32)).fetch(:data)
39
+ raise IOError, 'ROM flash MD5 mismatch' unless md5.downcase == Digest::MD5.hexdigest(bytes)
40
+
41
+ command(context.merge(op: 4, payload: [0].pack('V')))
42
+ reset_lines(io: port, bootloader: false) if opts.fetch(:reset, :classic) == :classic
43
+ { status: :verified, chip: chip, bytes: bytes.bytesize, offset: offset, md5: md5.downcase, sha256: Digest::SHA256.hexdigest(bytes), reboot_requested: true, boot_verified: false }
44
+ ensure
45
+ port&.close
46
+ end
47
+
48
+ private_class_method def self.synchronize(opts = {})
49
+ attempts = 0
50
+ begin
51
+ attempts += 1
52
+ response = command(opts.merge(op: 8, payload: "\x07\x07\x12\x20".b + ("\x55" * 32), timeout: [opts.fetch(:timeout), 1].min))
53
+ raise IOError, 'Flasher stub detected; reset into ROM first' if response.fetch(:value).zero?
54
+ rescue Timeout::Error
55
+ raise if attempts >= 3
56
+
57
+ retry
58
+ end
59
+ end
60
+
61
+ private_class_method def self.identify(opts = {})
62
+ if opts.fetch(:chip) == :esp32
63
+ magic = command(opts.merge(op: 10, payload: [0x40001000].pack('V'))).fetch(:value)
64
+ raise IOError, 'Connected chip is not ESP32' unless magic == 0x00f01d83
65
+
66
+ crypt = command(opts.merge(op: 10, payload: [0x3ff5a000].pack('V'))).fetch(:value)
67
+ secure = command(opts.merge(op: 10, payload: [0x3ff5a018].pack('V'))).fetch(:value)
68
+ raise IOError, 'Secure boot/encrypted flash is unsupported' unless crypt.nobits?(0x7f << 20) && secure.nobits?(0x30)
69
+ else
70
+ info = command(opts.merge(op: 20, response_size: 20)).fetch(:data)
71
+ raise IOError, 'Connected chip ID does not match selected chip' unless info.byteslice(12, 4).unpack1('V') == CHIP_IDS.fetch(opts.fetch(:chip))
72
+ raise IOError, 'Secure boot/encrypted flash/secure download is unsupported' unless info.unpack1('V').nobits?(5) && info.getbyte(4).zero?
73
+ end
74
+ end
75
+
76
+ private_class_method def self.reset_lines(opts = {})
77
+ io = opts.fetch(:io)
78
+ bootloader = opts.fetch(:bootloader)
79
+ io.ioctl(Termios::TIOCMBIC, [Termios::TIOCM_DTR].pack('i')) if bootloader
80
+ io.ioctl(Termios::TIOCMBIS, [Termios::TIOCM_RTS].pack('i'))
81
+ sleep 0.1
82
+ io.ioctl(Termios::TIOCMBIS, [Termios::TIOCM_DTR].pack('i')) if bootloader
83
+ io.ioctl(Termios::TIOCMBIC, [Termios::TIOCM_RTS].pack('i'))
84
+ return unless bootloader
85
+
86
+ sleep 0.05
87
+ io.ioctl(Termios::TIOCMBIC, [Termios::TIOCM_DTR].pack('i'))
88
+ end
89
+
90
+ private_class_method def self.validate(opts = {})
91
+ allowed = %i[protocol port chip bytes firmware offset flash_size reset timeout]
92
+ raise ArgumentError, 'Unsupported serial bootloader options' unless (opts.keys - allowed).empty?
93
+ raise ArgumentError, 'protocol must be :esp_rom' unless opts.fetch(:protocol, :esp_rom) == :esp_rom
94
+ raise ArgumentError, 'chip must be :esp32, :esp32s3 or :esp32c3' unless CHIP_IDS.key?(opts[:chip])
95
+ raise ArgumentError, 'port must be a nonempty device path' unless opts[:port].is_a?(String) && !opts[:port].strip.empty?
96
+ raise ArgumentError, 'reset must be :classic or :none' unless %i[classic none].include?(opts.fetch(:reset, :classic))
97
+
98
+ timeout = opts.fetch(:timeout, 120)
99
+ raise ArgumentError, 'timeout must be finite and positive' unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
100
+
101
+ capacity = opts[:flash_size]
102
+ raise ArgumentError, 'flash_size must be a power of two from 1 MiB to 16 MiB' unless capacity.is_a?(Integer) && (1_048_576..16_777_216).cover?(capacity) && capacity.nobits?(capacity - 1)
103
+
104
+ offset = opts[:offset]
105
+ raise ArgumentError, 'offset must be an application address >= 0x10000 aligned to 0x1000' unless offset.is_a?(Integer) && offset >= 0x10000 && (offset % 4096).zero?
106
+ raise ArgumentError, 'provide exactly one of bytes or firmware' unless opts.key?(:bytes) ^ opts.key?(:firmware)
107
+
108
+ bytes = opts.key?(:bytes) ? opts[:bytes] : File.binread(opts.fetch(:firmware))
109
+ raise ArgumentError, 'image must be nonempty binary String' unless bytes.is_a?(String) && !bytes.empty?
110
+ raise ArgumentError, 'image erase range exceeds flash_size' if offset + ((bytes.bytesize + 4095) / 4096 * 4096) > capacity
111
+
112
+ validate_image(bytes: bytes.b, chip_id: CHIP_IDS.fetch(opts[:chip]))
113
+ bytes.b
114
+ end
115
+
116
+ private_class_method def self.validate_image(opts = {})
117
+ bytes = opts.fetch(:bytes)
118
+ raise ArgumentError, 'expected ESP executable image header' unless bytes.bytesize >= 24 && bytes.getbyte(0) == 0xe9 && (1..16).cover?(bytes.getbyte(1))
119
+ raise ArgumentError, 'image chip ID does not match selected chip' unless bytes.byteslice(12, 2).unpack1('v') == opts.fetch(:chip_id)
120
+ raise ArgumentError, 'invalid image digest flag' unless [0, 1].include?(bytes.getbyte(23))
121
+
122
+ position = 24
123
+ checksum = 0xef
124
+ bytes.getbyte(1).times do
125
+ raise ArgumentError, 'truncated ESP segment header' if position + 8 > bytes.bytesize
126
+
127
+ size = bytes.byteslice(position + 4, 4).unpack1('V')
128
+ position += 8
129
+ raise ArgumentError, 'truncated or unaligned ESP segment' if size % 4 != 0 || position + size > bytes.bytesize
130
+
131
+ bytes.byteslice(position, size).each_byte { |byte| checksum ^= byte }
132
+ position += size
133
+ end
134
+ checksum_position = (position / 16 * 16) + 15
135
+ raise ArgumentError, 'ESP image checksum mismatch' unless bytes.getbyte(checksum_position) == checksum
136
+
137
+ ending = checksum_position + 1
138
+ if bytes.getbyte(23) == 1
139
+ raise ArgumentError, 'ESP image SHA-256 mismatch' unless bytes.byteslice(ending, 32) == Digest::SHA256.digest(bytes.byteslice(0, ending))
140
+
141
+ ending += 32
142
+ end
143
+ raise ArgumentError, 'trailing data: merged, signed and padded images are unsupported' unless bytes.bytesize == ending
144
+ end
145
+
146
+ private_class_method def self.command(opts = {})
147
+ io = opts.fetch(:io)
148
+ op = opts.fetch(:op)
149
+ payload = opts.fetch(:payload, ''.b)
150
+ packet = [0, op, payload.bytesize, opts.fetch(:checksum, 0)].pack('CCvV') + payload
151
+ encoded = packet.bytes.map do |byte|
152
+ if byte == 0xc0
153
+ "\xdb\xdc".b
154
+ else
155
+ byte == 0xdb ? "\xdb\xdd".b : byte.chr
156
+ end
157
+ end.join.b
158
+ Timeout.timeout(opts.fetch(:timeout)) do
159
+ io.write("\xc0".b + encoded + "\xc0".b)
160
+ loop do
161
+ reply = read_frame(io: io)
162
+ raise IOError, 'Truncated ROM response' if reply.bytesize < 12
163
+
164
+ direction, response_op, length, value = reply.unpack('CCvV')
165
+ raise IOError, 'Invalid ROM response header' unless direction == 1 && length == reply.bytesize - 8
166
+ next if response_op == 8 && op != 8 # ROM sends eight SYNC replies.
167
+ raise IOError, 'Unexpected ROM response opcode' unless response_op == op
168
+
169
+ data = reply.byteslice(8..)
170
+ # Error responses may omit the expected MD5/security data.
171
+ status = data.byteslice(-4, 4).bytes
172
+ raise IOError, format('ROM command 0x%<op>02x failed: status=%<status>d error=%<error>d', op: op, status: status[0], error: status[1]) unless status[0].zero?
173
+ raise IOError, 'Invalid ROM response length' unless data.bytesize == opts.fetch(:response_size, 0) + 4
174
+
175
+ return { value: value, data: data.byteslice(0, data.bytesize - 4) }
176
+ end
177
+ end
178
+ end
179
+
180
+ private_class_method def self.read_frame(opts = {})
181
+ io = opts.fetch(:io)
182
+ frame = +''.b
183
+ started = false
184
+ escaped = false
185
+ loop do
186
+ byte = io.readpartial(1).getbyte(0)
187
+ if byte == 0xc0
188
+ raise IOError, 'Truncated SLIP escape' if escaped
189
+ return frame if started && !frame.empty?
190
+
191
+ started = true
192
+ elsif started
193
+ if escaped
194
+ raise IOError, 'Invalid SLIP escape' unless [0xdc, 0xdd].include?(byte)
195
+
196
+ frame << (byte == 0xdc ? 0xc0 : 0xdb)
197
+ escaped = false
198
+ elsif byte == 0xdb
199
+ escaped = true
200
+ else
201
+ frame << byte
202
+ end
203
+ raise IOError, 'Oversized ROM response' if frame.bytesize > 4096
204
+ end
205
+ end
206
+ end
207
+
208
+ public_class_method def self.authors
209
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
210
+ end
211
+
212
+ public_class_method def self.help
213
+ puts "USAGE:
214
+ # Install and verify native ESP ROM firmware.
215
+ #{self}.install(
216
+ protocol: 'optional - :esp_rom only; defaults to :esp_rom',
217
+ port: 'required - dedicated UART device path, not an active PhoneAPI connection',
218
+ chip: 'required - :esp32, :esp32s3 or :esp32c3; UART ROM only',
219
+ bytes: 'optional - raw unmerged application image, exclusive with firmware',
220
+ firmware: 'optional - application .bin file path, exclusive with bytes',
221
+ offset: 'required - known application partition address, >= 0x10000, sector aligned',
222
+ flash_size: 'required - known physical flash capacity, power of two from 1 to 16 MiB',
223
+ reset: 'optional - :classic DTR/RTS reset (default) or :none for manual ROM entry',
224
+ timeout: 'optional - positive command deadline in seconds, default 120'
225
+ )
226
+ # Print author contact information.
227
+ #{self}.authors
228
+ "
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
@@ -1,27 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest'
4
- require 'meshtastic/xmodem_pb'
4
+ require 'socket'
5
+ require 'timeout'
6
+
5
7
 
6
8
  module Meshtastic
7
9
  module Admin
8
10
  module Firmware
9
- SOH_SIZE = 128
10
- PAD = 0x1A
11
-
12
11
  public_class_method def self.sha256(opts = {})
13
12
  Digest::SHA256.digest(firmware_bytes(opts.merge({})))
14
13
  end
15
14
 
16
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)
18
+
17
19
  hash = opts[:ota_hash] || sha256(opts)
18
- raise ArgumentError, 'ota_hash must be 32 bytes' unless hash.to_s.bytesize == 32
20
+ raise ArgumentError, 'ota_hash must be a raw 32-byte String' unless hash.is_a?(String) && hash.bytesize == 32
21
+ raise ArgumentError, 'ota_hash does not match firmware bytes' if opts[:ota_hash] && (opts.key?(:bytes) || opts.key?(:firmware)) && hash != sha256(opts)
19
22
 
20
23
  event = Meshtastic::AdminMessage::OTAEvent.new(
21
- reboot_ota_mode: opts[:mode] || :OTA_BLE,
22
- ota_hash: hash.to_s.b
24
+ reboot_ota_mode: mode,
25
+ ota_hash: hash.b
23
26
  )
24
- Admin.send(opts.merge(ota_request: event))
27
+ Admin.send(opts.except(:bytes, :firmware, :ota_hash, :mode).merge(ota_request: event))
25
28
  end
26
29
 
27
30
  public_class_method def self.enter_dfu(opts = {})
@@ -29,48 +32,176 @@ module Meshtastic
29
32
  end
30
33
 
31
34
  public_class_method def self.reboot_ota(opts = {})
32
- Admin.send(opts.merge(reboot_ota_seconds: opts[:seconds] || 10))
35
+ opts.merge({})
36
+ raise NotImplementedError, 'reboot_ota_seconds is not handled by current firmware; use request_ota'
33
37
  end
34
38
 
35
39
  public_class_method def self.xmodem_blocks(opts = {})
36
- payload = opts[:bytes].to_s.b
37
- block_size = opts[:block_size] || SOH_SIZE
38
- raise ArgumentError, 'firmware bytes are empty' if payload.empty?
39
-
40
- blocks = []
41
- seq = 1
42
- offset = 0
43
- while offset < payload.bytesize
44
- chunk = payload.byteslice(offset, block_size).to_s.b
45
- chunk = chunk.ljust(block_size, PAD.chr) if chunk.bytesize < block_size
46
- blocks << Meshtastic::XModem.new(
47
- control: block_size > SOH_SIZE ? :STX : :SOH,
48
- seq: seq,
49
- crc16: crc16(data: chunk),
50
- buffer: chunk
51
- )
52
- seq += 1
53
- offset += block_size
54
- end
55
- blocks << Meshtastic::XModem.new(control: :EOT, seq: seq)
56
- blocks
40
+ opts.merge({})
41
+ raise NotImplementedError, 'PhoneAPI XModem transfers filesystem files, not firmware images'
57
42
  end
58
43
 
59
44
  public_class_method def self.send_xmodem(opts = {})
60
- packet = opts[:xmodem]
61
- to_radio = Meshtastic::ToRadio.new
62
- to_radio.xmodemPacket = packet
63
- send_phone(opts.merge(to_radio: to_radio))
45
+ opts.merge({})
46
+ raise NotImplementedError, 'PhoneAPI XModem transfers filesystem files, not firmware images'
64
47
  end
65
48
 
66
49
  public_class_method def self.install(opts = {})
50
+ validate_verification(opts[:verify]) if opts.key?(:verify)
51
+ options = opts.except(:verify)
52
+ result = case opts[:protocol]
53
+ when :unified_ble then BLE.install(options)
54
+ when :esp_rom then SerialBootloader.install(options)
55
+ when :nordic_dfu then NordicDFU.install(options)
56
+ when :unified_wifi then install_wifi(options)
57
+ else raise NotImplementedError, 'install requires explicit protocol: :unified_wifi, :unified_ble, :esp_rom or :nordic_dfu'
58
+ end
59
+ return result unless opts[:verify]
60
+
61
+ result.merge(verify_reboot(opts[:verify])).merge(loader_status: result[:status], reboot_verified: true, boot_verified: true)
62
+ end
63
+
64
+ private_class_method def self.install_wifi(opts = {})
65
+ validate_install(opts.merge({}))
67
66
  bytes = firmware_bytes(opts.merge({}))
68
- request_ota(opts.merge(bytes: bytes))
69
- return if opts[:mqtt_obj]
67
+ digest = Digest::SHA256.hexdigest(bytes)
68
+ timeout = opts.fetch(:timeout, 120)
69
+ socket = connect_loader(opts.merge(timeout: timeout))
70
+ Timeout.timeout(timeout) do
71
+ socket.write("VERSION\n")
72
+ version = response(socket: socket)
73
+ raise IOError, "Invalid loader VERSION: #{version}" unless version.match?(/\AOK \d+ \S+ \d+ \S+\z/)
74
+
75
+ socket.write("OTA #{bytes.bytesize} #{digest}\n")
76
+ line = response(socket: socket)
77
+ line = response(socket: socket) if line == 'ERASING'
78
+ raise IOError, "OTA handshake rejected: #{line}" unless line == 'OK'
79
+
80
+ upload(socket: socket, bytes: bytes)
81
+ { status: :verified, bytes: bytes.bytesize, sha256: digest, loader_version: version.delete_prefix('OK ') }
82
+ end
83
+ ensure
84
+ socket&.close
85
+ end
86
+
87
+ public_class_method def self.verify_reboot(opts = {})
88
+ validate_verification(opts.merge({}))
89
+ handle = nil
90
+ transport = { tcp: Meshtastic::TCP, bluetooth: Meshtastic::Bluetooth, serial: Meshtastic::Serial }.fetch(opts.fetch(:transport))
91
+ key = { tcp: :tcp_obj, bluetooth: :bluetooth_obj, serial: :serial_obj }.fetch(opts.fetch(:transport))
92
+ Timeout.timeout(opts.fetch(:timeout, 60)) do
93
+ sleep opts.fetch(:reboot_delay, 3)
94
+ begin
95
+ handle = if opts[:reconnect]
96
+ opts[:reconnect].call(transport: opts.fetch(:transport), connection: opts.fetch(:connection, {}), timeout: opts.fetch(:timeout, 60))
97
+ else
98
+ transport.connect(opts.fetch(:connection).merge(want_config: true))
99
+ end
100
+ transport.wait_for_config(key => handle, timeout: opts.fetch(:timeout, 60))
101
+ rescue IOError, SystemCallError
102
+ transport.disconnect(key => handle) if handle
103
+ handle = nil
104
+ sleep 0.25
105
+ retry
106
+ end
107
+ reply = Admin.request(key => handle, get_device_metadata_request: true, timeout: opts.fetch(:timeout, 60))
108
+ metadata = reply.fetch(:value).to_h
109
+ raise IOError, "Firmware version mismatch: #{metadata[:firmware_version].inspect}" unless metadata[:firmware_version] == opts.fetch(:expected_version)
110
+ raise IOError, 'Post-reboot node identity mismatch' if opts[:expected_node] && handle[:my_node_num] != opts[:expected_node]
70
111
 
71
- xmodem_blocks(bytes: bytes).each do |packet|
72
- send_xmodem(opts.merge(xmodem: packet))
112
+ { status: :boot_verified, firmware_version: metadata[:firmware_version], node_num: handle[:my_node_num], metadata: metadata }
73
113
  end
114
+ ensure
115
+ transport.disconnect(key => handle) if handle
116
+ end
117
+
118
+ private_class_method def self.validate_verification(opts = {})
119
+ raise ArgumentError, 'verify must be a Hash of verify_reboot options' unless opts.is_a?(Hash)
120
+
121
+ allowed = %i[transport connection expected_version expected_node reconnect timeout reboot_delay]
122
+ raise ArgumentError, 'Unknown reboot verification option' unless (opts.keys - allowed).empty?
123
+ raise ArgumentError, 'transport must be :tcp, :bluetooth or :serial' unless %i[tcp bluetooth serial].include?(opts[:transport])
124
+ raise ArgumentError, 'expected_version must be a nonempty firmware version String' unless opts[:expected_version].is_a?(String) && !opts[:expected_version].empty?
125
+ raise ArgumentError, 'expected_node must be a numeric node ID' if opts.key?(:expected_node) && !(opts[:expected_node].is_a?(Integer) && (1..0xffffffff).cover?(opts[:expected_node]))
126
+
127
+ timeout = opts.fetch(:timeout, 60)
128
+ delay = opts.fetch(:reboot_delay, 3)
129
+ raise ArgumentError, 'timeout must be positive finite seconds' unless timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout.positive?
130
+ raise ArgumentError, 'reboot_delay must be nonnegative finite seconds' unless delay.is_a?(Numeric) && delay.real? && delay.finite? && delay >= 0
131
+ raise ArgumentError, 'reconnect must be callable' if opts.key?(:reconnect) && !opts[:reconnect].respond_to?(:call)
132
+ return if opts[:reconnect]
133
+
134
+ connection = opts[:connection]
135
+ required = { tcp: :host, bluetooth: :address, serial: :block_dev }.fetch(opts[:transport])
136
+ raise ArgumentError, "connection must specify #{required}" unless connection.is_a?(Hash) && connection[required].is_a?(String) && !connection[required].strip.empty?
137
+
138
+ keys = { tcp: %i[host port], bluetooth: %i[address adapter timeout], serial: %i[block_dev baud] }.fetch(opts[:transport])
139
+ raise ArgumentError, 'Only fresh connection endpoint options are allowed' unless (connection.keys - keys).empty?
140
+
141
+ if opts[:transport] == :tcp
142
+ port = connection.fetch(:port, 4403)
143
+ raise ArgumentError, 'Application TCP port must be in 1..65535' unless port.is_a?(Integer) && (1..65_535).cover?(port)
144
+ elsif opts[:transport] == :bluetooth
145
+ # Construction validates address/adapter/timeout without touching D-Bus.
146
+ Meshtastic::Bluetooth::BlueZ.new(address: connection[:address], adapter: connection.fetch(:adapter, 'hci0'), timeout: connection.fetch(:timeout, 15))
147
+ end
148
+ end
149
+
150
+ private_class_method def self.upload(opts = {})
151
+ socket = opts.fetch(:socket)
152
+ writer = Thread.new do
153
+ Thread.current.report_on_exception = false
154
+ socket.write(opts.fetch(:bytes))
155
+ rescue IOError, SystemCallError
156
+ socket.close
157
+ raise
158
+ end
159
+ loop do
160
+ line = response(socket: socket)
161
+ break if line == 'OK'
162
+ raise IOError, "OTA transfer rejected: #{line}" unless line == 'ACK'
163
+ end
164
+ writer.value
165
+ ensure
166
+ writer&.kill
167
+ writer&.join
168
+ end
169
+
170
+ private_class_method def self.connect_loader(opts = {})
171
+ attempts = 0
172
+ begin
173
+ attempts += 1
174
+ Socket.tcp(opts.fetch(:host), opts.fetch(:port, 3232), connect_timeout: opts.fetch(:timeout))
175
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT
176
+ raise if attempts > opts.fetch(:retries, 3)
177
+
178
+ sleep opts.fetch(:retry_delay, 1)
179
+ retry
180
+ end
181
+ end
182
+
183
+ private_class_method def self.response(opts = {})
184
+ line = opts.fetch(:socket).gets("\n", 513)
185
+ raise IOError, 'OTA connection closed before confirmation' unless line
186
+ raise IOError, 'Invalid OTA response framing' unless line.end_with?("\n") && line.bytesize <= 512
187
+
188
+ line.chomp
189
+ end
190
+
191
+ private_class_method def self.validate_install(opts = {})
192
+ allowed = %i[protocol host port bytes firmware timeout retries retry_delay]
193
+ unknown = opts.keys - allowed
194
+ raise ArgumentError, "Unsupported install options: #{unknown.join(', ')}" unless unknown.empty?
195
+ raise ArgumentError, 'host must be a nonempty string' unless opts[:host].is_a?(String) && !opts[:host].strip.empty?
196
+
197
+ port = opts.fetch(:port, 3232)
198
+ retries = opts.fetch(:retries, 3)
199
+ timeout = opts.fetch(:timeout, 120)
200
+ delay = opts.fetch(:retry_delay, 1)
201
+ raise ArgumentError, 'port must be in 1..65535' unless port.is_a?(Integer) && (1..65_535).cover?(port)
202
+ raise ArgumentError, 'retries must be in 0..20' unless retries.is_a?(Integer) && (0..20).cover?(retries)
203
+ raise ArgumentError, 'timeout must be finite and positive' unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
204
+ raise ArgumentError, 'retry_delay must be finite and nonnegative' unless delay.is_a?(Numeric) && delay.finite? && delay >= 0
74
205
  end
75
206
 
76
207
  public_class_method def self.authors
@@ -99,35 +230,40 @@ module Meshtastic
99
230
  serial_obj: 'optional - serial handle from Meshtastic::Serial.connect'
100
231
  )
101
232
 
102
- # Send the legacy reboot_ota_seconds admin field.
103
- #{self}.reboot_ota(
104
- serial_obj: 'optional - serial handle from Meshtastic::Serial.connect',
105
- seconds: 'optional - delay before OTA reboot in seconds (default: 10)'
106
- )
233
+ # Reject the obsolete unhandled legacy OTA reboot field.
234
+ #{self}.reboot_ota
107
235
 
108
- # Split firmware bytes into XModem SOH blocks plus EOT.
109
- #{self}.xmodem_blocks(
110
- bytes: 'required - raw firmware image bytes to chunk',
111
- block_size: 'optional - XModem block size in bytes (default: 128)'
112
- )
236
+ # Reject filesystem XModem as a firmware update mechanism.
237
+ #{self}.xmodem_blocks
113
238
 
114
- # Write one XModem protobuf as a PhoneAPI ToRadio frame.
115
- #{self}.send_xmodem(
116
- xmodem: 'required - Meshtastic::XModem protobuf to write',
117
- serial_obj: 'optional - serial handle from Meshtastic::Serial.connect',
118
- tcp_obj: 'optional - TCP handle from Meshtastic::TCP.connect',
119
- bluetooth_obj: 'optional - BLE handle from Meshtastic::Bluetooth.connect'
120
- )
239
+ # Reject filesystem XModem as a firmware update mechanism.
240
+ #{self}.send_xmodem
121
241
 
122
- # Hash, send ota_request, then stream XModem on serial/TCP/BLE.
242
+ # Upload using an explicitly selected native loader protocol.
123
243
  #{self}.install(
124
- firmware: 'optional - path to a firmware .bin on disk',
125
- bytes: 'optional - raw firmware image bytes if no path is given',
126
- serial_obj: 'optional - serial handle from Meshtastic::Serial.connect',
127
- tcp_obj: 'optional - TCP handle from Meshtastic::TCP.connect',
128
- bluetooth_obj: 'optional - BLE handle from Meshtastic::Bluetooth.connect',
129
- mqtt_obj: 'optional - MQTT client; publishes ota_request only',
130
- mode: 'optional - :OTA_BLE or :OTA_WIFI (default: :OTA_BLE)'
244
+ protocol: 'required - :unified_wifi, :unified_ble, :esp_rom or :nordic_dfu; no protocol guessing',
245
+ verify: 'optional - verify_reboot options Hash; success becomes :boot_verified only after a fresh reply',
246
+ host: 'required - OTA loader IP address or hostname, not a mesh node ID',
247
+ port: 'optional - separate OTA TCP service port (default: 3232)',
248
+ firmware: 'optional - matching application .bin path, exclusive with bytes',
249
+ bytes: 'optional - nonempty raw image String, exclusive with firmware',
250
+ timeout: 'optional - positive seconds per connect and whole transfer (default: 120)',
251
+ retries: 'optional - connection refusal/timeout retries, 0..20 (default: 3)',
252
+ retry_delay: 'optional - nonnegative seconds between connection retries (default: 1)'
253
+ )
254
+ # First use request_ota with the matching mode to pin the same image hash.
255
+ # install never sends preparation commands; :verified means loader OK, not boot confirmation.
256
+ # BLE.help, NordicDFU.help and SerialBootloader.help document protocol-specific options.
257
+
258
+ # Reconnect and request fresh correlated application firmware metadata.
259
+ #{self}.verify_reboot(
260
+ transport: 'required - :tcp, :bluetooth or :serial for the restarted application',
261
+ connection: 'optional - connect options Hash with explicit host/address/block_dev; required without reconnect',
262
+ expected_version: 'required - exact firmware version reported by the intended application',
263
+ expected_node: 'optional - numeric node identity to verify after restart',
264
+ reconnect: 'optional - callable accepting options Hash and returning a newly connected transport handle',
265
+ timeout: 'optional - entire reboot/reconnect/config/metadata deadline seconds, default 60',
266
+ reboot_delay: 'optional - initial wait for loader restart, default 3 seconds'
131
267
  )
132
268
 
133
269
  # Print the AUTHOR(S) string for this module.
@@ -136,38 +272,17 @@ module Meshtastic
136
272
  end
137
273
 
138
274
  private_class_method def self.firmware_bytes(opts = {})
139
- if opts[:bytes]
140
- opts[:bytes].to_s.b
141
- elsif opts[:firmware]
142
- File.binread(opts[:firmware])
143
- else
144
- raise ArgumentError, 'firmware path or bytes is required'
145
- end
146
- end
275
+ raise ArgumentError, 'provide exactly one of firmware or bytes' unless opts.key?(:bytes) ^ opts.key?(:firmware)
147
276
 
148
- private_class_method def self.crc16(opts = {})
149
- crc = 0
150
- opts[:data].to_s.b.each_byte do |byte|
151
- crc ^= byte << 8
152
- 8.times do
153
- crc = crc[15] == 1 ? ((crc << 1) ^ 0x1021) : (crc << 1)
154
- crc &= 0xffff
155
- end
156
- end
157
- crc
158
- end
159
-
160
- private_class_method def self.send_phone(opts = {})
161
- if opts[:serial_obj]
162
- Serial.send_to_radio(opts)
163
- elsif opts[:tcp_obj]
164
- TCP.send_to_radio(opts)
165
- elsif opts[:bluetooth_obj]
166
- Bluetooth.send_to_radio(opts)
167
- else
168
- raise ArgumentError, 'serial_obj, bluetooth_obj, or tcp_obj is required for XModem'
169
- end
277
+ bytes = opts.key?(:bytes) ? opts[:bytes] : File.binread(opts[:firmware])
278
+ raise ArgumentError, 'firmware bytes must be a nonempty String' unless bytes.is_a?(String) && !bytes.empty?
279
+
280
+ bytes.b
170
281
  end
171
282
  end
172
283
  end
173
284
  end
285
+
286
+ require 'meshtastic/admin/firmware/ble'
287
+ require 'meshtastic/admin/firmware/serial_bootloader'
288
+ require 'meshtastic/admin/firmware/nordic_dfu'