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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +152 -0
- data/lib/usbkit/alternate_interface.rb +56 -0
- data/lib/usbkit/async.rb +57 -0
- data/lib/usbkit/backend/base.rb +133 -0
- data/lib/usbkit/backend/usb_ruby/device_wrapper.rb +148 -0
- data/lib/usbkit/backend/usb_ruby.rb +316 -0
- data/lib/usbkit/backend/usbfs/ioctl_commands.rb +16 -0
- data/lib/usbkit/backend/usbfs/sysfs_reader.rb +102 -0
- data/lib/usbkit/backend/usbfs.rb +200 -0
- data/lib/usbkit/configuration.rb +36 -0
- data/lib/usbkit/connection_event.rb +41 -0
- data/lib/usbkit/constants.rb +88 -0
- data/lib/usbkit/context.rb +168 -0
- data/lib/usbkit/device.rb +479 -0
- data/lib/usbkit/endpoint.rb +54 -0
- data/lib/usbkit/errors.rb +71 -0
- data/lib/usbkit/filter.rb +51 -0
- data/lib/usbkit/interface.rb +52 -0
- data/lib/usbkit/transfer_results.rb +193 -0
- data/lib/usbkit/usb_configuration.rb +45 -0
- data/lib/usbkit/version.rb +5 -0
- data/lib/usbkit.rb +87 -0
- metadata +79 -0
|
@@ -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
|