qubitro-mqtt 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4394fc97758aeb4403c154e646b9e4594ad345cd9c91ec54e44ae0161066c5ee
4
+ data.tar.gz: 5387dc9e4854868ff846ef5be56d9e7e3d85c9adfd7bc33fc7a03458c2769fd7
5
+ SHA512:
6
+ metadata.gz: 035caedfae1cdfddc790339d8ac7f53e1a25b1253e447bcea2271bd5029d9fc364bb5726b5fc251aede3eab2fecf1e86ded676d6dc7f0096c8fc8d705a41f6a8
7
+ data.tar.gz: 0ba953bc06a85e6b3e4b7d732d84c6ec4642df9205b1f18cc8b26374a651e4b777e460bdaa7e8e8aeb942af62cac2f5d83b02674869173350c9ccbb8937911f4
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Qubitro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/NEWS.md ADDED
@@ -0,0 +1,7 @@
1
+ Qubitro Ruby MQTT Client NEWS
2
+ ==============
3
+
4
+ Ruby MQTT Version 0.0.1 (24.03.2020)
5
+ ------------------------------------
6
+
7
+ * Initial Release
data/README.md ADDED
@@ -0,0 +1,6 @@
1
+
2
+ Qubitro Ruby MQTT Client
3
+ =========
4
+
5
+ Qubitro Ruby gem that implements the [MQTT] protocol, a lightweight protocol for publish/subscribe messaging.
6
+
@@ -0,0 +1,617 @@
1
+ autoload :OpenSSL, 'openssl'
2
+ autoload :URI, 'uri'
3
+
4
+ # Client class for talking to an MQTT server
5
+ module MQTT
6
+ class Client
7
+ # Hostname of the remote server
8
+ attr_accessor :host
9
+
10
+ # Port number of the remote server
11
+ attr_accessor :port
12
+
13
+ # The version number of the MQTT protocol to use (default 3.1.1)
14
+ attr_accessor :version
15
+
16
+ # Set to true to enable SSL/TLS encrypted communication
17
+ #
18
+ # Set to a symbol to use a specific variant of SSL/TLS.
19
+ # Allowed values include:
20
+ #
21
+ # @example Using TLS 1.0
22
+ # client = Client.new('mqtt.example.com', :ssl => :TLSv1)
23
+ # @see OpenSSL::SSL::SSLContext::METHODS
24
+ attr_accessor :ssl
25
+
26
+ # Time (in seconds) between pings to remote server (default is 15 seconds)
27
+ attr_accessor :keep_alive
28
+
29
+ # Set the 'Clean Session' flag when connecting? (default is true)
30
+ attr_accessor :clean_session
31
+
32
+ # Client Identifier
33
+ attr_accessor :client_id
34
+
35
+ # Number of seconds to wait for acknowledgement packets (default is 5 seconds)
36
+ attr_accessor :ack_timeout
37
+
38
+ # Username to authenticate to the server with
39
+ attr_accessor :username
40
+
41
+ # Password to authenticate to the server with
42
+ attr_accessor :password
43
+
44
+ # The topic that the Will message is published to
45
+ attr_accessor :will_topic
46
+
47
+ # Contents of message that is sent by server when client disconnect
48
+ attr_accessor :will_payload
49
+
50
+ # The QoS level of the will message sent by the server
51
+ attr_accessor :will_qos
52
+
53
+ # If the Will message should be retain by the server after it is sent
54
+ attr_accessor :will_retain
55
+
56
+ # Last ping response time
57
+ attr_reader :last_ping_response
58
+
59
+ # Timeout between select polls (in seconds)
60
+ SELECT_TIMEOUT = 0.5
61
+
62
+ # Default attribute values
63
+ ATTR_DEFAULTS = {
64
+ :host => nil,
65
+ :port => nil,
66
+ :version => '3.1.1',
67
+ :keep_alive => 15,
68
+ :clean_session => true,
69
+ :client_id => nil,
70
+ :ack_timeout => 5,
71
+ :username => nil,
72
+ :password => nil,
73
+ :will_topic => nil,
74
+ :will_payload => nil,
75
+ :will_qos => 0,
76
+ :will_retain => false,
77
+ :ssl => false
78
+ }
79
+
80
+ # Create and connect a new MQTT Client
81
+ #
82
+ # Accepts the same arguments as creating a new client.
83
+ # If a block is given, then it will be executed before disconnecting again.
84
+ #
85
+ # Example:
86
+ # MQTT::Client.connect('myserver.example.com') do |client|
87
+ # # do stuff here
88
+ # end
89
+ #
90
+ def self.connect(*args, &block)
91
+ client = MQTT::Client.new(*args)
92
+ client.connect(&block)
93
+ client
94
+ end
95
+
96
+ # Generate a random client identifier
97
+ # (using the characters 0-9 and a-z)
98
+ def self.generate_client_id(prefix = 'ruby', length = 16)
99
+ str = prefix.dup
100
+ length.times do
101
+ num = rand(36)
102
+ # Adjust based on number or letter.
103
+ num += num < 10 ? 48 : 87
104
+ str += num.chr
105
+ end
106
+ str
107
+ end
108
+
109
+ # Create a new MQTT Client instance
110
+ #
111
+ # Accepts one of the following:
112
+ # - a URI that uses the MQTT scheme
113
+ # - a hostname and port
114
+ # - a Hash containing attributes to be set on the new instance
115
+ #
116
+ # If no arguments are given then the method will look for a URI
117
+ # in the MQTT_SERVER environment variable.
118
+ #
119
+ # Examples:
120
+ # client = MQTT::Client.new
121
+ # client = MQTT::Client.new('mqtt://myserver.example.com')
122
+ # client = MQTT::Client.new('mqtt://user:pass@myserver.example.com')
123
+ # client = MQTT::Client.new('myserver.example.com')
124
+ # client = MQTT::Client.new('myserver.example.com', 18830)
125
+ # client = MQTT::Client.new(:host => 'myserver.example.com')
126
+ # client = MQTT::Client.new(:host => 'myserver.example.com', :keep_alive => 30)
127
+ #
128
+ def initialize(*args)
129
+ attributes = args.last.is_a?(Hash) ? args.pop : {}
130
+
131
+ # Set server URI from environment if present
132
+ attributes.merge!(parse_uri(ENV['MQTT_SERVER'])) if args.length.zero? && ENV['MQTT_SERVER']
133
+
134
+ if args.length >= 1
135
+ case args[0]
136
+ when URI
137
+ attributes.merge!(parse_uri(args[0]))
138
+ when %r{^mqtts?://}
139
+ attributes.merge!(parse_uri(args[0]))
140
+ else
141
+ attributes[:host] = args[0]
142
+ end
143
+ end
144
+
145
+ if args.length >= 2
146
+ attributes[:port] = args[1] unless args[1].nil?
147
+ end
148
+
149
+ raise ArgumentError, 'Unsupported number of arguments' if args.length >= 3
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
+ @last_ping_request = Time.now
163
+ @last_ping_response = Time.now
164
+ @socket = nil
165
+ @read_queue = Queue.new
166
+ @pubacks = {}
167
+ @read_thread = nil
168
+ @write_semaphore = Mutex.new
169
+ @pubacks_semaphore = Mutex.new
170
+ end
171
+
172
+ # Get the OpenSSL context, that is used if SSL/TLS is enabled
173
+ def ssl_context
174
+ @ssl_context ||= OpenSSL::SSL::SSLContext.new
175
+ end
176
+
177
+ # Set a path to a file containing a PEM-format client certificate
178
+ def cert_file=(path)
179
+ self.cert = File.read(path)
180
+ end
181
+
182
+ # PEM-format client certificate
183
+ def cert=(cert)
184
+ ssl_context.cert = OpenSSL::X509::Certificate.new(cert)
185
+ end
186
+
187
+ # Set a path to a file containing a PEM-format client private key
188
+ def key_file=(*args)
189
+ path, passphrase = args.flatten
190
+ ssl_context.key = OpenSSL::PKey::RSA.new(File.open(path), passphrase)
191
+ end
192
+
193
+ # Set to a PEM-format client private key
194
+ def key=(*args)
195
+ cert, passphrase = args.flatten
196
+ ssl_context.key = OpenSSL::PKey::RSA.new(cert, passphrase)
197
+ end
198
+
199
+ # Set a path to a file containing a PEM-format CA certificate and enable peer verification
200
+ def ca_file=(path)
201
+ ssl_context.ca_file = path
202
+ ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER unless path.nil?
203
+ end
204
+
205
+ # Set the Will for the client
206
+ #
207
+ # The will is a message that will be delivered by the server when the client dies.
208
+ # The Will must be set before establishing a connection to the server
209
+ def set_will(topic, payload, retain = false, qos = 0)
210
+ self.will_topic = topic
211
+ self.will_payload = payload
212
+ self.will_retain = retain
213
+ self.will_qos = qos
214
+ end
215
+
216
+ # Connect to the MQTT server
217
+ # If a block is given, then yield to that block and then disconnect again.
218
+ def connect(clientid = nil)
219
+ @client_id = clientid unless clientid.nil?
220
+
221
+ if @client_id.nil? || @client_id.empty?
222
+ raise 'Must provide a client_id if clean_session is set to false' unless @clean_session
223
+
224
+ # Empty client id is not allowed for version 3.1.0
225
+ @client_id = MQTT::Client.generate_client_id if @version == '3.1.0'
226
+ end
227
+
228
+ raise 'No MQTT server host set when attempting to connect' if @host.nil?
229
+
230
+ unless connected?
231
+ # Create network socket
232
+ tcp_socket = TCPSocket.new(@host, @port)
233
+
234
+ if @ssl
235
+ # Set the protocol version
236
+ ssl_context.ssl_version = @ssl if @ssl.is_a?(Symbol)
237
+
238
+ @socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
239
+ @socket.sync_close = true
240
+
241
+ # Set hostname on secure socket for Server Name Indication (SNI)
242
+ @socket.hostname = @host if @socket.respond_to?(:hostname=)
243
+
244
+ @socket.connect
245
+ else
246
+ @socket = tcp_socket
247
+ end
248
+
249
+ # Construct a connect packet
250
+ packet = MQTT::Packet::Connect.new(
251
+ :version => @version,
252
+ :clean_session => @clean_session,
253
+ :keep_alive => @keep_alive,
254
+ :client_id => @client_id,
255
+ :username => @username,
256
+ :password => @password,
257
+ :will_topic => @will_topic,
258
+ :will_payload => @will_payload,
259
+ :will_qos => @will_qos,
260
+ :will_retain => @will_retain
261
+ )
262
+
263
+ # Send packet
264
+ send_packet(packet)
265
+
266
+ # Receive response
267
+ receive_connack
268
+
269
+ # Start packet reading thread
270
+ @read_thread = Thread.new(Thread.current) do |parent|
271
+ Thread.current[:parent] = parent
272
+ receive_packet while connected?
273
+ end
274
+ end
275
+
276
+ return unless block_given?
277
+
278
+ # If a block is given, then yield and disconnect
279
+ begin
280
+ yield(self)
281
+ ensure
282
+ disconnect
283
+ end
284
+ end
285
+
286
+ # Disconnect from the MQTT server.
287
+ # If you don't want to say goodbye to the server, set send_msg to false.
288
+ def disconnect(send_msg = true)
289
+ # Stop reading packets from the socket first
290
+ @read_thread.kill if @read_thread && @read_thread.alive?
291
+ @read_thread = nil
292
+
293
+ return unless connected?
294
+
295
+ # Close the socket if it is open
296
+ if send_msg
297
+ packet = MQTT::Packet::Disconnect.new
298
+ send_packet(packet)
299
+ end
300
+ @socket.close unless @socket.nil?
301
+ handle_close
302
+ @socket = nil
303
+ end
304
+
305
+ # Checks whether the client is connected to the server.
306
+ def connected?
307
+ !@socket.nil? && !@socket.closed?
308
+ end
309
+
310
+ # Publish a message on a particular topic to the MQTT server.
311
+ def publish(topic, payload = '', retain = false, qos = 0)
312
+ raise ArgumentError, 'Topic name cannot be nil' if topic.nil?
313
+ raise ArgumentError, 'Topic name cannot be empty' if topic.empty?
314
+
315
+ packet = MQTT::Packet::Publish.new(
316
+ :id => next_packet_id,
317
+ :qos => qos,
318
+ :retain => retain,
319
+ :topic => topic,
320
+ :payload => payload
321
+ )
322
+
323
+ # Send the packet
324
+ res = send_packet(packet)
325
+
326
+ return if qos.zero?
327
+
328
+ queue = Queue.new
329
+
330
+ wait_for_puback packet.id, queue
331
+
332
+ deadline = current_time + @ack_timeout
333
+
334
+ loop do
335
+ response = queue.pop
336
+ case response
337
+ when :read_timeout
338
+ return -1 if current_time > deadline
339
+ when :close
340
+ return -1
341
+ else
342
+ @pubacks_semaphore.synchronize do
343
+ @pubacks.delete packet.id
344
+ end
345
+ break
346
+ end
347
+ end
348
+
349
+ res
350
+ end
351
+
352
+ # Send a subscribe message for one or more topics on the MQTT server.
353
+ # The topics parameter should be one of the following:
354
+ # * String: subscribe to one topic with QoS 0
355
+ # * Array: subscribe to multiple topics with QoS 0
356
+ # * Hash: subscribe to multiple topics where the key is the topic and the value is the QoS level
357
+ #
358
+ # For example:
359
+ # client.subscribe( 'a/b' )
360
+ # client.subscribe( 'a/b', 'c/d' )
361
+ # client.subscribe( ['a/b',0], ['c/d',1] )
362
+ # client.subscribe( 'a/b' => 0, 'c/d' => 1 )
363
+ #
364
+ def subscribe(*topics)
365
+ packet = MQTT::Packet::Subscribe.new(
366
+ :id => next_packet_id,
367
+ :topics => topics
368
+ )
369
+ send_packet(packet)
370
+ end
371
+
372
+ # Return the next message received from the MQTT server.
373
+ # An optional topic can be given to subscribe to.
374
+ #
375
+ # The method either returns the topic and message as an array:
376
+ # topic,message = client.get
377
+ #
378
+ # Or can be used with a block to keep processing messages:
379
+ # client.get('test') do |topic,payload|
380
+ # # Do stuff here
381
+ # end
382
+ #
383
+ def get(topic = nil, options = {})
384
+ if block_given?
385
+ get_packet(topic) do |packet|
386
+ yield(packet.topic, packet.payload) unless packet.retain && options[:omit_retained]
387
+ end
388
+ else
389
+ loop do
390
+ # Wait for one packet to be available
391
+ packet = get_packet(topic)
392
+ return packet.topic, packet.payload unless packet.retain && options[:omit_retained]
393
+ end
394
+ end
395
+ end
396
+
397
+ # Return the next packet object received from the MQTT server.
398
+ # An optional topic can be given to subscribe to.
399
+ #
400
+ # The method either returns a single packet:
401
+ # packet = client.get_packet
402
+ # puts packet.topic
403
+ #
404
+ # Or can be used with a block to keep processing messages:
405
+ # client.get_packet('test') do |packet|
406
+ # # Do stuff here
407
+ # puts packet.topic
408
+ # end
409
+ #
410
+ def get_packet(topic = nil)
411
+ # Subscribe to a topic, if an argument is given
412
+ subscribe(topic) unless topic.nil?
413
+
414
+ if block_given?
415
+ # Loop forever!
416
+ loop do
417
+ packet = @read_queue.pop
418
+ yield(packet)
419
+ puback_packet(packet) if packet.qos > 0
420
+ end
421
+ else
422
+ # Wait for one packet to be available
423
+ packet = @read_queue.pop
424
+ puback_packet(packet) if packet.qos > 0
425
+ return packet
426
+ end
427
+ end
428
+
429
+ # Returns true if the incoming message queue is empty.
430
+ def queue_empty?
431
+ @read_queue.empty?
432
+ end
433
+
434
+ # Returns the length of the incoming message queue.
435
+ def queue_length
436
+ @read_queue.length
437
+ end
438
+
439
+ # Clear the incoming message queue.
440
+ def clear_queue
441
+ @read_queue.clear
442
+ end
443
+
444
+ # Send a unsubscribe message for one or more topics on the MQTT server
445
+ def unsubscribe(*topics)
446
+ topics = topics.first if topics.is_a?(Enumerable) && topics.count == 1
447
+
448
+ packet = MQTT::Packet::Unsubscribe.new(
449
+ :topics => topics,
450
+ :id => next_packet_id
451
+ )
452
+ send_packet(packet)
453
+ end
454
+
455
+ private
456
+
457
+ # Try to read a packet from the server
458
+ # Also sends keep-alive ping packets.
459
+ def receive_packet
460
+ # Poll socket - is there data waiting?
461
+ result = IO.select([@socket], [], [], SELECT_TIMEOUT)
462
+ handle_timeouts
463
+ unless result.nil?
464
+ # Yes - read in the packet
465
+ packet = MQTT::Packet.read(@socket)
466
+ handle_packet packet
467
+ end
468
+ keep_alive!
469
+ # Pass exceptions up to parent thread
470
+ rescue Exception => exp
471
+ unless @socket.nil?
472
+ @socket.close
473
+ @socket = nil
474
+ handle_close
475
+ end
476
+ Thread.current[:parent].raise(exp)
477
+ end
478
+
479
+ def wait_for_puback(id, queue)
480
+ @pubacks_semaphore.synchronize do
481
+ @pubacks[id] = queue
482
+ end
483
+ end
484
+
485
+ def handle_packet(packet)
486
+ if packet.class == MQTT::Packet::Publish
487
+ # Add to queue
488
+ @read_queue.push(packet)
489
+ elsif packet.class == MQTT::Packet::Pingresp
490
+ @last_ping_response = Time.now
491
+ elsif packet.class == MQTT::Packet::Puback
492
+ @pubacks_semaphore.synchronize do
493
+ @pubacks[packet.id] << packet
494
+ end
495
+ end
496
+ # Ignore all other packets
497
+ # FIXME: implement responses for QoS 2
498
+ end
499
+
500
+ def handle_timeouts
501
+ @pubacks_semaphore.synchronize do
502
+ @pubacks.each_value { |q| q << :read_timeout }
503
+ end
504
+ end
505
+
506
+ def handle_close
507
+ @pubacks_semaphore.synchronize do
508
+ @pubacks.each_value { |q| q << :close }
509
+ end
510
+ end
511
+
512
+ if Process.const_defined? :CLOCK_MONOTONIC
513
+ def current_time
514
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
515
+ end
516
+ else
517
+ # Support older Ruby
518
+ def current_time
519
+ Time.now.to_f
520
+ end
521
+ end
522
+
523
+ def keep_alive!
524
+ return unless @keep_alive > 0 && connected?
525
+
526
+ response_timeout = (@keep_alive * 1.5).ceil
527
+ if Time.now >= @last_ping_request + @keep_alive
528
+ packet = MQTT::Packet::Pingreq.new
529
+ send_packet(packet)
530
+ @last_ping_request = Time.now
531
+ elsif Time.now > @last_ping_response + response_timeout
532
+ raise MQTT::ProtocolException, "No Ping Response received for #{response_timeout} seconds"
533
+ end
534
+ end
535
+
536
+ def puback_packet(packet)
537
+ send_packet(MQTT::Packet::Puback.new(:id => packet.id))
538
+ end
539
+
540
+ # Read and check a connection acknowledgement packet
541
+ def receive_connack
542
+ Timeout.timeout(@ack_timeout) do
543
+ packet = MQTT::Packet.read(@socket)
544
+ if packet.class != MQTT::Packet::Connack
545
+ raise MQTT::ProtocolException, "Response wasn't a connection acknowledgement: #{packet.class}"
546
+ end
547
+
548
+ # Check the return code
549
+ if packet.return_code != 0x00
550
+ # 3.2.2.3 If a server sends a CONNACK packet containing a non-zero
551
+ # return code it MUST then close the Network Connection
552
+ @socket.close
553
+ raise MQTT::ProtocolException, packet.return_msg
554
+ end
555
+ end
556
+ end
557
+
558
+ # Send a packet to server
559
+ def send_packet(data)
560
+ # Raise exception if we aren't connected
561
+ raise MQTT::NotConnectedException unless connected?
562
+
563
+ # Only allow one thread to write to socket at a time
564
+ @write_semaphore.synchronize do
565
+ @socket.write(data.to_s)
566
+ end
567
+ end
568
+
569
+ def parse_uri(uri)
570
+ uri = URI.parse(uri) unless uri.is_a?(URI)
571
+ if uri.scheme == 'mqtt'
572
+ ssl = false
573
+ elsif uri.scheme == 'mqtts'
574
+ ssl = true
575
+ else
576
+ raise 'Only the mqtt:// and mqtts:// schemes are supported'
577
+ end
578
+
579
+ {
580
+ :host => uri.host,
581
+ :port => uri.port || nil,
582
+ :username => uri.user ? URI.unescape(uri.user) : nil,
583
+ :password => uri.password ? URI.unescape(uri.password) : nil,
584
+ :ssl => ssl
585
+ }
586
+ end
587
+
588
+ def next_packet_id
589
+ @last_packet_id = (@last_packet_id || 0).next
590
+ @last_packet_id = 1 if @last_packet_id > 0xffff
591
+ @last_packet_id
592
+ end
593
+
594
+ # ---- Deprecated attributes and methods ---- #
595
+ public
596
+
597
+ # @deprecated Please use {#host} instead
598
+ def remote_host
599
+ host
600
+ end
601
+
602
+ # @deprecated Please use {#host=} instead
603
+ def remote_host=(args)
604
+ self.host = args
605
+ end
606
+
607
+ # @deprecated Please use {#port} instead
608
+ def remote_port
609
+ port
610
+ end
611
+
612
+ # @deprecated Please use {#port=} instead
613
+ def remote_port=(args)
614
+ self.port = args
615
+ end
616
+ end
617
+ end