meshtastic 0.0.176 → 0.0.177

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.
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Meshtastic::Bluetooth::BlueZ do # rubocop:disable Metrics/BlockLength
6
+ let(:address) { 'AA:BB:CC:DD:EE:FF' }
7
+
8
+ let(:device_path) { '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF' }
9
+ let(:service_path) { "#{device_path}/service0001" }
10
+ let(:socket) { instance_double(UNIXSocket, close: nil, closed?: false) }
11
+ let(:bus) { double('private D-Bus connection', message_queue: double(socket: socket)) }
12
+ let(:calls) { [] }
13
+ let(:objects) do
14
+ {
15
+ '/org/bluez/hci0' => { 'org.bluez.Adapter1' => { 'Powered' => true } },
16
+ device_path => { 'org.bluez.Device1' => { 'Address' => address, 'Adapter' => '/org/bluez/hci0', 'Name' => 'Mesh', 'UUIDs' => [described_class::SERVICE_UUID], 'Paired' => true, 'Connected' => false, 'ServicesResolved' => true } },
17
+ service_path => { 'org.bluez.GattService1' => { 'UUID' => described_class::SERVICE_UUID, 'Device' => device_path } },
18
+ "#{service_path}/to" => { 'org.bluez.GattCharacteristic1' => { 'UUID' => described_class::TORADIO_UUID, 'Service' => service_path } },
19
+ "#{service_path}/from" => { 'org.bluez.GattCharacteristic1' => { 'UUID' => described_class::FROMRADIO_UUID, 'Service' => service_path } }
20
+ }
21
+ end
22
+ let(:backend) { described_class.new(address: address, timeout: 0.03) }
23
+
24
+ before do
25
+ allow(DBus::ASystemBus).to receive(:allocate).and_return(bus)
26
+ allow(bus).to receive(:initialize)
27
+ allow(bus).to receive(:send_sync_or_async) do |message|
28
+ calls << message
29
+ case message.member
30
+ when 'GetManagedObjects' then [objects]
31
+ when 'GetAll' then [objects.fetch(message.path).fetch(message.params.first.last)]
32
+ when 'Connect' then objects[device_path]['org.bluez.Device1']['Connected'] = true
33
+ []
34
+ when 'Disconnect' then objects[device_path]['org.bluez.Device1']['Connected'] = false
35
+ []
36
+ else []
37
+ end
38
+ end
39
+ end
40
+
41
+ it 'connects a paired device on the chosen powered adapter' do
42
+ expect(backend.connect).to equal(backend)
43
+ expect(calls.find { |call| call.member == 'Connect' }.path).to eq(device_path)
44
+ end
45
+
46
+ it 'requires pre-pairing and releases the bus after a refused connection' do
47
+ objects[device_path]['org.bluez.Device1']['Paired'] = false
48
+ expect { backend.connect }.to raise_error(IOError, /bluetoothctl pair AA:BB:CC:DD:EE:FF/)
49
+ expect(calls.map(&:member)).not_to include('Connect', 'Pair', 'Disconnect')
50
+ expect(socket).to have_received(:close).once
51
+ end
52
+
53
+ it 'rejects unavailable or unpowered adapters and missing devices without connecting' do
54
+ objects['/org/bluez/hci0']['org.bluez.Adapter1']['Powered'] = false
55
+ expect { backend.connect }.to raise_error(IOError, /powered/)
56
+ objects.delete('/org/bluez/hci0')
57
+ expect { backend.connect }.to raise_error(IOError, /adapter/)
58
+ objects['/org/bluez/hci0'] = { 'org.bluez.Adapter1' => { 'Powered' => true } }
59
+ objects.delete(device_path)
60
+ expect { backend.connect }.to raise_error(IOError, /not found/)
61
+ expect(calls.map(&:member)).not_to include('Connect')
62
+ end
63
+
64
+ it 'writes one unframed protobuf to the characteristic scoped to the chosen device and service' do
65
+ wrong_service = "#{device_path}/wrong"
66
+ decoys = {
67
+ '/other/service' => { 'org.bluez.GattService1' => { 'UUID' => described_class::SERVICE_UUID, 'Device' => '/other' } },
68
+ '/other/to' => { 'org.bluez.GattCharacteristic1' => { 'UUID' => described_class::TORADIO_UUID, 'Service' => '/other/service' } },
69
+ "#{wrong_service}/to" => { 'org.bluez.GattCharacteristic1' => { 'UUID' => described_class::TORADIO_UUID, 'Service' => wrong_service } }
70
+ }
71
+ objects.replace(decoys.merge(objects))
72
+ bytes = "\x00\xFF\x94\xC3".b * 100
73
+ backend.connect
74
+ expect(backend.write(bytes)).to eq(bytes.bytesize)
75
+ writes = calls.select { |call| call.member == 'WriteValue' }
76
+ expect(writes.size).to eq(1)
77
+ expect(writes.first.path).to eq("#{service_path}/to")
78
+ expect(writes.first.params).to eq([['ay', bytes.bytes], ['a{sv}', { 'type' => %w[s request] }]])
79
+ end
80
+
81
+ it 'reads raw binary FromRadio bytes and returns binary empty strings for an empty queue' do
82
+ backend.connect
83
+
84
+ allow(bus).to receive(:send_sync_or_async).with(have_attributes(member: 'ReadValue')).and_return([[0, 255, 128]], [[]])
85
+ expect(backend.read).to eq("\x00\xFF\x80".b)
86
+ expect(backend.read).to eq(''.b)
87
+ expect(bus).to have_received(:send_sync_or_async).with(have_attributes(path: "#{service_path}/from", member: 'ReadValue', params: [['a{sv}', {}]])).twice
88
+ end
89
+
90
+ it 'waits for ServicesResolved before selecting required characteristics, bounded by timeout' do
91
+ objects[device_path]['org.bluez.Device1']['ServicesResolved'] = false
92
+ expect { backend.connect }.to raise_error(IOError, /ServicesResolved.*timed out/)
93
+ expect(calls.map(&:member)).to include('Disconnect')
94
+ expect(socket).to have_received(:close).once
95
+ end
96
+
97
+ it 'requires both characteristics under the Meshtastic service during connect' do
98
+ objects.delete("#{service_path}/from")
99
+ expect { backend.connect }.to raise_error(IOError, /characteristic/)
100
+ expect(calls.map(&:member)).to include('Disconnect')
101
+ end
102
+
103
+ it 'raises on disconnected reads and writes instead of treating disconnect as an empty queue' do
104
+ backend.connect
105
+ objects[device_path]['org.bluez.Device1']['Connected'] = false
106
+ allow(bus).to receive(:send_sync_or_async).with(have_attributes(member: 'ReadValue')).and_return([[]])
107
+ expect { backend.read }.to raise_error(IOError, /disconnected/)
108
+ expect { backend.write('data') }.to raise_error(IOError, /disconnected/)
109
+ expect(calls.map(&:member)).not_to include('ReadValue', 'WriteValue')
110
+ end
111
+
112
+ it 'bounds a stalled D-Bus call and discards the interrupted connection' do
113
+ backend.connect
114
+ allow(bus).to receive(:send_sync_or_async).with(have_attributes(member: 'ReadValue')) {
115
+ sleep 1
116
+ [[]]
117
+ }
118
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
119
+ expect { backend.read }.to raise_error(IOError, /timed out/)
120
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 0.5
121
+ expect(socket).to have_received(:close).once
122
+ expect { backend.read }.to raise_error(IOError, /disconnected/)
123
+ end
124
+
125
+ it 'bounds D-Bus initialization and closes partially initialized resources' do
126
+ allow(bus).to receive(:initialize) { sleep 1 }
127
+ expect { backend.connect }.to raise_error(IOError, /timed out/)
128
+ expect(socket).to have_received(:close).once
129
+ end
130
+
131
+ it 'serializes read, write and close on the private D-Bus connection' do
132
+ connection = described_class.new(address: address, timeout: 1)
133
+ connection.connect
134
+ entered = Queue.new
135
+ release = Queue.new
136
+ reading = false
137
+ overlap = false
138
+ allow(bus).to receive(:send_sync_or_async).with(have_attributes(member: 'ReadValue')) do
139
+ reading = true
140
+ entered << true
141
+ release.pop
142
+ reading = false
143
+ [[]]
144
+ end
145
+ %w[WriteValue Disconnect].each do |member|
146
+ allow(bus).to receive(:send_sync_or_async).with(have_attributes(member: member)) {
147
+ overlap ||= reading
148
+ []
149
+ }
150
+ end
151
+ reader = Thread.new { connection.read }
152
+ entered.pop
153
+ writer = Thread.new do
154
+ connection.write('data')
155
+ rescue StandardError
156
+ IOError
157
+ end
158
+ closer = Thread.new { connection.close }
159
+ sleep 0.02
160
+ release << true
161
+ [reader, writer, closer].each(&:join)
162
+ expect(overlap).to be(false)
163
+ expect(socket).to have_received(:close).once
164
+ connection.close
165
+ expect(socket).to have_received(:close).once
166
+ end
167
+
168
+ it 'scans only Meshtastic devices on the requested adapter and releases its discovery session' do
169
+ objects['/other'] = { 'org.bluez.Device1' => objects[device_path]['org.bluez.Device1'].merge('Adapter' => '/org/bluez/hci1') }
170
+ objects['/nonmesh'] = { 'org.bluez.Device1' => objects[device_path]['org.bluez.Device1'].merge('UUIDs' => []) }
171
+ expect(described_class.scan(adapter: 'hci0', timeout: 0.001)).to eq([{ address: address, name: 'Mesh', paired: true }])
172
+ expect(calls.map(&:member)).to include('StartDiscovery', 'StopDiscovery')
173
+ expect(calls.map(&:member)).not_to include('Connect', 'Pair', 'Disconnect')
174
+ expect(calls.find { |call| call.member == 'StartDiscovery' }.path).to eq('/org/bluez/hci0')
175
+ expect(socket).to have_received(:close).once
176
+ end
177
+
178
+ it 'rejects invalid adapter names and nonpositive or nonfinite timeouts' do
179
+ ['../hci0', 'hci0/foo', nil].each do |adapter|
180
+ expect { described_class.new(address: address, adapter: adapter) }.to raise_error(ArgumentError, /adapter/)
181
+ end
182
+ [0, -1, Float::INFINITY, Float::NAN, '5'].each do |timeout|
183
+ expect { described_class.new(address: address, timeout: timeout) }.to raise_error(ArgumentError, /timeout/)
184
+ end
185
+ end
186
+
187
+ it 'rejects malformed Bluetooth addresses before accessing D-Bus' do
188
+ expect { described_class.new(address: 'AA-BB-CC-DD-EE-FF') }.to raise_error(ArgumentError, /address/)
189
+ end
190
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Meshtastic::Bluetooth do
6
+ let(:connection) do
7
+ Class.new do
8
+ attr_reader :writes, :incoming
9
+
10
+ def initialize
11
+ @writes = []
12
+ @incoming = Queue.new
13
+ @closed = false
14
+ end
15
+
16
+ def connect
17
+ self
18
+ end
19
+
20
+ def write(bytes)
21
+ raise IOError, 'disconnected' if @closed
22
+
23
+ @writes << bytes
24
+ bytes.bytesize
25
+ end
26
+
27
+ def read
28
+ raise IOError, 'disconnected' if @closed
29
+
30
+ @incoming.pop(true)
31
+ rescue ThreadError
32
+ ''.b
33
+ end
34
+
35
+ def close
36
+ @closed = true
37
+ end
38
+
39
+ def closed?
40
+ @closed
41
+ end
42
+ end.new
43
+ end
44
+
45
+ before do
46
+ backend = Class.new
47
+ stub_const('Meshtastic::Bluetooth::BlueZ', backend)
48
+ allow(backend).to receive(:new).with(address: 'AA:BB:CC:DD:EE:FF', adapter: 'hci0', timeout: 15).and_return(connection)
49
+ end
50
+
51
+ after do
52
+ described_class.disconnect(bluetooth_obj: @bluetooth_obj) if @bluetooth_obj
53
+ end
54
+
55
+ def connect(opts = {})
56
+ @bluetooth_obj = described_class.connect({ address: 'AA:BB:CC:DD:EE:FF', want_config: false }.merge(opts))
57
+ end
58
+
59
+ it 'writes text as a raw ToRadio protobuf without UART framing' do
60
+ handle = connect
61
+ size = described_class.send_text(bluetooth_obj: handle, text: 'hello', to: '!83726fb1', channel: 0, want_ack: true)
62
+ bytes = connection.writes.last
63
+ packet = Meshtastic::ToRadio.decode(bytes).packet
64
+ expect(size).to eq(bytes.bytesize)
65
+ expect(packet.decoded.payload).to eq('hello')
66
+ expect(packet.to).to eq(0x83726fb1)
67
+ expect(packet.channel).to eq(0)
68
+ expect(packet.want_ack).to be(true)
69
+ expect(packet.encrypted).to eq('')
70
+ expect(packet.from).to eq(0)
71
+ end
72
+
73
+ it 'receives FromRadio messages and completes the matching configuration handshake' do
74
+ handle = connect(want_config: true)
75
+ config_id = Meshtastic::ToRadio.decode(connection.writes.first).want_config_id
76
+ expect(config_id).to be_positive
77
+ info = Meshtastic::FromRadio.new(my_info: Meshtastic::MyNodeInfo.new(my_node_num: 123))
78
+ connection.incoming << info.to_proto
79
+ connection.incoming << Meshtastic::FromRadio.new(config_complete_id: config_id).to_proto
80
+ expect(described_class.wait_for_config(bluetooth_obj: handle, timeout: 1)).to eq(handle)
81
+ expect(handle[:my_node_num]).to eq(123)
82
+ expect(described_class.recv_from_radio(bluetooth_obj: handle, timeout: 0)).to eq(info)
83
+ end
84
+
85
+ it 'subscribes to filtered text messages with the same decoded hashes as Serial' do
86
+ handle = connect
87
+ %w[hidden hello].each do |text|
88
+ connection.incoming << Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(
89
+ from: 123, to: 0xffffffff, channel: 0,
90
+ decoded: Meshtastic::Data.new(portnum: :TEXT_MESSAGE_APP, payload: text)
91
+ )).to_proto
92
+ end
93
+ received = Timeout.timeout(1) do
94
+ described_class.subscribe(bluetooth_obj: handle, include: 'TEXT_MESSAGE_APP', exclude: 'hidden', include_raw: true) do |message|
95
+ break message
96
+ end
97
+ end
98
+ expect(received.dig(:packet, :decoded, :payload)).to eq('hello')
99
+ expect(received.dig(:packet, :node_id_from)).to eq('!7b')
100
+ expect(Meshtastic::FromRadio.decode(received.dig(:packet, :raw_packet)).packet.from).to eq(123)
101
+ end
102
+
103
+ it 'sends binary data to a shared channel with device-managed encryption' do
104
+ handle = connect
105
+ data = Meshtastic::Data.new(portnum: :PRIVATE_APP, payload: "\x00\xff".b)
106
+ described_class.send_data(bluetooth_obj: handle, data: data, channel: 2)
107
+ packet = Meshtastic::ToRadio.decode(connection.writes.last).packet
108
+ expect(packet.to).to eq(0xffffffff)
109
+ expect(packet.channel).to eq(2)
110
+ expect(packet.decoded).to eq(data)
111
+ end
112
+
113
+ it 'drains messages and snapshots or clears per-connection console and protobuf buffers' do
114
+ handle = connect
115
+ message = Meshtastic::FromRadio.new(log_record: Meshtastic::LogRecord.new(message: 'BLE log'))
116
+ connection.incoming << message.to_proto
117
+ expect(described_class.recv_from_radio(bluetooth_obj: handle, timeout: 1)).to eq(message)
118
+ expect(described_class.drain_from_radio(bluetooth_obj: handle)).to eq([])
119
+ expect(described_class.dump_stdout_data(bluetooth_obj: handle, type: :console)).to eq("BLE log\n")
120
+ expect(described_class.dump_stdout_data(type: :proto)).to eq([message.to_h])
121
+ yielded = []
122
+ described_class.dump_stdout_data(bluetooth_obj: handle, type: :console) { |line| yielded << line }
123
+ expect(yielded).to eq(['BLE log'])
124
+ described_class.flush_data(bluetooth_obj: handle, type: :console)
125
+ expect(described_class.dump_stdout_data(bluetooth_obj: handle, type: :console)).to eq('')
126
+ end
127
+
128
+ it 'cleans up a failed GATT write without recursive disconnect attempts' do
129
+ handle = connect
130
+ allow(connection).to receive(:write).and_raise(IOError, 'GATT write failed')
131
+ expect { described_class.send_text(bluetooth_obj: handle, text: 'hello') }.to raise_error(IOError, 'GATT write failed')
132
+ expect(connection.closed?).to be(true)
133
+ expect(handle[:rx_thread].alive?).to be(false)
134
+ expect(handle[:from_radio_queue].closed?).to be(true)
135
+ end
136
+
137
+ it 'scans with the selected adapter and bounded discovery timeout' do
138
+ devices = [{ address: 'AA:BB:CC:DD:EE:FF', name: 'Meshtastic_test', paired: true }]
139
+ expect(described_class::BlueZ).to receive(:scan).with(adapter: 'hci1', timeout: 2).and_return(devices)
140
+ expect(described_class.scan(adapter: 'hci1', timeout: 2)).to eq(devices)
141
+ end
142
+
143
+ it 'prints usage without raising' do
144
+ expect { described_class.help }.to output(/USAGE/).to_stdout
145
+ end
146
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Meshtastic::LoRaWANBridge do
6
+ end
@@ -2,5 +2,164 @@
2
2
 
