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
|
@@ -10,7 +10,14 @@ module Meshtastic
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
public_class_method def self.set(opts = {})
|
|
13
|
-
|
|
13
|
+
config = opts[:config]
|
|
14
|
+
raise ArgumentError, 'config must contain one Config section' unless config.is_a?(Meshtastic::Config) && config.payload_variant
|
|
15
|
+
raise ArgumentError, 'sessionkey is a request-only placeholder; use get_sessionkey' if config.payload_variant == :sessionkey
|
|
16
|
+
|
|
17
|
+
section_keys = Meshtastic::Config.descriptor.map { |field| field.name.to_sym }
|
|
18
|
+
return Admin.store_ui_config(opts.except(*section_keys, :config).merge(ui_config: config.device_ui)) if config.payload_variant == :device_ui
|
|
19
|
+
|
|
20
|
+
Admin.set_config(opts.except(*section_keys).merge(config: config))
|
|
14
21
|
end
|
|
15
22
|
|
|
16
23
|
public_class_method def self.get_device(opts = {})
|
|
@@ -50,19 +57,47 @@ module Meshtastic
|
|
|
50
57
|
end
|
|
51
58
|
|
|
52
59
|
public_class_method def self.get_device_ui(opts = {})
|
|
53
|
-
|
|
60
|
+
Admin.get_ui_config(opts.merge({}))
|
|
54
61
|
end
|
|
55
62
|
|
|
56
63
|
public_class_method def self.set_device(opts = {})
|
|
57
|
-
config
|
|
58
|
-
config.device = opts[:device]
|
|
59
|
-
set(opts.merge(config: config))
|
|
64
|
+
set(opts.merge(config: Meshtastic::Config.new(device: opts.fetch(:device))))
|
|
60
65
|
end
|
|
61
66
|
|
|
62
67
|
public_class_method def self.set_lora(opts = {})
|
|
63
|
-
config
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
set(opts.merge(config: Meshtastic::Config.new(lora: opts.fetch(:lora))))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
public_class_method def self.set_position(opts = {})
|
|
72
|
+
set(opts.merge(config: Meshtastic::Config.new(position: opts.fetch(:position))))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
public_class_method def self.set_power(opts = {})
|
|
76
|
+
set(opts.merge(config: Meshtastic::Config.new(power: opts.fetch(:power))))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
public_class_method def self.set_network(opts = {})
|
|
80
|
+
set(opts.merge(config: Meshtastic::Config.new(network: opts.fetch(:network))))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
public_class_method def self.set_display(opts = {})
|
|
84
|
+
set(opts.merge(config: Meshtastic::Config.new(display: opts.fetch(:display))))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
public_class_method def self.set_bluetooth(opts = {})
|
|
88
|
+
set(opts.merge(config: Meshtastic::Config.new(bluetooth: opts.fetch(:bluetooth))))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
public_class_method def self.set_security(opts = {})
|
|
92
|
+
set(opts.merge(config: Meshtastic::Config.new(security: opts.fetch(:security))))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
public_class_method def self.set_sessionkey(opts = {})
|
|
96
|
+
set(opts.merge(config: Meshtastic::Config.new(sessionkey: opts.fetch(:sessionkey))))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
public_class_method def self.set_device_ui(opts = {})
|
|
100
|
+
set(opts.merge(config: Meshtastic::Config.new(device_ui: opts.fetch(:device_ui))))
|
|
66
101
|
end
|
|
67
102
|
|
|
68
103
|
public_class_method def self.authors
|
|
@@ -128,7 +163,7 @@ module Meshtastic
|
|
|
128
163
|
serial_obj: 'optional - serial handle from Meshtastic::Serial.connect'
|
|
129
164
|
)
|
|
130
165
|
|
|
131
|
-
# Request
|
|
166
|
+
# Request DeviceUIConfig using dedicated firmware operation.
|
|
132
167
|
#{self}.get_device_ui(
|
|
133
168
|
serial_obj: 'optional - serial handle from Meshtastic::Serial.connect'
|
|
134
169
|
)
|
|
@@ -136,15 +171,39 @@ module Meshtastic
|
|
|
136
171
|
# Write DeviceConfig wrapped in a Config protobuf.
|
|
137
172
|
#{self}.set_device(
|
|
138
173
|
serial_obj: 'optional - serial handle from Meshtastic::Serial.connect',
|
|
139
|
-
device: 'required -
|
|
174
|
+
device: 'required - DeviceConfig protobuf or complete section field Hash'
|
|
140
175
|
)
|
|
141
176
|
|
|
142
177
|
# Write LoRaConfig wrapped in a Config protobuf.
|
|
143
178
|
#{self}.set_lora(
|
|
144
179
|
serial_obj: 'optional - serial handle from Meshtastic::Serial.connect',
|
|
145
|
-
lora: 'required -
|
|
180
|
+
lora: 'required - LoRaConfig protobuf or complete section field Hash'
|
|
146
181
|
)
|
|
147
182
|
|
|
183
|
+
# Write position configuration to node.
|
|
184
|
+
#{self}.set_position(position: 'required - PositionConfig protobuf or field hash')
|
|
185
|
+
|
|
186
|
+
# Write power configuration to node.
|
|
187
|
+
#{self}.set_power(power: 'required - PowerConfig protobuf or field hash')
|
|
188
|
+
|
|
189
|
+
# Write network configuration to node.
|
|
190
|
+
#{self}.set_network(network: 'required - NetworkConfig protobuf or field hash')
|
|
191
|
+
|
|
192
|
+
# Write display configuration to node.
|
|
193
|
+
#{self}.set_display(display: 'required - DisplayConfig protobuf or field hash')
|
|
194
|
+
|
|
195
|
+
# Write Bluetooth configuration to node.
|
|
196
|
+
#{self}.set_bluetooth(bluetooth: 'required - BluetoothConfig protobuf or field hash')
|
|
197
|
+
|
|
198
|
+
# Write security configuration to node.
|
|
199
|
+
#{self}.set_security(security: 'required - SecurityConfig protobuf or field hash')
|
|
200
|
+
|
|
201
|
+
# Reject writes to request-only session key placeholder.
|
|
202
|
+
#{self}.set_sessionkey(sessionkey: 'required - placeholder only; always raises ArgumentError')
|
|
203
|
+
|
|
204
|
+
# Write device UI configuration to node.
|
|
205
|
+
#{self}.set_device_ui(device_ui: 'required - DeviceUIConfig protobuf or field hash')
|
|
206
|
+
|
|
148
207
|
# Print the AUTHOR(S) string for this module.
|
|
149
208
|
#{self}.authors
|
|
150
209
|
"
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Meshtastic
|
|
4
|
+
module Admin
|
|
5
|
+
module Firmware
|
|
6
|
+
# ESP32 unified loader only; not the legacy firmware-ota protocol.
|
|
7
|
+
module BLE
|
|
8
|
+
SERVICE_UUID = '4fafc201-1fb5-459e-8fcc-c5c9c331914b'
|
|
9
|
+
OTA_UUID = '62ec0272-3ec5-11eb-b378-0242ac130005'
|
|
10
|
+
TX_UUID = '62ec0272-3ec5-11eb-b378-0242ac130003'
|
|
11
|
+
|
|
12
|
+
public_class_method def self.install(opts = {})
|
|
13
|
+
unknown = opts.keys - %i[protocol bytes firmware backend address adapter timeout]
|
|
14
|
+
raise ArgumentError, "Unsupported BLE options: #{unknown.join(', ')}" unless unknown.empty?
|
|
15
|
+
raise ArgumentError, 'protocol must be :unified_ble' unless opts.fetch(:protocol, :unified_ble) == :unified_ble
|
|
16
|
+
|
|
17
|
+
timeout = opts.fetch(:timeout, 120)
|
|
18
|
+
raise ArgumentError, 'timeout must be positive finite seconds' unless timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout.positive?
|
|
19
|
+
|
|
20
|
+
bytes = Firmware.__send__(:firmware_bytes, opts)
|
|
21
|
+
backend = opts[:backend] || BlueZ.new(address: opts[:address], adapter: opts.fetch(:adapter, 'hci0'), timeout: timeout, service_uuid: SERVICE_UUID).connect
|
|
22
|
+
Timeout.timeout(timeout) do
|
|
23
|
+
backend.subscribe(uuid: TX_UUID)
|
|
24
|
+
stream = Stream.new(backend)
|
|
25
|
+
stream.command("VERSION\n")
|
|
26
|
+
version = stream.line
|
|
27
|
+
raise IOError, "Invalid loader VERSION: #{version}" unless version.match?(/\AOK \d+ \S+ \d+ \S+\z/)
|
|
28
|
+
|
|
29
|
+
digest = Digest::SHA256.hexdigest(bytes)
|
|
30
|
+
stream.command("OTA #{bytes.bytesize} #{digest}\n")
|
|
31
|
+
reply = stream.line
|
|
32
|
+
reply = stream.line if reply == 'ERASING'
|
|
33
|
+
raise IOError, "OTA handshake rejected: #{reply}" unless reply == 'OK'
|
|
34
|
+
|
|
35
|
+
offset = 0
|
|
36
|
+
while offset < bytes.bytesize
|
|
37
|
+
chunk = bytes.byteslice(offset, 20)
|
|
38
|
+
backend.write(uuid: OTA_UUID, bytes: chunk, response: true)
|
|
39
|
+
offset += chunk.bytesize
|
|
40
|
+
reply = stream.line
|
|
41
|
+
expected = offset == bytes.bytesize ? 'OK' : 'ACK'
|
|
42
|
+
raise IOError, "OTA transfer expected #{expected}, received: #{reply}" unless reply == expected
|
|
43
|
+
end
|
|
44
|
+
{ status: :verified, bytes: bytes.bytesize, sha256: digest, loader_version: version.delete_prefix('OK ') }
|
|
45
|
+
end
|
|
46
|
+
ensure
|
|
47
|
+
backend&.close
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Shared backend for unified OTA and Nordic DFU. All bus I/O stays on
|
|
51
|
+
# the caller thread: synchronous replies also dispatch queued signals.
|
|
52
|
+
# No discovery, pairing, address inference, or cross-device UUID lookup.
|
|
53
|
+
class BlueZ < Meshtastic::Bluetooth::BlueZ
|
|
54
|
+
def initialize(address:, service_uuid:, adapter: 'hci0', timeout: 15)
|
|
55
|
+
super(address: address, adapter: adapter, timeout: timeout)
|
|
56
|
+
raise ArgumentError, 'Invalid GATT service UUID' unless service_uuid.is_a?(String) && /\A[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\z/i.match?(service_uuid)
|
|
57
|
+
|
|
58
|
+
@service_uuid = service_uuid
|
|
59
|
+
@notifications = []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def connect_locked
|
|
63
|
+
@bus = DBus::ASystemBus.allocate
|
|
64
|
+
bounded { @bus.__send__(:initialize) }
|
|
65
|
+
objects = managed_objects
|
|
66
|
+
check_adapter(objects)
|
|
67
|
+
@device_path = objects.find do |_path, interfaces|
|
|
68
|
+
device = interfaces['org.bluez.Device1']
|
|
69
|
+
device && device['Address']&.casecmp?(@address) && device['Adapter'] == "/org/bluez/#{@adapter}"
|
|
70
|
+
end&.first
|
|
71
|
+
raise IOError, 'Selected bootloader address not known to BlueZ; discover it explicitly first' unless @device_path
|
|
72
|
+
|
|
73
|
+
@disconnect_needed = true
|
|
74
|
+
call(@device_path, 'org.bluez.Device1', 'Connect')
|
|
75
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
|
|
76
|
+
until properties(@device_path, 'org.bluez.Device1')['ServicesResolved']
|
|
77
|
+
raise IOError, 'Bootloader ServicesResolved timed out' if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
78
|
+
|
|
79
|
+
sleep 0.05
|
|
80
|
+
end
|
|
81
|
+
self
|
|
82
|
+
rescue StandardError
|
|
83
|
+
close_locked
|
|
84
|
+
raise
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def characteristic(uuid)
|
|
88
|
+
objects = managed_objects
|
|
89
|
+
services = objects.filter_map do |path, interfaces|
|
|
90
|
+
props = interfaces['org.bluez.GattService1']
|
|
91
|
+
path if props && props['Device'] == @device_path && props['UUID']&.casecmp?(@service_uuid)
|
|
92
|
+
end
|
|
93
|
+
matches = objects.filter_map do |path, interfaces|
|
|
94
|
+
props = interfaces['org.bluez.GattCharacteristic1']
|
|
95
|
+
path if props && services.include?(props['Service']) && props['UUID']&.casecmp?(uuid)
|
|
96
|
+
end
|
|
97
|
+
raise IOError, "Expected exactly one GATT characteristic #{uuid} on selected device" unless matches.size == 1
|
|
98
|
+
|
|
99
|
+
matches.first
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def connected!
|
|
103
|
+
raise IOError, 'Bootloader disconnected' unless @bus && properties(@device_path, 'org.bluez.Device1')['Connected']
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def write(uuid:, bytes:, response: true)
|
|
107
|
+
@mutex.synchronize do
|
|
108
|
+
connected!
|
|
109
|
+
path = characteristic(uuid)
|
|
110
|
+
call(path, 'org.bluez.GattCharacteristic1', 'WriteValue', ['ay', bytes.bytes], ['a{sv}', { 'type' => ['s', response ? 'request' : 'command'] }])
|
|
111
|
+
bytes.bytesize
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def subscribe(uuid:)
|
|
116
|
+
@mutex.synchronize do
|
|
117
|
+
connected!
|
|
118
|
+
path = characteristic(uuid)
|
|
119
|
+
rule = "type='signal',sender='org.bluez',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path='#{path}'"
|
|
120
|
+
bounded do
|
|
121
|
+
@bus.add_match(rule) do |message|
|
|
122
|
+
interface, changed = message.params
|
|
123
|
+
next unless message.path == path && interface == 'org.bluez.GattCharacteristic1' && changed.key?('Value')
|
|
124
|
+
|
|
125
|
+
@notifications << { uuid: uuid, bytes: changed.fetch('Value').pack('C*') }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
call(path, 'org.bluez.GattCharacteristic1', 'StartNotify')
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def notification(timeout:)
|
|
133
|
+
@mutex.synchronize do
|
|
134
|
+
Timeout.timeout(timeout) do
|
|
135
|
+
loop do
|
|
136
|
+
return @notifications.shift unless @notifications.empty?
|
|
137
|
+
|
|
138
|
+
raise IOError, 'Bootloader bus closed' unless @bus
|
|
139
|
+
|
|
140
|
+
message = @bus.message_queue.pop
|
|
141
|
+
@bus.process(message)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
rescue Timeout::Error
|
|
146
|
+
release_bus
|
|
147
|
+
raise IOError, 'Bootloader notification timed out; transfer not retried'
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
class Stream
|
|
152
|
+
def initialize(backend)
|
|
153
|
+
@backend = backend
|
|
154
|
+
@buffer = +''.b
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def command(bytes)
|
|
158
|
+
offset = 0
|
|
159
|
+
while offset < bytes.bytesize
|
|
160
|
+
@backend.write(uuid: OTA_UUID, bytes: bytes.byteslice(offset, 20), response: true)
|
|
161
|
+
offset += 20
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def line
|
|
166
|
+
loop do
|
|
167
|
+
if (index = @buffer.index("\n"))
|
|
168
|
+
raise IOError, 'Oversized OTA response' if index > 511
|
|
169
|
+
|
|
170
|
+
return @buffer.slice!(0, index + 1).chomp
|
|
171
|
+
end
|
|
172
|
+
raise IOError, 'Oversized OTA response' if @buffer.bytesize > 512
|
|
173
|
+
|
|
174
|
+
event = @backend.notification(timeout: 120)
|
|
175
|
+
raise IOError, 'OTA disconnected before confirmation' unless event
|
|
176
|
+
raise IOError, 'Unexpected OTA notification UUID' unless event[:uuid].to_s.casecmp?(TX_UUID)
|
|
177
|
+
|
|
178
|
+
raise IOError, 'Invalid OTA notification bytes' unless event[:bytes].is_a?(String) && !event[:bytes].empty?
|
|
179
|
+
|
|
180
|
+
@buffer << event.fetch(:bytes)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
public_class_method def self.authors
|
|
186
|
+
Firmware.authors
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
public_class_method def self.help
|
|
190
|
+
puts "USAGE:
|
|
191
|
+
# Upload using the unified ESP32 BLE protocol.
|
|
192
|
+
#{self}.install(
|
|
193
|
+
backend: 'optional - connected GATT backend; default selected-device BlueZ',
|
|
194
|
+
address: 'optional - required bootloader BLE MAC address when backend is omitted',
|
|
195
|
+
adapter: 'optional - selected BlueZ adapter, default hci0',
|
|
196
|
+
bytes: 'optional - application image bytes, exclusive with firmware',
|
|
197
|
+
firmware: 'optional - application image path, exclusive with bytes',
|
|
198
|
+
timeout: 'optional - whole transfer deadline seconds, default 120'
|
|
199
|
+
)
|
|
200
|
+
# Print the authors of this module.
|
|
201
|
+
#{self}.authors
|
|
202
|
+
"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'zlib'
|
|
5
|
+
require 'digest'
|
|
6
|
+
require 'timeout'
|
|
7
|
+
|
|
8
|
+
module Meshtastic
|
|
9
|
+
module Admin
|
|
10
|
+
module Firmware
|
|
11
|
+
# Adafruit SDK11 legacy BLE DFU, not Nordic Secure DFU.
|
|
12
|
+
module NordicDFU
|
|
13
|
+
CONTROL_UUID = '00001531-1212-efde-1523-785feabcd123'
|
|
14
|
+
PACKET_UUID = '00001532-1212-efde-1523-785feabcd123'
|
|
15
|
+
MAX_PACKAGE = 8 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
public_class_method def self.install(opts = {})
|
|
18
|
+
unknown = opts.keys - %i[package package_bytes gatt address adapter timeout protocol]
|
|
19
|
+
raise ArgumentError, "Unsupported Nordic DFU options: #{unknown.join(', ')}" unless unknown.empty?
|
|
20
|
+
raise ArgumentError, 'protocol must be :nordic_dfu' unless opts.fetch(:protocol, :nordic_dfu) == :nordic_dfu
|
|
21
|
+
raise ArgumentError, 'Supply exactly one of package or package_bytes' unless opts.key?(:package) ^ opts.key?(:package_bytes)
|
|
22
|
+
|
|
23
|
+
image, init = package(opts.merge({}))
|
|
24
|
+
timeout = opts.fetch(:timeout, 30)
|
|
25
|
+
raise ArgumentError, 'timeout must be finite and positive' unless timeout.is_a?(Numeric) && timeout.positive? && timeout.finite?
|
|
26
|
+
|
|
27
|
+
gatt = opts[:gatt]
|
|
28
|
+
unless gatt
|
|
29
|
+
require_relative 'ble'
|
|
30
|
+
gatt = BLE::BlueZ.new(address: opts.fetch(:address), adapter: opts.fetch(:adapter, 'hci0'),
|
|
31
|
+
timeout: timeout, service_uuid: '00001530-1212-efde-1523-785feabcd123').connect
|
|
32
|
+
end
|
|
33
|
+
session = { gatt: gatt, timeout: timeout }
|
|
34
|
+
gatt.subscribe(uuid: CONTROL_UUID)
|
|
35
|
+
control(session.merge(bytes: [1, 4].pack('C*')))
|
|
36
|
+
packet(session.merge(bytes: [0, 0, image.bytesize].pack('V3')))
|
|
37
|
+
response(session.merge(opcode: 1))
|
|
38
|
+
control(session.merge(bytes: [2, 0].pack('C*')))
|
|
39
|
+
init.bytes.each_slice(20) { |chunk| packet(session.merge(bytes: chunk.pack('C*'))) }
|
|
40
|
+
control(session.merge(bytes: [2, 1].pack('C*')))
|
|
41
|
+
response(session.merge(opcode: 2))
|
|
42
|
+
# PRN=1 bounds outstanding data and catches loss before any next chunk.
|
|
43
|
+
control(session.merge(bytes: [8, 1].pack('Cv')))
|
|
44
|
+
control(session.merge(bytes: [3].pack('C')))
|
|
45
|
+
offset = 0
|
|
46
|
+
image.bytes.each_slice(20) do |chunk|
|
|
47
|
+
packet(session.merge(bytes: chunk.pack('C*')))
|
|
48
|
+
offset += chunk.length
|
|
49
|
+
if offset == image.bytesize
|
|
50
|
+
response(session.merge(opcode: 3))
|
|
51
|
+
else
|
|
52
|
+
receipt = notification(session)
|
|
53
|
+
raise IOError, 'DFU packet receipt offset mismatch' unless receipt == [17, offset].pack('CV')
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
control(session.merge(bytes: [4].pack('C')))
|
|
57
|
+
response(session.merge(opcode: 4))
|
|
58
|
+
control(session.merge(bytes: [5].pack('C')))
|
|
59
|
+
{ status: :verified, protocol: :nordic_dfu, bytes: image.bytesize,
|
|
60
|
+
sha256: Digest::SHA256.hexdigest(image), reboot_verified: false }
|
|
61
|
+
ensure
|
|
62
|
+
gatt&.close
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private_class_method def self.control(opts = {})
|
|
66
|
+
opts.fetch(:gatt).write(uuid: CONTROL_UUID, bytes: opts.fetch(:bytes), response: true)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private_class_method def self.packet(opts = {})
|
|
70
|
+
opts.fetch(:gatt).write(uuid: PACKET_UUID, bytes: opts.fetch(:bytes), response: false)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private_class_method def self.notification(opts = {})
|
|
74
|
+
event = opts.fetch(:gatt).notification(timeout: opts.fetch(:timeout))
|
|
75
|
+
raise Timeout::Error, 'DFU notification timeout; session cannot safely resume' unless event
|
|
76
|
+
raise IOError, 'Invalid DFU notification envelope' unless event.is_a?(Hash) && event[:uuid].to_s.downcase == CONTROL_UUID && event[:bytes].is_a?(String)
|
|
77
|
+
|
|
78
|
+
event[:bytes].b
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private_class_method def self.response(opts = {})
|
|
82
|
+
bytes = notification(opts.merge({}))
|
|
83
|
+
expected = [16, opts.fetch(:opcode), 1].pack('C*')
|
|
84
|
+
raise IOError, "DFU response rejected or out of order: #{bytes.unpack1('H*')}" unless bytes == expected
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private_class_method def self.package(opts = {})
|
|
88
|
+
raw = opts[:package_bytes] || File.binread(opts.fetch(:package), MAX_PACKAGE + 1)
|
|
89
|
+
raise ArgumentError, 'DFU ZIP must be a bounded binary String' unless raw.is_a?(String) && raw.bytesize <= MAX_PACKAGE
|
|
90
|
+
|
|
91
|
+
entries = archive(bytes: raw.b)
|
|
92
|
+
root = JSON.parse(entries.fetch('manifest.json'))
|
|
93
|
+
raise ArgumentError, 'Invalid manifest JSON object' unless root.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
manifest = root.fetch('manifest')
|
|
96
|
+
raise ArgumentError, 'Only application-only legacy Adafruit DFU packages are supported' unless manifest.is_a?(Hash) && manifest.compact.keys == ['application']
|
|
97
|
+
|
|
98
|
+
app = manifest.fetch('application')
|
|
99
|
+
raise ArgumentError, 'Invalid application manifest entry' unless app.is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
image = entries.fetch(app.fetch('bin_file'))
|
|
102
|
+
init = entries.fetch(app.fetch('dat_file'))
|
|
103
|
+
raise ArgumentError, 'DFU image must be nonempty and word aligned' if image.empty? || image.bytesize % 4 != 0
|
|
104
|
+
raise ArgumentError, 'Invalid legacy Adafruit init packet' if init.bytesize < 12 || init.unpack1('v') != 0x52
|
|
105
|
+
|
|
106
|
+
count = init.byteslice(8, 2).unpack1('v')
|
|
107
|
+
raise ArgumentError, 'Unsupported init variant: expected legacy CRC16, not Secure DFU or signed extension' unless count.positive? && init.bytesize == 12 + (2 * count)
|
|
108
|
+
raise ArgumentError, 'DFU image CRC16 mismatch' unless crc16(bytes: image) == init.byteslice(-2, 2).unpack1('v')
|
|
109
|
+
|
|
110
|
+
[image, init]
|
|
111
|
+
rescue KeyError, JSON::ParserError, TypeError => e
|
|
112
|
+
raise ArgumentError, "Invalid DFU package: #{e.message}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private_class_method def self.crc16(opts = {})
|
|
116
|
+
crc = 0xffff
|
|
117
|
+
opts.fetch(:bytes).each_byte do |byte|
|
|
118
|
+
crc = ((crc >> 8) | (crc << 8)) & 0xffff
|
|
119
|
+
crc ^= byte
|
|
120
|
+
crc ^= (crc & 0xff) >> 4
|
|
121
|
+
crc ^= (crc << 12) & 0xffff
|
|
122
|
+
crc ^= ((crc & 0xff) << 5) & 0xffff
|
|
123
|
+
end
|
|
124
|
+
crc
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Read ZIP entries in memory, never extract paths or invoke a CLI.
|
|
128
|
+
private_class_method def self.archive(opts = {})
|
|
129
|
+
raw = opts.fetch(:bytes)
|
|
130
|
+
eocd = raw.rindex("PK\x05\x06".b)
|
|
131
|
+
raise ArgumentError, 'Invalid ZIP end record' unless eocd && raw.bytesize >= eocd + 22
|
|
132
|
+
|
|
133
|
+
disk, cd_disk, disk_count, count, cd_size, cd_offset, comment = raw.byteslice(eocd + 4, 18).unpack('vvvvVVv')
|
|
134
|
+
unless disk.zero? && cd_disk.zero? && count == disk_count && count.between?(1, 32) &&
|
|
135
|
+
cd_offset + cd_size == eocd && eocd + 22 + comment == raw.bytesize
|
|
136
|
+
raise ArgumentError, 'Unsupported ZIP layout (split, ZIP64, or invalid directory)'
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
entries = {}
|
|
140
|
+
cursor = cd_offset
|
|
141
|
+
count.times do
|
|
142
|
+
header = raw.byteslice(cursor, 46)
|
|
143
|
+
raise ArgumentError, 'Invalid ZIP directory entry' unless header&.bytesize == 46 && header.unpack1('V') == 0x02014b50
|
|
144
|
+
|
|
145
|
+
fields = header.unpack('VvvvvvvVVVvvvvvVV')
|
|
146
|
+
flags, method, crc, packed, size = fields.values_at(3, 4, 7, 8, 9)
|
|
147
|
+
name_size, extra_size, comment_size, entry_disk, offset = fields.values_at(10, 11, 12, 13, 16)
|
|
148
|
+
name = raw.byteslice(cursor + 46, name_size)
|
|
149
|
+
unless flags.nobits?(~0x800) && [0, 8].include?(method) && entry_disk.zero? && size <= MAX_PACKAGE && packed <= MAX_PACKAGE &&
|
|
150
|
+
name && name.match?(%r{\A[\w./-]+\z}) && !name.split('/').include?('..') && !name.start_with?('/') && !entries.key?(name)
|
|
151
|
+
raise ArgumentError, 'Unsupported or unsafe ZIP entry'
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
local = raw.byteslice(offset, 30)
|
|
155
|
+
raise ArgumentError, 'Invalid ZIP local header' unless local&.bytesize == 30 && local.unpack1('V') == 0x04034b50
|
|
156
|
+
|
|
157
|
+
lfields = local.unpack('VvvvvvVVVvv')
|
|
158
|
+
lname_size, lextra_size = lfields.values_at(9, 10)
|
|
159
|
+
start = offset + 30 + lname_size + lextra_size
|
|
160
|
+
unless lfields.values_at(2, 3, 6, 7, 8) == [flags, method, crc, packed, size] &&
|
|
161
|
+
raw.byteslice(offset + 30, lname_size) == name && start + packed <= cd_offset
|
|
162
|
+
raise ArgumentError, 'Inconsistent ZIP local entry'
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
bytes = raw.byteslice(start, packed)
|
|
166
|
+
bytes = inflate(bytes: bytes, size: size) if method == 8
|
|
167
|
+
raise ArgumentError, 'ZIP entry size or CRC32 mismatch' unless bytes && bytes.bytesize == size && Zlib.crc32(bytes) == crc
|
|
168
|
+
|
|
169
|
+
entries[name] = bytes
|
|
170
|
+
cursor += 46 + name_size + extra_size + comment_size
|
|
171
|
+
end
|
|
172
|
+
raise ArgumentError, 'Invalid ZIP directory length' unless cursor == eocd
|
|
173
|
+
|
|
174
|
+
entries
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private_class_method def self.inflate(opts = {})
|
|
178
|
+
inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
|
|
179
|
+
output = ''.b
|
|
180
|
+
inflater.inflate(opts.fetch(:bytes)) do |chunk|
|
|
181
|
+
raise ArgumentError, 'ZIP decompression exceeds declared size' if output.bytesize + chunk.bytesize > opts.fetch(:size)
|
|
182
|
+
|
|
183
|
+
output << chunk
|
|
184
|
+
end
|
|
185
|
+
raise ArgumentError, 'Invalid ZIP deflate stream' unless inflater.finished? && inflater.total_in == opts.fetch(:bytes).bytesize
|
|
186
|
+
|
|
187
|
+
output
|
|
188
|
+
rescue Zlib::Error => e
|
|
189
|
+
raise ArgumentError, "Invalid ZIP deflate stream: #{e.message}"
|
|
190
|
+
ensure
|
|
191
|
+
inflater&.close
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
public_class_method def self.authors
|
|
195
|
+
['Meshtastic Ruby contributors']
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
public_class_method def self.help
|
|
199
|
+
puts <<~HELP
|
|
200
|
+
Install application via Adafruit legacy BLE DFU.
|
|
201
|
+
#{self}.install(
|
|
202
|
+
protocol: 'optional - protocol selector, must be nordic_dfu',
|
|
203
|
+
package: 'optional - DFU ZIP filename; required unless package_bytes is supplied',
|
|
204
|
+
package_bytes: 'optional - raw DFU ZIP bytes instead of filename',
|
|
205
|
+
gatt: 'optional - connected adapter implementing write/subscribe/notification/close',
|
|
206
|
+
address: 'optional - bootloader BLE address; required without gatt',
|
|
207
|
+
adapter: 'optional - BlueZ adapter name, default hci0',
|
|
208
|
+
timeout: 'optional - positive notification timeout seconds, default 30'
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
List module contributing authors.
|
|
212
|
+
#{self}.authors
|
|
213
|
+
HELP
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|