meshtastic 0.0.179 → 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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/README.md +3 -2
  4. data/documentation/admin-channel.md +64 -0
  5. data/documentation/admin-config.md +59 -0
  6. data/documentation/admin-firmware-nordic.md +86 -0
  7. data/documentation/admin-firmware-serial.md +180 -0
  8. data/documentation/admin-firmware.md +126 -0
  9. data/documentation/admin.md +88 -35
  10. data/documentation/atak.md +93 -10
  11. data/documentation/channel.md +1 -28
  12. data/documentation/config.md +1 -27
  13. data/documentation/mesh-interface.md +7 -0
  14. data/documentation/meshtastic.md +2 -2
  15. data/lib/meshtastic/admin/channel.rb +164 -0
  16. data/lib/meshtastic/admin/config.rb +213 -0
  17. data/lib/meshtastic/admin/firmware/ble.rb +207 -0
  18. data/lib/meshtastic/admin/firmware/nordic_dfu.rb +218 -0
  19. data/lib/meshtastic/admin/firmware/serial_bootloader.rb +233 -0
  20. data/lib/meshtastic/admin/firmware.rb +288 -0
  21. data/lib/meshtastic/admin.rb +688 -34
  22. data/lib/meshtastic/apponly.rb +19 -7
  23. data/lib/meshtastic/atak.rb +295 -17
  24. data/lib/meshtastic/bluetooth/bluez.rb +20 -1
  25. data/lib/meshtastic/bluetooth.rb +70 -59
  26. data/lib/meshtastic/cannedmessages.rb +13 -6
  27. data/lib/meshtastic/channel.rb +3 -25
  28. data/lib/meshtastic/clientonly.rb +19 -7
  29. data/lib/meshtastic/config.rb +3 -24
  30. data/lib/meshtastic/config_pb.rb +2 -1
  31. data/lib/meshtastic/connection_status.rb +12 -5
  32. data/lib/meshtastic/deviceonly.rb +19 -7
  33. data/lib/meshtastic/localonly.rb +19 -7
  34. data/lib/meshtastic/mesh_interface.rb +19 -0
  35. data/lib/meshtastic/module_config.rb +13 -7
  36. data/lib/meshtastic/mqtt.rb +72 -35
  37. data/lib/meshtastic/paxcount.rb +17 -6
  38. data/lib/meshtastic/portnums.rb +13 -6
  39. data/lib/meshtastic/position.rb +16 -6
  40. data/lib/meshtastic/remote_hardware.rb +27 -11
  41. data/lib/meshtastic/rtttl.rb +17 -8
  42. data/lib/meshtastic/serial.rb +81 -70
  43. data/lib/meshtastic/storeforward.rb +13 -6
  44. data/lib/meshtastic/storeforward_pb.rb +1 -1
  45. data/lib/meshtastic/stream_interface.rb +11 -0
  46. data/lib/meshtastic/tcp.rb +73 -22
  47. data/lib/meshtastic/telemetry.rb +15 -6
  48. data/lib/meshtastic/traceroute.rb +15 -6
  49. data/lib/meshtastic/util.rb +3 -1
  50. data/lib/meshtastic/version.rb +1 -1
  51. data/lib/meshtastic/xmodem.rb +12 -5
  52. data/lib/meshtastic.rb +22 -3
  53. data/spec/conventions_spec.rb +321 -0
  54. data/spec/lib/meshtastic/admin/channel_spec.rb +202 -0
  55. data/spec/lib/meshtastic/admin/config_spec.rb +114 -0
  56. data/spec/lib/meshtastic/admin/firmware/ble_spec.rb +170 -0
  57. data/spec/lib/meshtastic/admin/firmware/nordic_dfu_spec.rb +248 -0
  58. data/spec/lib/meshtastic/admin/firmware/serial_bootloader_spec.rb +263 -0
  59. data/spec/lib/meshtastic/admin/firmware_spec.rb +315 -0
  60. data/spec/lib/meshtastic/admin_spec.rb +412 -1
  61. data/spec/lib/meshtastic/apponly_spec.rb +1 -1
  62. data/spec/lib/meshtastic/atak_spec.rb +129 -4
  63. data/spec/lib/meshtastic/bluetooth_spec.rb +1 -1
  64. data/spec/lib/meshtastic/channel_spec.rb +2 -20
  65. data/spec/lib/meshtastic/config_spec.rb +2 -20
  66. data/spec/lib/meshtastic/connection_status_spec.rb +1 -1
  67. data/spec/lib/meshtastic/deviceonly_spec.rb +1 -1
  68. data/spec/lib/meshtastic/localonly_spec.rb +1 -1
  69. data/spec/lib/meshtastic/mesh_interface_spec.rb +31 -0
  70. data/spec/lib/meshtastic/portnums_spec.rb +2 -2
  71. metadata +24 -6
