mqtt-ccutrer 1.0.0
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 +7 -0
- data/LICENSE.md +21 -0
- data/NEWS.md +173 -0
- data/README.md +218 -0
- data/lib/mqtt-ccutrer.rb +3 -0
- data/lib/mqtt.rb +61 -0
- data/lib/mqtt/client.rb +772 -0
- data/lib/mqtt/packet.rb +1055 -0
- data/lib/mqtt/proxy.rb +117 -0
- data/lib/mqtt/sn/packet.rb +714 -0
- data/lib/mqtt/version.rb +6 -0
- data/spec/zz_client_integration_spec.rb +180 -0
- metadata +182 -0
data/lib/mqtt/client.rb
ADDED
@@ -0,0 +1,772 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
autoload :OpenSSL, 'openssl'
|
4
|
+
autoload :SecureRandom, 'securerandom'
|
5
|
+
autoload :URI, 'uri'
|
6
|
+
|
7
|
+
# Client class for talking to an MQTT server
|
8
|
+
module MQTT
|
9
|
+
class Client
|
10
|
+
# Hostname of the remote server
|
11
|
+
attr_accessor :host
|
12
|
+
|
13
|
+
# Port number of the remote server
|
14
|
+
attr_accessor :port
|
15
|
+
|
16
|
+
# The version number of the MQTT protocol to use (default 3.1.1)
|
17
|
+
attr_accessor :version
|
18
|
+
|
19
|
+
# Set to true to enable SSL/TLS encrypted communication
|
20
|
+
#
|
21
|
+
# Set to a symbol to use a specific variant of SSL/TLS.
|
22
|
+
# Allowed values include:
|
23
|
+
#
|
24
|
+
# @example Using TLS 1.0
|
25
|
+
# client = Client.new('mqtt.example.com', ssl: :TLSv1)
|
26
|
+
# @see OpenSSL::SSL::SSLContext::METHODS
|
27
|
+
attr_accessor :ssl
|
28
|
+
|
29
|
+
# Time (in seconds) between pings to remote server (default is 15 seconds)
|
30
|
+
attr_accessor :keep_alive
|
31
|
+
|
32
|
+
# Set the 'Clean Session' flag when connecting? (default is true)
|
33
|
+
attr_accessor :clean_session
|
34
|
+
|
35
|
+
# Client Identifier
|
36
|
+
attr_accessor :client_id
|
37
|
+
|
38
|
+
# Number of seconds to wait for acknowledgement packets (default is 5 seconds)
|
39
|
+
attr_accessor :ack_timeout
|
40
|
+
|
41
|
+
# How many times to attempt re-sending packets that weren't acknowledged
|
42
|
+
# (default is 5) before giving up
|
43
|
+
attr_accessor :resend_limit
|
44
|
+
|
45
|
+
# How many attempts to re-establish a connection after it drops before
|
46
|
+
# giving up (default 5)
|
47
|
+
attr_accessor :reconnect_limit
|
48
|
+
|
49
|
+
# How long to wait between re-connection attempts (exponential - i.e.
|
50
|
+
# immediately after first drop, then 5s, then 25s, then 125s, etc.
|
51
|
+
# when theis value defaults to 5)
|
52
|
+
attr_accessor :reconnect_backoff
|
53
|
+
|
54
|
+
# Username to authenticate to the server with
|
55
|
+
attr_accessor :username
|
56
|
+
|
57
|
+
# Password to authenticate to the server with
|
58
|
+
attr_accessor :password
|
59
|
+
|
60
|
+
# The topic that the Will message is published to
|
61
|
+
attr_accessor :will_topic
|
62
|
+
|
63
|
+
# Contents of message that is sent by server when client disconnect
|
64
|
+
attr_accessor :will_payload
|
65
|
+
|
66
|
+
# The QoS level of the will message sent by the server
|
67
|
+
attr_accessor :will_qos
|
68
|
+
|
69
|
+
# If the Will message should be retain by the server after it is sent
|
70
|
+
attr_accessor :will_retain
|
71
|
+
|
72
|
+
# Default attribute values
|
73
|
+
ATTR_DEFAULTS = {
|
74
|
+
host: nil,
|
75
|
+
port: nil,
|
76
|
+
version: '3.1.1',
|
77
|
+
keep_alive: 15,
|
78
|
+
clean_session: true,
|
79
|
+
client_id: nil,
|
80
|
+
ack_timeout: 5,
|
81
|
+
resend_limit: 5,
|
82
|
+
reconnect_limit: 5,
|
83
|
+
reconnect_backoff: 5,
|
84
|
+
username: nil,
|
85
|
+
password: nil,
|
86
|
+
will_topic: nil,
|
87
|
+
will_payload: nil,
|
88
|
+
will_qos: 0,
|
89
|
+
will_retain: false,
|
90
|
+
ssl: false
|
91
|
+
}.freeze
|
92
|
+
|
93
|
+
# Create and connect a new MQTT Client
|
94
|
+
#
|
95
|
+
# Accepts the same arguments as creating a new client.
|
96
|
+
# If a block is given, then it will be executed before disconnecting again.
|
97
|
+
#
|
98
|
+
# Example:
|
99
|
+
# MQTT::Client.connect('myserver.example.com') do |client|
|
100
|
+
# # do stuff here
|
101
|
+
# end
|
102
|
+
#
|
103
|
+
def self.connect(*args, &block)
|
104
|
+
client = MQTT::Client.new(*args)
|
105
|
+
client.connect(&block)
|
106
|
+
client
|
107
|
+
end
|
108
|
+
|
109
|
+
# Generate a random client identifier
|
110
|
+
# (using the characters 0-9 and a-z)
|
111
|
+
def self.generate_client_id(prefix = 'ruby', length = 16)
|
112
|
+
"#{prefix}#{SecureRandom.alphanumeric(length).downcase}"
|
113
|
+
end
|
114
|
+
|
115
|
+
# Create a new MQTT Client instance
|
116
|
+
#
|
117
|
+
# Accepts one of the following:
|
118
|
+
# - a URI that uses the MQTT scheme
|
119
|
+
# - a hostname and port
|
120
|
+
# - a Hash containing attributes to be set on the new instance
|
121
|
+
#
|
122
|
+
# If no arguments are given then the method will look for a URI
|
123
|
+
# in the MQTT_SERVER environment variable.
|
124
|
+
#
|
125
|
+
# Examples:
|
126
|
+
# client = MQTT::Client.new
|
127
|
+
# client = MQTT::Client.new('mqtt://myserver.example.com')
|
128
|
+
# client = MQTT::Client.new('mqtt://user:pass@myserver.example.com')
|
129
|
+
# client = MQTT::Client.new('myserver.example.com')
|
130
|
+
# client = MQTT::Client.new('myserver.example.com', 18830)
|
131
|
+
# client = MQTT::Client.new(host: 'myserver.example.com')
|
132
|
+
# client = MQTT::Client.new(host: 'myserver.example.com', keep_alive: 30)
|
133
|
+
#
|
134
|
+
def initialize(host = nil, port = nil, **attributes)
|
135
|
+
# Set server URI from environment if present
|
136
|
+
if host.nil? && port.nil? && attributes.empty? && ENV['MQTT_SERVER']
|
137
|
+
attributes.merge!(parse_uri(ENV['MQTT_SERVER']))
|
138
|
+
end
|
139
|
+
|
140
|
+
if host
|
141
|
+
case host
|
142
|
+
when URI, %r{^mqtts?://}
|
143
|
+
attributes.merge!(parse_uri(host))
|
144
|
+
else
|
145
|
+
attributes[:host] = host
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
attributes[:port] = port unless port.nil?
|
150
|
+
|
151
|
+
# Merge arguments with default values for attributes
|
152
|
+
ATTR_DEFAULTS.merge(attributes).each_pair do |k, v|
|
153
|
+
send("#{k}=", v)
|
154
|
+
end
|
155
|
+
|
156
|
+
# Set a default port number
|
157
|
+
if @port.nil?
|
158
|
+
@port = @ssl ? MQTT::DEFAULT_SSL_PORT : MQTT::DEFAULT_PORT
|
159
|
+
end
|
160
|
+
|
161
|
+
# Initialise private instance variables
|
162
|
+
@socket = nil
|
163
|
+
@read_queue = Queue.new
|
164
|
+
@write_queue = Queue.new
|
165
|
+
|
166
|
+
@read_thread = nil
|
167
|
+
@write_thread = nil
|
168
|
+
|
169
|
+
@acks = {}
|
170
|
+
|
171
|
+
@connection_mutex = Mutex.new
|
172
|
+
@acks_mutex = Mutex.new
|
173
|
+
@wake_up_pipe = IO.pipe
|
174
|
+
|
175
|
+
@connected = false
|
176
|
+
end
|
177
|
+
|
178
|
+
# Get the OpenSSL context, that is used if SSL/TLS is enabled
|
179
|
+
def ssl_context
|
180
|
+
@ssl_context ||= OpenSSL::SSL::SSLContext.new
|
181
|
+
end
|
182
|
+
|
183
|
+
# Set a path to a file containing a PEM-format client certificate
|
184
|
+
def cert_file=(path)
|
185
|
+
self.cert = File.read(path)
|
186
|
+
end
|
187
|
+
|
188
|
+
# PEM-format client certificate
|
189
|
+
def cert=(cert)
|
190
|
+
ssl_context.cert = OpenSSL::X509::Certificate.new(cert)
|
191
|
+
end
|
192
|
+
|
193
|
+
# Set a path to a file containing a PEM-format client private key
|
194
|
+
def key_file=(*args)
|
195
|
+
path, passphrase = args.flatten
|
196
|
+
ssl_context.key = OpenSSL::PKey::RSA.new(File.open(path), passphrase)
|
197
|
+
end
|
198
|
+
|
199
|
+
# Set to a PEM-format client private key
|
200
|
+
def key=(*args)
|
201
|
+
cert, passphrase = args.flatten
|
202
|
+
ssl_context.key = OpenSSL::PKey::RSA.new(cert, passphrase)
|
203
|
+
end
|
204
|
+
|
205
|
+
# Set a path to a file containing a PEM-format CA certificate and enable peer verification
|
206
|
+
def ca_file=(path)
|
207
|
+
ssl_context.ca_file = path
|
208
|
+
ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER unless path.nil?
|
209
|
+
end
|
210
|
+
|
211
|
+
# Set the Will for the client
|
212
|
+
#
|
213
|
+
# The will is a message that will be delivered by the server when the client dies.
|
214
|
+
# The Will must be set before establishing a connection to the server
|
215
|
+
def set_will(topic, payload, retain: false, qos: 0)
|
216
|
+
self.will_topic = topic
|
217
|
+
self.will_payload = payload
|
218
|
+
self.will_retain = retain
|
219
|
+
self.will_qos = qos
|
220
|
+
end
|
221
|
+
|
222
|
+
# Connect to the MQTT server
|
223
|
+
#
|
224
|
+
# If a block is given, then yield to that block and then disconnect again.
|
225
|
+
def connect
|
226
|
+
if connected?
|
227
|
+
yield(self) if block_given?
|
228
|
+
return
|
229
|
+
end
|
230
|
+
|
231
|
+
if @client_id.nil? || @client_id.empty?
|
232
|
+
raise 'Must provide a client_id if clean_session is set to false' unless @clean_session
|
233
|
+
|
234
|
+
# Empty client id is not allowed for version 3.1.0
|
235
|
+
@client_id = MQTT::Client.generate_client_id if @version == '3.1.0'
|
236
|
+
end
|
237
|
+
|
238
|
+
raise ArgumentError, 'No MQTT server host set when attempting to connect' if @host.nil?
|
239
|
+
|
240
|
+
connect_internal
|
241
|
+
|
242
|
+
return unless block_given?
|
243
|
+
|
244
|
+
# If a block is given, then yield and disconnect
|
245
|
+
begin
|
246
|
+
yield(self)
|
247
|
+
ensure
|
248
|
+
disconnect
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
# wait until all messages have been sent
|
253
|
+
def flush
|
254
|
+
raise NotConnectedException unless connected?
|
255
|
+
|
256
|
+
queue = Queue.new
|
257
|
+
@write_queue << queue
|
258
|
+
queue.pop
|
259
|
+
nil
|
260
|
+
end
|
261
|
+
|
262
|
+
# Disconnect from the MQTT server.
|
263
|
+
#
|
264
|
+
# If you don't want to say goodbye to the server, set send_msg to false.
|
265
|
+
def disconnect(send_msg: true)
|
266
|
+
return unless connected?
|
267
|
+
|
268
|
+
@read_queue << [ConnectionClosedException.new, current_time]
|
269
|
+
# Stop reading packets from the socket first
|
270
|
+
@connection_mutex.synchronize do
|
271
|
+
if @write_thread&.alive?
|
272
|
+
@write_thread.kill
|
273
|
+
@write_thread.join
|
274
|
+
end
|
275
|
+
@read_thread.kill if @read_thread&.alive?
|
276
|
+
@read_thread = @write_thread = nil
|
277
|
+
|
278
|
+
@connected = false
|
279
|
+
end
|
280
|
+
@acks_mutex.synchronize do
|
281
|
+
@acks.each_value do |pending_ack|
|
282
|
+
pending_ack.queue << :close
|
283
|
+
end
|
284
|
+
@acks.clear
|
285
|
+
end
|
286
|
+
|
287
|
+
return unless @socket
|
288
|
+
|
289
|
+
if send_msg
|
290
|
+
packet = MQTT::Packet::Disconnect.new
|
291
|
+
begin
|
292
|
+
@socket.write(packet.to_s)
|
293
|
+
rescue
|
294
|
+
nil
|
295
|
+
end
|
296
|
+
end
|
297
|
+
@socket.close
|
298
|
+
@socket = nil
|
299
|
+
end
|
300
|
+
|
301
|
+
# Checks whether the client is connected to the server.
|
302
|
+
#
|
303
|
+
# Note that this returns true even if the connection is down and we're
|
304
|
+
# trying to reconnect
|
305
|
+
def connected?
|
306
|
+
@connected
|
307
|
+
end
|
308
|
+
|
309
|
+
# registers a callback to be called when a connection is re-established
|
310
|
+
#
|
311
|
+
# can be used to re-subscribe (if you're not using persistent sessions)
|
312
|
+
# to topics, and/or re-publish aliveness (if you set a Will)
|
313
|
+
def on_reconnect(&block)
|
314
|
+
@on_reconnect = block
|
315
|
+
end
|
316
|
+
|
317
|
+
# yields a block, and after the block returns all messages are
|
318
|
+
# published at once, waiting for any necessary PubAcks for QoS 1
|
319
|
+
# packets as a batch at the end
|
320
|
+
#
|
321
|
+
# For example:
|
322
|
+
# client.batch_publish do
|
323
|
+
# client.publish("topic1", "value1", qos: 1)
|
324
|
+
# client.publish("topic2", "value2", qos: 1)
|
325
|
+
# end
|
326
|
+
def batch_publish
|
327
|
+
return yield if @batch_publish
|
328
|
+
|
329
|
+
@batch_publish = {}
|
330
|
+
|
331
|
+
begin
|
332
|
+
yield
|
333
|
+
|
334
|
+
batch = @batch_publish
|
335
|
+
@batch_publish = nil
|
336
|
+
batch.each do |(kwargs, values)|
|
337
|
+
publish(values, **kwargs)
|
338
|
+
end
|
339
|
+
ensure
|
340
|
+
@batch_publish = nil
|
341
|
+
end
|
342
|
+
end
|
343
|
+
|
344
|
+
# Publish a message on a particular topic to the MQTT server.
|
345
|
+
def publish(topics, payload = nil, retain: false, qos: 0)
|
346
|
+
if topics.is_a?(Hash) && !payload.nil?
|
347
|
+
raise ArgumentError, 'Payload cannot be passed if passing a hash for topics and payloads'
|
348
|
+
end
|
349
|
+
raise NotConnectedException unless connected?
|
350
|
+
|
351
|
+
if @batch_publish && qos != 0
|
352
|
+
values = @batch_publish[{ retain: retain, qos: qos }] ||= {}
|
353
|
+
if topics.is_a?(Hash)
|
354
|
+
values.merge!(topics)
|
355
|
+
else
|
356
|
+
values[topics] = payload
|
357
|
+
end
|
358
|
+
return
|
359
|
+
end
|
360
|
+
|
361
|
+
pending_acks = []
|
362
|
+
|
363
|
+
topics = { topics => payload } unless topics.is_a?(Hash)
|
364
|
+
|
365
|
+
topics.each do |(topic, topic_payload)|
|
366
|
+
raise ArgumentError, 'Topic name cannot be nil' if topic.nil?
|
367
|
+
raise ArgumentError, 'Topic name cannot be empty' if topic.empty?
|
368
|
+
|
369
|
+
packet = MQTT::Packet::Publish.new(
|
370
|
+
id: next_packet_id,
|
371
|
+
qos: qos,
|
372
|
+
retain: retain,
|
373
|
+
topic: topic,
|
374
|
+
payload: topic_payload
|
375
|
+
)
|
376
|
+
|
377
|
+
pending_acks << register_for_ack(packet) unless qos.zero?
|
378
|
+
|
379
|
+
# Send the packet
|
380
|
+
send_packet(packet)
|
381
|
+
end
|
382
|
+
|
383
|
+
return if qos.zero?
|
384
|
+
|
385
|
+
pending_acks.each do |ack|
|
386
|
+
wait_for_ack(ack)
|
387
|
+
end
|
388
|
+
nil
|
389
|
+
end
|
390
|
+
|
391
|
+
# Send a subscribe message for one or more topics on the MQTT server.
|
392
|
+
# The topics parameter should be one of the following:
|
393
|
+
# * String: subscribe to one topic with QoS 0
|
394
|
+
# * Array: subscribe to multiple topics with QoS 0
|
395
|
+
# * Hash: subscribe to multiple topics where the key is the topic and the value is the QoS level
|
396
|
+
#
|
397
|
+
# For example:
|
398
|
+
# client.subscribe( 'a/b' )
|
399
|
+
# client.subscribe( 'a/b', 'c/d' )
|
400
|
+
# client.subscribe( ['a/b',0], ['c/d',1] )
|
401
|
+
# client.subscribe( { 'a/b' => 0, 'c/d' => 1 } )
|
402
|
+
#
|
403
|
+
def subscribe(*topics, wait_for_ack: false)
|
404
|
+
raise NotConnectedException unless connected?
|
405
|
+
|
406
|
+
packet = MQTT::Packet::Subscribe.new(
|
407
|
+
id: next_packet_id,
|
408
|
+
topics: topics
|
409
|
+
)
|
410
|
+
token = register_for_ack(packet) if wait_for_ack
|
411
|
+
send_packet(packet)
|
412
|
+
wait_for_ack(token) if wait_for_ack
|
413
|
+
end
|
414
|
+
|
415
|
+
# Send a unsubscribe message for one or more topics on the MQTT server
|
416
|
+
def unsubscribe(*topics, wait_for_ack: false)
|
417
|
+
raise NotConnectedException unless connected?
|
418
|
+
|
419
|
+
topics = topics.first if topics.is_a?(Enumerable) && topics.count == 1
|
420
|
+
|
421
|
+
packet = MQTT::Packet::Unsubscribe.new(
|
422
|
+
topics: topics,
|
423
|
+
id: next_packet_id
|
424
|
+
)
|
425
|
+
token = register_for_ack(packet) if wait_for_ack
|
426
|
+
send_packet(packet)
|
427
|
+
wait_for_ack(token) if wait_for_ack
|
428
|
+
end
|
429
|
+
|
430
|
+
# Return the next message received from the MQTT server.
|
431
|
+
#
|
432
|
+
# The method either returns the Publish packet:
|
433
|
+
# packet = client.get
|
434
|
+
#
|
435
|
+
# Or can be used with a block to keep processing messages:
|
436
|
+
# client.get do |packet|
|
437
|
+
# # Do stuff here
|
438
|
+
# end
|
439
|
+
#
|
440
|
+
def get
|
441
|
+
raise NotConnectedException unless connected?
|
442
|
+
|
443
|
+
loop_start = current_time
|
444
|
+
loop do
|
445
|
+
packet = @read_queue.pop
|
446
|
+
if packet.is_a?(Array) && packet.last >= loop_start
|
447
|
+
e = packet.first
|
448
|
+
e.set_backtrace((e.backtrace || []) + ['<from MQTT worker thread>'] + caller)
|
449
|
+
raise e
|
450
|
+
end
|
451
|
+
next unless packet.is_a?(Packet)
|
452
|
+
|
453
|
+
unless block_given?
|
454
|
+
puback_packet(packet) if packet.qos > 0
|
455
|
+
return packet
|
456
|
+
end
|
457
|
+
|
458
|
+
yield packet
|
459
|
+
puback_packet(packet) if packet.qos > 0
|
460
|
+
end
|
461
|
+
end
|
462
|
+
|
463
|
+
# Returns true if the incoming message queue is empty.
|
464
|
+
def queue_empty?
|
465
|
+
@read_queue.empty?
|
466
|
+
end
|
467
|
+
|
468
|
+
# Returns the length of the incoming message queue.
|
469
|
+
def queue_length
|
470
|
+
@read_queue.length
|
471
|
+
end
|
472
|
+
|
473
|
+
# Clear the incoming message queue.
|
474
|
+
def clear_queue
|
475
|
+
@read_queue.clear
|
476
|
+
end
|
477
|
+
|
478
|
+
private
|
479
|
+
|
480
|
+
PendingAck = Struct.new(:packet, :queue, :timeout_at, :send_count)
|
481
|
+
|
482
|
+
def connect_internal
|
483
|
+
# Create network socket
|
484
|
+
tcp_socket = TCPSocket.new(@host, @port)
|
485
|
+
|
486
|
+
if @ssl
|
487
|
+
# Set the protocol version
|
488
|
+
ssl_context.ssl_version = @ssl if @ssl.is_a?(Symbol)
|
489
|
+
|
490
|
+
@socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
|
491
|
+
@socket.sync_close = true
|
492
|
+
|
493
|
+
# Set hostname on secure socket for Server Name Indication (SNI)
|
494
|
+
@socket.hostname = @host if @socket.respond_to?(:hostname=)
|
495
|
+
|
496
|
+
@socket.connect
|
497
|
+
else
|
498
|
+
@socket = tcp_socket
|
499
|
+
end
|
500
|
+
|
501
|
+
# Construct a connect packet
|
502
|
+
packet = MQTT::Packet::Connect.new(
|
503
|
+
version: @version,
|
504
|
+
clean_session: @clean_session,
|
505
|
+
keep_alive: @keep_alive,
|
506
|
+
client_id: @client_id,
|
507
|
+
username: @username,
|
508
|
+
password: @password,
|
509
|
+
will_topic: @will_topic,
|
510
|
+
will_payload: @will_payload,
|
511
|
+
will_qos: @will_qos,
|
512
|
+
will_retain: @will_retain
|
513
|
+
)
|
514
|
+
|
515
|
+
# Send packet
|
516
|
+
@socket.write(packet.to_s)
|
517
|
+
|
518
|
+
# Receive response
|
519
|
+
receive_connack
|
520
|
+
|
521
|
+
@connected = true
|
522
|
+
|
523
|
+
@write_thread = Thread.new do
|
524
|
+
while (packet = @write_queue.pop)
|
525
|
+
# flush command
|
526
|
+
if packet.is_a?(Queue)
|
527
|
+
packet << :flushed
|
528
|
+
next
|
529
|
+
end
|
530
|
+
@socket.write(packet.to_s)
|
531
|
+
end
|
532
|
+
rescue => e
|
533
|
+
@write_queue << packet if packet
|
534
|
+
reconnect(e)
|
535
|
+
end
|
536
|
+
|
537
|
+
@read_thread = Thread.new do
|
538
|
+
receive_packet while connected?
|
539
|
+
end
|
540
|
+
end
|
541
|
+
|
542
|
+
def reconnect(exception)
|
543
|
+
should_exit = nil
|
544
|
+
@connection_mutex.synchronize do
|
545
|
+
@socket&.close
|
546
|
+
@socket = nil
|
547
|
+
@read_thread&.kill if Thread.current != @read_thread
|
548
|
+
@write_thread&.kill if Thread.current != @write_thread
|
549
|
+
should_exit = Thread.current == @read_thread
|
550
|
+
@read_thread = @write_thread = nil
|
551
|
+
|
552
|
+
retries = 0
|
553
|
+
begin
|
554
|
+
connect_internal unless @reconnect_limit == 0
|
555
|
+
rescue
|
556
|
+
@socket&.close
|
557
|
+
@socket = nil
|
558
|
+
|
559
|
+
if (retries += 1) < @reconnect_limit
|
560
|
+
sleep @reconnect_backoff ** retries
|
561
|
+
retry
|
562
|
+
end
|
563
|
+
end
|
564
|
+
|
565
|
+
unless @socket
|
566
|
+
# couldn't reconnect
|
567
|
+
@acks_mutex.synchronize do
|
568
|
+
@acks.each_value do |pending_ack|
|
569
|
+
pending_ack.queue << :close
|
570
|
+
end
|
571
|
+
@acks.clear
|
572
|
+
end
|
573
|
+
@connected = false
|
574
|
+
@read_queue << [exception, current_time]
|
575
|
+
return
|
576
|
+
end
|
577
|
+
end
|
578
|
+
|
579
|
+
begin
|
580
|
+
if @on_reconnect&.arity == 0
|
581
|
+
@on_reconnect.call
|
582
|
+
else
|
583
|
+
@on_reconnect&.call(@connack)
|
584
|
+
end
|
585
|
+
rescue => e
|
586
|
+
@read_queue << [e, current_time]
|
587
|
+
disconnect
|
588
|
+
end
|
589
|
+
Thread.exit if should_exit
|
590
|
+
end
|
591
|
+
|
592
|
+
# Try to read a packet from the server
|
593
|
+
# Also sends keep-alive ping packets.
|
594
|
+
def receive_packet
|
595
|
+
# Poll socket - is there data waiting?
|
596
|
+
timeout = next_timeout
|
597
|
+
read_ready, = IO.select([@socket, @wake_up_pipe[0]], [], [], timeout)
|
598
|
+
|
599
|
+
# we just needed to break out of our select to set up a new timeout;
|
600
|
+
# we can discard the actual contents
|
601
|
+
@wake_up_pipe[0].readpartial(4096) if read_ready&.include?(@wake_up_pipe[0])
|
602
|
+
|
603
|
+
handle_timeouts
|
604
|
+
|
605
|
+
if read_ready&.include?(@socket)
|
606
|
+
packet = MQTT::Packet.read(@socket)
|
607
|
+
handle_packet(packet)
|
608
|
+
end
|
609
|
+
|
610
|
+
handle_keep_alives
|
611
|
+
rescue => e
|
612
|
+
reconnect(e)
|
613
|
+
end
|
614
|
+
|
615
|
+
def register_for_ack(packet)
|
616
|
+
queue = Queue.new
|
617
|
+
|
618
|
+
timeout_at = current_time + @ack_timeout
|
619
|
+
@acks_mutex.synchronize do
|
620
|
+
if @acks.empty?
|
621
|
+
# just need to wake up the read thread to set up the timeout for this packet
|
622
|
+
@wake_up_pipe[1].write('z')
|
623
|
+
end
|
624
|
+
@acks[packet.id] = PendingAck.new(packet, queue, timeout_at, 1)
|
625
|
+
end
|
626
|
+
end
|
627
|
+
|
628
|
+
def wait_for_ack(pending_ack)
|
629
|
+
response = pending_ack.queue.pop
|
630
|
+
case response
|
631
|
+
when :close
|
632
|
+
raise ConnectionClosedException
|
633
|
+
when :resend_limit_exceeded
|
634
|
+
raise ResendLimitExceededException
|
635
|
+
end
|
636
|
+
end
|
637
|
+
|
638
|
+
def handle_packet(packet)
|
639
|
+
@last_packet_received_at = current_time
|
640
|
+
@keep_alive_sent = false
|
641
|
+
case packet
|
642
|
+
when MQTT::Packet::Publish
|
643
|
+
# Add to queue
|
644
|
+
@read_queue.push(packet)
|
645
|
+
when MQTT::Packet::Pingresp
|
646
|
+
# do nothing; setting @last_packet_received_at already handled it
|
647
|
+
when MQTT::Packet::Puback,
|
648
|
+
MQTT::Packet::Suback,
|
649
|
+
MQTT::Packet::Unsuback
|
650
|
+
@acks_mutex.synchronize do
|
651
|
+
pending_ack = @acks[packet.id]
|
652
|
+
if pending_ack
|
653
|
+
@acks.delete(packet.id)
|
654
|
+
pending_ack.queue << packet
|
655
|
+
end
|
656
|
+
end
|
657
|
+
end
|
658
|
+
# Ignore all other packets
|
659
|
+
# FIXME: implement responses for QoS 2
|
660
|
+
end
|
661
|
+
|
662
|
+
def handle_timeouts
|
663
|
+
@acks_mutex.synchronize do
|
664
|
+
current_time = self.current_time
|
665
|
+
@acks.each_value do |pending_ack|
|
666
|
+
break unless pending_ack.timeout_at <= current_time
|
667
|
+
|
668
|
+
resend(pending_ack)
|
669
|
+
end
|
670
|
+
end
|
671
|
+
end
|
672
|
+
|
673
|
+
def resend(pending_ack)
|
674
|
+
packet = pending_ack.packet
|
675
|
+
if (pending_ack.send_count += 1) > @resend_limit
|
676
|
+
@acks.delete(packet.id)
|
677
|
+
pending_ack.queue << :resend_limit_exceeded
|
678
|
+
return
|
679
|
+
end
|
680
|
+
# timed out, or simple re-send
|
681
|
+
@wake_up_pipe[1].write('z') if @acks.first.first == packet.id
|
682
|
+
pending_ack.timeout_at = current_time + @ack_timeout
|
683
|
+
packet.duplicate = true
|
684
|
+
send_packet(packet)
|
685
|
+
end
|
686
|
+
|
687
|
+
def current_time
|
688
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
689
|
+
end
|
690
|
+
|
691
|
+
def next_timeout
|
692
|
+
timeout_from_acks = @acks_mutex.synchronize do
|
693
|
+
@acks.first&.last&.timeout_at
|
694
|
+
end
|
695
|
+
return nil if timeout_from_acks.nil? && @keep_alive.nil?
|
696
|
+
|
697
|
+
next_ping = @last_packet_received_at + @keep_alive if @keep_alive && !@keep_alive_sent
|
698
|
+
next_ping = @last_packet_received_at + @keep_alive + @ack_timeout if @keep_alive && @keep_alive_sent
|
699
|
+
current_time = self.current_time
|
700
|
+
[([timeout_from_acks, next_ping].compact.min || current_time) - current_time, 0].max
|
701
|
+
end
|
702
|
+
|
703
|
+
def handle_keep_alives
|
704
|
+
return unless @keep_alive && @keep_alive > 0
|
705
|
+
|
706
|
+
current_time = self.current_time
|
707
|
+
if current_time >= @last_packet_received_at + @keep_alive && !@keep_alive_sent
|
708
|
+
packet = MQTT::Packet::Pingreq.new
|
709
|
+
send_packet(packet)
|
710
|
+
@keep_alive_sent = true
|
711
|
+
elsif current_time >= @last_packet_received_at + @keep_alive + @ack_timeout
|
712
|
+
raise KeepAliveTimeout
|
713
|
+
end
|
714
|
+
end
|
715
|
+
|
716
|
+
def puback_packet(packet)
|
717
|
+
send_packet(MQTT::Packet::Puback.new(id: packet.id))
|
718
|
+
end
|
719
|
+
|
720
|
+
# Read and check a connection acknowledgement packet
|
721
|
+
def receive_connack
|
722
|
+
Timeout.timeout(@ack_timeout) do
|
723
|
+
packet = MQTT::Packet.read(@socket)
|
724
|
+
if packet.class != MQTT::Packet::Connack
|
725
|
+
raise MQTT::ProtocolException, "Response wasn't a connection acknowledgement: #{packet.class}"
|
726
|
+
end
|
727
|
+
|
728
|
+
# Check the return code
|
729
|
+
if packet.return_code != 0x00
|
730
|
+
# 3.2.2.3 If a server sends a CONNACK packet containing a non-zero
|
731
|
+
# return code it MUST then close the Network Connection
|
732
|
+
@socket.close
|
733
|
+
raise MQTT::ProtocolException, packet.return_msg
|
734
|
+
end
|
735
|
+
@last_packet_received_at = current_time
|
736
|
+
@keep_alive_sent = false
|
737
|
+
@connack = packet
|
738
|
+
end
|
739
|
+
end
|
740
|
+
|
741
|
+
# Send a packet to server
|
742
|
+
def send_packet(packet)
|
743
|
+
@write_queue << packet
|
744
|
+
end
|
745
|
+
|
746
|
+
def parse_uri(uri)
|
747
|
+
uri = URI.parse(uri) unless uri.is_a?(URI)
|
748
|
+
ssl = case uri.scheme
|
749
|
+
when 'mqtt'
|
750
|
+
false
|
751
|
+
when 'mqtts'
|
752
|
+
true
|
753
|
+
else
|
754
|
+
raise 'Only the mqtt:// and mqtts:// schemes are supported'
|
755
|
+
end
|
756
|
+
|
757
|
+
{
|
758
|
+
host: uri.host,
|
759
|
+
port: uri.port || nil,
|
760
|
+
username: uri.user ? URI::Parser.new.unescape(uri.user) : nil,
|
761
|
+
password: uri.password ? URI::Parser.new.unescape(uri.password) : nil,
|
762
|
+
ssl: ssl
|
763
|
+
}
|
764
|
+
end
|
765
|
+
|
766
|
+
def next_packet_id
|
767
|
+
@last_packet_id = (@last_packet_id || 0).next
|
768
|
+
@last_packet_id = 1 if @last_packet_id > 0xffff
|
769
|
+
@last_packet_id
|
770
|
+
end
|
771
|
+
end
|
772
|
+
end
|