go-mqtt 0.0.1 → 0.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8698ec3fcf903c009cffb7a8c9f4fefc17704577c05b629e7cf2192c2402de94
4
- data.tar.gz: 84deb7a3a57332f01b3d24b86b0dbb1bec8425c5586f851e2fb66aeb4b506b63
3
+ metadata.gz: 4af5bac1776bc58c17da97d092f8443f6de49ec78cb6e1674f413c8c5e8cdf43
4
+ data.tar.gz: 4131b5ef45dc34c890a5827d3e263a2703bcdad73acfaa5ba0f42da9e977be22
5
5
  SHA512:
6
- metadata.gz: cbcd90f72f086b3ee8e42daf3aaf4138f556932b5240a625dda5229f25e61eb05a3ec5536a8966acb2408ed52240465ef9ec654e93e07a50b41328cccb6aae6a
7
- data.tar.gz: a8cae3bc9bdf70ce094332991eb1468acb07b44eb8b0e82c72d3dbadf7bf0ecc8bd490e74a129b74ba497c5ad8c37c76b91fdef24dd76d38c5e8491266854b40
6
+ metadata.gz: bea4be741b694732831bc09d394e38bd84c2c51b0043a48fe5c5b056067b23549bab82d1b21ec6765805b981c12e63ee9531d0031739d5291114af2b348cd242
7
+ data.tar.gz: 69103699feef84a19de26d6b75ce44eddd7c5d08b9e8a47df1d1652557e9a122ba7f257b04b968f5e2db43e92c02996e9f9bcb9bcfe6d98883493e2d5cc9e7b5
data/lib/mqtt/client.rb CHANGED
@@ -1,145 +1,100 @@
1
+ # frozen_string_literal: true
2
+
1
3
  autoload :OpenSSL, 'openssl'
2
4
  autoload :URI, 'uri'
3
5
  autoload :CGI, 'cgi'
6
+ autoload :Logger, 'logger'
4
7
 
5
8
  # Client class for talking to an MQTT server
6
9
  module MQTT
7
10
  class Client
8
- # Hostname of the remote server
9
- attr_accessor :host
10
-
11
- # Port number of the remote server
12
- attr_accessor :port
13
-
14
- # The version number of the MQTT protocol to use (default 3.1.1)
15
- attr_accessor :version
16
-
17
- # Set to true to enable SSL/TLS encrypted communication
18
- #
19
- # Set to a symbol to use a specific variant of SSL/TLS.
20
- # Allowed values include:
21
- #
22
- # @example Using TLS 1.0
23
- # client = Client.new('mqtt.example.com', :ssl => :TLSv1)
24
- # @see OpenSSL::SSL::SSLContext::METHODS
25
- attr_accessor :ssl
26
-
27
- # Set to false to skip tls hostname verification
28
- attr_accessor :verify_host
29
-
30
- # Time (in seconds) between pings to remote server (default is 15 seconds)
31
- attr_accessor :keep_alive
32
-
33
- # Set the 'Clean Session' flag when connecting? (default is true)
34
- attr_accessor :clean_session
35
-
36
- # Client Identifier
37
- attr_accessor :client_id
38
-
39
- # Number of seconds to wait for acknowledgement packets (default is 5 seconds)
40
- attr_accessor :ack_timeout
41
-
42
- # Number of seconds to connect to the server (default is 90 seconds)
43
- attr_accessor :connect_timeout
44
-
45
- # Username to authenticate to the server with
46
- attr_accessor :username
47
-
48
- # Password to authenticate to the server with
49
- attr_accessor :password
50
-
51
- # The topic that the Will message is published to
52
- attr_accessor :will_topic
53
-
54
- # Contents of message that is sent by server when client disconnect
55
- attr_accessor :will_payload
56
-
57
- # The QoS level of the will message sent by the server
58
- attr_accessor :will_qos
59
-
60
- # If the Will message should be retain by the server after it is sent
61
- attr_accessor :will_retain
62
-
63
- # Last ping response time
11
+ # Connection-related attributes
12
+ attr_accessor :host # Hostname of the remote server
13
+ attr_accessor :port # Port number of the remote server
14
+ attr_accessor :version # MQTT protocol version (default 3.1.1)
15
+ attr_accessor :ssl # SSL/TLS encryption flag and version
16
+ attr_accessor :verify_host # Whether to verify SSL hostname
17
+ attr_accessor :keep_alive # Keep-alive timer in seconds (default 15)
18
+ attr_accessor :logger # Logger instance for debug output
19
+
20
+ # Authentication and session attributes
21
+ attr_accessor :clean_session # Whether to start a clean session
22
+ attr_accessor :client_id # Unique client identifier
23
+ attr_accessor :username # Username for authentication
24
+ attr_accessor :password # Password for authentication
25
+ attr_accessor :ack_timeout # Timeout for acknowledgments (default 5s)
26
+ attr_accessor :connect_timeout # Connection timeout (default 90s)
27
+ attr_accessor :max_retries # Maximum number of reconnection attempts (default 30)
28
+ attr_accessor :retry_interval # Base interval for retry backoff (default 5s)
29
+
30
+ # Will message attributes
31
+ attr_accessor :will_topic # Topic for will message
32
+ attr_accessor :will_payload # Content of will message
33
+ attr_accessor :will_qos # QoS level for will message
34
+ attr_accessor :will_retain # Whether to retain will message
35
+
36
+ # Last ping response timestamp
64
37
  attr_reader :last_ping_response
