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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'spec_helper'
4
4
 
5
- describe Meshtastic::Admin::Channel do
5
+ shared_context 'channel serial framing' do
6
6
  def fake_serial_obj
7
7
  written = +''.b
8
8
  serial_conn = Object.new
@@ -21,11 +21,23 @@ describe Meshtastic::Admin::Channel do
21
21
  body = frame.byteslice(4, (frame.getbyte(2) << 8) + frame.getbyte(3))
22
22
  Meshtastic::AdminMessage.decode(Meshtastic::ToRadio.decode(body).packet.decoded.payload)
23
23
  end
24
+ end
25
+
26
+ describe Meshtastic::Admin::Channel do
27
+ include_context 'channel serial framing'
24
28
 
25
29
  it 'requests a channel by index via Admin' do
26
30
  serial_obj = fake_serial_obj
27
31
  described_class.get(serial_obj: serial_obj, index: 1)
28
- expect(decode_admin(serial_obj).get_channel_request).to eq(1)
32
+ expect(decode_admin(serial_obj).get_channel_request).to eq(2)
33
+ end
34
+
35
+ [0, 7].each do |index|
36
+ it "requests zero-based slot #{index} with its one-based wire index" do
37
+ serial_obj = fake_serial_obj
38
+ described_class.get(serial_obj: serial_obj, index: index)
39
+ expect(decode_admin(serial_obj).get_channel_request).to eq(index + 1)
40
+ end
29
41
  end
30
42
 
31
43
  it 'sets a Channel protobuf including settings and role' do
@@ -39,6 +51,151 @@ describe Meshtastic::Admin::Channel do
39
51
  expect(channel.settings.uplink_enabled).to be true
40
52
  end
41
53
 
