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.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/documentation/README.md +3 -2
- data/documentation/admin-channel.md +64 -0
- data/documentation/admin-config.md +59 -0
- data/documentation/admin-firmware-nordic.md +86 -0
- data/documentation/admin-firmware-serial.md +180 -0
- data/documentation/admin-firmware.md +126 -0
- data/documentation/admin.md +88 -35
- data/documentation/atak.md +93 -10
- data/documentation/channel.md +1 -28
- data/documentation/config.md +1 -27
- data/documentation/mesh-interface.md +7 -0
- data/documentation/meshtastic.md +2 -2
- data/lib/meshtastic/admin/channel.rb +164 -0
- data/lib/meshtastic/admin/config.rb +213 -0
- 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 +288 -0
- data/lib/meshtastic/admin.rb +688 -34
- data/lib/meshtastic/apponly.rb +19 -7
- data/lib/meshtastic/atak.rb +295 -17
- data/lib/meshtastic/bluetooth/bluez.rb +20 -1
- data/lib/meshtastic/bluetooth.rb +70 -59
- data/lib/meshtastic/cannedmessages.rb +13 -6
- data/lib/meshtastic/channel.rb +3 -25
- data/lib/meshtastic/clientonly.rb +19 -7
- data/lib/meshtastic/config.rb +3 -24
- data/lib/meshtastic/config_pb.rb +2 -1
- data/lib/meshtastic/connection_status.rb +12 -5
- data/lib/meshtastic/deviceonly.rb +19 -7
- data/lib/meshtastic/localonly.rb +19 -7
- data/lib/meshtastic/mesh_interface.rb +19 -0
- data/lib/meshtastic/module_config.rb +13 -7
- data/lib/meshtastic/mqtt.rb +72 -35
- data/lib/meshtastic/paxcount.rb +17 -6
- data/lib/meshtastic/portnums.rb +13 -6
- data/lib/meshtastic/position.rb +16 -6
- data/lib/meshtastic/remote_hardware.rb +27 -11
- data/lib/meshtastic/rtttl.rb +17 -8
- data/lib/meshtastic/serial.rb +81 -70
- data/lib/meshtastic/storeforward.rb +13 -6
- data/lib/meshtastic/storeforward_pb.rb +1 -1
- data/lib/meshtastic/stream_interface.rb +11 -0
- data/lib/meshtastic/tcp.rb +73 -22
- data/lib/meshtastic/telemetry.rb +15 -6
- data/lib/meshtastic/traceroute.rb +15 -6
- data/lib/meshtastic/util.rb +3 -1
- data/lib/meshtastic/version.rb +1 -1
- data/lib/meshtastic/xmodem.rb +12 -5
- data/lib/meshtastic.rb +22 -3
- data/spec/conventions_spec.rb +321 -0
- data/spec/lib/meshtastic/admin/channel_spec.rb +202 -0
- data/spec/lib/meshtastic/admin/config_spec.rb +114 -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 +315 -0
- data/spec/lib/meshtastic/admin_spec.rb +412 -1
- data/spec/lib/meshtastic/apponly_spec.rb +1 -1
- data/spec/lib/meshtastic/atak_spec.rb +129 -4
- data/spec/lib/meshtastic/bluetooth_spec.rb +1 -1
- data/spec/lib/meshtastic/channel_spec.rb +2 -20
- data/spec/lib/meshtastic/config_spec.rb +2 -20
- data/spec/lib/meshtastic/connection_status_spec.rb +1 -1
- data/spec/lib/meshtastic/deviceonly_spec.rb +1 -1
- data/spec/lib/meshtastic/localonly_spec.rb +1 -1
- data/spec/lib/meshtastic/mesh_interface_spec.rb +31 -0
- data/spec/lib/meshtastic/portnums_spec.rb +2 -2
- 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'
|