65
38
 
66
39
  # Timeout between select polls (in seconds)
67
- SELECT_TIMEOUT = 0.5
40
+ SELECT_TIMEOUT = 1
68
41
 
69
42
  # Default attribute values
70
43
  ATTR_DEFAULTS = {
71
- :host => nil,
72
- :port => nil,
73
- :version => '3.1.1',
74
- :keep_alive => 15,
75
- :clean_session => true,
76
- :client_id => nil,
77
- :ack_timeout => 5,
78
- :connect_timeout => 90,
79
- :username => nil,
80
- :password => nil,
81
- :will_topic => nil,
82
- :will_payload => nil,
83
- :will_qos => 0,
84
- :will_retain => false,
85
- :ssl => false,
86
- :verify_host => true
44
+ host: nil,
45
+ port: nil,
46
+ version: '3.1.1',
47
+ keep_alive: 15,
48
+ clean_session: true,
49
+ client_id: nil,
50
+ ack_timeout: 5,
51
+ connect_timeout: 30,
52
+ username: nil,
53
+ password: nil,
54
+ will_topic: nil,
55
+ will_payload: nil,
56
+ will_qos: 0,
57
+ will_retain: false,
58
+ ssl: false,
59
+ verify_host: true,
60
+ max_retries: 30,
61
+ retry_interval: 5,
62
+ logger: Logger.new($stdout).tap { |l| l.level = Logger::WARN }
87
63
  }
88
64
 
89
65
  # Create and connect a new MQTT Client
90
- #
91
- # Accepts the same arguments as creating a new client.
92
- # If a block is given, then it will be executed before disconnecting again.
93
- #
94
- # Example:
95
- # MQTT::Client.connect('myserver.example.com') do |client|
96
- # # do stuff here
97
- # end
98
- #
66
+ # If a block is given, execute it and then disconnect
99
67
  def self.connect(*args, &block)
100
68
  client = MQTT::Client.new(*args)
101
69
  client.connect(&block)
102
70
  client
71
+ rescue StandardError => e
72
+ @logger.error("Failed to connect: #{e.message}")
73
+ raise e
103
74
  end
104
75
 
105
- # Generate a random client identifier
106
- # (using the characters 0-9 and a-z)
76
+ # Generate a random client identifier with given prefix and length
107
77
  def self.generate_client_id(prefix = 'ruby', length = 16)
108
78
  str = prefix.dup
109
79
  length.times do
110
80
  num = rand(36)
111
- # Adjust based on number or letter.
112
- num += num < 10 ? 48 : 87
113
- str += num.chr
81
+ str << (num < 10 ? (48 + num).chr : (87 + num).chr)
114
82
  end
115
83
  str
116
84
  end
117
85
 
118
- # Create a new MQTT Client instance
119
- #
120
- # Accepts one of the following:
121
- # - a URI that uses the MQTT scheme
122
- # - a hostname and port
123
- # - a Hash containing attributes to be set on the new instance
124
- #
125
- # If no arguments are given then the method will look for a URI
126
- # in the MQTT_SERVER environment variable.
127
- #
128
- # Examples:
129
- # client = MQTT::Client.new
130
- # client = MQTT::Client.new('mqtt://myserver.example.com')
131
- # client = MQTT::Client.new('mqtt://user:pass@myserver.example.com')
132
- # client = MQTT::Client.new('myserver.example.com')
133
- # client = MQTT::Client.new('myserver.example.com', 18830)
134
- # client = MQTT::Client.new(:host => 'myserver.example.com')
135
- # client = MQTT::Client.new(:host => 'myserver.example.com', :keep_alive => 30)
136
- #
86
+ # Initialize a new MQTT Client instance
87
+ # Accepts:
88
+ # - URI with MQTT scheme
89
+ # - hostname and port
90
+ # - Hash of attributes
137
91
  def initialize(*args)
138
92
  attributes = args.last.is_a?(Hash) ? args.pop : {}
139
93
 
140
94
  # Set server URI from environment if present
141
95
  attributes.merge!(parse_uri(ENV['MQTT_SERVER'])) if args.length.zero? && ENV['MQTT_SERVER']
142
96
 
97
+ # Parse the first argument as URI or hostname
143
98
  if args.length >= 1
144
99
  case args[0]
145
100
  when URI
@@ -151,79 +106,76 @@ module MQTT
151
106
  end
152
107
  end
153
108
 
109
+ # Set port from second argument if provided
154
110
  if args.length >= 2
155
111
  attributes[:port] = args[1] unless args[1].nil?
156
112
  end
157
113
 
158
114
  raise ArgumentError, 'Unsupported number of arguments' if args.length >= 3
159
115
 
