mqtt-core 0.0.1.ci.release
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/lib/mqtt/core/client/acknowledgement.rb +31 -0
- data/lib/mqtt/core/client/client_id_generator.rb +19 -0
- data/lib/mqtt/core/client/connection.rb +193 -0
- data/lib/mqtt/core/client/enumerable_subscription.rb +224 -0
- data/lib/mqtt/core/client/filesystem_session_store.rb +197 -0
- data/lib/mqtt/core/client/memory_session_store.rb +84 -0
- data/lib/mqtt/core/client/qos0_session_store.rb +56 -0
- data/lib/mqtt/core/client/qos2_session_store.rb +45 -0
- data/lib/mqtt/core/client/qos_tracker.rb +119 -0
- data/lib/mqtt/core/client/retry_strategy.rb +63 -0
- data/lib/mqtt/core/client/session.rb +195 -0
- data/lib/mqtt/core/client/session_store.rb +128 -0
- data/lib/mqtt/core/client/socket_factory.rb +268 -0
- data/lib/mqtt/core/client/subscription.rb +109 -0
- data/lib/mqtt/core/client/uri.rb +77 -0
- data/lib/mqtt/core/client.rb +700 -0
- data/lib/mqtt/core/packet/connect.rb +39 -0
- data/lib/mqtt/core/packet/publish.rb +45 -0
- data/lib/mqtt/core/packet/subscribe.rb +190 -0
- data/lib/mqtt/core/packet/unsubscribe.rb +21 -0
- data/lib/mqtt/core/packet.rb +168 -0
- data/lib/mqtt/core/type/binary.rb +35 -0
- data/lib/mqtt/core/type/bit_flags.rb +89 -0
- data/lib/mqtt/core/type/boolean_byte.rb +48 -0
- data/lib/mqtt/core/type/fixed_int.rb +43 -0
- data/lib/mqtt/core/type/list.rb +41 -0
- data/lib/mqtt/core/type/password.rb +36 -0
- data/lib/mqtt/core/type/properties.rb +124 -0
- data/lib/mqtt/core/type/reason_codes.rb +60 -0
- data/lib/mqtt/core/type/remaining.rb +30 -0
- data/lib/mqtt/core/type/shape.rb +177 -0
- data/lib/mqtt/core/type/sub_type.rb +34 -0
- data/lib/mqtt/core/type/utf8_string.rb +39 -0
- data/lib/mqtt/core/type/utf8_string_pair.rb +29 -0
- data/lib/mqtt/core/type/var_int.rb +56 -0
- data/lib/mqtt/core/version.rb +10 -0
- data/lib/mqtt/core.rb +5 -0
- data/lib/mqtt/errors.rb +39 -0
- data/lib/mqtt/logger.rb +92 -0
- data/lib/mqtt/open.rb +239 -0
- data/lib/mqtt/options.rb +59 -0
- data/lib/mqtt/version.rb +6 -0
- data/lib/mqtt.rb +3 -0
- data/lib/patches/openssl.rb +19 -0
- metadata +98 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'client/client_id_generator'
|
|
4
|
+
require_relative 'client/enumerable_subscription'
|
|
5
|
+
require_relative 'client/socket_factory'
|
|
6
|
+
require_relative 'client/connection'
|
|
7
|
+
require_relative 'client/session'
|
|
8
|
+
require_relative 'client/acknowledgement'
|
|
9
|
+
require_relative 'client/retry_strategy'
|
|
10
|
+
require_relative '../logger'
|
|
11
|
+
require_relative '../errors'
|
|
12
|
+
require 'forwardable'
|
|
13
|
+
require 'concurrent_monitor'
|
|
14
|
+
|
|
15
|
+
module MQTT
|
|
16
|
+
module Core
|
|
17
|
+
# rubocop:disable Metrics/ClassLength
|
|
18
|
+
|
|
19
|
+
# MQTT Client
|
|
20
|
+
#
|
|
21
|
+
# @note In this documentation, "thread" refers to a {ConcurrentMonitor::Task}, which may be implemented
|
|
22
|
+
# as either a Thread (default) or a Fiber (when using async variants).
|
|
23
|
+
#
|
|
24
|
+
# @abstract use a specific MQTT protocol version subclass.
|
|
25
|
+
# @see MQTT.open
|
|
26
|
+
class Client
|
|
27
|
+
extend ClientIdGenerator
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Open an MQTT client connection
|
|
31
|
+
#
|
|
32
|
+
# If a block is provided the client is yielded to the block and disconnection is ensured, otherwise
|
|
33
|
+
# the client is returned directly.
|
|
34
|
+
# @overload open(*io_args, session_store:, **configure)
|
|
35
|
+
# @param [Array] io_args see {SocketFactory}
|
|
36
|
+
# @param [SessionStore] session_store MQTT session store for unacknowledged QOS1/QOS2 packets
|
|
37
|
+
# see {memory_store} or {file_store}
|
|
38
|
+
# @param [Hash] configure data for {#configure}
|
|
39
|
+
# @return [Client] (if a block is not given)
|
|
40
|
+
# @yield [client]
|
|
41
|
+
# @yieldparam [Client] client
|
|
42
|
+
# @yieldreturn [void]
|
|
43
|
+
# @see MQTT.open
|
|
44
|
+
def open(*io_args, monitor: mqtt_monitor, session_store: memory_store, **configure)
|
|
45
|
+
socket_factory = SocketFactory.create(*io_args, options: configure)
|
|
46
|
+
new_opts = { socket_factory:, monitor:, session_store:, **new_options(configure) }
|
|
47
|
+
client = new(**new_opts).tap { |c| c.configure(**configure) }
|
|
48
|
+
|
|
49
|
+
return client unless block_given?
|
|
50
|
+
|
|
51
|
+
client.sync do
|
|
52
|
+
yield client
|
|
53
|
+
client.disconnect
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
client.disconnect(cause: e)
|
|
56
|
+
raise
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @!group Configuration Factories
|
|
61
|
+
|
|
62
|
+
# A session store that only allows messages with QoS 0
|
|
63
|
+
# @return [Qos0SessionStore]
|
|
64
|
+
def qos0_store(...)
|
|
65
|
+
require_relative 'client/qos0_session_store'
|
|
66
|
+
Qos0SessionStore.new(...)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# An in-memory session store. Can recover from network interruptions but not a crash of the client.
|
|
70
|
+
# @return [MemorySessionStore]
|
|
71
|
+
def memory_store(...)
|
|
72
|
+
require_relative 'client/memory_session_store'
|
|
73
|
+
MemorySessionStore.new(...)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A filesystem based session store. Can recover safely from network interruptions and process restarts
|
|
77
|
+
# @return [FilesystemSessionStore]
|
|
78
|
+
def file_store(...)
|
|
79
|
+
require_relative 'client/filesystem_session_store'
|
|
80
|
+
FilesystemSessionStore.new(...)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Construct a retry strategy for automatic reconnection
|
|
84
|
+
# @return [RetryStrategy]
|
|
85
|
+
def retry_strategy(...)
|
|
86
|
+
RetryStrategy.new(...)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @!endgroup
|
|
90
|
+
|
|
91
|
+
# @!visibility private
|
|
92
|
+
def thread_monitor = ConcurrentMonitor.thread_monitor
|
|
93
|
+
|
|
94
|
+
# @!visibility private
|
|
95
|
+
def async_monitor = ConcurrentMonitor.async_monitor
|
|
96
|
+
|
|
97
|
+
# @!visibility private
|
|
98
|
+
# the default concurrent_monitor uses Threads for concurrency
|
|
99
|
+
def mqtt_monitor = thread_monitor
|
|
100
|
+
|
|
101
|
+
# @!visibility private
|
|
102
|
+
def create_session(*io_args, **session_args)
|
|
103
|
+
self::Session.new(*io_args, **session_args)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @!visibility private
|
|
107
|
+
def create_connection(**connection_args)
|
|
108
|
+
self::Connection.new(**connection_args)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @!visibility private
|
|
112
|
+
# Extract and remove options for new - subclasses can override to add more
|
|
113
|
+
def new_options(_opts)
|
|
114
|
+
{}
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
extend Forwardable
|
|
119
|
+
include Logger
|
|
120
|
+
|
|
121
|
+
# @!visibility private
|
|
122
|
+
include ConcurrentMonitor
|
|
123
|
+
|
|
124
|
+
# @!attribute [r] uri
|
|
125
|
+
# @return [URI] universal resource indicator derived from io_args passed to {MQTT.open}
|
|
126
|
+
def_delegator :socket_factory, :uri
|
|
127
|
+
|
|
128
|
+
# @return [String] client_id and connection status
|
|
129
|
+
def to_s
|
|
130
|
+
"#{self.class.name}:#{uri} client_id=#{client_id}, status=#{status}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def_delegators :connection, :keep_alive, :connected?
|
|
134
|
+
|
|
135
|
+
def_delegators :session, :client_id, :expiry_interval, :max_qos
|
|
136
|
+
|
|
137
|
+
# @!visibility private
|
|
138
|
+
def initialize(socket_factory:, monitor:, session_store:)
|
|
139
|
+
@socket_factory = socket_factory
|
|
140
|
+
@session = self.class.create_session(client: self, monitor:, session_store:)
|
|
141
|
+
monitor_extended(monitor.new_monitor)
|
|
142
|
+
@status = :configure
|
|
143
|
+
@acks = {}
|
|
144
|
+
@subs = Set.new
|
|
145
|
+
@unsubs = Set.new
|
|
146
|
+
@run_args = {}
|
|
147
|
+
@events = {}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Configure client connection behaviour
|
|
151
|
+
# @param [Hash<Symbol>] connect options controlling connection and retry behaviour.
|
|
152
|
+
# Options not explicitly specified below are passed to the `CONNECT` packet
|
|
153
|
+
# @option connect [RetryStrategy,true] retry_strategy See {configure_retry}
|
|
154
|
+
# @raise [Error] if called after a connection has left the initial `:configure` state
|
|
155
|
+
# @return [self]
|
|
156
|
+
def configure(**connect)
|
|
157
|
+
synchronize { raise Error, "Can't configure with status: #{@status}" unless @status == :configure }
|
|
158
|
+
|
|
159
|
+
configure_retry(connect.delete(:retry_strategy)) if connect.key?(:retry_strategy)
|
|
160
|
+
|
|
161
|
+
@run_args.merge!(connect)
|
|
162
|
+
self
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Register a retry strategy as the disconnect handler
|
|
166
|
+
# @overload configure_retry(**retry_args)
|
|
167
|
+
# @param [Hash<Symbol>] retry_args sugar to construct a {RetryStrategy}
|
|
168
|
+
# @overload configure_retry(retry_strategy)
|
|
169
|
+
# @param [RetryStrategy|:retry!|Hash|Boolean] retry_strategy
|
|
170
|
+
# - true: default RetryStrategy
|
|
171
|
+
# - Hash: RetryStrategy.new(**hash)
|
|
172
|
+
# - RetryStrategy or has #retry!: use as-is
|
|
173
|
+
# - false/nil: no retries
|
|
174
|
+
# @return [self]
|
|
175
|
+
# @see on_disconnect
|
|
176
|
+
def configure_retry(*retry_strategy, **retry_args)
|
|
177
|
+
retry_strategy = retry_strategy.empty? ? RetryStrategy.new(**retry_args) : retry_strategy.first
|
|
178
|
+
retry_strategy = RetryStrategy.new if retry_strategy == true
|
|
179
|
+
retry_strategy = RetryStrategy.new(**retry_strategy) if retry_strategy.is_a?(Hash)
|
|
180
|
+
if retry_strategy
|
|
181
|
+
raise ArgumentError, "Invalid retry strategy: #{retry_strategy}" unless retry_strategy.respond_to?(:retry!)
|
|
182
|
+
|
|
183
|
+
on(:disconnect) { |retry_count, &raiser| retry_strategy.retry!(retry_count, &raiser) }
|
|
184
|
+
else
|
|
185
|
+
on(:disconnect) { |_c, &r| r&.call }
|
|
186
|
+
end
|
|
187
|
+
self
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# @!group Event Handlers
|
|
191
|
+
|
|
192
|
+
# @!method on_birth(&block)
|
|
193
|
+
# Birth Handler: Block is executed asynchronously after the first successful connection for a session.
|
|
194
|
+
#
|
|
195
|
+
# Typically used to establish {EnumerableSubscription#async} handlers that process received messages for the
|
|
196
|
+
# duration of a Session.
|
|
197
|
+
#
|
|
198
|
+
# @note Prior to the completion of this event, received QoS 1/2 messages are retained in memory and matched for
|
|
199
|
+
# delivery against new {#subscribe} requests.
|
|
200
|
+
# @yield
|
|
201
|
+
# @yieldreturn [void]
|
|
202
|
+
# @return [self]
|
|
203
|
+
|
|
204
|
+
# Connect Handler
|
|
205
|
+
# @!method on_connect(&block)
|
|
206
|
+
# Called each time the client is successfully (re)connected to a broker
|
|
207
|
+
# @yield [connect, connack]
|
|
208
|
+
# @yieldparam [Packet] connect the `CONNECT` packet sent by this client
|
|
209
|
+
# @yieldparam [Packet] connack the `CONNACK` packet received from the server
|
|
210
|
+
# @yieldreturn [void]
|
|
211
|
+
# @return [self]
|
|
212
|
+
|
|
213
|
+
# @!method on_disconnect(&block)
|
|
214
|
+
# Called each time the client is disconnected from a broker.
|
|
215
|
+
#
|
|
216
|
+
# The default handler calls raiser without rescuing any errors and thus prevents the client
|
|
217
|
+
# from reconnecting.
|
|
218
|
+
#
|
|
219
|
+
# {#configure_retry} can be used to register a {RetryStrategy} which rescues retriable protocol and networking
|
|
220
|
+
# errors and uses exponential backoff before allowing reconnection.
|
|
221
|
+
#
|
|
222
|
+
# Installing a custom handler that does not re-raise errors will cause the client to retry connections forever.
|
|
223
|
+
#
|
|
224
|
+
# @yield [retry_count, &raiser]
|
|
225
|
+
# @yieldparam [Integer] retry_count number of retry attempts since the last successful connection
|
|
226
|
+
# @yieldparam [Proc] raiser callable will raise the error (if any) that caused the connection to be closed,
|
|
227
|
+
# @yieldreturn [void]
|
|
228
|
+
# @return [self]
|
|
229
|
+
|
|
230
|
+
# @!method on_publish(&block)
|
|
231
|
+
# Called on {#publish}
|
|
232
|
+
# @yield [publish, ack]
|
|
233
|
+
# @yieldparam [Packet] publish the `PUBLISH` packet sent by this client
|
|
234
|
+
# @yieldparam [Packet|nil] ack qos 0: nil, qos 1: `PUBACK`, qos 2: `PUBCOMP`
|
|
235
|
+
# @yieldreturn [void]
|
|
236
|
+
# @return [self]
|
|
237
|
+
|
|
238
|
+
# @!method on_subscribe(&block)
|
|
239
|
+
# Called on {#subscribe}
|
|
240
|
+
# @yield [subscribe, suback]
|
|
241
|
+
# @yieldparam [Packet] subscribe the `SUBSCRIBE` packet sent by this client
|
|
242
|
+
# @yieldparam [Packet] suback the `SUBACK` packet received from the server
|
|
243
|
+
# @yieldreturn [void]
|
|
244
|
+
# @return [self]
|
|
245
|
+
|
|
246
|
+
# @!method on_unsubscribe(&block)
|
|
247
|
+
# Called on {#unsubscribe}
|
|
248
|
+
# @yield [unsubscribe, unsuback]
|
|
249
|
+
# @yieldparam [Packet] unsubscribe the `UNSUBSCRIBE` packet sent by this client
|
|
250
|
+
# @yieldparam [Packet] unsuback the `UNSUBACK` packet received from the server
|
|
251
|
+
# @yieldreturn [void]
|
|
252
|
+
# @return [self]
|
|
253
|
+
|
|
254
|
+
# @!method on_send(&block)
|
|
255
|
+
# Called before a packet is sent
|
|
256
|
+
# @yield [packet]
|
|
257
|
+
# @yieldparam [Packet] packet
|
|
258
|
+
# @yieldreturn [void]
|
|
259
|
+
# @return [self]
|
|
260
|
+
|
|
261
|
+
# @!method on_receive(&block)
|
|
262
|
+
# Called when a packet is received
|
|
263
|
+
# @yield [packet]
|
|
264
|
+
# @yieldparam [Packet] packet
|
|
265
|
+
# @yieldreturn [void]
|
|
266
|
+
# @return [self]
|
|
267
|
+
|
|
268
|
+
%i[birth connect disconnect publish subscribe unsubscribe send receive].each do |event|
|
|
269
|
+
define_method "on_#{event}" do |&block|
|
|
270
|
+
on(event, &block)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# @!endgroup
|
|
275
|
+
|
|
276
|
+
# @return [Symbol] the current status of the client
|
|
277
|
+
attr_reader :status
|
|
278
|
+
|
|
279
|
+
# Start the MQTT connection
|
|
280
|
+
# @param [Hash<Symbol>] connect additional options for the `CONNECT` packet.
|
|
281
|
+
# Client must still be in the initial `:configure` state to pass options
|
|
282
|
+
# @return [self]
|
|
283
|
+
def connect(**connect)
|
|
284
|
+
configure(**connect) unless connect.empty?
|
|
285
|
+
connection
|
|
286
|
+
self
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Disconnect cleanly and stop the client
|
|
290
|
+
#
|
|
291
|
+
# Once called, no further calls can be made on the client.
|
|
292
|
+
#
|
|
293
|
+
# @param [Exception|nil] cause Used to set error information in the `DISCONNECT` packet
|
|
294
|
+
# @param [Hash<Symbol>] disconnect Additional properties for the `DISCONNECT` packet
|
|
295
|
+
# @return [self] with state `:stopped`
|
|
296
|
+
def disconnect(cause: nil, **disconnect)
|
|
297
|
+
synchronize do
|
|
298
|
+
@status = :stopped if @status == :configure
|
|
299
|
+
@stopping ||= current_task
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# At this point only the original disconnect thread can use the connection
|
|
303
|
+
cleanup_connection(cause, **disconnect) if @stopping == current_task && @status != :stopped
|
|
304
|
+
@run&.wait
|
|
305
|
+
self
|
|
306
|
+
ensure
|
|
307
|
+
stop!
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# @overload publish(topic_name, payload, retain: false, qos: 0, timeout: 0, **publish)
|
|
311
|
+
# @param [String<UTF8>> topic_name UTF8 encoded topic name
|
|
312
|
+
# @param [String<Binary>] payload the message payload
|
|
313
|
+
# @param [Boolean] retain true if the message should be retained
|
|
314
|
+
# @param [Integer] qos the Quality of Service level (0, 1 or 2)
|
|
315
|
+
# @param [Hash<Symbol>] **publish additional properties for the `PUBLISH` packet (version-dependent)
|
|
316
|
+
# @return [self]
|
|
317
|
+
# @see on_publish
|
|
318
|
+
def publish(*pub_args, **publish)
|
|
319
|
+
topic_name, payload = pub_args
|
|
320
|
+
publish[:topic_name] = topic_name if topic_name
|
|
321
|
+
publish[:payload] = payload if payload
|
|
322
|
+
connection.publish(**publish) { |pub| send_and_wait(pub) { |ack| handle_ack(pub, ack) } }
|
|
323
|
+
self
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Subscribe to topics
|
|
327
|
+
#
|
|
328
|
+
# @overload subscribe(*topic_filters,**subscribe)
|
|
329
|
+
# Subscribe and return an {EnumerableSubscription} for enumeration.
|
|
330
|
+
#
|
|
331
|
+
# The returned {EnumerableSubscription} holds received and matching messages in an internal
|
|
332
|
+
# queue which can be enumerated over using {EnumerableSubscription#each} or {EnumerableSubscription#async}
|
|
333
|
+
#
|
|
334
|
+
# @param [Array<String<UTF8>|Hash>] topic_filters List of filter expressions. Each element can be
|
|
335
|
+
# a String or a Hash with `:topic_filter` and `:max_qos` keys.
|
|
336
|
+
# @param [Hash<Symbol>] **subscribe additional properties for the `SUBSCRIBE` packet (version-dependent)
|
|
337
|
+
# @option subscribe [Integer] max_qos default maximum QoS to request for each topic_filter
|
|
338
|
+
# @return [EnumerableSubscription]
|
|
339
|
+
# @raise [SubscriptionError] if the server rejects any topic filters
|
|
340
|
+
# @example Wait for and return the first message
|
|
341
|
+
# topic, message = client.subscribe('some/topic').first
|
|
342
|
+
# @example Using enumerator from {EnumerableSubscription#each}
|
|
343
|
+
# client.subscribe('some/topic').each { |topic,msg| process(topic,msg) or break }
|
|
344
|
+
# @example Enumerating in a new thread via {EnumerableSubscription#async}
|
|
345
|
+
# client.subscribe('some/topic#').async { |topic, msg| process(topic, msg) }
|
|
346
|
+
# @example With different QoS levels per topic filter
|
|
347
|
+
# client.subscribe('status/#', { topic_filter: 'data/#', max_qos: 2 }, max_qos: 1)
|
|
348
|
+
#
|
|
349
|
+
# @overload subscribe(*topic_filters, **subscribe, &handler)
|
|
350
|
+
# Subscribe with a block handler for direct packet processing
|
|
351
|
+
#
|
|
352
|
+
# @param [Array<String<UTF8>|Hash>] topic_filters List of filter expressions
|
|
353
|
+
# @param [Hash<Symbol>] **subscribe additional properties for the `SUBSCRIBE` packet (version-dependent)
|
|
354
|
+
# @option subscribe [Integer] max_qos default maximum QoS to request for each topic_filter
|
|
355
|
+
# @yield [packet] Block is called directly from the receive thread for each matching packet
|
|
356
|
+
# @yieldparam [Packet<PUBLISH>|nil] packet the received `PUBLISH` packet, or nil on disconnect
|
|
357
|
+
# @yieldreturn [void]
|
|
358
|
+
# @return [Subscription]
|
|
359
|
+
# @raise [SubscriptionError] if the server rejects any topic filters
|
|
360
|
+
#
|
|
361
|
+
# @note WARNING: This block is executed synchronously on the IO thread that is receiving packets.
|
|
362
|
+
# Any blocking operations will prevent other packets from being received, causing timeouts
|
|
363
|
+
# and eventual disconnection.
|
|
364
|
+
#
|
|
365
|
+
# @example Direct packet processing (use with caution)
|
|
366
|
+
# sub = client.subscribe('some/topic') { |pkt| puts pkt.payload if pkt }
|
|
367
|
+
# # ...
|
|
368
|
+
# sub.unsubscribe
|
|
369
|
+
#
|
|
370
|
+
# @see Subscription
|
|
371
|
+
# @see on_subscribe
|
|
372
|
+
def subscribe(*topic_filters, **subscribe, &handler)
|
|
373
|
+
handler ||= new_queue
|
|
374
|
+
topic_filters += subscribe.delete(:topic_filters) || []
|
|
375
|
+
connection.subscribe(topic_filters:, **subscribe) do |sub_pkt|
|
|
376
|
+
send_and_wait(sub_pkt) do |suback_pkt|
|
|
377
|
+
handle_ack(sub_pkt, suback_pkt)
|
|
378
|
+
new_subscription(sub_pkt, suback_pkt, handler).tap { |sub| qos_subscription(sub) }
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
rescue SubscribeError
|
|
382
|
+
unsubscribe(*topic_filters)
|
|
383
|
+
raise
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Safely unsubscribe inactive topic filters
|
|
387
|
+
#
|
|
388
|
+
# @param [Array<String>] topic_filters list of filters
|
|
389
|
+
# @param [Hash<Symbol>] unsubscribe additional properties for the `UNSUBSCRIBE` packet
|
|
390
|
+
# @return [self]
|
|
391
|
+
# @note Topic filters that are in use by active Subscriptions are removed from the `UNSUBSCRIBE` request.
|
|
392
|
+
# @see Subscription#unsubscribe
|
|
393
|
+
# @see #on_unsubscribe
|
|
394
|
+
def unsubscribe(*topic_filters, **unsubscribe)
|
|
395
|
+
topic_filters += unsubscribe.delete(:topic_filters) || []
|
|
396
|
+
|
|
397
|
+
synchronize do
|
|
398
|
+
topic_filters -= (@subs - @unsubs).flat_map(&:subscribed_topic_filters)
|
|
399
|
+
return [] unless topic_filters.any?
|
|
400
|
+
|
|
401
|
+
connection.unsubscribe(topic_filters:, **unsubscribe) do |unsub_pkt|
|
|
402
|
+
send_and_wait(unsub_pkt) { |unsuback_pkt| handle_ack(unsub_pkt, unsuback_pkt) }
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
self
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# @!visibility private
|
|
409
|
+
# Called by Subscription#unsubscribe.
|
|
410
|
+
def delete_subscription(subscription, **unsubscribe_params)
|
|
411
|
+
synchronize do
|
|
412
|
+
@unsubs.add(subscription)
|
|
413
|
+
unsubscribe(**unsubscribe_params).tap do
|
|
414
|
+
@unsubs.delete(subscription)
|
|
415
|
+
@subs.delete(subscription)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
subscription.put(nil)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# @!visibility private
|
|
422
|
+
# @return [Boolean] true if this subscription is active - will receive a final 'put'
|
|
423
|
+
def active_subscription?(subscription)
|
|
424
|
+
synchronize { @subs.include?(subscription) }
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# @!visibility private
|
|
428
|
+
# Called by self to enqueue packets from client threads,
|
|
429
|
+
# or by receive thread to enqueue packets related to protocol flow
|
|
430
|
+
def push_packet(*packets)
|
|
431
|
+
synchronize { send_queue.push(*packets) }
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# @!visibility private
|
|
435
|
+
# Called by: Connection's receive thread for received ACK type packets
|
|
436
|
+
def receive_ack(packet)
|
|
437
|
+
synchronize { acks.delete(packet.id)&.fulfill(packet) }
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# @!visibility private
|
|
441
|
+
# Called by: Connection receive loop for received `PUBLISH` packets
|
|
442
|
+
# @return [Array<Subscription>] matched subscriptions
|
|
443
|
+
def receive_publish(packet)
|
|
444
|
+
synchronize do
|
|
445
|
+
subs.select { |s| s.match?(packet) } # rubocop:disable Style/SelectByRegexp
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# @!visibility private
|
|
450
|
+
# Called by: {Connection} receive_loop when it reaches io end of stream
|
|
451
|
+
def receive_eof
|
|
452
|
+
push_packet(:eof)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# @!visibility private
|
|
456
|
+
def packet_module
|
|
457
|
+
self.class.packet_module
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# @!visibility private
|
|
461
|
+
def handle_event(event, *, **kw_args, &)
|
|
462
|
+
events[event]&.call(*, **kw_args, &)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# @!visibility private
|
|
466
|
+
def_delegators :packet_module, :build_packet, :deserialize
|
|
467
|
+
|
|
468
|
+
# @!visibility private
|
|
469
|
+
# session methods called via Subscription
|
|
470
|
+
def_delegators :session, :handled!
|
|
471
|
+
|
|
472
|
+
private
|
|
473
|
+
|
|
474
|
+
attr_reader :events, :session, :send_queue, :monitor, :socket_factory, :conn_cond, :subs, :acks, :conn_count
|
|
475
|
+
|
|
476
|
+
def monitor_extended(monitor)
|
|
477
|
+
@monitor = monitor
|
|
478
|
+
@send_queue = new_queue
|
|
479
|
+
@conn_cond = new_condition
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def on(event, &handler)
|
|
483
|
+
synchronize do
|
|
484
|
+
raise ArgumentError, 'Configuration must be called before first packet is sent' if events.frozen?
|
|
485
|
+
|
|
486
|
+
events[event] = handler
|
|
487
|
+
end
|
|
488
|
+
self
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# This is used for the API methods to obtain an active connection
|
|
492
|
+
# It starts the connect loops if necessary and blocks until a connection is available
|
|
493
|
+
def connection
|
|
494
|
+
synchronize do
|
|
495
|
+
run if @status == :configure
|
|
496
|
+
conn_cond.wait_while { @status == :disconnected }
|
|
497
|
+
raise ConnectionError, 'Stopped.', cause: @exception if @status == :stopped && @exception
|
|
498
|
+
raise ConnectionError, "Not connected. #{@status}" unless @status == :connected
|
|
499
|
+
raise ConnectionError, 'Disconnecting...' if @stopping && @stopping != current_task
|
|
500
|
+
|
|
501
|
+
@connection
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def new_connection
|
|
506
|
+
log.info { "Connecting to #{uri}" }
|
|
507
|
+
io = socket_factory.new_io
|
|
508
|
+
self.class.create_connection(client: self, session:, io:, monitor:)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def run
|
|
512
|
+
return unless @status == :configure
|
|
513
|
+
|
|
514
|
+
configure_defaults
|
|
515
|
+
@status = :disconnected
|
|
516
|
+
# don't allow any more on_ events.
|
|
517
|
+
events.freeze
|
|
518
|
+
@run ||= async(:run) { safe_run_task }
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def configure_defaults
|
|
522
|
+
configure_default_retry unless @events[:disconnect]
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def configure_default_retry
|
|
526
|
+
log.warn <<~WARNING if session.max_qos.positive?
|
|
527
|
+
No automatic retry strategy has been configured for this MQTT client.
|
|
528
|
+
|
|
529
|
+
This is not recommended for applications using QoS levels 1 or 2, as message
|
|
530
|
+
delivery guarantees may be compromised during network interruptions.
|
|
531
|
+
|
|
532
|
+
To suppress this warning use #configure_retry to explicitly configure or disable the retry strategy.
|
|
533
|
+
Alternatively use the #qos0_store to limit PUBLISH and SUBSCRIBE to QoS 0.
|
|
534
|
+
WARNING
|
|
535
|
+
configure_retry(false)
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def run_task(retry_count: 0)
|
|
539
|
+
# no point trying to connect if the session is already expired
|
|
540
|
+
session.expired!
|
|
541
|
+
run_connection(**@run_args) { retry_count = 0 }
|
|
542
|
+
disconnected!(0) { nil }
|
|
543
|
+
rescue StandardError => e
|
|
544
|
+
disconnected!(retry_count += 1) { raise e }
|
|
545
|
+
# If we are going to restart with a clean session existing acks and subs need to be cancelled.
|
|
546
|
+
retry unless synchronize do
|
|
547
|
+
@stopping.tap { |stopping| cancel_session('Restarting session') if !stopping && session.clean? }
|
|
548
|
+
end
|
|
549
|
+
ensure
|
|
550
|
+
stop!(e)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def safe_run_task
|
|
554
|
+
run_task
|
|
555
|
+
rescue StandardError => e
|
|
556
|
+
log.error(e)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def run_connection(**connect_data)
|
|
560
|
+
conn = establish_connection(**connect_data)
|
|
561
|
+
yield if block_given?
|
|
562
|
+
with_barrier do |b|
|
|
563
|
+
b.async(:send_loop) { send_loop(conn) }
|
|
564
|
+
b.async(:recv_loop) { receive_loop(conn) }
|
|
565
|
+
b.wait!
|
|
566
|
+
ensure
|
|
567
|
+
conn.close
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def establish_connection(**connect)
|
|
572
|
+
# Socket factory URI can contain username, password
|
|
573
|
+
connect.merge!(socket_factory.auth) if socket_factory.respond_to?(:auth)
|
|
574
|
+
|
|
575
|
+
new_connection.tap do |conn|
|
|
576
|
+
connect_packet, connack_packet = conn.connect(**connect)
|
|
577
|
+
synchronize { connected!(conn, connect_packet, connack_packet) }
|
|
578
|
+
birth! unless session.birth_complete?
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def birth!
|
|
583
|
+
async(:birth) do
|
|
584
|
+
handle_event(:birth)
|
|
585
|
+
session.birth_complete!
|
|
586
|
+
rescue ConnectionError => e
|
|
587
|
+
log.warn { "Ignoring ConnectionError in birth handler: #{e.class}: #{e.message}" }
|
|
588
|
+
rescue StandardError => e
|
|
589
|
+
log.error { "Unexpected error in birth handler: #{e.class}: #{e.message}. Disconnecting..." }
|
|
590
|
+
disconnect(cause: e)
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# @note synchronized - sub needs to be available to immediate receive publish
|
|
595
|
+
def new_subscription(sub_packet, ack_packet, handler)
|
|
596
|
+
# noinspection RubyArgCount
|
|
597
|
+
klass = handler.respond_to?(:call) ? Subscription : EnumerableSubscription
|
|
598
|
+
klass.new(sub_packet, ack_packet, handler || new_queue, self).tap { |sub| @subs.add(sub) }
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def qos_subscription(sub)
|
|
602
|
+
return unless sub.sub_packet.max_qos.positive?
|
|
603
|
+
|
|
604
|
+
# When re-establishing a subscription to a live session, there may be matching messages already received
|
|
605
|
+
session.qos_subscribed { |p| sub.match?(p) }.each { |p| sub.put(p) }
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def connected!(conn, connect_pkt, connack_pkt)
|
|
609
|
+
session.connected!(connect_pkt, connack_pkt)
|
|
610
|
+
handle_event(:connect, connect_pkt, connack_pkt)
|
|
611
|
+
send_queue.push(*session.retry_packets)
|
|
612
|
+
@connection = conn
|
|
613
|
+
@status = :connected
|
|
614
|
+
conn_cond.broadcast
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# @!visibility private
|
|
618
|
+
# @param [Connection] connection as yielded from {#run}
|
|
619
|
+
def send_loop(connection, keep_alive_factor: 0.7)
|
|
620
|
+
connection.send_loop { |keep_alive| next_packet(send_timeout(keep_alive, keep_alive_factor)) }
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# @!visibility private
|
|
624
|
+
# @param [Connection] connection as yielded from {#run}
|
|
625
|
+
def receive_loop(connection)
|
|
626
|
+
connection.receive_loop
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def send_timeout(keep_alive, factor)
|
|
630
|
+
return nil unless keep_alive&.positive?
|
|
631
|
+
|
|
632
|
+
keep_alive * factor
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def disconnected!(retry_count, &)
|
|
636
|
+
synchronize { @status = :disconnected if @status == :connected }
|
|
637
|
+
session.disconnected!
|
|
638
|
+
handle_event(:disconnect, retry_count, &)
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def next_packet(keep_alive_timeout = nil)
|
|
642
|
+
synchronize { send_queue.shift(keep_alive_timeout) }
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def handle_ack(pkt, ack)
|
|
646
|
+
handle_event(pkt.packet_name, pkt, ack)
|
|
647
|
+
pkt.success!(ack)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def send_and_wait(packet, &)
|
|
651
|
+
synchronize do
|
|
652
|
+
ack = acks[packet.id] = Acknowledgement.new(packet, monitor:, &) if packet.id
|
|
653
|
+
|
|
654
|
+
send_queue.push(packet)
|
|
655
|
+
|
|
656
|
+
ack&.value || yield(nil)
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def cleanup_connection(cause, **disconnect)
|
|
661
|
+
# wait for acks
|
|
662
|
+
acks.each_value(&:wait) if cause
|
|
663
|
+
|
|
664
|
+
connection.disconnect(cause, **disconnect) { |disconnect_packet| send_queue.push(disconnect_packet) }
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def stop!(exception = nil)
|
|
668
|
+
return if @status == :stopped
|
|
669
|
+
|
|
670
|
+
synchronize do
|
|
671
|
+
next if @status == :stopped
|
|
672
|
+
|
|
673
|
+
@exception = exception
|
|
674
|
+
@status = :stopped
|
|
675
|
+
cancel_session('Connection stopped')
|
|
676
|
+
conn_cond.broadcast
|
|
677
|
+
@run&.stop unless @run&.current?
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# called before retrying with a clean session or while stopping?
|
|
682
|
+
def cancel_session(msg)
|
|
683
|
+
cause = ConnectionError.new(msg)
|
|
684
|
+
cancel_acks(cause)
|
|
685
|
+
cancel_subs(cause)
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def cancel_subs(cause)
|
|
689
|
+
subs.each { |sub| sub.put(cause) }
|
|
690
|
+
subs.clear
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def cancel_acks(cause)
|
|
694
|
+
acks.each_value { |a| a.cancel(cause) }
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# rubocop:enable Metrics/ClassLength
|
|
699
|
+
end
|
|
700
|
+
end
|