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.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/documentation/admin-channel.md +51 -20
- data/documentation/admin-config.md +45 -14
- data/documentation/admin-firmware-nordic.md +86 -0
- data/documentation/admin-firmware-serial.md +180 -0
- data/documentation/admin-firmware.md +103 -46
- data/documentation/admin.md +88 -36
- data/documentation/mesh-interface.md +7 -0
- data/lib/meshtastic/admin/channel.rb +99 -25
- data/lib/meshtastic/admin/config.rb +70 -11
- data/lib/meshtastic/admin/firmware/ble.rb +207 -0
- data/lib/meshtastic/admin/firmware/nordic_dfu.rb +218 -0
- data/lib/meshtastic/admin/firmware/serial_bootloader.rb +233 -0
- data/lib/meshtastic/admin/firmware.rb +208 -93
- data/lib/meshtastic/admin.rb +251 -27
- data/lib/meshtastic/config_pb.rb +2 -1
- data/lib/meshtastic/mesh_interface.rb +8 -0
- data/lib/meshtastic/storeforward_pb.rb +1 -1
- data/lib/meshtastic/version.rb +1 -1
- data/spec/lib/meshtastic/admin/channel_spec.rb +159 -2
- data/spec/lib/meshtastic/admin/config_spec.rb +66 -0
- data/spec/lib/meshtastic/admin/firmware/ble_spec.rb +170 -0
- data/spec/lib/meshtastic/admin/firmware/nordic_dfu_spec.rb +248 -0
- data/spec/lib/meshtastic/admin/firmware/serial_bootloader_spec.rb +263 -0
- data/spec/lib/meshtastic/admin/firmware_spec.rb +292 -95
- data/spec/lib/meshtastic/admin_spec.rb +372 -1
- data/spec/lib/meshtastic/mesh_interface_spec.rb +31 -0
- metadata +14 -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
|
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'digest'
|
|
4
|
-
require '
|
|
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
|
|
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:
|
|
22
|
-
ota_hash: hash.
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
242
|
+
# Upload using an explicitly selected native loader protocol.
|
|
123
243
|
#{self}.install(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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'
|