160
- # Merge arguments with default values for attributes
116
+ # Initialize attributes with defaults
161
117
  ATTR_DEFAULTS.merge(attributes).each_pair do |k, v|
162
118
  send("#{k}=", v)
163
119
  end
164
120
 
165
- # Set a default port number
121
+ # Set default port based on SSL
166
122
  if @port.nil?
167
123
  @port = @ssl ? MQTT::DEFAULT_SSL_PORT : MQTT::DEFAULT_PORT
168
124
  end
169
125
 
126
+ # Load SSL libraries if needed
170
127
  if @ssl
171
128
  require 'openssl'
172
129
  require 'mqtt/openssl_fix'
173
130
  end
174
131
 
175
- # Initialise private instance variables
132
+ # Initialize internal state variables
133
+ @subscriptions = {} # Track active subscriptions
134
+ @subscriptions_mutex = Mutex.new
176
135
  @last_ping_request = current_time
177
136
  @last_ping_response = current_time
178
137
  @socket = nil
179
138
  @read_queue = Queue.new
180
- @pubacks = {}
181
- @pubrecs = {} # For QoS 2 PUBREC packets
182
- @pubrels = {} # For QoS 2 PUBREL packets
183
- @pubcomps = {} # For QoS 2 PUBCOMP packets
139
+ @pubacks = {} # For QoS 1 acknowledgments
140
+ @pubrecs = {} # For QoS 2 PUBREC packets
141
+ @pubrels = {} # For QoS 2 PUBREL packets
142
+ @pubcomps = {} # For QoS 2 PUBCOMP packets
184
143
  @read_thread = nil
185
144
  @write_semaphore = Mutex.new
186
145
  @pubacks_semaphore = Mutex.new
187
146
  @message_id = 0
188
147
  end
189
148
 
190
- # Get the OpenSSL context, that is used if SSL/TLS is enabled
149
+ # Get the SSL context for secure connections
191
150
  def ssl_context
192
151
  @ssl_context ||= OpenSSL::SSL::SSLContext.new
193
152
  end
194
153
 
195
- # Set a path to a file containing a PEM-format client certificate
154
+ # SSL certificate handling methods
196
155
  def cert_file=(path)
197
156
  self.cert = File.read(path)
198
157
  end
199
158
 
200
- # PEM-format client certificate
201
159
  def cert=(cert)
202
160
  ssl_context.cert = OpenSSL::X509::Certificate.new(cert)
203
161
  end
204
162
 
205
- # Set a path to a file containing a PEM-format client private key
206
163
  def key_file=(*args)
207
164
  path, passphrase = args.flatten
208
165
  ssl_context.key = OpenSSL::PKey.read(File.binread(path), passphrase)
209
166
  end
210
167
 
211
- # Set to a PEM-format client private key
212
168
  def key=(*args)
213
169
  cert, passphrase = args.flatten
214
170
  ssl_context.key = OpenSSL::PKey.read(cert, passphrase)
215
171
  end
216
172
 
217
- # Set a path to a file containing a PEM-format CA certificate and enable peer verification
218
173
  def ca_file=(path)
219
174
  ssl_context.ca_file = path
220
175
  ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER unless path.nil?
221
176
  end
222
177
 
223
- # Set the Will for the client
224
- #
225
- # The will is a message that will be delivered by the server when the client dies.
226
- # The Will must be set before establishing a connection to the server
178
+ # Set up a will message (last testament)
227
179
  def set_will(topic, payload, retain = false, qos = 0)
228
180
  self.will_topic = topic
229
181
  self.will_payload = payload
@@ -231,71 +183,36 @@ module MQTT
231
183
  self.will_qos = qos
232
184
  end
233
185
 
234
- # Connect to the MQTT server
235
- # If a block is given, then yield to that block and then disconnect again.
186
+ # Connect to the MQTT server with automatic reconnection
236
187
  def connect(clientid = nil)
237
188
  @client_id = clientid unless clientid.nil?
238
189
 
190
+ # Validate client ID
239
191
  if @client_id.nil? || @client_id.empty?
240
192
  raise 'Must provide a client_id if clean_session is set to false' unless @clean_session
241
-
242
- # Empty client id is not allowed for version 3.1.0
243
193
  @client_id = MQTT::Client.generate_client_id if @version == '3.1.0'
244
194
  end
245
195
 
246
196
  raise 'No MQTT server host set when attempting to connect' if @host.nil?
247
197
 
248
- unless connected?
249
- # Create network socket
250
- tcp_socket = open_tcp_socket
251
-
252
- if @ssl
253
- # Set the protocol version
254
- ssl_context.ssl_version = @ssl if @ssl.is_a?(Symbol)
255
-
256
- @socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
257
- @socket.sync_close = true
198
+ # Return early if already connected
199
+ return if connected?
258
200
 
259
- # Set hostname on secure socket for Server Name Indication (SNI)
260
- @socket.hostname = @host if @socket.respond_to?(:hostname=)
201
+ # Establish network connection
202
+ tcp_socket = open_tcp_socket
261
203
 
262
- @socket.connect
204
+ @socket = @ssl ? setup_ssl_connection(tcp_socket) : tcp_socket
205
+ establish_mqtt_connection
263
206
 
