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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/lib/mqtt/core/client/acknowledgement.rb +31 -0
  3. data/lib/mqtt/core/client/client_id_generator.rb +19 -0
  4. data/lib/mqtt/core/client/connection.rb +193 -0
  5. data/lib/mqtt/core/client/enumerable_subscription.rb +224 -0
  6. data/lib/mqtt/core/client/filesystem_session_store.rb +197 -0
  7. data/lib/mqtt/core/client/memory_session_store.rb +84 -0
  8. data/lib/mqtt/core/client/qos0_session_store.rb +56 -0
  9. data/lib/mqtt/core/client/qos2_session_store.rb +45 -0
  10. data/lib/mqtt/core/client/qos_tracker.rb +119 -0
  11. data/lib/mqtt/core/client/retry_strategy.rb +63 -0
  12. data/lib/mqtt/core/client/session.rb +195 -0
  13. data/lib/mqtt/core/client/session_store.rb +128 -0
  14. data/lib/mqtt/core/client/socket_factory.rb +268 -0
  15. data/lib/mqtt/core/client/subscription.rb +109 -0
  16. data/lib/mqtt/core/client/uri.rb +77 -0
  17. data/lib/mqtt/core/client.rb +700 -0
  18. data/lib/mqtt/core/packet/connect.rb +39 -0
  19. data/lib/mqtt/core/packet/publish.rb +45 -0
  20. data/lib/mqtt/core/packet/subscribe.rb +190 -0
  21. data/lib/mqtt/core/packet/unsubscribe.rb +21 -0
  22. data/lib/mqtt/core/packet.rb +168 -0
  23. data/lib/mqtt/core/type/binary.rb +35 -0
  24. data/lib/mqtt/core/type/bit_flags.rb +89 -0
  25. data/lib/mqtt/core/type/boolean_byte.rb +48 -0
  26. data/lib/mqtt/core/type/fixed_int.rb +43 -0
  27. data/lib/mqtt/core/type/list.rb +41 -0
  28. data/lib/mqtt/core/type/password.rb +36 -0
  29. data/lib/mqtt/core/type/properties.rb +124 -0
  30. data/lib/mqtt/core/type/reason_codes.rb +60 -0
  31. data/lib/mqtt/core/type/remaining.rb +30 -0
  32. data/lib/mqtt/core/type/shape.rb +177 -0
  33. data/lib/mqtt/core/type/sub_type.rb +34 -0
  34. data/lib/mqtt/core/type/utf8_string.rb +39 -0
  35. data/lib/mqtt/core/type/utf8_string_pair.rb +29 -0
  36. data/lib/mqtt/core/type/var_int.rb +56 -0
  37. data/lib/mqtt/core/version.rb +10 -0
  38. data/lib/mqtt/core.rb +5 -0
  39. data/lib/mqtt/errors.rb +39 -0
  40. data/lib/mqtt/logger.rb +92 -0
  41. data/lib/mqtt/open.rb +239 -0
  42. data/lib/mqtt/options.rb +59 -0
  43. data/lib/mqtt/version.rb +6 -0
  44. data/lib/mqtt.rb +3 -0
  45. data/lib/patches/openssl.rb +19 -0
  46. 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