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.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -0
- data/.rubocop_todo.yml +0 -21
- data/Gemfile +1 -0
- data/README.md +79 -82
- data/lib/meshtastic/bluetooth/bluez.rb +187 -0
- data/lib/meshtastic/bluetooth.rb +299 -0
- data/lib/meshtastic/lorawan_bridge_pb.rb +20 -0
- data/lib/meshtastic/mesh_interface.rb +3 -1
- data/lib/meshtastic/mqtt.rb +7 -34
- data/lib/meshtastic/{serial_interface.rb → serial.rb} +144 -106
- data/lib/meshtastic/stream_interface.rb +1 -1
- data/lib/meshtastic/version.rb +1 -1
- data/lib/meshtastic.rb +3 -1
- data/spec/lib/meshtastic/bluetooth/bluez_spec.rb +190 -0
- data/spec/lib/meshtastic/bluetooth_spec.rb +146 -0
- data/spec/lib/meshtastic/lorawan_bridge_pb_spec.rb +6 -0
- data/spec/lib/meshtastic/mqtt_spec.rb +160 -1
- data/spec/lib/meshtastic/serial_spec.rb +295 -0
- metadata +23 -3
- data/spec/lib/meshtastic/serial_interface_spec.rb +0 -78
|
@@ -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
|
|
@@ -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
|