264
- @socket.post_connection_check(@host) if @verify_host
265
- else
266
- @socket = tcp_socket
267
- end
268
-
269
- # Construct a connect packet
270
- packet = MQTT::Packet::Connect.new(
271
- :version => @version,
272
- :clean_session => @clean_session,
273
- :keep_alive => @keep_alive,
274
- :client_id => @client_id,
275
- :username => @username,
276
- :password => @password,
277
- :will_topic => @will_topic,
278
- :will_payload => @will_payload,
279
- :will_qos => @will_qos,
280
- :will_retain => @will_retain
281
- )
282
-
283
- # Send packet
284
- send_packet(packet)
285
-
286
- # Receive response
287
- receive_connack
288
-
289
- # Start packet reading thread
290
- @read_thread = Thread.new(Thread.current) do |parent|
291
- Thread.current[:parent] = parent
292
- receive_packet while connected?
293
- end
207
+ # Start packet reading thread with retry logic
208
+ @read_thread&.kill if @read_thread&.alive?
209
+ @read_thread = Thread.new(Thread.current) do |parent|
210
+ Thread.current[:parent] = parent
211
+ receive_packet while connected?
294
212
  end
295
213
 
296
214
  return unless block_given?
297
215
 
298
- # If a block is given, then yield and disconnect
299
216
  begin
300
217
  yield(self)
301
218
  ensure
@@ -303,31 +220,36 @@ module MQTT
303
220
  end
304
221
  end
305
222
 
306
- # Disconnect from the MQTT server.
307
- # If you don't want to say goodbye to the server, set send_msg to false.
223
+ # Gracefully disconnect from the MQTT server
308
224
  def disconnect(send_msg = true)
309
- # Stop reading packets from the socket first
310
- @read_thread.kill if @read_thread && @read_thread.alive?
225
+ @read_thread&.kill if @read_thread&.alive?
311
226
  @read_thread = nil
312
227
 
313
228
  return unless connected?
314
229
 
315
- # Close the socket if it is open
316
230
  if send_msg
317
- packet = MQTT::Packet::Disconnect.new
318
- send_packet(packet)
231
+ begin
232
+ packet = MQTT::Packet::Disconnect.new
233
+ send_packet(packet)
234
+ rescue StandardError
235
+ # Ignore errors when sending disconnect packet
236
+ end
237
+ end
238
+
239
+ begin
240
+ @socket&.close
241
+ ensure
242
+ handle_close
243
+ @socket = nil
319
244
  end
320
- @socket.close unless @socket.nil?
321
- handle_close
322
- @socket = nil
323
245
  end
324
246
 
325
- # Checks whether the client is connected to the server.
247
+ # Check if client is currently connected
326
248
  def connected?
327
249
  !@socket.nil? && !@socket.closed?
328
250
  end
329
251
 
330
- # Publish a message on a particular topic to the MQTT server.
252
+ # Publish a message with specified QoS
331
253
  def publish(topic, payload = '', retain = false, qos = 0)
332
254
  raise ArgumentError, 'Topic name cannot be nil' if topic.nil?
333
255
  raise ArgumentError, 'Topic name cannot be empty' if topic.empty?
@@ -341,19 +263,20 @@ module MQTT
341
263
  :payload => payload
342
264
  )
343
265
 
266
+ # Handle different QoS levels
344
267
  case qos
345
- when 0
268
+ when 0 # At most once
346
269
  send_packet(packet)
347
- when 1
270
+ when 1 # At least once
348
271
  queue = wait_for_puback(packet.id)
349
272
  send_packet(packet)
350
273
  wait_for_ack(queue, packet.id, @pubacks)
351
- when 2
274
+ when 2 # Exactly once
352
275
  queue = wait_for_pubrec(packet.id)
353
276
  send_packet(packet)
354
277
 
355
278
  if wait_for_ack(queue, packet.id, @pubrecs)
356
- # Send PUBREL
279
+ # Send PUBREL and wait for PUBCOMP
357
280
  pubrel = MQTT::Packet::Pubrel.new(:id => packet.id)
358
281
  queue = wait_for_pubcomp(packet.id)
359
282
  send_packet(pubrel)
@@ -362,37 +285,42 @@ module MQTT
362
285
  end
363
286
  end
364
287
 
365
- # Send a subscribe message for one or more topics on the MQTT server.
366
- # The topics parameter should be one of the following:
367
- # * String: subscribe to one topic with QoS 0
368
- # * Array: subscribe to multiple topics with QoS 0
369
- # * Hash: subscribe to multiple topics where the key is the topic and the value is the QoS level
370
- #
371
- # For example:
372
- # client.subscribe( 'a/b' )
373
- # client.subscribe( 'a/b', 'c/d' )
374
- # client.subscribe( ['a/b',0], ['c/d',1] )
375
- # client.subscribe( 'a/b' => 0, 'c/d' => 1 )
376
- #
288
+ # Subscribe to one or more topics
377
289
  def subscribe(*topics)
