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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
|
-
|
|
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(
|
|
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
|