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,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MQTT
|
|
4
|
+
module Core
|
|
5
|
+
module Packet
|
|
6
|
+
# Common processing of CONNECT packets between MQTT versions
|
|
7
|
+
module Connect
|
|
8
|
+
# @!visibility private
|
|
9
|
+
def apply_overrides(data)
|
|
10
|
+
super
|
|
11
|
+
data[:connect_flags] ||= {}
|
|
12
|
+
data[:connect_flags][:will_flag] = !(will_topic || '').empty?
|
|
13
|
+
data[:connect_flags][:username_flag] = !username.nil?
|
|
14
|
+
data[:connect_flags][:password_flag] = !password.nil?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @!visibility private
|
|
18
|
+
def validate
|
|
19
|
+
will_flag ? validate_will_set : validate_will_not_set
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @!visibility private
|
|
23
|
+
def validate_will_set
|
|
24
|
+
raise ArgumentError, 'Will (flag:true) topic must be set' if (will_topic || '').empty?
|
|
25
|
+
raise ArgumentError, 'Will (flag:true) topic must not contain wildcards' if will_topic.match?(/[#+]/)
|
|
26
|
+
raise ArgumentError, 'Will (flag:true) QoS must be 0, 1, or 2' unless (0..2).include?(will_qos)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @!visibility private
|
|
30
|
+
def validate_will_not_set
|
|
31
|
+
raise ArgumentError, 'Will (flag:false) topic must not be set' unless (will_topic || '').empty?
|
|
32
|
+
raise ArgumentError, 'Will (flag:false) payload must not be set' unless (will_payload || '').empty?
|
|
33
|
+
raise ArgumentError, 'Will (flag:false) QoS must be 0' unless (will_qos || 0).zero?
|
|
34
|
+
raise ArgumentError, 'Will (flag:false) retain must be false' if will_retain
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MQTT
|
|
4
|
+
module Core
|
|
5
|
+
module Packet
|
|
6
|
+
# Common processing of PUBLISH packets between MQTT versions
|
|
7
|
+
module Publish
|
|
8
|
+
# Deconstruct message for subscription enumeration
|
|
9
|
+
# @yield [topic, payload, attributes] optional block to yield deconstructed values
|
|
10
|
+
# @return [Array<String, String, Hash>] topic, payload, attributes when no block given
|
|
11
|
+
# @return [Object] block result when block given
|
|
12
|
+
def deconstruct_message(&)
|
|
13
|
+
block_given? ? yield(topic, payload, **to_h) : [topic, payload, to_h]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_s
|
|
17
|
+
"#{super}(#{topic})"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @!visibility private
|
|
21
|
+
def success!(ack)
|
|
22
|
+
return true if qos.zero?
|
|
23
|
+
|
|
24
|
+
ack&.success! || raise(ProtocolError, 'No ACK')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @!visibility private
|
|
28
|
+
def topic_alias
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @!visibility private
|
|
33
|
+
def validate
|
|
34
|
+
raise ArgumentError, 'QoS must be 0, 1, or 2' unless (0..2).include?(qos)
|
|
35
|
+
raise ArgumentError, 'Topic name cannot be empty' if !topic_alias && topic_name.to_s.empty?
|
|
36
|
+
raise ArgumentError, 'Topic name cannot contain wildcards' if topic_name.to_s.match?(/[#+]/)
|
|
37
|
+
|
|
38
|
+
return unless qos.zero? && (packet_identifier || 0).positive?
|
|
39
|
+
|
|
40
|
+
raise MQTT::ProtocolError, 'Must not have packet id for QOS 0'
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MQTT
|
|
4
|
+
module Core
|
|
5
|
+
module Packet
|
|
6
|
+
# Common processing of SUBSCRIBE packets between MQTT versions
|
|
7
|
+
module Subscribe
|
|
8
|
+
# @!attribute [r] ignore_qos_limited
|
|
9
|
+
# @return [Boolean] treat qos_limited topic_filters as successful - default (true)
|
|
10
|
+
|
|
11
|
+
# @!attribute [r] ignore_failed
|
|
12
|
+
# @return [Boolean] treat failed topic_filters as successful - default (false)
|
|
13
|
+
|
|
14
|
+
def defaults
|
|
15
|
+
{
|
|
16
|
+
ignore_failed: false,
|
|
17
|
+
ignore_qos_limited: false
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def apply_data(data)
|
|
22
|
+
@ignore_failed = data.delete(:ignore_failed) if data.include?(:ignore_failed)
|
|
23
|
+
@ignore_qos_limited = data.delete(:ignore_qos_limited) if data.include?(:ignore_qos_limited)
|
|
24
|
+
|
|
25
|
+
if data.include?(:topic_filters)
|
|
26
|
+
map_topic_filters(data[:topic_filters], data.delete(:max_qos) || data.delete(:requested_qos) || 0)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @!visibility private
|
|
33
|
+
def validate
|
|
34
|
+
return unless topic_filters
|
|
35
|
+
|
|
36
|
+
raise ArgumentError, 'Must contain at least one topic filter' if topic_filters.empty?
|
|
37
|
+
|
|
38
|
+
topic_filters.each do |tf|
|
|
39
|
+
filter = case tf
|
|
40
|
+
when String then tf
|
|
41
|
+
when Hash then tf[:topic_filter]
|
|
42
|
+
else tf.topic_filter
|
|
43
|
+
end
|
|
44
|
+
raise ArgumentError, 'Topic filter cannot be empty' if filter.to_s.empty?
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def apply_overrides(data)
|
|
49
|
+
super
|
|
50
|
+
topic_filters = data.fetch(:topic_filters, [])
|
|
51
|
+
@wildcard_topics, @fully_qualified_topics = topic_filters.map(&:topic_filter).partition do |t|
|
|
52
|
+
contains_wildcard?(t)
|
|
53
|
+
end
|
|
54
|
+
@max_qos = topic_filters.map(&:max_qos).max
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
attr_reader :fully_qualified_topics, :wildcard_topics, :max_qos
|
|
58
|
+
|
|
59
|
+
def match?(publish_packet)
|
|
60
|
+
match_topic?(publish_packet.topic_name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def ===(other)
|
|
64
|
+
case other
|
|
65
|
+
when Packet
|
|
66
|
+
match?(other)
|
|
67
|
+
when String
|
|
68
|
+
match_topic?(other)
|
|
69
|
+
else
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def match_topic?(topic_name)
|
|
75
|
+
match_fully_qualified_topic?(topic_name) || match_wildcard_topic?(topic_name)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def match_fully_qualified_topic?(topic_name)
|
|
79
|
+
fully_qualified_topics.include?(topic_name)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def match_wildcard_topic?(topic_name)
|
|
83
|
+
wildcard_topics.any? { |wt| wc_match?(topic_name, wt) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def contains_wildcard?(topic)
|
|
87
|
+
topic =~ /[#+]/
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def wc_match?(topic, wc_topic)
|
|
91
|
+
wc_parts = wc_topic.split('/')
|
|
92
|
+
topic_parts = topic.split('/')
|
|
93
|
+
|
|
94
|
+
wc_parts.zip(topic_parts).all? do |(wc, t)|
|
|
95
|
+
return true if wc == '#'
|
|
96
|
+
return false if t.nil?
|
|
97
|
+
|
|
98
|
+
wc == '+' || t == wc
|
|
99
|
+
end && (wc_parts.last == '#' || topic_parts.size == wc_parts.size)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Map filter expressions to suback status
|
|
103
|
+
#
|
|
104
|
+
# * :`success` - subscription successful with requested QOS
|
|
105
|
+
# * :`qos_limited` - subscription accepted but acknowledged QOS is less than the requested QOS
|
|
106
|
+
# * :`failed` - subscription failed
|
|
107
|
+
# @param [Packet] suback the SUBACK packet
|
|
108
|
+
# @return [Hash<String,Symbol>] map of topic filter to status
|
|
109
|
+
def filter_status(suback)
|
|
110
|
+
topic_filters.zip(suback.return_codes).to_h { |tf, rc| [tf.topic_filter, classify(tf, rc)] }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Version dependent partition topic_filters into success and failures
|
|
114
|
+
#
|
|
115
|
+
# Attributes {#ignore_qos_limited} and {#ignore_failed} can control whether these statuses are considered
|
|
116
|
+
# successful or not.
|
|
117
|
+
# @return <Array<Hash<String,Symbol>> pair of Maps as per #filter_status
|
|
118
|
+
def partition_success(suback)
|
|
119
|
+
filter_status(suback).partition { |(_tf, ack_status)| ack_success?(ack_status) }.map(&:to_h)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def success!(suback)
|
|
123
|
+
success, failed = partition_success(suback)
|
|
124
|
+
|
|
125
|
+
raise SubscribeError, failed unless failed.empty?
|
|
126
|
+
|
|
127
|
+
success
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @return [Array<TopicFilter>]
|
|
131
|
+
def subscribed_topic_filter_requests(suback = nil)
|
|
132
|
+
return topic_filters unless suback
|
|
133
|
+
|
|
134
|
+
topic_filters.zip(suback.return_codes).filter_map { |tf, rc| tf unless failed?(rc) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
alias resubscribe_topic_filters subscribed_topic_filter_requests
|
|
138
|
+
alias unsubscribe_topic_filters subscribed_topic_filter_requests
|
|
139
|
+
|
|
140
|
+
# @return [Array<String>]
|
|
141
|
+
def subscribed_topic_filters(suback = nil)
|
|
142
|
+
subscribed_topic_filter_requests(suback).map(&:topic_filter)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def unsubscribe_params(suback = nil)
|
|
146
|
+
{ topic_filters: subscribed_topic_filters(suback) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def map_topic_filters(topic_filters, max_qos)
|
|
152
|
+
topic_filters.map! do |tf|
|
|
153
|
+
(tf.is_a?(String) ? { topic_filter: tf } : tf).tap do |tf_hash|
|
|
154
|
+
raise ArgumentError, 'topic filter must be a String or Hash<Symbol>' unless tf_hash.is_a?(Hash)
|
|
155
|
+
|
|
156
|
+
tf_hash[self.class::MAX_QOS_FIELD] = max_qos
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def failed?(return_code)
|
|
162
|
+
return_code >= 0x80
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def ack_success?(ack_status)
|
|
166
|
+
case ack_status
|
|
167
|
+
when :success
|
|
168
|
+
true
|
|
169
|
+
when :qos_limited
|
|
170
|
+
@ignore_qos_limited
|
|
171
|
+
when :failed
|
|
172
|
+
@ignore_failed
|
|
173
|
+
else
|
|
174
|
+
false
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def classify(topic_filter, return_code)
|
|
179
|
+
if failed?(return_code)
|
|
180
|
+
:failed
|
|
181
|
+
elsif return_code < topic_filter.requested_qos
|
|
182
|
+
:qos_limited
|
|
183
|
+
else
|
|
184
|
+
:success
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MQTT
|
|
4
|
+
module Core
|
|
5
|
+
module Packet
|
|
6
|
+
# Common processing of UNSUBSCRIBE packets between MQTT versions
|
|
7
|
+
module Unsubscribe
|
|
8
|
+
# @!visibility private
|
|
9
|
+
def validate
|
|
10
|
+
return unless topic_filters
|
|
11
|
+
|
|
12
|
+
raise ArgumentError, 'Must contain at least one topic filter' if topic_filters.empty?
|
|
13
|
+
|
|
14
|
+
topic_filters.each do |tf|
|
|
15
|
+
raise ArgumentError, 'Topic filter cannot be empty' if tf.to_s.empty?
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mqtt/core/packet'
|
|
4
|
+
require_relative '../errors'
|
|
5
|
+
require_relative 'type/shape'
|
|
6
|
+
require_relative 'type/sub_type'
|
|
7
|
+
require_relative 'type/utf8_string'
|
|
8
|
+
require_relative 'type/utf8_string_pair'
|
|
9
|
+
require_relative 'type/binary'
|
|
10
|
+
require_relative 'type/fixed_int'
|
|
11
|
+
require_relative 'type/var_int'
|
|
12
|
+
require_relative 'type/boolean_byte'
|
|
13
|
+
require_relative 'type/remaining'
|
|
14
|
+
require_relative 'type/properties'
|
|
15
|
+
require_relative 'type/bit_flags'
|
|
16
|
+
require_relative 'type/reason_codes'
|
|
17
|
+
require_relative 'type/list'
|
|
18
|
+
require 'stringio'
|
|
19
|
+
|
|
20
|
+
module MQTT
|
|
21
|
+
module Core
|
|
22
|
+
# Version agnostic packet structure
|
|
23
|
+
module Packet
|
|
24
|
+
# Class Methods for defining packet structures (and substructures)
|
|
25
|
+
module Definition
|
|
26
|
+
include Type::Shape::Definition
|
|
27
|
+
|
|
28
|
+
attr_reader :fixed_fields, :variable_fields, :payload_fields, :packet_type
|
|
29
|
+
|
|
30
|
+
# Flags in the fixed header
|
|
31
|
+
def fixed(packet_type, *flags)
|
|
32
|
+
self::PACKET_TYPES[packet_type] = self
|
|
33
|
+
self::PACKET_TYPES[packet_name] = self
|
|
34
|
+
flags = [[:reserved, 4]] if flags.empty?
|
|
35
|
+
flags.unshift([:packet_type, 4])
|
|
36
|
+
@packet_type = packet_type
|
|
37
|
+
@packet_name = packet_name
|
|
38
|
+
fields = { fixed_flags: Type::BitFlags.new(*flags) }
|
|
39
|
+
@fixed_fields = resolve_fields(fields).tap { |resolved| define_field_methods(resolved) }
|
|
40
|
+
@variable_fields = []
|
|
41
|
+
@payload_fields = []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def packet_name
|
|
45
|
+
name.split('::').last.downcase.to_sym
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Fields in the variable header
|
|
49
|
+
def variable(**fields)
|
|
50
|
+
@variable_fields = resolve_fields(fields).tap { |resolved| define_field_methods(resolved) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Payload structure
|
|
54
|
+
def payload(**fields)
|
|
55
|
+
@payload_fields = resolve_fields(fields).tap { |resolved| define_field_methods(resolved) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def properties(packet_name = self.packet_name)
|
|
59
|
+
Type::Properties.new(packet_name, self::PROPERTY_TYPES)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# for Shape#apply_data
|
|
63
|
+
def fields
|
|
64
|
+
@fixed_fields + @variable_fields + @payload_fields
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Methods to be extended into specific version module methods
|
|
69
|
+
module ModuleMethods
|
|
70
|
+
def deserialize(io)
|
|
71
|
+
return nil unless (header_byte = io.getbyte)
|
|
72
|
+
|
|
73
|
+
packet(header_byte >> 4).new(header_byte, io)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def packet(packet_name)
|
|
77
|
+
self::PACKET_TYPES.fetch(packet_name) { raise ProtocolError, "Unknown packet id or name #{packet_name}" }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_packet(packet_name, **packet_data)
|
|
81
|
+
packet(packet_name).new(**packet_data)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.included(mod)
|
|
86
|
+
mod.extend Definition
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
include Type::Shape
|
|
90
|
+
|
|
91
|
+
def apply_overrides(data)
|
|
92
|
+
data[:fixed_flags] ||= {}
|
|
93
|
+
data[:fixed_flags][:packet_type] = self.class.packet_type
|
|
94
|
+
super
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def packet_type
|
|
98
|
+
self.class.packet_type
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def packet_name
|
|
102
|
+
self.class.packet_name
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def id
|
|
106
|
+
return nil if packet_identifier&.zero?
|
|
107
|
+
|
|
108
|
+
packet_identifier
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def to_s
|
|
112
|
+
@to_s ||=
|
|
113
|
+
begin
|
|
114
|
+
parts = [self.class.name]
|
|
115
|
+
parts << "(#{id})" if respond_to?(:packet_identifier) && id
|
|
116
|
+
parts.join
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def serialize(io)
|
|
121
|
+
serialize_fields(self.class.fixed_fields, io)
|
|
122
|
+
|
|
123
|
+
Packet.write_sio(io) do |sio|
|
|
124
|
+
serialize_fields(self.class.variable_fields, sio)
|
|
125
|
+
serialize_fields(self.class.payload_fields, sio)
|
|
126
|
+
end
|
|
127
|
+
# io.flush
|
|
128
|
+
self
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def deserialize(header_byte, io)
|
|
132
|
+
@data.merge!(deserialize_fields(self.class.fixed_fields, header_byte))
|
|
133
|
+
Packet.read_sio(io) do |sio|
|
|
134
|
+
@data.merge!(deserialize_fields(self.class.variable_fields, sio))
|
|
135
|
+
@data.merge!(deserialize_fields(self.class.payload_fields, sio))
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
class << self
|
|
140
|
+
def read_sio(io, &)
|
|
141
|
+
length = Type::VarInt.read(io)
|
|
142
|
+
return unless length
|
|
143
|
+
raise ProtocolError, "Cannot read negative length #{length}" if length.negative?
|
|
144
|
+
return if length.zero?
|
|
145
|
+
|
|
146
|
+
StringIO.new.binmode
|
|
147
|
+
.tap { |sio| length -= IO.copy_stream(io, sio, length) while length.positive? }
|
|
148
|
+
.tap(&:rewind)
|
|
149
|
+
.tap(&)
|
|
150
|
+
rescue EOFError
|
|
151
|
+
raise ProtocolError, "Packet length #{length} exceeds available data"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def write_sio(io, &)
|
|
155
|
+
StringIO.new.binmode.tap(&).tap(&:rewind).tap do |sio|
|
|
156
|
+
Type::VarInt.write(sio.size, io)
|
|
157
|
+
IO.copy_stream(sio, io)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# debugging/testing do not log
|
|
162
|
+
def hex(payload)
|
|
163
|
+
payload.bytes.map { |byte| format('%02x', byte) }.join(' ')
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MQTT
|
|
4
|
+
module Core
|
|
5
|
+
module Type
|
|
6
|
+
# Binary data
|
|
7
|
+
# converts to String with binary encoding
|
|
8
|
+
module Binary
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# return [String with binary encoding]
|
|
12
|
+
def read(io)
|
|
13
|
+
size = io.read(2)&.unpack1('S>')
|
|
14
|
+
io.read(size)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def write(value, io)
|
|
18
|
+
value ||= default_value
|
|
19
|
+
io.write([value.size, value].pack('S>A*'))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [String(binary)]
|
|
23
|
+
def from(value, **)
|
|
24
|
+
return nil if value.nil?
|
|
25
|
+
|
|
26
|
+
value.to_s.b
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def default_value
|
|
30
|
+
@default_value ||= ''.b.freeze
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../errors'
|
|
4
|
+
require_relative 'fixed_int'
|
|
5
|
+
|
|
6
|
+
module MQTT
|
|
7
|
+
module Core
|
|
8
|
+
module Type
|
|
9
|
+
# Booleans and small integers encoded in a single byte
|
|
10
|
+
class BitFlags
|
|
11
|
+
def initialize(*flags)
|
|
12
|
+
@flags = flags.map { |f| f.is_a?(Array) ? f : [f, 1] }
|
|
13
|
+
raise ProtocolError, 'Total bits must be 8' unless @flags.sum { |(_, bitsize)| bitsize } == 8
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :flags
|
|
17
|
+
|
|
18
|
+
def sub_properties
|
|
19
|
+
@sub_properties ||=
|
|
20
|
+
@flags.filter_map do |(name, bitsize, *)|
|
|
21
|
+
[name, bitsize == 1 ? BooleanByte : Int8] unless name == :reserved
|
|
22
|
+
end.to_h
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# rubocop:disable Metrics/AbcSize
|
|
26
|
+
|
|
27
|
+
# @param [Integer,IO] io can be a previously readbyte or an IO to read the byte from
|
|
28
|
+
# @return [Hash<Symbol, Integer|Boolean>]
|
|
29
|
+
def read(io)
|
|
30
|
+
flags_byte = io.is_a?(Integer) ? io : Int8.read(io)
|
|
31
|
+
|
|
32
|
+
flags.reverse.filter_map do |(flag, bitsize, reserved)|
|
|
33
|
+
mask = (1 << bitsize) - 1
|
|
34
|
+
flag_value = (flags_byte & mask)
|
|
35
|
+
raise ProtocolError if flag == :reserved && flag_value != (reserved || 0)
|
|
36
|
+
|
|
37
|
+
# If bitsize is one, flag is a Boolean, otherwise leave as Integer
|
|
38
|
+
flag_value = (flag_value == 1) if bitsize == 1
|
|
39
|
+
flags_byte >>= bitsize
|
|
40
|
+
[flag, flag_value] unless flag == :reserved
|
|
41
|
+
end.to_h
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def from(input_value)
|
|
45
|
+
input_value ||= 0
|
|
46
|
+
input_value = read(input_value) if input_value.is_a?(Integer)
|
|
47
|
+
|
|
48
|
+
flags.each do |(flag_name, bitsize)|
|
|
49
|
+
next if flag_name == :reserved
|
|
50
|
+
|
|
51
|
+
# convert to int and validate
|
|
52
|
+
input_value[flag_name] = to_int(input_value[flag_name], bitsize, exception_class: ArgumentError)
|
|
53
|
+
# convert to boolean if bitsize is 1
|
|
54
|
+
input_value[flag_name] = (input_value[flag_name] == 1) if bitsize == 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Reject unknown flags
|
|
58
|
+
invalid = input_value.keys.reject { |k| sub_properties.include?(k) }
|
|
59
|
+
|
|
60
|
+
return input_value if invalid.empty?
|
|
61
|
+
|
|
62
|
+
raise ArgumentError, "Unknown bitflags #{invalid}, expected only #{sub_properties}"
|
|
63
|
+
end
|
|
64
|
+
# rubocop:enable Metrics/AbcSize
|
|
65
|
+
|
|
66
|
+
def write(values, io)
|
|
67
|
+
output_byte = 0
|
|
68
|
+
flags.each do |(property_name, bitsize, reserved)|
|
|
69
|
+
output_byte <<= bitsize
|
|
70
|
+
output_byte += to_int(values.fetch(property_name, reserved || 0), bitsize, exception_class: ProtocolError)
|
|
71
|
+
end
|
|
72
|
+
Int8.write(output_byte, io)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_int(value, bitsize, exception_class:)
|
|
76
|
+
if value.is_a?(Integer)
|
|
77
|
+
return value if value.between?(0, (1 << bitsize) - 1)
|
|
78
|
+
|
|
79
|
+
raise exception_class, "Invalid bitflag value #{value} for bitsize #{bitsize}"
|
|
80
|
+
elsif bitsize == 1
|
|
81
|
+
value ? 1 : 0
|
|
82
|
+
else
|
|
83
|
+
raise exception_class, "Invalid bitflag value #{value}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MQTT
|
|
4
|
+
module Core
|
|
5
|
+
module Type
|
|
6
|
+
# If we are reading or writing a boolean byte then it will exist
|
|
7
|
+
# but if it does not exist then it is assumed to be true
|
|
8
|
+
module BooleanByte
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# rubocop:disable Naming/PredicateMethod
|
|
12
|
+
def read(io)
|
|
13
|
+
int_value = Int8.read(io)
|
|
14
|
+
raise ProtocolError, 'Value must be 0 or 1' unless int_value.between?(0, 1)
|
|
15
|
+
|
|
16
|
+
!int_value.zero?
|
|
17
|
+
end
|
|
18
|
+
# rubocop:enable Naming/PredicateMethod
|
|
19
|
+
|
|
20
|
+
def write(value, io)
|
|
21
|
+
value = 1 if value.nil?
|
|
22
|
+
value = value ? 1 : 0 unless value.is_a?(Integer)
|
|
23
|
+
raise ProtocolError, 'Value must be 0 or 1' unless value.between?(0, 1)
|
|
24
|
+
|
|
25
|
+
io.write([value].pack('C'))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# param [Integer,Object, nil] value
|
|
29
|
+
# for integers anything non-zero is considered true
|
|
30
|
+
# everything else uses ruby truthiness
|
|
31
|
+
# nil is considered true
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
def from(value, **)
|
|
34
|
+
return nil if value.nil?
|
|
35
|
+
return !value.zero? if value.is_a?(Integer)
|
|
36
|
+
|
|
37
|
+
!!value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# rubocop:disable Naming/PredicateMethod
|
|
41
|
+
def default_value
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
# rubocop:enable Naming/PredicateMethod
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MQTT
|
|
4
|
+
module Core
|
|
5
|
+
module Type
|
|
6
|
+
# unsigned integers of fixed bit size
|
|
7
|
+
class FixedInt
|
|
8
|
+
def initialize(bitsize, pack)
|
|
9
|
+
@bitsize = bitsize
|
|
10
|
+
@pack = pack
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def read(io)
|
|
14
|
+
io.read(@bitsize / 8)&.unpack1(@pack)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def from(value, **)
|
|
18
|
+
return value.to_i if value.respond_to?(:to_i)
|
|
19
|
+
|
|
20
|
+
value ? 1 : 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def write(value, io)
|
|
24
|
+
value = from(value)
|
|
25
|
+
raise ProtocolError, "Value '#{value}' out of range" unless value.between?(0, (2**@bitsize) - 1)
|
|
26
|
+
|
|
27
|
+
io.write([value].pack(@pack))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def default_value
|
|
31
|
+
0
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# 8 bit unsigned integer
|
|
36
|
+
Int8 = FixedInt.new(8, 'C')
|
|
37
|
+
# 16 bit unsigned integer
|
|
38
|
+
Int16 = FixedInt.new(16, 'S>')
|
|
39
|
+
# 32 bit unsigned integer
|
|
40
|
+
Int32 = FixedInt.new(32, 'L>')
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|