378
290
  packet = MQTT::Packet::Subscribe.new(
379
291
  :id => next_packet_id,
380
292
  :topics => topics
381
293
  )
294
+
295
+ # Track subscriptions for reconnection
296
+ @subscriptions_mutex.synchronize do
297
+ topics.each do |topic|
298
+ if topic.is_a?(Hash)
299
+ topic.each { |t, qos| @subscriptions[t] = qos }
300
+ else
301
+ @subscriptions[topic] = 0 # Default QoS 0
302
+ end
303
+ end
304
+ end
305
+
382
306
  send_packet(packet)
383
307
  end
384
308
 
385
- # Return the next message received from the MQTT server.
386
- # An optional topic can be given to subscribe to.
387
- #
388
- # The method either returns the topic and message as an array:
389
- # topic,message = client.get
390
- #
391
- # Or can be used with a block to keep processing messages:
392
- # client.get('test') do |topic,payload|
393
- # # Do stuff here
394
- # end
395
- #
309
+ # Resubscribe to all active topics (used after reconnection)
310
+ def resubscribe_all
311
+ @subscriptions_mutex.synchronize do
312
+ @subscriptions.each_slice(20) do |slice|
313
+ subscribe_hash = slice.to_h
314
+ packet = MQTT::Packet::Subscribe.new(
315
+ :id => next_packet_id,
316
+ :topics => subscribe_hash
317
+ )
318
+ send_packet(packet)
319
+ end
320
+ end
321
+ end
322
+
323
+ # Get next message with optional topic subscription
396
324
  def get(topic = nil, options = {})
397
325
  if block_given?
398
326
  get_packet(topic) do |packet|
@@ -400,39 +328,25 @@ module MQTT
400
328
  end
401
329
  else
402
330
  loop do
403
- # Wait for one packet to be available
404
331
  packet = get_packet(topic)
405
332
  return packet.topic, packet.payload unless packet.retain && options[:omit_retained]
406
333
  end
407
334
  end
408
335
  end
409
336
 
410
- # Return the next packet object received from the MQTT server.
411
- # An optional topic can be given to subscribe to.
412
- #
413
- # The method either returns a single packet:
414
- # packet = client.get_packet
415
- # puts packet.topic
416
- #
417
- # Or can be used with a block to keep processing messages:
418
- # client.get_packet('test') do |packet|
419
- # # Do stuff here
420
- # puts packet.topic
421
- # end
422
- #
337
+ # Get next packet with optional topic subscription
423
338
  def get_packet(topic = nil)
424
- # Subscribe to a topic, if an argument is given
425
339
  subscribe(topic) unless topic.nil?
426
340
 
427
341
  if block_given?
428
- # Loop forever!
429
342
  loop do
430
343
  packet = @read_queue.pop
431
344
  yield(packet)
345
+ # Handle QoS acknowledgments
432
346
  case packet.qos
433
- when 1
347
+ when 1 # At least once
434
348
  puback_packet(packet)
435
- when 2
349
+ when 2 # Exactly once
436
350
  pubrec = MQTT::Packet::Pubrec.new(:id => packet.id)
437
351
  send_packet(pubrec)
438
352
  # Wait for PUBREL before delivering
@@ -445,7 +359,6 @@ module MQTT
445
359
  end
446
360
  end
447
361
  else
448
- # Wait for one packet to be available
449
362
  packet = @read_queue.pop
450
363
  case packet.qos
451
364
  when 1
@@ -453,10 +366,8 @@ module MQTT
453
366
  when 2
454
367
  pubrec = MQTT::Packet::Pubrec.new(:id => packet.id)
455
368
  send_packet(pubrec)
456
- # Wait for PUBREL before delivering
457
369
  queue = wait_for_pubrel(packet.id)
458
370
  if wait_for_ack(queue, packet.id, @pubrels)
459
- # Send PUBCOMP
460
371
  pubcomp = MQTT::Packet::Pubcomp.new(:id => packet.id)
461
372
  send_packet(pubcomp)
462
373
  end
@@ -465,24 +376,29 @@ module MQTT
465
376
  end
466
377
  end
467
378
 
468
- # Returns true if the incoming message queue is empty.
379
+ # Queue management methods
469
380
  def queue_empty?
470
381
  @read_queue.empty?
471
382
  end
472
383
 
473
- # Returns the length of the incoming message queue.
474
384
  def queue_length
475
385
  @read_queue.length
476
386
  end
477
387
 
478
- # Clear the incoming message queue.
479
388
  def clear_queue
480
389
  @read_queue.clear
481
390
  end
482
391
 
483
- # Send a unsubscribe message for one or more topics on the MQTT server
392
+ # Unsubscribe from topics
484
393
  def unsubscribe(*topics)
485
- topics = topics.first if topics.is_a?(Enumerable) && topics.count == 1
394
+ # Convert single topic string to array
395
+ topics = [topics].flatten
396
+
397
+ @subscriptions_mutex.synchronize do
398
+ topics.each do |topic|
399
+ @subscriptions.delete(topic)
400
+ end
401
+ end
486
402
 
