usbkit 0.1.0

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.
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "usb"
5
+ rescue LoadError
6
+ nil
7
+ end
8
+
9
+ require_relative "base"
10
+
11
+ module UsbKit
12
+ module Backend
13
+ class UsbRuby < Base
14
+ end
15
+ end
16
+ end
17
+
18
+ require_relative "usb_ruby/device_wrapper"
19
+
20
+ module UsbKit
21
+ module Backend
22
+ ##
23
+ # usb-ruby backed implementation of the UsbKit backend contract.
24
+ #
25
+ class UsbRuby
26
+ REQUEST_TYPE_BITS = {
27
+ standard: 0x00,
28
+ class: 0x20,
29
+ vendor: 0x40
30
+ }.freeze
31
+
32
+ RECIPIENT_BITS = {
33
+ device: 0x00,
34
+ interface: 0x01,
35
+ endpoint: 0x02,
36
+ other: 0x03
37
+ }.freeze
38
+
39
+ # @param usb_context [Object, nil]
40
+ # @param mutex [Mutex]
41
+ # @raise [BackendNotAvailableError] when usb-ruby is not available
42
+ # @return [void]
43
+ def initialize(usb_context: nil, mutex: Mutex.new)
44
+ raise BackendNotAvailableError, "usb-ruby backend not available" unless defined?(USB::Context)
45
+
46
+ @mutex = mutex
47
+ @usb_context = usb_context || USB::Context.new
48
+ end
49
+
50
+ # @return [void]
51
+ def finalize
52
+ synchronize do
53
+ @usb_context&.close if @usb_context.respond_to?(:close)
54
+ end
55
+ end
56
+
57
+ # @return [Array<Hash>]
58
+ def enumerate_devices
59
+ synchronize do
60
+ Array(@usb_context.devices).map { |usb_device| DeviceWrapper.new(usb_device).to_h }
61
+ end
62
+ rescue StandardError => error
63
+ raise map_error(error)
64
+ end
65
+
66
+ # @param raw_device [Object]
67
+ # @return [Object]
68
+ def open_device(raw_device)
69
+ synchronize { raw_device.open }
70
+ rescue StandardError => error
71
+ raise map_error(error)
72
+ end
73
+
74
+ # @param device_handle [Object]
75
+ # @return [void]
76
+ def close_device(device_handle)
77
+ synchronize { device_handle.close }
78
+ rescue StandardError => error
79
+ raise map_error(error)
80
+ end
81
+
82
+ # @param device_handle [Object]
83
+ # @param config_value [Integer]
84
+ # @return [void]
85
+ def set_configuration(device_handle, config_value)
86
+ synchronize { device_handle.set_configuration(config_value) }
87
+ rescue StandardError => error
88
+ raise map_error(error)
89
+ end
90
+
91
+ # @param device_handle [Object]
92
+ # @param interface_number [Integer]
93
+ # @return [void]
94
+ def claim_interface(device_handle, interface_number)
95
+ synchronize { device_handle.claim_interface(interface_number) }
96
+ rescue StandardError => error
97
+ raise map_error(error)
98
+ end
99
+
100
+ # @param device_handle [Object]
101
+ # @param interface_number [Integer]
102
+ # @return [void]
103
+ def release_interface(device_handle, interface_number)
104
+ synchronize { device_handle.release_interface(interface_number) }
105
+ rescue StandardError => error
106
+ raise map_error(error)
107
+ end
108
+
109
+ # @param device_handle [Object]
110
+ # @param interface_number [Integer]
111
+ # @param alternate_setting [Integer]
112
+ # @return [void]
113
+ def set_alternate_interface(device_handle, interface_number, alternate_setting)
114
+ synchronize { device_handle.set_interface_alt_setting(interface_number, alternate_setting) }
115
+ rescue StandardError => error
116
+ raise map_error(error)
117
+ end
118
+
119
+ # @param device_handle [Object]
120
+ # @param setup [Hash]
121
+ # @param length [Integer]
122
+ # @return [Hash]
123
+ def control_transfer_in(device_handle, setup, length)
124
+ response = synchronize do
125
+ device_handle.control_transfer(
126
+ bmRequestType: build_request_type(setup, :in),
127
+ bRequest: setup[:request],
128
+ wValue: setup[:value],
129
+ wIndex: setup[:index],
130
+ dataIn: length
131
+ )
132
+ end
133
+
134
+ { status: TransferStatus::OK, data: response }
135
+ rescue StandardError => error
136
+ raise map_error(error)
137
+ end
138
+
139
+ # @param device_handle [Object]
140
+ # @param setup [Hash]
141
+ # @param data [String, nil]
142
+ # @return [Hash]
143
+ def control_transfer_out(device_handle, setup, data)
144
+ bytes_written = synchronize do
145
+ params = {
146
+ bmRequestType: build_request_type(setup, :out),
147
+ bRequest: setup[:request],
148
+ wValue: setup[:value],
149
+ wIndex: setup[:index]
150
+ }
151
+ params[:dataOut] = data if data
152
+ device_handle.control_transfer(**params)
153
+ end
154
+
155
+ { status: TransferStatus::OK, bytes_written: bytes_written || data&.bytesize.to_i }
156
+ rescue StandardError => error
157
+ raise map_error(error)
158
+ end
159
+
160
+ # @param device_handle [Object]
161
+ # @param endpoint [Integer]
162
+ # @param length [Integer]
163
+ # @param timeout [Integer]
164
+ # @return [Hash]
165
+ def bulk_transfer_in(device_handle, endpoint, length, timeout:)
166
+ data = synchronize do
167
+ device_handle.bulk_transfer(
168
+ endpoint: endpoint | 0x80,
169
+ dataIn: length,
170
+ timeout: timeout
171
+ )
172
+ end
173
+
174
+ { status: TransferStatus::OK, data: data }
175
+ rescue StandardError => error
176
+ raise map_error(error)
177
+ end
178
+
179
+ # @param device_handle [Object]
180
+ # @param endpoint [Integer]
181
+ # @param data [String]
182
+ # @param timeout [Integer]
183
+ # @return [Hash]
184
+ def bulk_transfer_out(device_handle, endpoint, data, timeout:)
185
+ bytes_written = synchronize do
186
+ device_handle.bulk_transfer(
187
+ endpoint: endpoint,
188
+ dataOut: data,
189
+ timeout: timeout
190
+ )
191
+ end
192
+
193
+ { status: TransferStatus::OK, bytes_written: bytes_written || data.bytesize }
194
+ rescue StandardError => error
195
+ raise map_error(error)
196
+ end
197
+
198
+ # @param device_handle [Object]
199
+ # @param endpoint [Integer]
200
+ # @param packet_lengths [Array<Integer>]
201
+ # @raise [NotImplementedError] when usb-ruby has no isochronous support
202
+ # @return [Hash]
203
+ def isochronous_transfer_in(device_handle, endpoint, packet_lengths)
204
+ raise NotImplementedError, "isochronous transfers require usb-ruby isochronous support" unless device_handle.respond_to?(:isochronous_transfer)
205
+
206
+ transfer = synchronize do
207
+ device_handle.isochronous_transfer(endpoint: endpoint | 0x80, packet_lengths: packet_lengths)
208
+ end
209
+
210
+ {
211
+ data: transfer[:data],
212
+ packets: Array(transfer[:packets]).map { |packet| packet.merge(status: packet.fetch(:status, TransferStatus::OK)) }
213
+ }
214
+ rescue NotImplementedError
215
+ raise
216
+ rescue StandardError => error
217
+ raise map_error(error)
218
+ end
219
+
220
+ # @param device_handle [Object]
221
+ # @param endpoint [Integer]
222
+ # @param data [String]
223
+ # @param packet_lengths [Array<Integer>]
224
+ # @raise [NotImplementedError] when usb-ruby has no isochronous support
225
+ # @return [Hash]
226
+ def isochronous_transfer_out(device_handle, endpoint, data, packet_lengths)
227
+ raise NotImplementedError, "isochronous transfers require usb-ruby isochronous support" unless device_handle.respond_to?(:isochronous_transfer)
228
+
229
+ transfer = synchronize do
230
+ device_handle.isochronous_transfer(
231
+ endpoint: endpoint,
232
+ dataOut: data,
233
+ packet_lengths: packet_lengths
234
+ )
235
+ end
236
+
237
+ {
238
+ packets: Array(transfer[:packets]).map do |packet|
239
+ packet.merge(status: packet.fetch(:status, TransferStatus::OK))
240
+ end
241
+ }
242
+ rescue NotImplementedError
243
+ raise
244
+ rescue StandardError => error
245
+ raise map_error(error)
246
+ end
247
+
248
+ # @param device_handle [Object]
249
+ # @param endpoint [Integer]
250
+ # @return [void]
251
+ def clear_halt(device_handle, endpoint)
252
+ synchronize { device_handle.clear_halt(endpoint) }
253
+ rescue StandardError => error
254
+ raise map_error(error)
255
+ end
256
+
257
+ # @param device_handle [Object]
258
+ # @return [void]
259
+ def reset_device(device_handle)
260
+ synchronize { device_handle.reset_device }
261
+ rescue StandardError => error
262
+ raise map_error(error)
263
+ end
264
+
265
+ # @param raw_device [Object]
266
+ # @return [Hash]
267
+ def get_device_descriptor(raw_device)
268
+ DeviceWrapper.new(raw_device).to_h
269
+ rescue StandardError => error
270
+ raise map_error(error)
271
+ end
272
+
273
+ # @param raw_device [Object]
274
+ # @param index [Integer]
275
+ # @return [Object]
276
+ def get_configuration_descriptor(raw_device, index)
277
+ synchronize do
278
+ if raw_device.respond_to?(:config_descriptor)
279
+ raw_device.config_descriptor(index)
280
+ else
281
+ Array(raw_device.configurations).fetch(index)
282
+ end
283
+ end
284
+ rescue StandardError => error
285
+ raise map_error(error)
286
+ end
287
+
288
+ private
289
+
290
+ def synchronize(&block)
291
+ @mutex.synchronize(&block)
292
+ end
293
+
294
+ def build_request_type(setup, direction)
295
+ direction_bit = direction == :in ? 0x80 : 0x00
296
+ direction_bit | REQUEST_TYPE_BITS.fetch(setup[:request_type]) | RECIPIENT_BITS.fetch(setup[:recipient])
297
+ end
298
+
299
+ def map_error(error)
300
+ return error if error.is_a?(UsbKit::Error)
301
+
302
+ message = error.message.to_s
303
+ lowered = message.downcase
304
+
305
+ return TimeoutError.new(message) if lowered.include?("timeout")
306
+ return DeviceBusyError.new(message) if lowered.include?("busy")
307
+ return DeviceAccessError.new(message) if lowered.include?("access") || lowered.include?("permission")
308
+ return DeviceNotFoundError.new(message) if lowered.include?("not found")
309
+ return TransferError.new(message, status: :stall) if lowered.include?("stall")
310
+ return TransferError.new(message, status: :babble) if lowered.include?("babble")
311
+
312
+ Error.new(message.empty? ? error.class.name : message)
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsbKit
4
+ module Backend
5
+ class Usbfs
6
+ USBDEVFS_CONTROL = 0xC0185500
7
+ USBDEVFS_BULK = 0xC0185502
8
+ USBDEVFS_SETINTF = 0x80085504
9
+ USBDEVFS_SETCONFIG = 0x80045505
10
+ USBDEVFS_CLAIMINTF = 0x8004550F
11
+ USBDEVFS_RELEASEINTF = 0x80045510
12
+ USBDEVFS_CLEAR_HALT = 0x80045515
13
+ USBDEVFS_RESET = 0x00005514
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsbKit
4
+ module Backend
5
+ class Usbfs
6
+ ##
7
+ # Reads USB device metadata from Linux sysfs.
8
+ #
9
+ class SysfsReader
10
+ SYSFS_PATH = "/sys/bus/usb/devices"
11
+
12
+ # @param sysfs_path [String]
13
+ # @return [void]
14
+ def initialize(sysfs_path: SYSFS_PATH)
15
+ @sysfs_path = sysfs_path
16
+ end
17
+
18
+ # @return [Array<Hash>]
19
+ def enumerate
20
+ Dir.glob(File.join(@sysfs_path, "[0-9]*-[0-9]*")).filter_map do |path|
21
+ next unless File.exist?(File.join(path, "idVendor"))
22
+
23
+ read_device_info(path)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def read_device_info(path)
30
+ {
31
+ raw_device: {
32
+ sysfs_path: path,
33
+ dev_path: derive_dev_path(path)
34
+ },
35
+ vendor_id: read_hex(path, "idVendor"),
36
+ product_id: read_hex(path, "idProduct"),
37
+ device_class: read_hex(path, "bDeviceClass"),
38
+ device_subclass: read_hex(path, "bDeviceSubClass"),
39
+ device_protocol: read_hex(path, "bDeviceProtocol"),
40
+ device_version_major: bcd_major(read_hex(path, "bcdDevice")),
41
+ device_version_minor: bcd_minor(read_hex(path, "bcdDevice")),
42
+ device_version_subminor: bcd_subminor(read_hex(path, "bcdDevice")),
43
+ usb_version_major: bcd_major(read_hex(path, "version")),
44
+ usb_version_minor: bcd_minor(read_hex(path, "version")),
45
+ usb_version_subminor: bcd_subminor(read_hex(path, "version")),
46
+ manufacturer_name: read_string(path, "manufacturer"),
47
+ product_name: read_string(path, "product"),
48
+ serial_number: read_string(path, "serial"),
49
+ configurations: [],
50
+ configuration_value: nil,
51
+ bus_number: read_decimal(path, "busnum"),
52
+ device_address: read_decimal(path, "devnum")
53
+ }
54
+ end
55
+
56
+ def derive_dev_path(path)
57
+ bus_number = read_decimal(path, "busnum")
58
+ device_number = read_decimal(path, "devnum")
59
+ return nil unless bus_number && device_number
60
+
61
+ format("/dev/bus/usb/%<bus>03d/%<device>03d", bus: bus_number, device: device_number)
62
+ end
63
+
64
+ def read_hex(path, attr)
65
+ content = read_string(path, attr)
66
+ return 0 if content.nil? || content.empty?
67
+
68
+ content.include?(".") ? decimal_version_to_bcd(content) : content.to_i(16)
69
+ end
70
+
71
+ def read_decimal(path, attr)
72
+ Integer(read_string(path, attr), 10)
73
+ rescue ArgumentError, TypeError
74
+ nil
75
+ end
76
+
77
+ def read_string(path, attr)
78
+ File.read(File.join(path, attr)).strip
79
+ rescue Errno::ENOENT
80
+ nil
81
+ end
82
+
83
+ def decimal_version_to_bcd(content)
84
+ major, minor = content.split(".", 2)
85
+ ((major.to_i & 0xFF) << 8) | ((minor.to_i & 0x0F) << 4)
86
+ end
87
+
88
+ def bcd_major(value)
89
+ (value >> 8) & 0xFF
90
+ end
91
+
92
+ def bcd_minor(value)
93
+ (value >> 4) & 0x0F
94
+ end
95
+
96
+ def bcd_subminor(value)
97
+ value & 0x0F
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module UsbKit
6
+ module Backend
7
+ class Usbfs < Base
8
+ end
9
+ end
10
+ end
11
+
12
+ require_relative "usbfs/ioctl_commands"
13
+ require_relative "usbfs/sysfs_reader"
14
+
15
+ module UsbKit
16
+ module Backend
17
+ ##
18
+ # Linux usbfs fallback backend.
19
+ #
20
+ class Usbfs
21
+ REQUEST_TYPE_BITS = {
22
+ standard: 0x00,
23
+ class: 0x20,
24
+ vendor: 0x40
25
+ }.freeze
26
+
27
+ RECIPIENT_BITS = {
28
+ device: 0x00,
29
+ interface: 0x01,
30
+ endpoint: 0x02,
31
+ other: 0x03
32
+ }.freeze
33
+
34
+ # @param sysfs_reader [SysfsReader]
35
+ # @param file_class [Class]
36
+ # @param linux [Boolean]
37
+ # @raise [BackendNotAvailableError] unless running on Linux
38
+ # @return [void]
39
+ def initialize(sysfs_reader: SysfsReader.new, file_class: File, linux: RUBY_PLATFORM.match?(/linux/i))
40
+ raise BackendNotAvailableError, "usbfs backend is only available on Linux" unless linux
41
+
42
+ @sysfs_reader = sysfs_reader
43
+ @file_class = file_class
44
+ end
45
+
46
+ # @return [Array<Hash>]
47
+ def enumerate_devices
48
+ @sysfs_reader.enumerate
49
+ end
50
+
51
+ # @param raw_device [Hash, String]
52
+ # @raise [DeviceAccessError, DeviceNotFoundError]
53
+ # @return [File]
54
+ def open_device(raw_device)
55
+ path = raw_device.is_a?(Hash) ? raw_device[:dev_path] : raw_device
56
+ raise DeviceNotFoundError, "usbfs device path is unavailable" unless path
57
+
58
+ @file_class.open(path, "rb+")
59
+ rescue Errno::EACCES => error
60
+ raise DeviceAccessError, error.message
61
+ rescue Errno::ENOENT => error
62
+ raise DeviceNotFoundError, error.message
63
+ end
64
+
65
+ # @param device_handle [File]
66
+ # @return [void]
67
+ def close_device(device_handle)
68
+ device_handle.close
69
+ end
70
+
71
+ # @param device_handle [File]
72
+ # @param config_value [Integer]
73
+ # @return [Integer]
74
+ def set_configuration(device_handle, config_value)
75
+ device_handle.ioctl(self.class::USBDEVFS_SETCONFIG, [config_value].pack("I"))
76
+ end
77
+
78
+ # @param device_handle [File]
79
+ # @param interface_number [Integer]
80
+ # @return [Integer]
81
+ def claim_interface(device_handle, interface_number)
82
+ device_handle.ioctl(self.class::USBDEVFS_CLAIMINTF, [interface_number].pack("I"))
83
+ end
84
+
85
+ # @param device_handle [File]
86
+ # @param interface_number [Integer]
87
+ # @return [Integer]
88
+ def release_interface(device_handle, interface_number)
89
+ device_handle.ioctl(self.class::USBDEVFS_RELEASEINTF, [interface_number].pack("I"))
90
+ end
91
+
92
+ # @param device_handle [File]
93
+ # @param interface_number [Integer]
94
+ # @param alternate_setting [Integer]
95
+ # @return [Integer]
96
+ def set_alternate_interface(device_handle, interface_number, alternate_setting)
97
+ payload = [interface_number, alternate_setting].pack("II")
98
+ device_handle.ioctl(self.class::USBDEVFS_SETINTF, payload)
99
+ end
100
+
101
+ # @param device_handle [File]
102
+ # @param setup [Hash]
103
+ # @param length [Integer]
104
+ # @return [Hash]
105
+ def control_transfer_in(device_handle, setup, length)
106
+ payload = build_control_payload(setup, :in, length: length)
107
+ device_handle.ioctl(self.class::USBDEVFS_CONTROL, payload)
108
+ { status: TransferStatus::OK, data: device_handle.read(length) }
109
+ end
110
+
111
+ # @param device_handle [File]
112
+ # @param setup [Hash]
113
+ # @param data [String, nil]
114
+ # @return [Hash]
115
+ def control_transfer_out(device_handle, setup, data)
116
+ payload = build_control_payload(setup, :out, data: data.to_s)
117
+ device_handle.ioctl(self.class::USBDEVFS_CONTROL, payload)
118
+ { status: TransferStatus::OK, bytes_written: data.to_s.bytesize }
119
+ end
120
+
121
+ # @param device_handle [File]
122
+ # @param endpoint [Integer]
123
+ # @param length [Integer]
124
+ # @param timeout [Integer]
125
+ # @return [Hash]
126
+ def bulk_transfer_in(device_handle, endpoint, length, timeout:)
127
+ payload = [endpoint | 0x80, length, timeout].pack("III")
128
+ device_handle.ioctl(self.class::USBDEVFS_BULK, payload)
129
+ { status: TransferStatus::OK, data: device_handle.read(length) }
130
+ end
131
+
132
+ # @param device_handle [File]
133
+ # @param endpoint [Integer]
134
+ # @param data [String]
135
+ # @param timeout [Integer]
136
+ # @return [Hash]
137
+ def bulk_transfer_out(device_handle, endpoint, data, timeout:)
138
+ payload = [endpoint, data.bytesize, timeout].pack("III") + data
139
+ device_handle.ioctl(self.class::USBDEVFS_BULK, payload)
140
+ { status: TransferStatus::OK, bytes_written: data.bytesize }
141
+ end
142
+
143
+ # @raise [NotImplementedError]
144
+ # @return [void]
145
+ def isochronous_transfer_in(_device_handle, _endpoint, _packet_lengths)
146
+ raise NotImplementedError, "usbfs isochronous transfers are not implemented"
147
+ end
148
+
149
+ # @raise [NotImplementedError]
150
+ # @return [void]
151
+ def isochronous_transfer_out(_device_handle, _endpoint, _data, _packet_lengths)
152
+ raise NotImplementedError, "usbfs isochronous transfers are not implemented"
153
+ end
154
+
155
+ # @param device_handle [File]
156
+ # @param endpoint [Integer]
157
+ # @return [Integer]
158
+ def clear_halt(device_handle, endpoint)
159
+ device_handle.ioctl(self.class::USBDEVFS_CLEAR_HALT, [endpoint].pack("I"))
160
+ end
161
+
162
+ # @param device_handle [File]
163
+ # @return [Integer]
164
+ def reset_device(device_handle)
165
+ device_handle.ioctl(self.class::USBDEVFS_RESET, "".b)
166
+ end
167
+
168
+ # @param device_handle [Object]
169
+ # @return [Object]
170
+ def get_device_descriptor(device_handle)
171
+ device_handle
172
+ end
173
+
174
+ # @raise [NotImplementedError]
175
+ # @return [void]
176
+ def get_configuration_descriptor(_device_handle, _index)
177
+ raise NotImplementedError, "usbfs configuration descriptors are not implemented"
178
+ end
179
+
180
+ private
181
+
182
+ def build_control_payload(setup, direction, length: 0, data: "".b)
183
+ [
184
+ build_request_type(setup, direction),
185
+ setup[:request],
186
+ setup[:value],
187
+ setup[:index],
188
+ data.bytesize,
189
+ UsbKit.config.transfer_timeout,
190
+ length
191
+ ].pack("CCSSIII") + data
192
+ end
193
+
194
+ def build_request_type(setup, direction)
195
+ direction_bit = direction == :in ? 0x80 : 0x00
196
+ direction_bit | REQUEST_TYPE_BITS.fetch(setup[:request_type]) | RECIPIENT_BITS.fetch(setup[:recipient])
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsbKit
4
+ ##
5
+ # Gem-level UsbKit configuration.
6
+ #
7
+ class GemConfiguration
8
+ # @return [Symbol] :auto, :usb_ruby, or :usbfs
9
+ # @return [Symbol] selected log level
10
+ # @return [Integer] transfer timeout in milliseconds
11
+ # @return [Array<Integer>] blocked USB class codes
12
+ attr_accessor :backend, :log_level, :transfer_timeout, :blocked_classes
13
+
14
+ # @return [void]
15
+ def initialize
16
+ @backend = :auto
17
+ @log_level = :warn
18
+ @transfer_timeout = 5000
19
+ @blocked_classes = default_blocked_classes
20
+ end
21
+
22
+ private
23
+
24
+ def default_blocked_classes
25
+ [
26
+ 0x01,
27
+ 0x03,
28
+ 0x08,
29
+ 0x0B,
30
+ 0x0E,
31
+ 0x10,
32
+ 0xE0
33
+ ].freeze
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsbKit
4
+ ##
5
+ # Device connection lifecycle event.
6
+ #
7
+ class ConnectionEvent
8
+ EVENT_TYPES = %i[connect disconnect].freeze
9
+
10
+ # @return [Device] related device
11
+ # @return [Symbol] :connect or :disconnect
12
+ attr_reader :device, :type
13
+
14
+ # @param attrs [Hash]
15
+ # @raise [ArgumentError] when the event type is unsupported
16
+ # @return [void]
17
+ def initialize(attrs)
18
+ @device = attrs.fetch(:device)
19
+ @type = validate_type(attrs.fetch(:type))
20
+ freeze
21
+ end
22
+
23
+ # @return [Hash]
24
+ def to_h
25
+ { device: device, type: type }
26
+ end
27
+
28
+ # @return [String]
29
+ def inspect
30
+ "#<#{self.class} type=#{type.inspect} device=#{device.inspect}>"
31
+ end
32
+
33
+ private
34
+
35
+ def validate_type(type)
36
+ return type if EVENT_TYPES.include?(type)
37
+
38
+ raise ArgumentError, "Invalid event type: #{type.inspect}"
39
+ end
40
+ end
41
+ end