54
+ it 'copies an existing channel and preserves settings when changing its role' do
55
+ original = Meshtastic::Channel.new(index: 2, role: :SECONDARY, settings: { name: 'test', psk: "\x01".b })
56
+ updated = described_class.build(channel: original, role: :DISABLED)
57
+ expect(updated.settings).to eq(original.settings)
58
+ expect(original.role).to eq(:SECONDARY)
59
+ expect(updated).not_to equal(original)
60
+ end
61
+
62
+ it 'builds all settings including nested hashes without mutating existing settings' do
63
+ original = Meshtastic::ChannelSettings.new(name: 'before', uplink_enabled: true)
64
+ settings = described_class.build_settings(settings: original, name: 'after', psk: "\x01".b, channel_num: 3,
65
+ id: 17, uplink_enabled: false, downlink_enabled: true,
66
+ use_aead: true, module_settings: { position_precision: 13 })
67
+ expect(settings.to_h).to include(name: 'after', psk: "\x01".b, channel_num: 3, id: 17,
68
+ downlink_enabled: true, use_aead: true)
69
+ expect(settings.uplink_enabled).to be false
70
+ expect(settings.module_settings.position_precision).to eq(13)
71
+ expect(original.name).to eq('before')
72
+ expect(original.uplink_enabled).to be true
73
+ end
74
+ end
75
+
76
+ describe Meshtastic::Admin::Channel, 'channel URL support' do
77
+ include_context 'channel serial framing'
78
+
79
+ it 'exports primary first and enabled secondary settings with LoRa in a channel URL' do
80
+ primary = Meshtastic::Channel.new(index: 0, role: :PRIMARY, settings: { name: 'main', psk: "\x01".b })
81
+ secondary = Meshtastic::Channel.new(index: 2, role: :SECONDARY, settings: { name: 'other' })
82
+ disabled = Meshtastic::Channel.new(index: 3, settings: { name: 'hidden' })
83
+ lora = Meshtastic::Config::LoRaConfig.new(region: :US, use_preset: true)
84
+ url = described_class.export_url(channels: [secondary, disabled, primary], lora_config: lora)
85
+ expect(url).to start_with('https://meshtastic.org/e/#')
86
+ require 'base64'
87
+ channel_set = Meshtastic::ChannelSet.decode(Base64.urlsafe_decode64(url.split('#').last))
88
+ expect(channel_set.settings.map(&:name)).to eq(%w[main other])
89
+ expect(channel_set.lora_config).to eq(lora)
90
+ expect(url).not_to end_with('=')
91
+ end
92
+
93
+ it 'imports official e and legacy d URLs as offline ChannelSet protobufs' do
94
+ expected = Meshtastic::ChannelSet.new(settings: [{ name: 'test', psk: "\x01".b }], lora_config: { region: :US })
95
+ encoded = Base64.urlsafe_encode64(expected.to_proto, padding: false)
96
+ %w[e d].each do |path|
97
+ expect(described_class.import_url(url: "https://meshtastic.org/#{path}/##{encoded}")).to eq(expected)
98
+ end
99
+ end
100
+
101
+ it 'rejects invalid URL envelopes and empty or oversized channel sets without leaking the URL' do
102
+ empty = Base64.urlsafe_encode64(Meshtastic::ChannelSet.new.to_proto, padding: false)
103
+ oversized = Base64.urlsafe_encode64(Meshtastic::ChannelSet.new(settings: Array.new(9) { { name: 'test' } }).to_proto, padding: false)
104
+ valid = Base64.urlsafe_encode64(Meshtastic::ChannelSet.new(settings: [{ name: 'test' }]).to_proto, padding: false)
105
+ urls = ["https://example.org/e/##{valid}", "https://meshtastic.org/v/##{valid}",
106
+ 'https://meshtastic.org/e/', 'https://meshtastic.org/e/#not!base64',
107
+ 'https://meshtastic.org/e/#_w', "https://meshtastic.org/e/##{empty}",
108
+ "https://meshtastic.org/e/##{oversized}", "https://user:secret@meshtastic.org/e/##{valid}"]
109
+ urls.each do |url|
110
+ expect { described_class.import_url(url: url) }.to raise_error(ArgumentError, 'invalid channel URL')
111
+ end
112
+ end
113
+
114
+ it 'exports only primary when include_all is false and requires exactly one primary' do
115
+ primary = Meshtastic::Channel.new(role: :PRIMARY, settings: { name: 'main' })
116
+ secondary = Meshtastic::Channel.new(index: 1, role: :SECONDARY, settings: { name: 'other' })
117
+ url = described_class.export_url(channels: [primary, secondary], include_all: false)
118
+ expect(described_class.import_url(url: url).settings.map(&:name)).to eq(['main'])
119
+ [[], [secondary], [primary, primary]].each do |channels|
120
+ expect { described_class.export_url(channels: channels) }.to raise_error(ArgumentError)
121
+ end
122
+ end
123
+
124
+ it 'rejects invalid settings lengths and slot indexes before any write' do
125
+ [-1, 8, 0.5, 'junk'].each do |index|
126
+ serial_obj = fake_serial_obj
127
+ expect { described_class.set(serial_obj: serial_obj, index: index, role: :PRIMARY) }.to raise_error(ArgumentError)
128
+ expect(serial_obj[:written]).to be_empty
129
+ end
130
+ expect { described_class.build_settings(psk: 'bad') }.to raise_error(ArgumentError)
131
+ expect { described_class.build_settings(name: 'a' * 12) }.to raise_error(ArgumentError)
132
+ [0, 1, 16, 32].each do |length|
133
+ expect(described_class.build_settings(psk: 'x' * length).psk.bytesize).to eq(length)
134
+ end
135
+ end
136
+
137
+ it 'validates and overlays supplied channel/settings before transmission' do
138
+ serial_obj = fake_serial_obj
139
+ original = Meshtastic::Channel.new(index: 2, role: :SECONDARY, settings: { name: 'before', uplink_enabled: true })
140
+ described_class.set(serial_obj: serial_obj, channel: original, name: 'after', uplink_enabled: false)
141
+ written = decode_admin(serial_obj).set_channel
142
+ expect(written.index).to eq(2)
143
+ expect(written.settings.name).to eq('after')
144
+ expect(written.settings.uplink_enabled).to be false
145
+ expect(original.settings.name).to eq('before')
146
+ expect(described_class.build(settings: { name: 'hash' }).settings.name).to eq('hash')
147
+ invalid = Meshtastic::Channel.new(index: 8)
148
+ expect { described_class.set(serial_obj: fake_serial_obj, channel: invalid) }.to raise_error(ArgumentError)
149
+ expect { described_class.build(settings: { psk: 'bad' }) }.to raise_error(ArgumentError)
150
+ end
151
+
152
+ it 'applies an imported URL as indexed channel writes followed by its LoRa section' do
153
+ serial_obj = fake_serial_obj
154
+ channel_set = Meshtastic::ChannelSet.new(settings: [{ name: 'main' }, { name: 'other' }], lora_config: { region: :US })
155
+ url = "https://meshtastic.org/e/##{Base64.urlsafe_encode64(channel_set.to_proto, padding: false)}"
156
+ results = described_class.apply_url(serial_obj: serial_obj, url: url, session_passkey: 'test-key')
157
+ frames = serial_obj[:written].dup
158
+ messages = []
159
+ until frames.empty?
160
+ length = (frames.getbyte(2) << 8) + frames.getbyte(3)
161
+ packet = Meshtastic::ToRadio.decode(frames.byteslice(4, length)).packet
162
+ messages << Meshtastic::AdminMessage.decode(packet.decoded.payload)
163
+ frames = frames.byteslice((length + 4)..)
164
+ end
165
+ expect(results.length).to eq(3)
166
+ expect(messages.map(&:payload_variant)).to eq(%i[set_channel set_channel set_config])
167
+ expect(messages.first.set_channel.index).to eq(0)
168
+ expect(messages.first.set_channel.role).to eq(:PRIMARY)
169
+ expect(messages[1].set_channel.index).to eq(1)
170
+ expect(messages[1].set_channel.role).to eq(:SECONDARY)
171
+ expect(messages.last.set_config.lora.region).to eq(:US)
172
+ expect(messages.map(&:session_passkey)).to all(eq('test-key'))
173
+ end
174
+
175
+ it 'preflights all URL settings and refuses add-only links before writing' do
176
+ serial_obj = fake_serial_obj
177
+ invalid = Meshtastic::ChannelSet.new(settings: [{ name: 'good' }, { psk: 'bad' }])
178
+ url = "https://meshtastic.org/e/##{Base64.urlsafe_encode64(invalid.to_proto, padding: false)}"
179
+ expect { described_class.apply_url(serial_obj: serial_obj, url: url) }.to raise_error(ArgumentError)
180
+ expect(serial_obj[:written]).to be_empty
181
+ valid = Meshtastic::ChannelSet.new(settings: [{ name: 'test' }])
182
+ url = "https://meshtastic.org/e/?add=true##{Base64.urlsafe_encode64(valid.to_proto, padding: false)}"
183
+ expect { described_class.apply_url(serial_obj: serial_obj, url: url) }.to raise_error(ArgumentError, /add-only/)
184
+ expect(serial_obj[:written]).to be_empty
185
+ end
186
+
187
+ it 'rejects unsupported channel roles before writing' do
188
+ expect { described_class.build(role: 99) }.to raise_error(ArgumentError)
189
+ end
190
+
191
+ it 'rejects oversized or invalid enabled channel sets on export' do
192
+ primary = Meshtastic::Channel.new(role: :PRIMARY, settings: { name: 'main' })
193
+ secondary = Meshtastic::Channel.new(role: :SECONDARY, settings: { name: 'other' })
194
+ expect { described_class.export_url(channels: [primary] + Array.new(8, secondary)) }.to raise_error(ArgumentError)
195
+ primary.settings.psk = 'bad'
196
+ expect { described_class.export_url(channels: [primary]) }.to raise_error(ArgumentError)
197
+ end
198
+
42
199
  it 'prints usage without raising' do