3
3
  require 'spec_helper'
4
4
 
5
- describe Meshtastic::MQTT do
5
+ describe Meshtastic::MQTT do # rubocop:disable Metrics/BlockLength
6
+ def fake_mqtt_obj(client_id: '00000b0b')
7
+ published = []
8
+ subscribed = []
9
+ incoming = Queue.new
10
+ client = Object.new
11
+ client.define_singleton_method(:client_id) { client_id }
12
+ client.define_singleton_method(:published) { published }
13
+ client.define_singleton_method(:subscribed) { subscribed }
14
+ client.define_singleton_method(:incoming) { incoming }
15
+ client.define_singleton_method(:subscribe) do |topic, qos|
16
+ subscribed << { topic: topic, qos: qos }
17
+ end
18
+ client.define_singleton_method(:publish) do |topic, payload|
19
+ published << { topic: topic, payload: payload }
20
+ payload.bytesize
21
+ end
22
+ client.define_singleton_method(:get_packet) do |&block|
23
+ loop do
24
+ packet = incoming.pop
25
+ break if packet.nil?
26
+
27
+ block.call(packet)
28
+ end
29
+ end
30
+ client.define_singleton_method(:disconnect) { @disconnected = true }
31
+ client.define_singleton_method(:disconnected?) { @disconnected == true }
32
+ client
33
+ end
34
+
35
+ def mqtt_packet(topic:, payload:)
36
+ packet = Object.new
37
+ packet.define_singleton_method(:topic) { topic }
38
+ packet.define_singleton_method(:payload) { payload }
39
+ packet.define_singleton_method(:to_s) { payload.to_s }
40
+ packet
41
+ end
42
+
43
+ def publish_and_subscribe(opts = {})
44
+ mqtt_obj = fake_mqtt_obj
45
+ described_class.send_text({ mqtt_obj: mqtt_obj }.merge(opts))
46
+ published = mqtt_obj.published.last
47
+ mqtt_obj.incoming << mqtt_packet(topic: published[:topic], payload: published[:payload])
48
+ mqtt_obj.incoming << nil
49
+ received = nil
50
+ described_class.subscribe({ mqtt_obj: mqtt_obj }.merge(opts)) do |message|
51
+ received = message
52
+ break
53
+ end
54
+ [mqtt_obj, received]
55
+ end
56
+
57
+ describe '.connect' do
58
+ it 'configures keep_alive and ack_timeout on the MQTT client' do
59
+ client = instance_double(MQTT::Client, keep_alive: 15, ack_timeout: 30)
60
+ allow(client).to receive(:keep_alive=)
61
+ allow(client).to receive(:ack_timeout=)
62
+ expect(MQTTClient).to receive(:connect).with(
63
+ hash_including(host: 'localhost', port: 1883, ssl: false, username: 'meshdev', client_id: 'abcd1234')
64
+ ).and_return(client)
65
+
66
+ described_class.connect(host: 'localhost', client_id: 'abcd1234', keep_alive: 15, ack_timeout: 30)
67
+ expect(client).to have_received(:keep_alive=).with(15)
68
+ expect(client).to have_received(:ack_timeout=).with(30)
69
+ end
70
+ end
71
+
72
+ describe '.send_text' do
73
+ it 'publishes an encrypted ServiceEnvelope without UART framing' do
74
+ mqtt_obj = fake_mqtt_obj
75
+ described_class.send_text(
76
+ mqtt_obj: mqtt_obj,
77
+ from: '!00000b0b',
78
+ to: '!ffffffff',
79
+ channel: 0,
80
+ text: 'ping',
81
+ psks: { LongFast: 'AQ==' }
82
+ )
83
+ published = mqtt_obj.published.last
84
+ expect(published[:topic]).to eq('msh/US/2/e/LongFast/!00000b0b')
85
+ expect(published[:payload].getbyte(0)).not_to eq(Meshtastic::START1)
86
+ envelope = Meshtastic::ServiceEnvelope.decode(published[:payload])
87
+ expect(envelope.channel_id).to eq('LongFast')
88
+ expect(envelope.packet.decoded.to_s).to eq('')
89
+ expect(envelope.packet.encrypted.to_s).not_to eq('')
90
+ expect(envelope.packet.to).to eq(0xffffffff)
91
+ expect(envelope.packet.from).to eq(0xb0b)
92
+ end
93
+
94
+ it 'rejects text exceeding the byte limit without publishing' do
95
+ mqtt_obj = fake_mqtt_obj
96
+ expect do
97
+ described_class.send_text(
98
+ mqtt_obj: mqtt_obj,
99
+ text: 'é' * Meshtastic::Constants::DATA_PAYLOAD_LEN
100
+ )
101
+ end.to raise_error(ArgumentError, /Bytes/)
102
+ expect(mqtt_obj.published).to be_empty
103
+ end
104
+ end
105
+
106
+ describe 'mqtt reception' do
107
+ it 'decrypts a published text message as UTF-8, never as a nested protobuf' do
108
+ text = "\n\x02hi"
109
+ _mqtt_obj, received = publish_and_subscribe(text: text, from: '!00000b0b', psks: { LongFast: 'AQ==' })
110
+ expect(received.dig(:packet, :decoded, :payload)).to eq(text)
111
+ expect(received.dig(:packet, :decoded, :payload).encoding).to eq(Encoding::UTF_8)
112
+ expect(received.dig(:packet, :node_id_from)).to eq('!b0b')
113
+ end
114
+
115
+ it 'yields received text through subscription filters' do
116
+ mqtt_obj = fake_mqtt_obj
117
+ %w[hidden hello].each do |text|
118
+ described_class.send_text(mqtt_obj: mqtt_obj, text: text, from: '!00000b0b', psks: { LongFast: 'AQ==' })
119
+ end
120
+ mqtt_obj.published.each do |item|
121
+ mqtt_obj.incoming << mqtt_packet(topic: item[:topic], payload: item[:payload])
122
+ end
123
+ mqtt_obj.incoming << nil
124
+ received = []
125
+ described_class.subscribe(mqtt_obj: mqtt_obj, include: 'TEXT_MESSAGE_APP', exclude: 'hidden') do |message|
126
+ received << message.dig(:packet, :decoded, :payload)
127
+ end
128
+ expect(received).to eq(['hello'])
129
+ end
130
+
131
+ it 'subscribes to the assembled root/region/topic path' do
132
+ mqtt_obj = fake_mqtt_obj
133
+ mqtt_obj.incoming << nil
134
+ described_class.subscribe(mqtt_obj: mqtt_obj, root_topic: 'msh', region: 'US', topic: '2/e/LongFast/#', qos: 1)
135
+ expect(mqtt_obj.subscribed).to eq([{ topic: 'msh/US/2/e/LongFast/#', qos: 1 }])
136
+ end
137
+
138
+ it 'includes the raw MQTT packet when requested' do
139
+ mqtt_obj, received = publish_and_subscribe(text: 'hello', from: '!00000b0b', include_raw: true)
140
+ expect(Meshtastic::ServiceEnvelope.decode(received.dig(:packet, :raw_packet)).packet.from).to eq(0xb0b)
141
+ expect(mqtt_obj.disconnected?).to be(true)
142
+ end
143
+
144
+ it 'requires psks to be a hash of channel keys' do
145
+ mqtt_obj = fake_mqtt_obj
146
+ expect do
147
+ described_class.subscribe(mqtt_obj: mqtt_obj, psks: 'AQ==')
148
+ end.to raise_error(/psks parameter must be a hash/)
149
+ end
150
+ end
151
+
152
+ describe '.disconnect' do
153
+ it 'returns nil and disconnects the MQTT client' do
154
+ mqtt_obj = fake_mqtt_obj
155
+ expect(described_class.disconnect(mqtt_obj: mqtt_obj)).to be_nil
156
+ expect(mqtt_obj.disconnected?).to be(true)
157
+ end
158
+ end
159
+
160
+ describe '.help' do
161
+ it 'prints usage without raising' do
162
+ expect { described_class.help }.to output(/USAGE/).to_stdout
163
+ end
164
+ end
6
165
  end