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,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ shared_context 'channel serial framing' do
6
+ def fake_serial_obj
7
+ written = +''.b
8
+ serial_conn = Object.new
9
+ serial_conn.define_singleton_method(:write) do |b|
10
+ written << b
11
+ b.bytesize
12
+ end
13
+ serial_conn.define_singleton_method(:flush) { true }
14
+ serial_conn.define_singleton_method(:closed?) { false }
15
+ serial_conn.define_singleton_method(:close) { true }
16
+ { serial_conn: serial_conn, written: written, my_node_num: 0xb0b }
17
+ end
18
+
19
+ def decode_admin(serial_obj)
20
+ frame = serial_obj[:written]
21
+ body = frame.byteslice(4, (frame.getbyte(2) << 8) + frame.getbyte(3))
22
+ Meshtastic::AdminMessage.decode(Meshtastic::ToRadio.decode(body).packet.decoded.payload)
23
+ end
24
+ end
25
+
26
+ describe Meshtastic::Admin::Channel do
27
+ include_context 'channel serial framing'
28
+
29
+ it 'requests a channel by index via Admin' do
30
+ serial_obj = fake_serial_obj
31
+ described_class.get(serial_obj: serial_obj, index: 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
41
+ end
42
+
43
+ it 'sets a Channel protobuf including settings and role' do
44
+ serial_obj = fake_serial_obj
45
+ settings = described_class.build_settings(name: 'LongFast', uplink_enabled: true)
46
+ described_class.set(serial_obj: serial_obj, index: 0, role: :PRIMARY, settings: settings)
47
+ channel = decode_admin(serial_obj).set_channel
48
+ expect(channel.index).to eq(0)
49
+ expect(channel.role).to eq(:PRIMARY)
50
+ expect(channel.settings.name).to eq('LongFast')
51
+ expect(channel.settings.uplink_enabled).to be true
52
+ end
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
+
199
+ it 'prints usage without raising' do
200
+ expect { described_class.help }.to output(/USAGE/).to_stdout
201
+ end
202
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Meshtastic::Admin::Config do
6
+ def fake_serial_obj
7
+ written = +''.b
8
+ serial_conn = Object.new
9
+ serial_conn.define_singleton_method(:write) do |b|
10
+ written << b
11
+ b.bytesize
12
+ end
13
+ serial_conn.define_singleton_method(:flush) { true }
14
+ serial_conn.define_singleton_method(:closed?) { false }
15
+ serial_conn.define_singleton_method(:close) { true }
16
+ { serial_conn: serial_conn, written: written, my_node_num: 0xb0b }
17
+ end
18
+
19
+ def decode_admin(serial_obj)
20
+ frame = serial_obj[:written]
21
+ body = frame.byteslice(4, (frame.getbyte(2) << 8) + frame.getbyte(3))
22
+ Meshtastic::AdminMessage.decode(Meshtastic::ToRadio.decode(body).packet.decoded.payload)
23
+ end
24
+
25
+ it 'requests LoRa config via Admin' do
26
+ serial_obj = fake_serial_obj
27
+ described_class.get(serial_obj: serial_obj, config_type: :LORA_CONFIG)
28
+ expect(decode_admin(serial_obj).get_config_request).to eq(:LORA_CONFIG)
29
+ end
30
+
31
+ it 'sets a Config protobuf' do
32
+ serial_obj = fake_serial_obj
33
+ config = Meshtastic::Config.new
34
+ config.device = Meshtastic::Config::DeviceConfig.new(role: :CLIENT)
35
+ described_class.set(serial_obj: serial_obj, config: config)
36
+ expect(decode_admin(serial_obj).set_config.device.role).to eq(:CLIENT)
37
+ end
38
+
39
+ it 'requests each ConfigType via named getters' do
40
+ serial_obj = fake_serial_obj
41
+ described_class.get_lora(serial_obj: serial_obj)
42
+ expect(decode_admin(serial_obj).get_config_request).to eq(:LORA_CONFIG)
43
+ end
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
+
111
+ it 'prints usage without raising' do
112
+ expect { described_class.help }.to output(/USAGE/).to_stdout
113
+ end
114
+ 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