qubitro-mqtt 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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