487
403
  packet = MQTT::Packet::Unsubscribe.new(
488
404
  :topics => topics,
@@ -491,32 +407,7 @@ module MQTT
491
407
  send_packet(packet)
492
408
  end
493
409
 
494
- private
495
-
496
- # Try to read a packet from the server
497
- # Also sends keep-alive ping packets.
498
- def receive_packet
499
- # Poll socket - is there data waiting?
500
-
501
- return unless @socket && !@socket.closed? && @socket.respond_to?(:fileno)
502
- result = IO.select([@socket], [], [], SELECT_TIMEOUT)
503
- handle_timeouts
504
- unless result.nil?
505
- # Yes - read in the packet
506
- packet = MQTT::Packet.read(@socket)
507
- handle_packet packet
508
- end
509
- keep_alive!
510
- # Pass exceptions up to parent thread
511
- rescue ::Exception => exp
512
- unless @socket.nil?
513
- @socket.close
514
- @socket = nil
515
- handle_close
516
- end
517
- Thread.current[:parent].raise(exp)
518
- end
519
-
410
+ # QoS acknowledgment handling
520
411
  def wait_for_puback(id)
521
412
  @pubacks_semaphore.synchronize do
522
413
  @pubacks[id] = Queue.new
@@ -574,43 +465,116 @@ module MQTT
574
465
  end
575
466
  end
576
467
 
468
+ private
469
+
470
+ # Read packets from server with automatic reconnection
471
+ def receive_packet
472
+ return unless @socket && !@socket.closed? && @socket.respond_to?(:fileno)
473
+
474
+ result = IO.select([@socket], [], [], SELECT_TIMEOUT)
475
+ handle_timeouts
476
+
477
+ unless result.nil?
478
+ packet = MQTT::Packet.read(@socket)
479
+ handle_packet packet
480
+ end
481
+ keep_alive!
482
+ rescue MQTT::ProtocolException, Errno::ECONNRESET, MQTT::NotConnectedException, IOError => e
483
+ handle_connection_error(e)
484
+ rescue Exception => exp
485
+ unless @socket.nil?
486
+ @socket.close
487
+ @socket = nil
488
+ handle_close
489
+ end
490
+ Thread.current[:parent].raise(exp)
491
+ end
492
+
493
+ def handle_connection_error(error)
494
+ retry_count = 0
495
+
496
+ while retry_count < @max_retries
497
+ begin
498
+ retry_count += 1
499
+
500
+ @logger.warn("Reconnecting to MQTT server: #{@host}:#{@port} (attempt #{retry_count})")
501
+
502
+ @socket.close unless @socket.nil? || @socket.closed?
503
+ @socket = nil
504
+
505
+ # Reconnect
506
+ tcp_socket = open_tcp_socket
507
+ @socket = @ssl ? setup_ssl_connection(tcp_socket) : tcp_socket
508
+ establish_mqtt_connection
509
+
510
+ # Reset ping timers after successful reconnection
511
+ @last_ping_request = current_time
512
+ @last_ping_response = current_time
513
+
514
+ return
515
+ rescue StandardError => _e
516
+ delay = if retry_count < 15
517
+ @retry_interval * retry_count
518
+ else
519
+ @retry_interval * retry_count * 10
520
+ end
521
+
522
+ sleep(delay)
523
+ end
524
+ end
525
+
526
+ handle_close
527
+ Thread.current[:parent].raise(error)
528
+ end
529
+
530
+ def keep_alive!
531
+ return unless @keep_alive > 0 && connected?
532
+
533
+ response_timeout = (@keep_alive * 1.5).ceil
534
+ if current_time >= @last_ping_request + @keep_alive
535
+ packet = MQTT::Packet::Pingreq.new
536
+ send_packet(packet)
537
+ @last_ping_request = current_time
538
+ elsif current_time > @last_ping_response + response_timeout
539
+ raise MQTT::ProtocolException, "No Ping Response received for #{response_timeout} seconds"
540
+ end
541
+ end
542
+
577
543
  def handle_packet(packet)
578
544
  case packet
579
545
  when MQTT::Packet::Publish
580
- if packet.qos == 2
581
- # Send PUBREC
582
- pubrec = MQTT::Packet::Pubrec.new(:id => packet.id)
583
- send_packet(pubrec)
584
-
585
- # Store the packet for later delivery
586
- @read_queue.push(packet)
587
- else
588
- # QoS 0 or 1
589
- @read_queue.push(packet)
590
- # Send PUBACK for QoS 1
591
- puback_packet(packet) if packet.qos == 1
592
- end
593
- when MQTT::Packet::Puback
594
- @pubacks_semaphore.synchronize do
595
- @pubacks[packet.id] << packet if @pubacks[packet.id]
596
- end
597
- when MQTT::Packet::Pubrec
598
- @pubacks_semaphore.synchronize do
599
- @pubrecs[packet.id] << packet if @pubrecs[packet.id]
600
- end
601
- when MQTT::Packet::Pubrel
602
- @pubacks_semaphore.synchronize do
603
- @pubrels[packet.id] << packet if @pubrels[packet.id]
604
- end
605
- when MQTT::Packet::Pubcomp
606
- @pubacks_semaphore.synchronize do
607
- @pubcomps[packet.id] << packet if @pubcomps[packet.id]
608
- end
546
+ handle_publish_packet(packet)
547
+ when MQTT::Packet::Puback, MQTT::Packet::Pubrec,
548
+ MQTT::Packet::Pubrel, MQTT::Packet::Pubcomp
549
+ handle_acknowledgment_packet(packet)
609
550
  when MQTT::Packet::Pingresp
610
551
  @last_ping_response = current_time
611
552
  end
612
553
  end
613
554
 
555
+ def handle_publish_packet(packet)
556
+ if packet.qos == 2
557
+ pubrec = MQTT::Packet::Pubrec.new(:id => packet.id)
558
+ send_packet(pubrec)
559
+ @read_queue.push(packet)
560
+ else
561
+ @read_queue.push(packet)
562
+ puback_packet(packet) if packet.qos == 1
563
+ end
564
+ end
565
+
566
+ def handle_acknowledgment_packet(packet)
567
+ @pubacks_semaphore.synchronize do
568
+ queue = case packet
569
+ when MQTT::Packet::Puback then @pubacks[packet.id]
570
+ when MQTT::Packet::Pubrec then @pubrecs[packet.id]
571
+ when MQTT::Packet::Pubrel then @pubrels[packet.id]
572
+ when MQTT::Packet::Pubcomp then @pubcomps[packet.id]
573
+ end
574
+ queue&.<< packet
575
+ end
576
+ end
577
+
614
578
  def handle_timeouts
615
579
  @pubacks_semaphore.synchronize do
616
580
  @pubacks.each_value { |q| q << :read_timeout }
@@ -640,19 +604,6 @@ module MQTT
640
604
  end
641
605
  end
642
606
 
643
- def keep_alive!
644
- return unless @keep_alive > 0 && connected?
645
-
646
- response_timeout = (@keep_alive * 1.5).ceil
647
- if current_time >= @last_ping_request + @keep_alive
648
- packet = MQTT::Packet::Pingreq.new
649
- send_packet(packet)
650
- @last_ping_request = current_time
651
- elsif current_time > @last_ping_response + response_timeout
652
- raise MQTT::ProtocolException, "No Ping Response received for #{response_timeout} seconds"
653
- end
654
- end
655
-
656
607
  def puback_packet(packet)
657
608
  send_packet(MQTT::Packet::Puback.new(:id => packet.id))
658
609
  end
@@ -729,27 +680,33 @@ module MQTT
729
680
  end
730
681
  end
731
682
 
732
- # ---- Deprecated attributes and methods ---- #
733
- public
734
-
735
- # @deprecated Please use {#host} instead
736
- def remote_host
737
- host
738
- end
739
-
740
- # @deprecated Please use {#host=} instead
741
- def remote_host=(args)
742
- self.host = args
743
- end
744
-
745
- # @deprecated Please use {#port} instead
746
- def remote_port
747
- port
748
- end
683
+ def setup_ssl_connection(tcp_socket)
684
+ ssl_context.ssl_version = @ssl if @ssl.is_a?(Symbol)
685
+ socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
686
+ socket.sync_close = true
687
+ socket.hostname = @host if socket.respond_to?(:hostname=)
688
+ socket.connect
689
+ socket.post_connection_check(@host) if @verify_host
690
+ socket
691
+ end
692
+
693
+ def establish_mqtt_connection
694
+ packet = MQTT::Packet::Connect.new(
695
+ :version => @version,
696
+ :clean_session => @clean_session,
697
+ :keep_alive => @keep_alive,
698
+ :client_id => @client_id,
699
+ :username => @username,
700
+ :password => @password,
701
+ :will_topic => @will_topic,
702
+ :will_payload => @will_payload,
703
+ :will_qos => @will_qos,
704
+ :will_retain => @will_retain
705
+ )
749
706
 
750
- # @deprecated Please use {#port=} instead
751
- def remote_port=(args)
752
- self.port = args
707
+ send_packet(packet)
708
+ receive_connack
709
+ resubscribe_all
753
710
  end
754
711
  end
755
712
  end
data/lib/mqtt/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  module MQTT
2
2
  # The version number of the MQTT gem
3
- VERSION = '0.0.1'
3
+ VERSION = '0.0.2'
4
4
  end
@@ -288,26 +288,6 @@ describe MQTT::Client do
288
288
  end
289
289
  end
290
290
 
291
- describe "deprecated attributes" do
292
- it "should allow getting and setting the host name using the remote_host method" do
293
- client.remote_host = 'remote-host.example.com'
294
- expect(client.host).to eq('remote-host.example.com')
295
- expect(client.remote_host).to eq('remote-host.example.com')
296
- client.host = 'foo.example.org'
297
- expect(client.host).to eq('foo.example.org')
298
- expect(client.remote_host).to eq('foo.example.org')
299
- end
300
-
301
- it "should allow getting and setting the port using the remote_port method" do
302
- client.remote_port = 9999
303
- expect(client.port).to eq(9999)
304
- expect(client.remote_port).to eq(9999)
305
- client.port = 1234
306
- expect(client.port).to eq(1234)
307
- expect(client.remote_port).to eq(1234)
308
- end
309
- end
310
-
311
291
  describe "when calling the 'connect' method on a client" do
312
292
  before(:each) do
313
293
  allow(TCPSocket).to receive(:new).and_return(socket)
@@ -321,6 +301,8 @@ describe MQTT::Client do
321
301
  end
322
302
 
323
303
  it "should not create a new TCP Socket if connected" do
304
+ allow(socket).to receive(:closed?).and_return(false)
305
+ client.instance_variable_set('@socket', socket)
324
306
  allow(client).to receive(:connected?).and_return(true)
325
307
  expect(TCPSocket).to receive(:new).never
326
308
  client.connect('myclient')
@@ -964,6 +946,9 @@ describe MQTT::Client do
964
946
  describe "when calling the 'unsubscribe' method" do
965
947
  before(:each) do
966
948
  client.instance_variable_set('@socket', socket)
949
+ # Initialize the subscriptions hash
950
+ client.instance_variable_set('@subscriptions', {})
951
+ client.instance_variable_set('@subscriptions_mutex', Mutex.new)
967
952
  end
968
953
 
969
954
  it "should write a valid UNSUBSCRIBE packet to the socket if given a single topic String" do
@@ -982,63 +967,6 @@ describe MQTT::Client do
982
967
  end
983
968
  end
984
969
 
985
- describe "when calling the 'receive_packet' method" do
986
- before(:each) do
987
- client.instance_variable_set('@socket', socket)
988
- allow(IO).to receive(:select).and_return([[socket], [], []])
989
- @read_queue = client.instance_variable_get('@read_queue')
990
- @parent_thread = Thread.current[:parent] = double('Parent Thread')
991
- allow(@parent_thread).to receive(:raise)
992
- end
993
-
994
- it "should put PUBLISH messages on to the read queue" do
995
- socket.write("\x30\x0e\x00\x05topicpayload")
996
- socket.rewind
997
- client.send(:receive_packet)
998
- expect(@read_queue.size).to eq(1)
999
- end
1000
-
1001
- it "should not put other messages on to the read queue" do
1002
- socket.write("\x20\x02\x00\x00")
1003
- socket.rewind
1004
- client.send(:receive_packet)
1005
- expect(@read_queue.size).to eq(0)
1006
- end
1007
-
1008
- it "should close the socket if there is an MQTT exception" do
1009
- expect(socket).to receive(:close).once
1010
- allow(MQTT::Packet).to receive(:read).and_raise(MQTT::Exception)
1011
- client.send(:receive_packet)
1012
- end
1013
-
1014
- it "should close the socket if there is a system call error" do
1015
- expect(socket).to receive(:close).once
1016
- allow(MQTT::Packet).to receive(:read).and_raise(Errno::ECONNRESET)
1017
- client.send(:receive_packet)
1018
- end
1019
-
1020
- it "should pass exceptions up to parent thread" do
1021
- e = MQTT::Exception.new
1022
- expect(@parent_thread).to receive(:raise).with(e).once
1023
- allow(MQTT::Packet).to receive(:read).and_raise(e)
1024
- client.send(:receive_packet)
1025
- end
1026
-
1027
- it "should pass a system call error up to parent thread" do
1028
- e = Errno::ECONNRESET.new
1029
- expect(@parent_thread).to receive(:raise).with(e).once
1030
- allow(MQTT::Packet).to receive(:read).and_raise(e)
1031
- client.send(:receive_packet)
1032
- end
1033
-
1034
- it "should update last_ping_response when receiving a Pingresp" do
1035
- allow(MQTT::Packet).to receive(:read).and_return MQTT::Packet::Pingresp.new
1036
- client.instance_variable_set '@last_ping_response', Time.at(0)
1037
- client.send :receive_packet
1038
- expect(client.last_ping_response).to be_within(1).of now
1039
- end
1040
- end
1041
-
1042
970
  describe "when calling the 'keep_alive!' method" do
1043
971
  before(:each) do
1044
972
  client.instance_variable_set('@socket', socket)
@@ -159,7 +159,22 @@ describe "a client talking to a server" do
159
159
  @server.respond_to_pings = false
160
160
  @client.keep_alive = keep_alive
161
161
  @client.connect
162
- sleep(keep_alive * 3)
162
+
163
+ # Wait for the first ping to be sent
164
+ start_time = Time.now
165
+ while Time.now - start_time < keep_alive + 1
166
+ sleep 0.1
167
+ break if @server.pings_received > 0
168
+ end
169
+
170
+ # Ensure we received at least one ping
171
+ expect(@server.pings_received).to be >= 1
172
+
173
+ # Wait for the response timeout
174
+ sleep((keep_alive * 1.5).ceil + 1)
175
+
176
+ # Force a keep_alive check
177
+ @client.send(:keep_alive!)
163
178
  end
164
179
 
165
180
  context "when keep-alive=1" do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: go-mqtt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicholas J Humfrey
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2025-05-21 00:00:00.000000000 Z
12
+ date: 2025-05-22 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: bundler