43
200
  expect { described_class.help }.to output(/USAGE/).to_stdout
44
201
  end
@@ -42,6 +42,72 @@ describe Meshtastic::Admin::Config do
42
42
  expect(decode_admin(serial_obj).get_config_request).to eq(:LORA_CONFIG)
43
43
  end
44
44
 
45
+ Meshtastic::Config.descriptor.each do |field|
46
+ next if %w[sessionkey device_ui].include?(field.name)
47
+
48
+ it "writes the #{field.name} section through real serial framing" do
49
+ serial_obj = fake_serial_obj
50
+ section = field.subtype.msgclass.new
51
+ described_class.public_send("set_#{field.name}", serial_obj: serial_obj, field.name.to_sym => section)
52
+ config = decode_admin(serial_obj).set_config
53
+ expect(config.payload_variant).to eq(field.name.to_sym)
54
+ expect(config[field.name]).to eq(section)
55
+ end
56
+ end
57
+
58
+ it 'rejects an empty config before writing bytes' do
59
+ serial_obj = fake_serial_obj
60
+ expect { described_class.set(serial_obj: serial_obj, config: Meshtastic::Config.new) }.to raise_error(ArgumentError)
61
+ expect(serial_obj[:written]).to be_empty
62
+ end
63
+
64
+ Meshtastic::Config.descriptor.each do |field|
65
+ next if %w[sessionkey device_ui].include?(field.name)
66
+
67
+ it "accepts a field hash for #{field.name}" do
68
+ serial_obj = fake_serial_obj
69
+ described_class.public_send("set_#{field.name}", serial_obj: serial_obj, field.name.to_sym => {})
70
+ expect(decode_admin(serial_obj).set_config.payload_variant).to eq(field.name.to_sym)
71
+ end
72
+
73
+ it "rejects missing #{field.name} before writing bytes" do
74
+ serial_obj = fake_serial_obj
75
+ expect { described_class.public_send("set_#{field.name}", serial_obj: serial_obj) }.to(raise_error { |error| expect([ArgumentError, KeyError]).to include(error.class) })
76
+ expect(serial_obj[:written]).to be_empty
77
+ end
78
+ end
79
+
80
+ it 'uses dedicated device UI requests and stores instead of firmware no-op Config fields' do
81
+ serial_obj = fake_serial_obj
82
+ described_class.get_device_ui(serial_obj: serial_obj)
83
+ expect(decode_admin(serial_obj).payload_variant).to eq(:get_ui_config_request)
84
+ serial_obj = fake_serial_obj
85
+ described_class.set_device_ui(serial_obj: serial_obj, device_ui: {})
86
+ expect(decode_admin(serial_obj).payload_variant).to eq(:store_ui_config)
87
+ end
88
+
89
+ it 'rejects the read-only session-key placeholder without transmitting' do
90
+ serial_obj = fake_serial_obj
91
+ config = Meshtastic::Config.new(sessionkey: {})
92
+ expect { described_class.set(serial_obj: serial_obj, config: config) }.to raise_error(ArgumentError, /request-only/)
93
+ expect { described_class.set_sessionkey(serial_obj: serial_obj, sessionkey: {}) }.to raise_error(ArgumentError, /request-only/)
94
+ expect(serial_obj[:written]).to be_empty
95
+ end
96
+
97
+ {
98
+ device: :DEVICE_CONFIG, position: :POSITION_CONFIG, power: :POWER_CONFIG,
99
+ network: :NETWORK_CONFIG, display: :DISPLAY_CONFIG, lora: :LORA_CONFIG,
100
+ bluetooth: :BLUETOOTH_CONFIG, security: :SECURITY_CONFIG, sessionkey: :SESSIONKEY_CONFIG
101
+ }.each do |section, config_type|
102
+ it "requests #{section} using its protocol ConfigType" do
103
+ serial_obj = fake_serial_obj
104
+ described_class.public_send("get_#{section}", serial_obj: serial_obj)
105
+ message = decode_admin(serial_obj)
106
+ expect(message.payload_variant).to eq(:get_config_request)
107
+ expect(message.get_config_request).to eq(config_type)
108
+ end
109
+ end
110
+
45
111
  it 'prints usage without raising' do
