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.
@@ -10,7 +10,14 @@ module Meshtastic
10
10
  end
11
11
 
12
12
  public_class_method def self.set(opts = {})
13
- Admin.set_config(opts.merge(config: opts[:config]))
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
- get(opts.merge(config_type: :DEVICEUI_CONFIG))
60
+ Admin.get_ui_config(opts.merge({}))
54
61
  end
55
62
 
56
63
  public_class_method def self.set_device(opts = {})
57
- config = Meshtastic::Config.new
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 = Meshtastic::Config.new
64
- config.lora = opts[:lora]
65
- set(opts.merge(config: config))
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 DEVICEUI_CONFIG from the node.
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 - Meshtastic::Config::DeviceConfig protobuf to write'
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 - Meshtastic::Config::LoRaConfig protobuf to write'
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