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,479 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module UsbKit
6
+ ##
7
+ # WebUSB-style USB device wrapper.
8
+ #
9
+ class Device
10
+ SETUP_KEYS = %i[request_type recipient request value index].freeze
11
+
12
+ # @return [Configuration, nil] currently selected configuration
13
+ # @return [Array<Configuration>] all known configurations
14
+ # @return [Integer] device class code
15
+ # @return [Integer] device subclass code
16
+ # @return [Integer] device protocol code
17
+ # @return [Integer] device version components
18
+ # @return [String, nil] string descriptors
19
+ # @return [Boolean] whether the device is currently opened
20
+ # @return [Integer] product identifier
21
+ # @return [Integer] vendor identifier
22
+ # @return [Integer, nil] USB bus number
23
+ # @return [Integer, nil] USB address on the bus
24
+ attr_reader :configuration,
25
+ :configurations,
26
+ :device_class,
27
+ :device_subclass,
28
+ :device_protocol,
29
+ :device_version_major,
30
+ :device_version_minor,
31
+ :device_version_subminor,
32
+ :manufacturer_name,
33
+ :opened,
34
+ :product_id,
35
+ :product_name,
36
+ :serial_number,
37
+ :usb_version_major,
38
+ :usb_version_minor,
39
+ :usb_version_subminor,
40
+ :vendor_id,
41
+ :bus_number,
42
+ :device_address
43
+ alias opened? opened
44
+
45
+ # @param attrs [Hash]
46
+ # @return [void]
47
+ def initialize(attrs)
48
+ @raw_device = attrs[:raw_device]
49
+ @vendor_id = attrs.fetch(:vendor_id)
50
+ @product_id = attrs.fetch(:product_id)
51
+ @device_class = attrs.fetch(:device_class)
52
+ @device_subclass = attrs.fetch(:device_subclass)
53
+ @device_protocol = attrs.fetch(:device_protocol)
54
+ @device_version_major = attrs.fetch(:device_version_major, 0)
55
+ @device_version_minor = attrs.fetch(:device_version_minor, 0)
56
+ @device_version_subminor = attrs.fetch(:device_version_subminor, 0)
57
+ @manufacturer_name = attrs[:manufacturer_name]
58
+ @product_name = attrs[:product_name]
59
+ @serial_number = attrs[:serial_number]
60
+ @usb_version_major = attrs.fetch(:usb_version_major, 0)
61
+ @usb_version_minor = attrs.fetch(:usb_version_minor, 0)
62
+ @usb_version_subminor = attrs.fetch(:usb_version_subminor, 0)
63
+ @bus_number = attrs[:bus_number]
64
+ @device_address = attrs[:device_address]
65
+ @configuration_sources = deep_copy(Array(attrs[:configurations])).freeze
66
+ @selected_configuration_value = attrs[:configuration_value]
67
+ @selected_alternates = {}
68
+ @claimed_interfaces = Set.new
69
+ @device_handle = nil
70
+ @opened = false
71
+ @forgotten = false
72
+ rebuild_configurations!
73
+ end
74
+
75
+ # Open the device session.
76
+ #
77
+ # @raise [DeviceNotFoundError] when the device was forgotten
78
+ # @return [Device]
79
+ def open
80
+ raise DeviceNotFoundError, "Device has been forgotten" if @forgotten
81
+ return self if opened?
82
+
83
+ @device_handle = UsbKit.backend.open_device(@raw_device)
84
+ @opened = true
85
+ hydrate_string_descriptors!
86
+ self
87
+ end
88
+
89
+ # Close the device session and release claimed interfaces.
90
+ #
91
+ # @return [nil]
92
+ def close
93
+ return nil unless opened?
94
+
95
+ @claimed_interfaces.to_a.each do |interface_number|
96
+ UsbKit.backend.release_interface(@device_handle, interface_number)
97
+ rescue Error
98
+ nil
99
+ end
100
+
101
+ UsbKit.backend.close_device(@device_handle)
102
+ nil
103
+ ensure
104
+ @device_handle = nil
105
+ @opened = false
106
+ @claimed_interfaces.clear
107
+ rebuild_configurations!
108
+ end
109
+
110
+ # Forget the device and clear local references.
111
+ #
112
+ # @return [Device]
113
+ def forget
114
+ close
115
+ @forgotten = true
116
+ @raw_device = nil
117
+ @selected_configuration_value = nil
118
+ @selected_alternates = {}
119
+ @configuration_sources = [].freeze
120
+ rebuild_configurations!
121
+ self
122
+ end
123
+
124
+ # Run a block with an open device session.
125
+ #
126
+ # @yieldparam device [Device]
127
+ # @raise [ArgumentError] when no block is provided
128
+ # @return [Object]
129
+ def with_session
130
+ raise ArgumentError, "A block is required" unless block_given?
131
+
132
+ already_open = opened?
133
+ open unless already_open
134
+ yield self
135
+ ensure
136
+ close unless already_open
137
+ end
138
+
139
+ # Select a device configuration.
140
+ #
141
+ # @param configuration_value [Integer]
142
+ # @raise [DeviceNotOpenedError, ConfigurationError]
143
+ # @return [nil]
144
+ def select_configuration(configuration_value)
145
+ ensure_open!
146
+ find_configuration_source!(configuration_value)
147
+ UsbKit.backend.set_configuration(@device_handle, configuration_value)
148
+ @selected_configuration_value = configuration_value
149
+ @claimed_interfaces.clear
150
+ @selected_alternates = {}
151
+ rebuild_configurations!
152
+ nil
153
+ end
154
+
155
+ # Claim an interface for exclusive access.
156
+ #
157
+ # @param interface_number [Integer]
158
+ # @raise [DeviceNotOpenedError, InterfaceError]
159
+ # @return [nil]
160
+ def claim_interface(interface_number)
161
+ ensure_open!
162
+ find_interface_source!(interface_number)
163
+ UsbKit.backend.claim_interface(@device_handle, interface_number)
164
+ @claimed_interfaces.add(interface_number)
165
+ rebuild_configurations!
166
+ nil
167
+ end
168
+
169
+ # Release a previously claimed interface.
170
+ #
171
+ # @param interface_number [Integer]
172
+ # @raise [DeviceNotOpenedError, InterfaceError]
173
+ # @return [nil]
174
+ def release_interface(interface_number)
175
+ ensure_open!
176
+ find_interface_source!(interface_number)
177
+ UsbKit.backend.release_interface(@device_handle, interface_number)
178
+ @claimed_interfaces.delete(interface_number)
179
+ rebuild_configurations!
180
+ nil
181
+ end
182
+
183
+ # Select an alternate interface setting.
184
+ #
185
+ # @param interface_number [Integer]
186
+ # @param alternate_setting [Integer]
187
+ # @raise [DeviceNotOpenedError, InterfaceError]
188
+ # @return [nil]
189
+ def select_alternate_interface(interface_number, alternate_setting)
190
+ ensure_open!
191
+ interface_source = find_interface_source!(interface_number)
192
+ unless interface_source.fetch(:alternates, []).any? { |alternate| alternate[:alternate_setting] == alternate_setting }
193
+ raise InterfaceError, "Unknown alternate setting: #{alternate_setting}"
194
+ end
195
+
196
+ UsbKit.backend.set_alternate_interface(@device_handle, interface_number, alternate_setting)
197
+ @selected_alternates[interface_number] = alternate_setting
198
+ rebuild_configurations!
199
+ nil
200
+ end
201
+
202
+ # Perform a control IN transfer.
203
+ #
204
+ # @param setup [Hash]
205
+ # @param length [Integer]
206
+ # @raise [DeviceNotOpenedError, ArgumentError]
207
+ # @return [InTransferResult]
208
+ def control_transfer_in(setup, length)
209
+ ensure_open!
210
+ validate_setup!(setup)
211
+ build_in_transfer_result(UsbKit.backend.control_transfer_in(@device_handle, setup, length))
212
+ end
213
+
214
+ # Perform a control OUT transfer.
215
+ #
216
+ # @param setup [Hash]
217
+ # @param data [String, nil]
218
+ # @raise [DeviceNotOpenedError, ArgumentError]
219
+ # @return [OutTransferResult]
220
+ def control_transfer_out(setup, data = nil)
221
+ ensure_open!
222
+ validate_setup!(setup)
223
+ build_out_transfer_result(UsbKit.backend.control_transfer_out(@device_handle, setup, data))
224
+ end
225
+
226
+ # Perform a bulk or interrupt IN transfer.
227
+ #
228
+ # @param endpoint_number [Integer]
229
+ # @param length [Integer]
230
+ # @raise [DeviceNotOpenedError]
231
+ # @return [InTransferResult]
232
+ def transfer_in(endpoint_number, length)
233
+ ensure_open!
234
+ build_in_transfer_result(
235
+ UsbKit.backend.bulk_transfer_in(@device_handle, endpoint_number, length, timeout: UsbKit.config.transfer_timeout)
236
+ )
237
+ end
238
+
239
+ # Perform a bulk or interrupt OUT transfer.
240
+ #
241
+ # @param endpoint_number [Integer]
242
+ # @param data [String]
243
+ # @raise [DeviceNotOpenedError]
244
+ # @return [OutTransferResult]
245
+ def transfer_out(endpoint_number, data)
246
+ ensure_open!
247
+ build_out_transfer_result(
248
+ UsbKit.backend.bulk_transfer_out(@device_handle, endpoint_number, data, timeout: UsbKit.config.transfer_timeout)
249
+ )
250
+ end
251
+
252
+ # Perform an isochronous IN transfer.
253
+ #
254
+ # @param endpoint_number [Integer]
255
+ # @param packet_lengths [Array<Integer>]
256
+ # @raise [DeviceNotOpenedError, Error]
257
+ # @return [IsochronousInTransferResult]
258
+ def isochronous_transfer_in(endpoint_number, packet_lengths)
259
+ ensure_open!
260
+ build_isochronous_in_transfer_result(
261
+ UsbKit.backend.isochronous_transfer_in(@device_handle, endpoint_number, packet_lengths)
262
+ )
263
+ rescue NotImplementedError => error
264
+ raise Error, error.message
265
+ end
266
+
267
+ # Perform an isochronous OUT transfer.
268
+ #
269
+ # @param endpoint_number [Integer]
270
+ # @param data [String]
271
+ # @param packet_lengths [Array<Integer>]
272
+ # @raise [DeviceNotOpenedError, Error]
273
+ # @return [IsochronousOutTransferResult]
274
+ def isochronous_transfer_out(endpoint_number, data, packet_lengths)
275
+ ensure_open!
276
+ build_isochronous_out_transfer_result(
277
+ UsbKit.backend.isochronous_transfer_out(@device_handle, endpoint_number, data, packet_lengths)
278
+ )
279
+ rescue NotImplementedError => error
280
+ raise Error, error.message
281
+ end
282
+
283
+ # Clear a halted endpoint.
284
+ #
285
+ # @param direction [Symbol]
286
+ # @param endpoint_number [Integer]
287
+ # @raise [DeviceNotOpenedError, ArgumentError]
288
+ # @return [nil]
289
+ def clear_halt(direction, endpoint_number)
290
+ ensure_open!
291
+ raise ArgumentError, "Invalid direction: #{direction.inspect}" unless Direction.valid?(direction)
292
+
293
+ UsbKit.backend.clear_halt(@device_handle, endpoint_address(direction, endpoint_number))
294
+ nil
295
+ end
296
+
297
+ # Reset the device.
298
+ #
299
+ # @raise [DeviceNotOpenedError]
300
+ # @return [nil]
301
+ def reset
302
+ ensure_open!
303
+ UsbKit.backend.reset_device(@device_handle)
304
+ nil
305
+ end
306
+
307
+ # Build an async proxy bound to this device.
308
+ #
309
+ # @return [Async::AsyncProxy]
310
+ def async
311
+ require_relative "async"
312
+ Async::AsyncProxy.new(self)
313
+ end
314
+
315
+ # @return [Hash]
316
+ def to_h
317
+ {
318
+ configuration: configuration&.to_h,
319
+ configurations: configurations.map(&:to_h),
320
+ device_class: device_class,
321
+ device_subclass: device_subclass,
322
+ device_protocol: device_protocol,
323
+ device_version_major: device_version_major,
324
+ device_version_minor: device_version_minor,
325
+ device_version_subminor: device_version_subminor,
326
+ manufacturer_name: manufacturer_name,
327
+ opened: opened?,
328
+ product_id: product_id,
329
+ product_name: product_name,
330
+ serial_number: serial_number,
331
+ usb_version_major: usb_version_major,
332
+ usb_version_minor: usb_version_minor,
333
+ usb_version_subminor: usb_version_subminor,
334
+ vendor_id: vendor_id,
335
+ bus_number: bus_number,
336
+ device_address: device_address
337
+ }
338
+ end
339
+
340
+ # @return [String]
341
+ def inspect
342
+ "#<#{self.class} vendor_id=0x#{vendor_id.to_s(16).rjust(4, '0')} " \
343
+ "product_id=0x#{product_id.to_s(16).rjust(4, '0')} opened=#{opened?.inspect}>"
344
+ end
345
+
346
+ private
347
+
348
+ def ensure_open!
349
+ return if opened?
350
+
351
+ raise DeviceNotOpenedError, "Device must be opened before performing USB operations"
352
+ end
353
+
354
+ def rebuild_configurations!
355
+ @configurations = @configuration_sources.map { |source| build_configuration(source) }.freeze
356
+ @configuration = @configurations.find do |candidate|
357
+ candidate.configuration_value == @selected_configuration_value
358
+ end
359
+ end
360
+
361
+ def build_configuration(source)
362
+ Configuration.new(
363
+ {
364
+ configuration_value: source[:configuration_value],
365
+ configuration_name: source[:configuration_name],
366
+ interfaces: Array(source[:interfaces]).map { |interface| build_interface(interface) }
367
+ },
368
+ device: self
369
+ )
370
+ end
371
+
372
+ def build_interface(source)
373
+ Interface.new(
374
+ interface_number: source[:interface_number],
375
+ alternates: Array(source[:alternates]).map { |alternate| build_alternate(alternate) },
376
+ alternate_setting: selected_alternate_for(source),
377
+ claimed: @claimed_interfaces.include?(source[:interface_number])
378
+ )
379
+ end
380
+
381
+ def build_alternate(source)
382
+ AlternateInterface.new(
383
+ alternate_setting: source[:alternate_setting],
384
+ interface_class: source.fetch(:interface_class, 0),
385
+ interface_subclass: source.fetch(:interface_subclass, 0),
386
+ interface_protocol: source.fetch(:interface_protocol, 0),
387
+ interface_name: source[:interface_name],
388
+ endpoints: Array(source[:endpoints]).map { |endpoint| endpoint.is_a?(Endpoint) ? endpoint : Endpoint.new(endpoint) }
389
+ )
390
+ end
391
+
392
+ def selected_alternate_for(source)
393
+ @selected_alternates.fetch(source[:interface_number], source[:alternate_setting])
394
+ end
395
+
396
+ def find_configuration_source!(configuration_value)
397
+ @configuration_sources.find { |source| source[:configuration_value] == configuration_value } ||
398
+ raise(ConfigurationError, "Unknown configuration value: #{configuration_value}")
399
+ end
400
+
401
+ def find_interface_source!(interface_number)
402
+ configuration_source = @configuration_sources.find do |source|
403
+ source[:configuration_value] == @selected_configuration_value
404
+ end || @configuration_sources.first
405
+
406
+ interfaces = Array(configuration_source&.fetch(:interfaces, []))
407
+ interfaces.find { |source| source[:interface_number] == interface_number } ||
408
+ raise(InterfaceError, "Unknown interface number: #{interface_number}")
409
+ end
410
+
411
+ def validate_setup!(setup)
412
+ raise ArgumentError, "setup must be a Hash" unless setup.is_a?(Hash)
413
+
414
+ missing_keys = SETUP_KEYS - setup.keys
415
+ raise ArgumentError, "Missing setup keys: #{missing_keys.inspect}" unless missing_keys.empty?
416
+
417
+ extra_keys = setup.keys - SETUP_KEYS
418
+ raise ArgumentError, "Unknown setup keys: #{extra_keys.inspect}" unless extra_keys.empty?
419
+ raise ArgumentError, "Invalid request type: #{setup[:request_type].inspect}" unless RequestType.valid?(setup[:request_type])
420
+ raise ArgumentError, "Invalid recipient: #{setup[:recipient].inspect}" unless Recipient.valid?(setup[:recipient])
421
+
422
+ %i[request value index].each do |key|
423
+ raise ArgumentError, "#{key} must be an Integer" unless setup[key].is_a?(Integer)
424
+ end
425
+ end
426
+
427
+ def endpoint_address(direction, endpoint_number)
428
+ endpoint_number | (direction == :in ? 0x80 : 0x00)
429
+ end
430
+
431
+ def build_in_transfer_result(result)
432
+ return result if result.is_a?(InTransferResult)
433
+ return InTransferResult.new(status: TransferStatus.OK, data: result) if result.is_a?(String)
434
+
435
+ InTransferResult.new(result)
436
+ end
437
+
438
+ def build_out_transfer_result(result)
439
+ return result if result.is_a?(OutTransferResult)
440
+ return OutTransferResult.new(status: TransferStatus.OK, bytes_written: result) if result.is_a?(Integer)
441
+
442
+ OutTransferResult.new(result)
443
+ end
444
+
445
+ def build_isochronous_in_transfer_result(result)
446
+ return result if result.is_a?(IsochronousInTransferResult)
447
+
448
+ IsochronousInTransferResult.new(result)
449
+ end
450
+
451
+ def build_isochronous_out_transfer_result(result)
452
+ return result if result.is_a?(IsochronousOutTransferResult)
453
+
454
+ IsochronousOutTransferResult.new(result)
455
+ end
456
+
457
+ def hydrate_string_descriptors!
458
+ return unless @device_handle
459
+
460
+ %i[manufacturer_name product_name serial_number].each do |attribute|
461
+ next unless @device_handle.respond_to?(attribute)
462
+ next if instance_variable_get(:"@#{attribute}")
463
+
464
+ instance_variable_set(:"@#{attribute}", @device_handle.public_send(attribute))
465
+ end
466
+ end
467
+
468
+ def deep_copy(value)
469
+ case value
470
+ when Array
471
+ value.map { |item| deep_copy(item) }
472
+ when Hash
473
+ value.each_with_object({}) { |(key, item), copy| copy[key] = deep_copy(item) }
474
+ else
475
+ value
476
+ end
477
+ end
478
+ end
479
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsbKit
4
+ ##
5
+ # Immutable USB endpoint descriptor.
6
+ #
7
+ class Endpoint
8
+ # @return [Integer] endpoint number without direction bit
9
+ # @return [Symbol] transfer direction
10
+ # @return [Symbol] endpoint type
11
+ # @return [Integer] packet size in bytes
12
+ attr_reader :endpoint_number, :direction, :type, :packet_size
13
+
14
+ # @param attrs [Hash]
15
+ # @raise [ArgumentError] when direction or type is invalid
16
+ # @return [void]
17
+ def initialize(attrs)
18
+ @endpoint_number = attrs.fetch(:endpoint_number)
19
+ @direction = validate_direction(attrs.fetch(:direction))
20
+ @type = validate_type(attrs.fetch(:type))
21
+ @packet_size = attrs.fetch(:packet_size)
22
+ freeze
23
+ end
24
+
25
+ # @return [Hash]
26
+ def to_h
27
+ {
28
+ endpoint_number: endpoint_number,
29
+ direction: direction,
30
+ type: type,
31
+ packet_size: packet_size
32
+ }
33
+ end
34
+
35
+ # @return [String]
36
+ def inspect
37
+ "#<#{self.class} #{to_h.map { |key, value| "#{key}=#{value.inspect}" }.join(' ')}>"
38
+ end
39
+
40
+ private
41
+
42
+ def validate_direction(direction)
43
+ return direction if Direction.valid?(direction)
44
+
45
+ raise ArgumentError, "Invalid direction: #{direction.inspect}"
46
+ end
47
+
48
+ def validate_type(type)
49
+ return type if EndpointType.valid?(type)
50
+
51
+ raise ArgumentError, "Invalid endpoint type: #{type.inspect}"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsbKit
4
+ ##
5
+ # Base UsbKit error.
6
+ #
7
+ class Error < StandardError; end
8
+
9
+ ##
10
+ # Raised when no device matches the requested filters.
11
+ #
12
+ class DeviceNotFoundError < Error; end
13
+
14
+ ##
15
+ # Raised for permissions and access failures.
16
+ #
17
+ class DeviceAccessError < Error; end
18
+
19
+ ##
20
+ # Raised when a device or interface is already in use.
21
+ #
22
+ class DeviceBusyError < Error; end
23
+
24
+ ##
25
+ # Raised when an operation requires an open device session.
26
+ #
27
+ class DeviceNotOpenedError < Error; end
28
+
29
+ ##
30
+ # Raised for USB transfer failures.
31
+ #
32
+ class TransferError < Error
33
+ # @return [Symbol, nil] transfer failure status
34
+ attr_reader :status
35
+
36
+ # @param message [String]
37
+ # @param status [Symbol, nil]
38
+ # @return [void]
39
+ def initialize(message = "Transfer failed", status: nil)
40
+ @status = status
41
+ super(message)
42
+ end
43
+ end
44
+
45
+ ##
46
+ # Raised when selecting a configuration fails.
47
+ #
48
+ class ConfigurationError < Error; end
49
+
50
+ ##
51
+ # Raised when claiming or selecting interfaces fails.
52
+ #
53
+ class InterfaceError < Error; end
54
+
55
+ ##
56
+ # Raised when no backend can satisfy the request.
57
+ #
58
+ class BackendNotAvailableError < Error; end
59
+
60
+ ##
61
+ # Raised when a transfer exceeds its timeout budget.
62
+ #
63
+ class TimeoutError < TransferError
64
+ # @param message [String]
65
+ # @param status [Symbol]
66
+ # @return [void]
67
+ def initialize(message = "Transfer timed out", status: :timeout)
68
+ super(message, status: status)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsbKit
4
+ ##
5
+ # WebUSB-style device filter matcher.
6
+ #
7
+ class Filter
8
+ ALLOWED_KEYS = %i[vendor_id product_id class_code subclass_code protocol_code serial_number].freeze
9
+
10
+ KEY_MAP = {
11
+ vendor_id: :vendor_id,
12
+ product_id: :product_id,
13
+ class_code: :device_class,
14
+ subclass_code: :device_subclass,
15
+ protocol_code: :device_protocol,
16
+ serial_number: :serial_number
17
+ }.freeze
18
+
19
+ # @param filters [Array<Hash>]
20
+ # @raise [ArgumentError] when a filter contains unsupported keys
21
+ # @return [void]
22
+ def initialize(filters)
23
+ @filters = Array(filters).map { |filter| validate_and_normalize(filter) }.freeze
24
+ end
25
+
26
+ # @param device_attrs [Hash]
27
+ # @return [Boolean]
28
+ def match?(device_attrs)
29
+ return true if @filters.empty?
30
+
31
+ @filters.any? { |filter| filter_matches?(filter, device_attrs) }
32
+ end
33
+
34
+ private
35
+
36
+ def validate_and_normalize(filter)
37
+ raise ArgumentError, "Filter must be a Hash" unless filter.is_a?(Hash)
38
+
39
+ unknown_keys = filter.keys - ALLOWED_KEYS
40
+ raise ArgumentError, "Unknown filter keys: #{unknown_keys.inspect}" unless unknown_keys.empty?
41
+
42
+ filter.each_with_object({}) do |(key, value), normalized|
43
+ normalized[KEY_MAP.fetch(key)] = value
44
+ end.freeze
45
+ end
46
+
47
+ def filter_matches?(filter, attrs)
48
+ filter.all? { |key, value| attrs[key] == value }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsbKit
4
+ ##
5
+ # Immutable USB interface descriptor.
6
+ #
7
+ class Interface
8
+ # @return [Integer] interface number
9
+ # @return [AlternateInterface, nil] currently selected alternate
10
+ # @return [Array<AlternateInterface>] available alternates
11
+ # @return [Boolean] whether the interface is claimed
12
+ attr_reader :interface_number, :alternate, :alternates, :claimed
13
+ alias claimed? claimed
14
+
15
+ # @param attrs [Hash]
16
+ # @return [void]
17
+ def initialize(attrs)
18
+ @interface_number = attrs.fetch(:interface_number)
19
+ @alternates = Array(attrs.fetch(:alternates, [])).map { |alternate| build_alternate(alternate) }.freeze
20
+ @alternate = resolve_alternate(attrs[:alternate_setting])
21
+ @claimed = attrs.fetch(:claimed, false)
22
+ freeze
23
+ end
24
+
25
+ # @return [Hash]
26
+ def to_h
27
+ {
28
+ interface_number: interface_number,
29
+ claimed: claimed,
30
+ alternate_setting: alternate&.alternate_setting,
31
+ alternates: alternates.map(&:to_h)
32
+ }
33
+ end
34
+
35
+ # @return [String]
36
+ def inspect
37
+ "#<#{self.class} interface_number=#{interface_number.inspect} claimed=#{claimed.inspect}>"
38
+ end
39
+
40
+ private
41
+
42
+ def build_alternate(alternate)
43
+ alternate.is_a?(AlternateInterface) ? alternate : AlternateInterface.new(alternate)
44
+ end
45
+
46
+ def resolve_alternate(selected_setting)
47
+ return alternates.first if selected_setting.nil?
48
+
49
+ alternates.find { |alternate| alternate.alternate_setting == selected_setting } || alternates.first
50
+ end
51
+ end
52
+ end