46
112
  expect { described_class.help }.to output(/USAGE/).to_stdout
47
113
  end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ class UnifiedGattFixture
6
+ attr_reader :writes, :closed, :subscriptions
7
+
8
+ def initialize(final: "OK\n")
9
+ @writes = []
10
+ @notifications = []
11
+ @subscriptions = []
12
+ @command = +''
13
+ @binary = false
14
+ @received = 0
15
+ @final = final
16
+ end
17
+
18
+ def subscribe(uuid:)
19
+ @subscriptions << uuid
20
+ end
21
+
22
+ def write(uuid:, bytes:, response:)
23
+ raise 'must subscribe before writing' if @subscriptions.empty?
24
+ raise 'unconsumed ACK' unless @notifications.empty?
25
+
26
+ @writes << { uuid: uuid, bytes: bytes, response: response }
27
+ if @binary
28
+ @received += bytes.bytesize
29
+ @notifications << (@received == @size ? @final : "ACK\n")
30
+ else
31
+ @command << bytes
32
+ if @command.end_with?("\n")
33
+ if @command == "VERSION\n"
34
+ @notifications << "OK 1 2.7.0 3 v1.0\n"
35
+ else
36
+ @size = @command.split[1].to_i
37
+ @notifications << "ERASING\nOK\n"
38
+ @binary = true
39
+ end
40
+ @command.clear
41
+ end
42
+ end
43
+ bytes.bytesize
44
+ end
45
+
46
+ def notification(timeout:)
47
+ raise ArgumentError, 'timeout must be positive' unless timeout.positive?
48
+ raise Timeout::Error, 'silent GATT' if @notifications.empty?
49
+
50
+ { uuid: Meshtastic::Admin::Firmware::BLE::TX_UUID, bytes: @notifications.shift }
51
+ end
52
+
53
+ def close
54
+ @closed = true
55
+ end
56
+ end
57
+
58
+ describe 'Firmware selected-device BlueZ GATT' do
59
+ it 'scopes writes and notification matches to the selected device without requiring bootloader pairing' do
60
+ klass = Meshtastic::Admin::Firmware::BLE.const_get(:BlueZ)
61
+ service = Meshtastic::Admin::Firmware::BLE::SERVICE_UUID
62
+ uuid = Meshtastic::Admin::Firmware::BLE::TX_UUID
63
+ device = '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF'
64
+ objects = {
65
+ '/org/bluez/hci0' => { 'org.bluez.Adapter1' => { 'Powered' => true } },
66
+ '/other/s' => { 'org.bluez.GattService1' => { 'Device' => '/other', 'UUID' => service } },
67
+ '/other/c' => { 'org.bluez.GattCharacteristic1' => { 'Service' => '/other/s', 'UUID' => uuid } },
68
+ device => { 'org.bluez.Device1' => { 'Address' => 'AA:BB:CC:DD:EE:FF', 'Adapter' => '/org/bluez/hci0', 'ServicesResolved' => true, 'Connected' => true, 'Paired' => false } },
69
+ "#{device}/s" => { 'org.bluez.GattService1' => { 'Device' => device, 'UUID' => service } },
70
+ "#{device}/s/c" => { 'org.bluez.GattCharacteristic1' => { 'Service' => "#{device}/s", 'UUID' => uuid } }
71
+ }
72
+ socket, peer = UNIXSocket.pair
73
+ queue = DBus::MessageQueue.allocate
74
+ queue.instance_variable_set(:@socket, socket)
75
+ queue.instance_variable_set(:@buffer, +''.b)
76
+ queue.instance_variable_set(:@read_buffer, +''.b)
77
+ queue.instance_variable_set(:@mutex, Mutex.new)
78
+ bus = double('private bus', message_queue: queue)
79
+ calls = []
80
+ signal_handler = nil
81
+ allow(bus).to receive(:process) { |message| signal_handler.call(message) }
82
+ allow(DBus::ASystemBus).to receive(:allocate).and_return(bus)
83
+ allow(bus).to receive(:initialize)
84
+ allow(bus).to receive(:add_match) do |rule, &handler|
85
+ expect(rule.to_s).to include("path='#{device}/s/c'")
86
+ signal_handler = handler
87
+ end
88
+ allow(bus).to receive(:send_sync_or_async) do |message|
89
+ calls << message
90
+ case message.member
91
+ when 'GetManagedObjects' then [objects]
92
+ when 'GetAll' then [objects.fetch(message.path).fetch(message.params.first.last)]
93
+ when 'WriteValue'
94
+ signal = DBus::Message.new(DBus::Message::SIGNAL)
95
+ signal.path = "#{device}/s/c"
96
+ signal.interface = 'org.freedesktop.DBus.Properties'
97
+ signal.member = 'PropertiesChanged'
98
+ signal.add_param('s', 'org.bluez.GattCharacteristic1')
99
+ signal.add_param('a{sv}', { 'Value' => ['ay', [79, 75, 10]] })
100
+ signal.add_param('as', [])
101
+ peer.write(signal.marshall)
102
+ []
103
+ else []
104
+ end
105
+ end
106
+ backend = klass.new(address: 'AA:BB:CC:DD:EE:FF', service_uuid: service, timeout: 0.1).connect
107
+ backend.subscribe(uuid: uuid)
108
+ backend.write(uuid: uuid, bytes: 'abc', response: true)
109
+ expect(backend.notification(timeout: 0.1)).to eq(uuid: uuid, bytes: "OK\n")
110
+ expect(calls.select { |m| m.member == 'WriteValue' }.map(&:path)).to eq(["#{device}/s/c"])
111
+ expect(calls.map(&:member)).not_to include('Pair', 'StartDiscovery')
112
+ objects.delete("#{device}/s/c")
113
+ expect { backend.write(uuid: uuid, bytes: 'must not reach decoy', response: false) }.to raise_error(IOError, /selected device/)
114
+ expect { backend.notification(timeout: 0.01) }.to raise_error(IOError, /notification timed out/)
115
+ backend.close
116
+ expect(socket).to be_closed
117
+ ensure
118
+ socket&.close unless socket&.closed?
119
+ peer&.close
120
+ end
121
+ end
122
+
123
+ describe 'Unified BLE firmware installation' do
124
+ it 'rejects another protocol in the direct BLE helper before touching GATT' do
125
+ backend = UnifiedGattFixture.new
126
+ expect do
127
+ Meshtastic::Admin::Firmware::BLE.install(protocol: :legacy_ble, backend: backend, bytes: 'abc')
128
+ end.to raise_error(ArgumentError, /protocol/)
129
+ expect(backend.writes).to be_empty
130
+ end
131
+
132
+ it 'uses the selected-address BlueZ backend by default' do
133
+ backend = UnifiedGattFixture.new
134
+ expect(Meshtastic::Admin::Firmware::BLE::BlueZ).to receive(:new)
135
+ .with(address: 'AA:BB:CC:DD:EE:FF', adapter: 'hci0', timeout: 120, service_uuid: Meshtastic::Admin::Firmware::BLE::SERVICE_UUID)
136
+ .and_return(backend)
137
+ expect(backend).to receive(:connect).and_return(backend)
138
+ expect(Meshtastic::Admin::Firmware.install(protocol: :unified_ble, address: 'AA:BB:CC:DD:EE:FF', bytes: 'abc')[:status]).to eq(:verified)
139
+ end
140
+
141
+ it 'validates timeout and unknown options before any BLE command' do
142
+ [{ timeout: 0 }, { timeout: Float::INFINITY }, { to: '!aabbccdd' }, { chunk_size: 512 }].each do |invalid|
143
+ backend = UnifiedGattFixture.new
144
+ expect do
145
+ Meshtastic::Admin::Firmware.install({ protocol: :unified_ble, backend: backend, bytes: 'abc' }.merge(invalid))
146
+ end.to raise_error(ArgumentError)
147
+ expect(backend.writes).to be_empty
148
+ end
149
+ end
150
+
151
+ it 'never treats ACK, ERR or disconnect as final completion or retries image bytes' do
152
+ ["ACK\n", "ERR Hash Mismatch\n", nil].each do |final|
153
+ backend = UnifiedGattFixture.new(final: final)
154
+ expect do
155
+ Meshtastic::Admin::Firmware.install(protocol: :unified_ble, backend: backend, bytes: 'abc')
156
+ end.to raise_error(IOError)
157
+ expect(backend.writes.count { |w| w[:bytes] == 'abc' }).to eq(1)
158
+ expect(backend.closed).to be true
159
+ end
160
+ end
161
+
162
+ it 'fragments commands to ATT size and waits for ACK per data chunk and final OK' do
163
+ backend = UnifiedGattFixture.new
164
+ result = Meshtastic::Admin::Firmware.install(protocol: :unified_ble, backend: backend, bytes: 'x' * 45)
165
+ expect(result).to include(status: :verified, bytes: 45, sha256: Digest::SHA256.hexdigest('x' * 45))
166
+ expect(backend.writes.map { |write| write[:bytes].bytesize }.max).to be <= 20
167
+ expect(backend.writes.map { |write| write[:bytes] }.join).to eq("VERSION\nOTA 45 #{Digest::SHA256.hexdigest('x' * 45)}\n#{'x' * 45}")
168
+ expect(backend.closed).to be true
169
+ end
170
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+ require 'zlib'
6
+ require 'meshtastic/admin/firmware/nordic_dfu' if File.exist?(File.expand_path('../../../../../lib/meshtastic/admin/firmware/nordic_dfu.rb', __dir__))
7
+
8
+ # A stateful byte-level peer, not canned success responses. It enforces the
9
+ # SDK11 command order, image sizing, PRN cadence, and independent image CRC.
10
+ class NordicDFUPeer
11
+ CONTROL = '00001531-1212-efde-1523-785feabcd123'
12
+ PACKET = '00001532-1212-efde-1523-785feabcd123'
13
+ attr_reader :image, :activated, :closed, :writes
14
+ attr_accessor :fault
15
+
16
+ def initialize
17
+ @state = :idle
18
+ @events = []
19
+ @image = ''.b
20
+ @init = ''.b
21
+ @writes = []
22
+ @count = 0
23
+ end
24
+
25
+ def subscribe(uuid:)
26
+ raise 'wrong subscription' unless uuid == CONTROL
27
+
28
+ @subscribed = true
29
+ end
30
+
31
+ def write(uuid:, bytes:, response:)
32
+ raise 'not subscribed' unless @subscribed
33
+
34
+ @writes << [uuid, bytes, response]
35
+ if uuid == CONTROL
36
+ raise 'control requires write response' unless response
37
+
38
+ control(bytes)
39
+ else
40
+ raise 'invalid packet write' unless uuid == PACKET && !response && bytes.bytesize <= 20
41
+
42
+ packet(bytes)
43
+ end
44
+ end
45
+
46
+ def control(bytes)
47
+ case [@state, bytes.bytes]
48
+ when [:idle, [1, 4]] then @state = :size
49
+ when [:started, [2, 0]] then @state = :init
50
+ when [:init, [2, 1]]
51
+ raise 'bad init' unless @init.bytesize == 14 && @init.unpack1('v') == 0x52
52
+
53
+ reply(2)
54
+ @state = :initialized
55
+ when [:ready, [3]] then @state = :image
56
+ when [:received, [4]]
57
+ @image.setbyte(0, @image.getbyte(0) ^ 1) if @fault == :flash_corruption
58
+ reply(4, crc(@image) == @init.byteslice(-2, 2).unpack1('v') ? 1 : 5)
59
+ @state = :validated
60
+ when [:validated, [5]] then @activated = true
61
+ else
62
+ raise "unexpected command #{@state}: #{bytes.bytes}" unless @state == :initialized && bytes.getbyte(0) == 8 && bytes.bytesize == 3
63
+
64
+ @interval = bytes.byteslice(1, 2).unpack1('v')
65
+ raise 'receipts disabled' unless @interval.positive?
66
+
67
+ @state = :ready
68
+ end
69
+ end
70
+
71
+ def packet(bytes)
72
+ case @state
73
+ when :size
74
+ sd, bl, @size = bytes.unpack('V3')
75
+ raise 'bad image sizes' unless bytes.bytesize == 12 && sd.zero? && bl.zero? && @size.positive?
76
+
77
+ @state = :started
78
+ reply(1)
79
+ when :init then @init << bytes
80
+ when :image
81
+ raise 'unaligned image packet' unless (bytes.bytesize % 4).zero?
82
+
83
+ @image << bytes
84
+ raise 'image overflow' if @image.bytesize > @size
85
+
86
+ @count += 1
87
+ if @image.bytesize == @size
88
+ @state = :received
89
+ reply(3)
90
+ elsif (@count % @interval).zero?
91
+ receipt = @image.bytesize + (@fault == :receipt ? 4 : 0)
92
+ @events << [17, receipt].pack('CV')
93
+ end
94
+ else raise "packet in #{@state}"
95
+ end
96
+ end
97
+
98
+ def reply(opcode, status = 1)
99
+ status = 6 if @fault == :reject && opcode == 2
100
+ opcode = 4 if @fault == :wrong_opcode && opcode == 1
101
+ @events << [16, opcode, status].pack('C*')
102
+ end
103
+
104
+ def notification(timeout:)
105
+ raise 'unbounded wait' unless timeout.positive?
106
+ return nil if @fault == :timeout
107
+
108
+ bytes = @events.shift
109
+ raise 'client waited when peer owes no response' unless bytes
110
+
111
+ { uuid: CONTROL, bytes: bytes }
112
+ end
113
+
114
+ def close
115
+ @closed = true
116
+ end
117
+
118
+ def crc(bytes)
119
+ crc = 0xffff
120
+ bytes.each_byte do |byte|
121
+ crc ^= byte << 8
122
+ 8.times { crc = ((crc << 1) ^ (crc.anybits?(0x8000) ? 0x1021 : 0)) & 0xffff }
123
+ end
124
+ crc
125
+ end
126
+ end
127
+
128
+ RSpec.describe 'Meshtastic::Admin::Firmware::NordicDFU' do
129
+ let(:installer) { Meshtastic::Admin::Firmware::NordicDFU }
130
+ let(:peer) { NordicDFUPeer.new }
131
+ let(:image) { (0...244).to_a.pack('C*') }
132
+ let(:init) { [0x52, 0xffff, 1, 1, 0xfffe, peer.crc(image)].pack('vvVvvv') }
133
+ let(:manifest) { { manifest: { application: { bin_file: 'app.bin', dat_file: 'app.dat' } } } }
134
+ let(:entries) { { 'manifest.json' => JSON.generate(manifest), 'app.bin' => image, 'app.dat' => init } }
135
+
136
+ def zip(entries, compression: 0)
137
+ local = ''.b
138
+ central = ''.b
139
+ entries.each do |name, bytes|
140
+ raw = if compression == 8
141
+ deflater = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -Zlib::MAX_WBITS)
142
+ begin
143
+ deflater.deflate(bytes, Zlib::FINISH)
144
+ ensure
145
+ deflater.close
146
+ end
147
+ else
148
+ bytes
149
+ end
150
+ crc = Zlib.crc32(bytes)
151
+ central << [0x02014b50, 20, 20, 0, compression, 0, 0, crc, raw.bytesize, bytes.bytesize,
152
+ name.bytesize, 0, 0, 0, 0, 0, local.bytesize].pack('VvvvvvvVVVvvvvvVV') << name
153
+ local << [0x04034b50, 20, 0, compression, 0, 0, crc, raw.bytesize, bytes.bytesize,
154
+ name.bytesize, 0].pack('VvvvvvVVVvv') << name << raw
155
+ end
156
+ local + central + [0x06054b50, 0, 0, entries.length, entries.length, central.bytesize, local.bytesize, 0].pack('VvvvvVVv')
157
+ end
158
+
159
+ {
160
+ 'image CRC mismatch' => ->(entries) { entries.merge('app.bin' => 'x' * 244) },
161
+ 'unaligned image' => ->(entries) { entries.merge('app.bin' => 'x') },
162
+ 'empty image' => ->(entries) { entries.merge('app.bin' => '') },
163
+ 'signed extension' => ->(entries) { entries.merge('app.dat' => entries.fetch('app.dat').byteslice(0, 12) + [2, 244].pack('V2') + ('x' * 96)) },
164
+ 'secure protobuf init' => ->(entries) { entries.merge('app.dat' => "\x12\x08\x0a\x06secure".b) },
165
+ 'bootloader update' => ->(entries) { entries.merge('manifest.json' => JSON.generate(manifest: { bootloader: { bin_file: 'app.bin', dat_file: 'app.dat' } })) },
166
+ 'traversal entry' => ->(entries) { entries.merge('../outside' => 'unsafe') },
167
+ 'missing init' => ->(entries) { entries.except('app.dat') }
168
+ }.each do |description, mutate|
169
+ it "rejects #{description} before any bootloader writes" do
170
+ expect { installer.install(package_bytes: zip(mutate.call(entries)), gatt: peer) }.to raise_error(ArgumentError)
171
+ expect(peer.writes).to be_empty
172
+ end
173
+ end
174
+
175
+ it 'rejects truncated ZIP data without writing to the bootloader' do
176
+ expect { installer.install(package_bytes: zip(entries).byteslice(0, 100), gatt: peer) }.to raise_error(ArgumentError)
177
+ expect(peer.writes).to be_empty
178
+ end
179
+
180
+ it 'reads a package file without requiring any external unpacker' do
181
+ require 'tempfile'
182
+ Tempfile.create(['nordic', '.zip']) do |file|
183
+ file.binmode
184
+ file.write(zip(entries))
185
+ file.flush
186
+ installer.install(package: file.path, gatt: peer)
187
+ expect(peer.activated).to be(true)
188
+ end
189
+ end
190
+
191
+ it 'rejects ambiguous package sources before touching GATT' do
192
+ expect { installer.install(package_bytes: zip(entries), package: '/unused.zip', gatt: peer) }.to raise_error(ArgumentError, /exactly one/)
193
+ expect(peer.writes).to be_empty
194
+ end
195
+
196
+ it 'rejects unknown options rather than ignoring secure or retry requests' do
197
+ expect { installer.install(package_bytes: zip(entries), gatt: peer, secure: true) }.to raise_error(ArgumentError, /Unsupported.*secure/)
198
+ expect(peer.writes).to be_empty
199
+ end
200
+
201
+ it 'rejects a JSON scalar manifest with an operator-usable error' do
202
+ malformed = entries.merge('manifest.json' => JSON.generate('not a manifest'))
203
+ expect { installer.install(package_bytes: zip(malformed), gatt: peer) }.to raise_error(ArgumentError, /manifest/)
204
+ expect(peer.writes).to be_empty
205
+ end
206
+
207
+ it 'rejects malformed manifest application types clearly before I/O' do
208
+ malformed = entries.merge('manifest.json' => JSON.generate(manifest: { application: 'oops' }))
209
+ expect { installer.install(package_bytes: zip(malformed), gatt: peer) }.to raise_error(ArgumentError, /application/)
210
+ expect(peer.writes).to be_empty
211
+ end
212
+
213
+ %i[receipt reject wrong_opcode timeout flash_corruption].each do |fault|
214
+ it "fails closed on #{fault} without replaying unsequenced packets" do
215
+ peer.fault = fault
216
+ expect { installer.install(package_bytes: zip(entries), gatt: peer) }.to raise_error(fault == :timeout ? Timeout::Error : IOError)
217
+ expect(peer.activated).not_to be(true)
218
+ expect(peer.closed).to be(true)
219
+ expect(peer.writes.count { |_uuid, bytes, _response| bytes == [1, 4].pack('C*') }).to eq(1)
220
+ end
221
+ end
222
+
223
+ it 'connects the shared production BlueZ backend to the legacy service by default' do
224
+ require 'meshtastic/admin/firmware/ble'
225
+ expect(Meshtastic::Admin::Firmware::BLE::BlueZ).to receive(:new).with(
226
+ address: 'AA:BB:CC:DD:EE:FF', adapter: 'hci0', timeout: 30,
227
+ service_uuid: '00001530-1212-efde-1523-785feabcd123'
228
+ ).and_return(peer)
229
+ expect(peer).to receive(:connect).and_return(peer)
230
+ installer.install(package_bytes: zip(entries), address: 'AA:BB:CC:DD:EE:FF')
231
+ expect(peer.activated).to be(true)
232
+ end
233
+
234
+ it 'reads a deflated ZIP generated by normal DFU package tools' do
235
+ installer.install(package_bytes: zip(entries, compression: 8), gatt: peer)
236
+ expect(peer.image).to eq(image)
237
+ expect(peer.activated).to be(true)
238
+ end
239
+
240
+ it 'installs a legacy application ZIP through real command and receipt states' do
241
+ expect(defined?(Meshtastic::Admin::Firmware::NordicDFU)).to eq('constant')
242
+ result = installer.install(package_bytes: zip(entries), gatt: peer)
243
+ expect(peer.image).to eq(image)
244
+ expect(peer.activated).to be(true)
245
+ expect(peer.closed).to be(true)
246
+ expect(result).to include(status: :verified, bytes: image.bytesize, protocol: :nordic_dfu, reboot_verified: false)
247
+ end
248
+ end