@@ -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
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'socket'
5
+ require 'timeout'
6
+
7
+
8
+ module Meshtastic
9
+ module Admin
10
+ module Firmware
11
+ public_class_method def self.sha256(opts = {})
12
+ Digest::SHA256.digest(firmware_bytes(opts.merge({})))
13
+ end
14
+
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
+
19
+ hash = opts[:ota_hash] || sha256(opts)
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)
22
+
23
+ event = Meshtastic::AdminMessage::OTAEvent.new(
24
+ reboot_ota_mode: mode,
25
+ ota_hash: hash.b
26
+ )
27
+ Admin.send(opts.except(:bytes, :firmware, :ota_hash, :mode).merge(ota_request: event))
28
+ end
29
+
30
+ public_class_method def self.enter_dfu(opts = {})
31
+ Admin.send(opts.merge(enter_dfu_mode_request: true))
32
+ end
33
+
34
+ public_class_method def self.reboot_ota(opts = {})
35
+ opts.merge({})
36
+ raise NotImplementedError, 'reboot_ota_seconds is not handled by current firmware; use request_ota'
37
+ end
38
+
39
+ public_class_method def self.xmodem_blocks(opts = {})
40
+ opts.merge({})
41
+ raise NotImplementedError, 'PhoneAPI XModem transfers filesystem files, not firmware images'
42
+ end
43
+
44
+ public_class_method def self.send_xmodem(opts = {})
45
+ opts.merge({})
46
+ raise NotImplementedError, 'PhoneAPI XModem transfers filesystem files, not firmware images'
47
+ end
48
+
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({}))
66
+ bytes = firmware_bytes(opts.merge({}))
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]
111
+
112
+ { status: :boot_verified, firmware_version: metadata[:firmware_version], node_num: handle[:my_node_num], metadata: metadata }
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
205
+ end
206
+
207
+ public_class_method def self.authors
208
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n "
209
+ end
210
+
211
+ public_class_method def self.help
212
+ puts "USAGE:
213
+ # SHA-256 the firmware image bytes or file.
214
+ #{self}.sha256(
215
+ firmware: 'optional - path to a firmware .bin on disk',
216
+ bytes: 'optional - raw firmware image bytes if no path is given'
217
+ )
218
+
219
+ # Send Admin ota_request with the image hash and OTA mode.
220
+ #{self}.request_ota(
221
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect',
222
+ mqtt_obj: 'optional - MQTT client from Meshtastic::MQTT.connect',
223
+ firmware: 'optional - path to a firmware .bin on disk',
224
+ ota_hash: 'optional - 32-byte SHA-256 digest if not hashing firmware',
225
+ mode: 'optional - :OTA_BLE or :OTA_WIFI (default: :OTA_BLE)'
226
+ )
227
+
228
+ # Ask the node to enter DFU / UF2 bootloader mode.
229
+ #{self}.enter_dfu(
230
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect'
231
+ )
232
+
233
+ # Reject the obsolete unhandled legacy OTA reboot field.
234
+ #{self}.reboot_ota
235
+
236
+ # Reject filesystem XModem as a firmware update mechanism.
237
+ #{self}.xmodem_blocks
238
+
239
+ # Reject filesystem XModem as a firmware update mechanism.
240
+ #{self}.send_xmodem
241
+
242
+ # Upload using an explicitly selected native loader protocol.
243
+ #{self}.install(
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'
267
+ )
268
+
269
+ # Print the AUTHOR(S) string for this module.
270
+ #{self}.authors
271
+ "
272
+ end
273
+
274
+ private_class_method def self.firmware_bytes(opts = {})
275
+ raise ArgumentError, 'provide exactly one of firmware or bytes' unless opts.key?(:bytes) ^ opts.key?(:firmware)
276
+
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
281
+ end
282
+ end
283
+ end
284
+ end
285
+
286
+ require 'meshtastic/admin/firmware/ble'
287
+ require 'meshtastic/admin/firmware/serial_bootloader'
288
+ require 'meshtastic/admin/firmware/nordic_dfu'