meshtastic 0.0.179 → 0.0.181

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/README.md +3 -2
  4. data/documentation/admin-channel.md +64 -0
  5. data/documentation/admin-config.md +59 -0
  6. data/documentation/admin-firmware-nordic.md +86 -0
  7. data/documentation/admin-firmware-serial.md +180 -0
  8. data/documentation/admin-firmware.md +126 -0
  9. data/documentation/admin.md +88 -35
  10. data/documentation/atak.md +93 -10
  11. data/documentation/channel.md +1 -28
  12. data/documentation/config.md +1 -27
  13. data/documentation/mesh-interface.md +7 -0
  14. data/documentation/meshtastic.md +2 -2
  15. data/lib/meshtastic/admin/channel.rb +164 -0
  16. data/lib/meshtastic/admin/config.rb +213 -0
  17. data/lib/meshtastic/admin/firmware/ble.rb +207 -0
  18. data/lib/meshtastic/admin/firmware/nordic_dfu.rb +218 -0
  19. data/lib/meshtastic/admin/firmware/serial_bootloader.rb +233 -0
  20. data/lib/meshtastic/admin/firmware.rb +288 -0
  21. data/lib/meshtastic/admin.rb +688 -34
  22. data/lib/meshtastic/apponly.rb +19 -7
  23. data/lib/meshtastic/atak.rb +295 -17
  24. data/lib/meshtastic/bluetooth/bluez.rb +20 -1
  25. data/lib/meshtastic/bluetooth.rb +70 -59
  26. data/lib/meshtastic/cannedmessages.rb +13 -6
  27. data/lib/meshtastic/channel.rb +3 -25
  28. data/lib/meshtastic/clientonly.rb +19 -7
  29. data/lib/meshtastic/config.rb +3 -24
  30. data/lib/meshtastic/config_pb.rb +2 -1
  31. data/lib/meshtastic/connection_status.rb +12 -5
  32. data/lib/meshtastic/deviceonly.rb +19 -7
  33. data/lib/meshtastic/localonly.rb +19 -7
  34. data/lib/meshtastic/mesh_interface.rb +19 -0
  35. data/lib/meshtastic/module_config.rb +13 -7
  36. data/lib/meshtastic/mqtt.rb +72 -35
  37. data/lib/meshtastic/paxcount.rb +17 -6
  38. data/lib/meshtastic/portnums.rb +13 -6
  39. data/lib/meshtastic/position.rb +16 -6
  40. data/lib/meshtastic/remote_hardware.rb +27 -11
  41. data/lib/meshtastic/rtttl.rb +17 -8
  42. data/lib/meshtastic/serial.rb +81 -70
  43. data/lib/meshtastic/storeforward.rb +13 -6
  44. data/lib/meshtastic/storeforward_pb.rb +1 -1
  45. data/lib/meshtastic/stream_interface.rb +11 -0
  46. data/lib/meshtastic/tcp.rb +73 -22
  47. data/lib/meshtastic/telemetry.rb +15 -6
  48. data/lib/meshtastic/traceroute.rb +15 -6
  49. data/lib/meshtastic/util.rb +3 -1
  50. data/lib/meshtastic/version.rb +1 -1
  51. data/lib/meshtastic/xmodem.rb +12 -5
  52. data/lib/meshtastic.rb +22 -3
  53. data/spec/conventions_spec.rb +321 -0
  54. data/spec/lib/meshtastic/admin/channel_spec.rb +202 -0
  55. data/spec/lib/meshtastic/admin/config_spec.rb +114 -0
  56. data/spec/lib/meshtastic/admin/firmware/ble_spec.rb +170 -0
  57. data/spec/lib/meshtastic/admin/firmware/nordic_dfu_spec.rb +248 -0
  58. data/spec/lib/meshtastic/admin/firmware/serial_bootloader_spec.rb +263 -0
  59. data/spec/lib/meshtastic/admin/firmware_spec.rb +315 -0
  60. data/spec/lib/meshtastic/admin_spec.rb +412 -1
  61. data/spec/lib/meshtastic/apponly_spec.rb +1 -1
  62. data/spec/lib/meshtastic/atak_spec.rb +129 -4
  63. data/spec/lib/meshtastic/bluetooth_spec.rb +1 -1
  64. data/spec/lib/meshtastic/channel_spec.rb +2 -20
  65. data/spec/lib/meshtastic/config_spec.rb +2 -20
  66. data/spec/lib/meshtastic/connection_status_spec.rb +1 -1
  67. data/spec/lib/meshtastic/deviceonly_spec.rb +1 -1
  68. data/spec/lib/meshtastic/localonly_spec.rb +1 -1
  69. data/spec/lib/meshtastic/mesh_interface_spec.rb +31 -0
  70. data/spec/lib/meshtastic/portnums_spec.rb +2 -2
  71. metadata +24 -6
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'meshtastic/config_pb'
4
+
5
+ module Meshtastic
6
+ module Admin
7
+ module Config
8
+ public_class_method def self.get(opts = {})
9
+ Admin.get_config(opts.merge(config_type: opts[:config_type] || :DEVICE_CONFIG))
10
+ end
11
+
12
+ public_class_method def self.set(opts = {})
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))
21
+ end
22
+
23
+ public_class_method def self.get_device(opts = {})
24
+ get(opts.merge(config_type: :DEVICE_CONFIG))
25
+ end
26
+
27
+ public_class_method def self.get_position(opts = {})
28
+ get(opts.merge(config_type: :POSITION_CONFIG))
29
+ end
30
+
31
+ public_class_method def self.get_power(opts = {})
32
+ get(opts.merge(config_type: :POWER_CONFIG))
33
+ end
34
+
35
+ public_class_method def self.get_network(opts = {})
36
+ get(opts.merge(config_type: :NETWORK_CONFIG))
37
+ end
38
+
39
+ public_class_method def self.get_display(opts = {})
40
+ get(opts.merge(config_type: :DISPLAY_CONFIG))
41
+ end
42
+
43
+ public_class_method def self.get_lora(opts = {})
44
+ get(opts.merge(config_type: :LORA_CONFIG))
45
+ end
46
+
47
+ public_class_method def self.get_bluetooth(opts = {})
48
+ get(opts.merge(config_type: :BLUETOOTH_CONFIG))
49
+ end
50
+
51
+ public_class_method def self.get_security(opts = {})
52
+ get(opts.merge(config_type: :SECURITY_CONFIG))
53
+ end
54
+
55
+ public_class_method def self.get_sessionkey(opts = {})
56
+ get(opts.merge(config_type: :SESSIONKEY_CONFIG))
57
+ end
58
+
59
+ public_class_method def self.get_device_ui(opts = {})
60
+ Admin.get_ui_config(opts.merge({}))
61
+ end
62
+
63
+ public_class_method def self.set_device(opts = {})
64
+ set(opts.merge(config: Meshtastic::Config.new(device: opts.fetch(:device))))
65
+ end
66
+
67
+ public_class_method def self.set_lora(opts = {})
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))))
101
+ end
102
+
103
+ public_class_method def self.authors
104
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n "
105
+ end
106
+
107
+ public_class_method def self.help
108
+ puts "USAGE:
109
+ # Request a radio Config section by ConfigType.
110
+ #{self}.get(
111
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect',
112
+ config_type: 'optional - :DEVICE_CONFIG or another ConfigType (default: :DEVICE_CONFIG)'
113
+ )
114
+
115
+ # Write a Config protobuf to the node.
116
+ #{self}.set(
117
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect',
118
+ config: 'required - Meshtastic::Config protobuf to write'
119
+ )
120
+
121
+ # Request DEVICE_CONFIG from the node.
122
+ #{self}.get_device(
123
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect'
124
+ )
125
+
126
+ # Request POSITION_CONFIG from the node.
127
+ #{self}.get_position(
128
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect'
129
+ )
130
+
131
+ # Request POWER_CONFIG from the node.
132
+ #{self}.get_power(
133
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect'
134
+ )
135
+
136
+ # Request NETWORK_CONFIG from the node.
137
+ #{self}.get_network(
138
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect'
139
+ )
140
+
141
+ # Request DISPLAY_CONFIG from the node.
142
+ #{self}.get_display(
143
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect'
144
+ )
145
+
146
+ # Request LORA_CONFIG from the node.
147
+ #{self}.get_lora(
148
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect'
149
+ )
150
+
151
+ # Request BLUETOOTH_CONFIG from the node.
152
+ #{self}.get_bluetooth(
153
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect'
154
+ )
155
+
156
+ # Request SECURITY_CONFIG from the node.
157
+ #{self}.get_security(
158
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect'
159
+ )
160
+
161
+ # Request SESSIONKEY_CONFIG from the node.
162
+ #{self}.get_sessionkey(
163
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect'
164
+ )
165
+
166
+ # Request DeviceUIConfig using dedicated firmware operation.
167
+ #{self}.get_device_ui(
168
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect'
169
+ )
170
+
171
+ # Write DeviceConfig wrapped in a Config protobuf.
172
+ #{self}.set_device(
173
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect',
174
+ device: 'required - DeviceConfig protobuf or complete section field Hash'
175
+ )
176
+
177
+ # Write LoRaConfig wrapped in a Config protobuf.
178
+ #{self}.set_lora(
179
+ serial_obj: 'optional - serial handle from Meshtastic::Serial.connect',
180
+ lora: 'required - LoRaConfig protobuf or complete section field Hash'
181
+ )
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
+
207
+ # Print the AUTHOR(S) string for this module.
208
+ #{self}.authors
209
+ "
210
+ end
211
+ end
212
+ end
213
+ end
@@ -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