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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a316c9064db6d4e7cf6c446bd732a5b90ebd86b7e93d9770f782a339ebbbf4bc
4
+ data.tar.gz: 176a74ed2d34cd1d354b7d2a2162d324357cbc2d512af93603804774323446ea
5
+ SHA512:
6
+ metadata.gz: a37ddbfd7d868ab4c1a63e8c69c41c768764ff6a11da7c581f15ad27601835691015bb6d699f62c13b3bbf33c2a1d4b6e2e08a303025ea5d1356db67e237ac15
7
+ data.tar.gz: 959235c30c65d104f73d8d17c420284686c8b69d25e4295b7b67460c2904ce3a93c6d99e789d6015c65bfb9c284c8f2ff5daa42c6946cededfb46398d8ef5bde
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-03-27
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # UsbKit
2
+
3
+ UsbKit is a Ruby gem that brings a WebUSB-style API to Ruby applications. It exposes `Context`, `Device`, descriptor objects, transfer result objects, hotplug callbacks, and an async wrapper while keeping the public API close to the WebUSB mental model.
4
+
5
+ ## Installation
6
+
7
+ Add the gem to your application:
8
+
9
+ ```ruby
10
+ gem "usbkit"
11
+ ```
12
+
13
+ Or install it directly:
14
+
15
+ ```bash
16
+ gem install usbkit
17
+ ```
18
+
19
+ UsbKit uses `usb-ruby` as the primary backend and falls back to Linux `usbfs` where available.
20
+
21
+ ## System Requirements
22
+
23
+ - Ruby 3.1+
24
+ - `libusb` when using the `usb-ruby` backend
25
+
26
+ Install `libusb` with one of the following:
27
+
28
+ ```bash
29
+ brew install libusb
30
+ sudo apt-get install libusb-1.0-0-dev
31
+ choco install libusb
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ```ruby
37
+ require "usbkit"
38
+
39
+ context = UsbKit::Context.new
40
+ devices = context.get_devices
41
+
42
+ devices.each do |device|
43
+ puts "#{device.vendor_id.to_s(16)}:#{device.product_id.to_s(16)} #{device.product_name}"
44
+ end
45
+ ```
46
+
47
+ Request a device and communicate with it:
48
+
49
+ ```ruby
50
+ require "usbkit"
51
+
52
+ context = UsbKit::Context.new
53
+ device = context.request_device(filters: [{ vendor_id: 0x2341 }])
54
+
55
+ device.with_session do |usb_device|
56
+ usb_device.select_configuration(1)
57
+ usb_device.claim_interface(0)
58
+ result = usb_device.transfer_in(1, 64)
59
+ puts result.data if result.status == :ok
60
+ end
61
+ ```
62
+
63
+ ## API Overview
64
+
65
+ ### `UsbKit::Context`
66
+
67
+ - `get_devices(filters: nil)` enumerates visible devices
68
+ - `request_device(filters:)` returns the first matching device
69
+ - `on(:connect)` and `on(:disconnect)` register hotplug callbacks
70
+ - `start_event_monitoring` and `stop_event_monitoring` manage the polling thread
71
+
72
+ ### `UsbKit::Device`
73
+
74
+ - Session: `open`, `close`, `forget`, `with_session`
75
+ - Configuration: `select_configuration`
76
+ - Interfaces: `claim_interface`, `release_interface`, `select_alternate_interface`
77
+ - Transfers: `control_transfer_in`, `control_transfer_out`, `transfer_in`, `transfer_out`
78
+ - Utilities: `clear_halt`, `reset`
79
+ - Async: `async.transfer_in(...)`
80
+
81
+ ## WebUSB Mapping
82
+
83
+ | WebUSB | UsbKit |
84
+ | --- | --- |
85
+ | `navigator.usb` | `UsbKit::Context` |
86
+ | `USBDevice` | `UsbKit::Device` |
87
+ | `USBConfiguration` | `UsbKit::Configuration` |
88
+ | `USBInterface` | `UsbKit::Interface` |
89
+ | `USBAlternateInterface` | `UsbKit::AlternateInterface` |
90
+ | `USBEndpoint` | `UsbKit::Endpoint` |
91
+
92
+ ## Backend Selection
93
+
94
+ UsbKit resolves a backend automatically by default:
95
+
96
+ ```ruby
97
+ UsbKit.backend
98
+ ```
99
+
100
+ You can pin a backend explicitly:
101
+
102
+ ```ruby
103
+ UsbKit.configure do |config|
104
+ config.backend = :usb_ruby
105
+ config.transfer_timeout = 2_000
106
+ end
107
+ ```
108
+
109
+ Available values:
110
+
111
+ - `:auto`
112
+ - `:usb_ruby`
113
+ - `:usbfs` on Linux only
114
+
115
+ ## Async Usage
116
+
117
+ ```ruby
118
+ future = device.async.transfer_in(1, 64)
119
+ result = future.value(timeout: 1.0)
120
+ ```
121
+
122
+ The async wrapper uses a Ruby `Thread` and returns a `UsbKit::Async::Future`.
123
+
124
+ ## Troubleshooting
125
+
126
+ - `BackendNotAvailableError`: install `usb-ruby` and ensure `libusb` is available.
127
+ - `DeviceAccessError`: fix OS-level permissions for the device node.
128
+ - `DeviceNotOpenedError`: call `open` or use `with_session`.
129
+ - `TimeoutError`: increase `UsbKit.config.transfer_timeout`.
130
+
131
+ On Linux you may need a udev rule for non-root access. On macOS you typically need `libusb` installed via Homebrew.
132
+
133
+ ## Development
134
+
135
+ Install dependencies and run the verification suite:
136
+
137
+ ```bash
138
+ bundle install
139
+ bundle exec rspec
140
+ bundle exec yard doc --output-dir doc/
141
+ gem build usbkit.gemspec
142
+ ```
143
+
144
+ Examples live in `examples/`.
145
+
146
+ ## Contributing
147
+
148
+ Bug reports and pull requests are welcome. Keep changes covered by tests and update the README and changelog when behavior changes.
149
+
150
+ ## License
151
+
152
+ Released under the MIT License. See `LICENSE.txt`.
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsbKit
4
+ ##
5
+ # Immutable USB alternate interface descriptor.
6
+ #
7
+ class AlternateInterface
8
+ # @return [Integer] alternate setting number
9
+ # @return [Integer] interface class code
10
+ # @return [Integer] interface subclass code
11
+ # @return [Integer] interface protocol code
12
+ # @return [String, nil] human-readable interface name
13
+ # @return [Array<Endpoint>] endpoints exposed by this alternate
14
+ attr_reader :alternate_setting,
15
+ :interface_class,
16
+ :interface_subclass,
17
+ :interface_protocol,
18
+ :interface_name,
19
+ :endpoints
20
+
21
+ # @param attrs [Hash]
22
+ # @return [void]
23
+ def initialize(attrs)
24
+ @alternate_setting = attrs.fetch(:alternate_setting)
25
+ @interface_class = attrs.fetch(:interface_class)
26
+ @interface_subclass = attrs.fetch(:interface_subclass)
27
+ @interface_protocol = attrs.fetch(:interface_protocol)
28
+ @interface_name = attrs[:interface_name]
29
+ @endpoints = Array(attrs.fetch(:endpoints, [])).map { |endpoint| build_endpoint(endpoint) }.freeze
30
+ freeze
31
+ end
32
+
33
+ # @return [Hash]
34
+ def to_h
35
+ {
36
+ alternate_setting: alternate_setting,
37
+ interface_class: interface_class,
38
+ interface_subclass: interface_subclass,
39
+ interface_protocol: interface_protocol,
40
+ interface_name: interface_name,
41
+ endpoints: endpoints.map(&:to_h)
42
+ }
43
+ end
44
+
45
+ # @return [String]
46
+ def inspect
47
+ "#<#{self.class} alternate_setting=#{alternate_setting.inspect} endpoints=#{endpoints.size}>"
48
+ end
49
+
50
+ private
51
+
52
+ def build_endpoint(endpoint)
53
+ endpoint.is_a?(Endpoint) ? endpoint : Endpoint.new(endpoint)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsbKit
4
+ module Async
5
+ ##
6
+ # Thread-backed future for asynchronous device operations.
7
+ #
8
+ class Future
9
+ # @yieldreturn [Object]
10
+ # @return [void]
11
+ def initialize(&block)
12
+ @thread = Thread.new(&block)
13
+ end
14
+
15
+ # @param timeout [Numeric, nil]
16
+ # @raise [TimeoutError] when the background operation does not finish in time
17
+ # @return [Object]
18
+ def value(timeout: nil)
19
+ @thread.join(timeout)
20
+ raise TimeoutError, "Async operation timed out" if @thread.alive?
21
+
22
+ @thread.value
23
+ end
24
+
25
+ # @return [Boolean]
26
+ def ready?
27
+ !@thread.alive?
28
+ end
29
+ end
30
+
31
+ ##
32
+ # Async wrapper around transfer-oriented device methods.
33
+ #
34
+ class AsyncProxy
35
+ TRANSFER_METHODS = %i[
36
+ transfer_in
37
+ transfer_out
38
+ control_transfer_in
39
+ control_transfer_out
40
+ isochronous_transfer_in
41
+ isochronous_transfer_out
42
+ ].freeze
43
+
44
+ # @param device [Device]
45
+ # @return [void]
46
+ def initialize(device)
47
+ @device = device
48
+ end
49
+
50
+ TRANSFER_METHODS.each do |method_name|
51
+ define_method(method_name) do |*args|
52
+ Future.new { @device.public_send(method_name, *args) }
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsbKit
4
+ module Backend
5
+ ##
6
+ # Abstract backend contract for USB operations.
7
+ #
8
+ class Base
9
+ # @return [Array<Hash>]
10
+ def enumerate_devices
11
+ raise NotImplementedError
12
+ end
13
+
14
+ # @param device_handle [Object]
15
+ # @return [Object]
16
+ def open_device(device_handle)
17
+ raise NotImplementedError
18
+ end
19
+
20
+ # @param device_handle [Object]
21
+ # @return [void]
22
+ def close_device(device_handle)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ # @param device_handle [Object]
27
+ # @param config_value [Integer]
28
+ # @return [void]
29
+ def set_configuration(device_handle, config_value)
30
+ raise NotImplementedError
31
+ end
32
+
33
+ # @param device_handle [Object]
34
+ # @param interface_number [Integer]
35
+ # @return [void]
36
+ def claim_interface(device_handle, interface_number)
37
+ raise NotImplementedError
38
+ end
39
+
40
+ # @param device_handle [Object]
41
+ # @param interface_number [Integer]
42
+ # @return [void]
43
+ def release_interface(device_handle, interface_number)
44
+ raise NotImplementedError
45
+ end
46
+
47
+ # @param device_handle [Object]
48
+ # @param interface_number [Integer]
49
+ # @param alternate_setting [Integer]
50
+ # @return [void]
51
+ def set_alternate_interface(device_handle, interface_number, alternate_setting)
52
+ raise NotImplementedError
53
+ end
54
+
55
+ # @param device_handle [Object]
56
+ # @param setup [Hash]
57
+ # @param length [Integer]
58
+ # @return [Hash]
59
+ def control_transfer_in(device_handle, setup, length)
60
+ raise NotImplementedError
61
+ end
62
+
63
+ # @param device_handle [Object]
64
+ # @param setup [Hash]
65
+ # @param data [String, nil]
66
+ # @return [Hash]
67
+ def control_transfer_out(device_handle, setup, data)
68
+ raise NotImplementedError
69
+ end
70
+
71
+ # @param device_handle [Object]
72
+ # @param endpoint [Integer]
73
+ # @param length [Integer]
74
+ # @param timeout [Integer]
75
+ # @return [Hash]
76
+ def bulk_transfer_in(device_handle, endpoint, length, timeout:)
77
+ raise NotImplementedError
78
+ end
79
+
80
+ # @param device_handle [Object]
81
+ # @param endpoint [Integer]
82
+ # @param data [String]
83
+ # @param timeout [Integer]
84
+ # @return [Hash]
85
+ def bulk_transfer_out(device_handle, endpoint, data, timeout:)
86
+ raise NotImplementedError
87
+ end
88
+
89
+ # @param device_handle [Object]
90
+ # @param endpoint [Integer]
91
+ # @param packet_lengths [Array<Integer>]
92
+ # @return [Hash]
93
+ def isochronous_transfer_in(device_handle, endpoint, packet_lengths)
94
+ raise NotImplementedError
95
+ end
96
+
97
+ # @param device_handle [Object]
98
+ # @param endpoint [Integer]
99
+ # @param data [String]
100
+ # @param packet_lengths [Array<Integer>]
101
+ # @return [Hash]
102
+ def isochronous_transfer_out(device_handle, endpoint, data, packet_lengths)
103
+ raise NotImplementedError
104
+ end
105
+
106
+ # @param device_handle [Object]
107
+ # @param endpoint [Integer]
108
+ # @return [void]
109
+ def clear_halt(device_handle, endpoint)
110
+ raise NotImplementedError
111
+ end
112
+
113
+ # @param device_handle [Object]
114
+ # @return [void]
115
+ def reset_device(device_handle)
116
+ raise NotImplementedError
117
+ end
118
+
119
+ # @param device_handle [Object]
120
+ # @return [Hash]
121
+ def get_device_descriptor(device_handle)
122
+ raise NotImplementedError
123
+ end
124
+
125
+ # @param device_handle [Object]
126
+ # @param index [Integer]
127
+ # @return [Object]
128
+ def get_configuration_descriptor(device_handle, index)
129
+ raise NotImplementedError
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsbKit
4
+ module Backend
5
+ class UsbRuby
6
+ ##
7
+ # Adapter that normalizes usb-ruby descriptors into UsbKit hashes.
8
+ #
9
+ class DeviceWrapper
10
+ ENDPOINT_TYPE_MAP = {
11
+ 0 => :control,
12
+ 1 => EndpointType::ISOCHRONOUS,
13
+ 2 => EndpointType::BULK,
14
+ 3 => EndpointType::INTERRUPT
15
+ }.freeze
16
+
17
+ # @param usb_device [Object]
18
+ # @return [void]
19
+ def initialize(usb_device)
20
+ @usb_device = usb_device
21
+ @descriptor = usb_device.device_descriptor
22
+ end
23
+
24
+ # @return [Hash]
25
+ def to_h
26
+ {
27
+ raw_device: @usb_device,
28
+ vendor_id: value_for(@descriptor, :vendor_id, :id_vendor),
29
+ product_id: value_for(@descriptor, :product_id, :id_product),
30
+ device_class: value_for(@descriptor, :device_class, :b_device_class, default: 0),
31
+ device_subclass: value_for(@descriptor, :device_sub_class, :device_subclass, :b_device_sub_class, default: 0),
32
+ device_protocol: value_for(@descriptor, :device_protocol, :b_device_protocol, default: 0),
33
+ device_version_major: bcd_major(value_for(@descriptor, :bcd_device, default: 0)),
34
+ device_version_minor: bcd_minor(value_for(@descriptor, :bcd_device, default: 0)),
35
+ device_version_subminor: bcd_subminor(value_for(@descriptor, :bcd_device, default: 0)),
36
+ usb_version_major: bcd_major(value_for(@descriptor, :bcd_usb, default: 0)),
37
+ usb_version_minor: bcd_minor(value_for(@descriptor, :bcd_usb, default: 0)),
38
+ usb_version_subminor: bcd_subminor(value_for(@descriptor, :bcd_usb, default: 0)),
39
+ manufacturer_name: value_for(@usb_device, :manufacturer_name, :manufacturer, default: nil),
40
+ product_name: value_for(@usb_device, :product_name, :product, default: nil),
41
+ serial_number: value_for(@usb_device, :serial_number, default: nil),
42
+ configurations: extract_configurations,
43
+ configuration_value: nil,
44
+ bus_number: value_for(@usb_device, :bus_number, default: nil),
45
+ device_address: value_for(@usb_device, :device_address, :address, default: nil)
46
+ }
47
+ end
48
+
49
+ private
50
+
51
+ def extract_configurations
52
+ configurations = if @usb_device.respond_to?(:configurations)
53
+ Array(@usb_device.configurations)
54
+ else
55
+ count = value_for(@descriptor, :num_configurations, :b_num_configurations, default: 0)
56
+ Array.new(count) { |index| @usb_device.config_descriptor(index) }
57
+ end
58
+
59
+ configurations.map { |configuration| extract_configuration(configuration) }
60
+ rescue StandardError
61
+ []
62
+ end
63
+
64
+ def extract_configuration(configuration)
65
+ {
66
+ configuration_value: value_for(configuration, :configuration_value, :b_configuration_value, default: 0),
67
+ configuration_name: value_for(configuration, :configuration_name, :description, default: nil),
68
+ interfaces: extract_interfaces(configuration)
69
+ }
70
+ end
71
+
72
+ def extract_interfaces(configuration)
73
+ interfaces = collection_for(configuration, :interfaces, :interface_descriptors)
74
+
75
+ interfaces.each_with_index.map do |interface_descriptor, index|
76
+ alternates = collection_for(interface_descriptor, :alternates, :alt_settings, :alternate_settings)
77
+ alternates = [interface_descriptor] if alternates.empty?
78
+
79
+ {
80
+ interface_number: value_for(interface_descriptor, :interface_number, :b_interface_number, default: index),
81
+ alternate_setting: value_for(alternates.first, :alternate_setting, :b_alternate_setting, default: 0),
82
+ alternates: alternates.map { |alternate| extract_alternate(alternate) }
83
+ }
84
+ end
85
+ end
86
+
87
+ def extract_alternate(alternate)
88
+ {
89
+ alternate_setting: value_for(alternate, :alternate_setting, :b_alternate_setting, default: 0),
90
+ interface_class: value_for(alternate, :interface_class, :b_interface_class, default: 0),
91
+ interface_subclass: value_for(alternate, :interface_subclass, :b_interface_sub_class, default: 0),
92
+ interface_protocol: value_for(alternate, :interface_protocol, :b_interface_protocol, default: 0),
93
+ interface_name: value_for(alternate, :interface_name, :description, default: nil),
94
+ endpoints: extract_endpoints(alternate)
95
+ }
96
+ end
97
+
98
+ def extract_endpoints(alternate)
99
+ collection_for(alternate, :endpoints, :endpoint_descriptors).map do |endpoint|
100
+ address = value_for(endpoint, :endpoint_address, :b_endpoint_address, default: 0)
101
+ {
102
+ endpoint_number: address & 0x0F,
103
+ direction: (address & 0x80).zero? ? Direction::OUT : Direction::IN,
104
+ type: endpoint_type(endpoint),
105
+ packet_size: value_for(endpoint, :max_packet_size, :w_max_packet_size, default: 0)
106
+ }
107
+ end
108
+ end
109
+
110
+ def endpoint_type(endpoint)
111
+ raw_type = value_for(endpoint, :transfer_type, :attributes, default: EndpointType::BULK)
112
+ return raw_type if EndpointType.valid?(raw_type)
113
+
114
+ mapped = ENDPOINT_TYPE_MAP[raw_type.to_i & 0x03]
115
+ EndpointType.valid?(mapped) ? mapped : EndpointType::BULK
116
+ end
117
+
118
+ def collection_for(object, *methods)
119
+ methods.each do |method_name|
120
+ return Array(object.public_send(method_name)) if object.respond_to?(method_name)
121
+ end
122
+
123
+ []
124
+ end
125
+
126
+ def value_for(object, *methods, default: nil)
127
+ methods.each do |method_name|
128
+ return object.public_send(method_name) if object.respond_to?(method_name)
129
+ end
130
+
131
+ default
132
+ end
133
+
134
+ def bcd_major(value)
135
+ (value >> 8) & 0xFF
136
+ end
137
+
138
+ def bcd_minor(value)
139
+ (value >> 4) & 0x0F
140
+ end
141
+
142
+ def bcd_subminor(value)
143
+ value & 0x0F
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end