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 +4 -4
- data/lib/mqtt/client.rb +281 -324
- data/lib/mqtt/version.rb +1 -1
- data/spec/mqtt_client_spec.rb +5 -77
- data/spec/zz_client_integration_spec.rb +16 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4af5bac1776bc58c17da97d092f8443f6de49ec78cb6e1674f413c8c5e8cdf43
|
4
|
+
data.tar.gz: 4131b5ef45dc34c890a5827d3e263a2703bcdad73acfaa5ba0f42da9e977be22
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
#
|
9
|
-
attr_accessor :host
|
10
|
-
|
11
|
-
#
|
12
|
-
attr_accessor :
|
13
|
-
|
14
|
-
#
|
15
|
-
attr_accessor :
|
16
|
-
|
17
|
-
#
|
18
|
-
#
|
19
|
-
#
|
20
|
-
#
|
21
|
-
#
|
22
|
-
#
|
23
|
-
#
|
24
|
-
#
|
25
|
-
attr_accessor :
|
26
|
-
|
27
|
-
#
|
28
|
-
attr_accessor :
|
29
|
-
|
30
|
-
#
|
31
|
-
attr_accessor :
|
32
|
-
|
33
|
-
#
|
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 =
|
40
|
+
SELECT_TIMEOUT = 1
|
68
41
|
|
69
42
|
# Default attribute values
|
70
43
|
ATTR_DEFAULTS = {
|
71
|
-
:
|
72
|
-
:
|
73
|
-
:
|
74
|
-
:
|
75
|
-
:
|
76
|
-
:
|
77
|
-
:
|
78
|
-
:
|
79
|
-
:
|
80
|
-
:
|
81
|
-
:
|
82
|
-
:
|
83
|
-
:
|
84
|
-
:
|
85
|
-
:
|
86
|
-
:
|
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
|
-
|
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
|
-
#
|
119
|
-
#
|
120
|
-
#
|
121
|
-
# -
|
122
|
-
# -
|
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
|
-
#
|
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
|
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
|
-
#
|
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 = {}
|
182
|
-
@pubrels = {}
|
183
|
-
@pubcomps = {}
|
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
|
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
|
-
#
|
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
|
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
|
-
|
249
|
-
|
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
|
-
|
260
|
-
|
201
|
+
# Establish network connection
|
202
|
+
tcp_socket = open_tcp_socket
|
261
203
|
|
262
|
-
|
204
|
+
@socket = @ssl ? setup_ssl_connection(tcp_socket) : tcp_socket
|
205
|
+
establish_mqtt_connection
|
263
206
|
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
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
|
-
#
|
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
|
-
|
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
|
-
|
318
|
-
|
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
|
-
#
|
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
|
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
|
-
#
|
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
|
-
#
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
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
|
-
#
|
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
|
-
#
|
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
|
-
#
|
392
|
+
# Unsubscribe from topics
|
484
393
|
def unsubscribe(*topics)
|
485
|
-
|
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
|
-
|
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
|
-
|
581
|
-
|
582
|
-
|
583
|
-
|
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
|
-
|
733
|
-
|
734
|
-
|
735
|
-
|
736
|
-
|
737
|
-
|
738
|
-
|
739
|
-
|
740
|
-
|
741
|
-
|
742
|
-
|
743
|
-
|
744
|
-
|
745
|
-
|
746
|
-
|
747
|
-
|
748
|
-
|
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
|
-
|
751
|
-
|
752
|
-
|
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
data/spec/mqtt_client_spec.rb
CHANGED
@@ -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
|
-
|
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.
|
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-
|
12
|
+
date: 2025-05-22 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: bundler
|