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,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsbKit
4
+ ##
5
+ # Direction enum used by endpoints and transfers.
6
+ #
7
+ module Direction
8
+ IN = :in
9
+ OUT = :out
10
+
11
+ ALL = [IN, OUT].freeze
12
+
13
+ # @param value [Object]
14
+ # @return [Boolean]
15
+ def self.valid?(value)
16
+ ALL.include?(value)
17
+ end
18
+ end
19
+
20
+ ##
21
+ # Endpoint transfer type enum.
22
+ #
23
+ module EndpointType
24
+ BULK = :bulk
25
+ INTERRUPT = :interrupt
26
+ ISOCHRONOUS = :isochronous
27
+
28
+ ALL = [BULK, INTERRUPT, ISOCHRONOUS].freeze
29
+
30
+ # @param value [Object]
31
+ # @return [Boolean]
32
+ def self.valid?(value)
33
+ ALL.include?(value)
34
+ end
35
+ end
36
+
37
+ ##
38
+ # Control transfer request type enum.
39
+ #
40
+ module RequestType
41
+ STANDARD = :standard
42
+ CLASS = :class
43
+ VENDOR = :vendor
44
+
45
+ ALL = [STANDARD, CLASS, VENDOR].freeze
46
+
47
+ # @param value [Object]
48
+ # @return [Boolean]
49
+ def self.valid?(value)
50
+ ALL.include?(value)
51
+ end
52
+ end
53
+
54
+ ##
55
+ # Control transfer recipient enum.
56
+ #
57
+ module Recipient
58
+ DEVICE = :device
59
+ INTERFACE = :interface
60
+ ENDPOINT = :endpoint
61
+ OTHER = :other
62
+
63
+ ALL = [DEVICE, INTERFACE, ENDPOINT, OTHER].freeze
64
+
65
+ # @param value [Object]
66
+ # @return [Boolean]
67
+ def self.valid?(value)
68
+ ALL.include?(value)
69
+ end
70
+ end
71
+
72
+ ##
73
+ # Transfer status enum.
74
+ #
75
+ module TransferStatus
76
+ OK = :ok
77
+ STALL = :stall
78
+ BABBLE = :babble
79
+
80
+ ALL = [OK, STALL, BABBLE].freeze
81
+
82
+ # @param value [Object]
83
+ # @return [Boolean]
84
+ def self.valid?(value)
85
+ ALL.include?(value)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsbKit
4
+ ##
5
+ # Entry point for WebUSB-style device discovery and monitoring.
6
+ #
7
+ class Context
8
+ EVENT_TYPES = %i[connect disconnect].freeze
9
+
10
+ # @param backend [Backend::Base]
11
+ # @param config [GemConfiguration]
12
+ # @return [void]
13
+ def initialize(backend: UsbKit.backend, config: UsbKit.config)
14
+ @backend = backend
15
+ @config = config
16
+ @callbacks = Hash.new { |hash, key| hash[key] = [] }
17
+ @mutex = Mutex.new
18
+ @monitoring = false
19
+ @monitor_thread = nil
20
+ @snapshot = {}
21
+ end
22
+
23
+ # Enumerate visible devices.
24
+ #
25
+ # @param filters [Array<Hash>, nil]
26
+ # @return [Array<Device>]
27
+ def get_devices(filters: nil)
28
+ devices = enumerate_visible_devices
29
+ return devices if filters.nil?
30
+
31
+ matcher = Filter.new(filters)
32
+ devices.select { |device| matcher.match?(device.to_h) }
33
+ end
34
+
35
+ # Return the first device that matches the provided filters.
36
+ #
37
+ # @param filters [Array<Hash>]
38
+ # @raise [ArgumentError] when filters are missing
39
+ # @raise [DeviceNotFoundError] when no device matches
40
+ # @return [Device]
41
+ def request_device(filters:)
42
+ raise ArgumentError, "filters are required" if filters.nil? || Array(filters).empty?
43
+
44
+ matcher = Filter.new(filters)
45
+ device = enumerate_visible_devices.find { |candidate| matcher.match?(candidate.to_h) }
46
+ return device if device
47
+
48
+ raise DeviceNotFoundError, "No USB device matched the provided filters"
49
+ end
50
+
51
+ # Register a connection lifecycle callback.
52
+ #
53
+ # @param event_type [Symbol] :connect or :disconnect
54
+ # @yieldparam event [ConnectionEvent]
55
+ # @raise [ArgumentError] when the event type or block is invalid
56
+ # @return [Context]
57
+ def on(event_type, &block)
58
+ raise ArgumentError, "Unsupported event type: #{event_type.inspect}" unless EVENT_TYPES.include?(event_type)
59
+ raise ArgumentError, "A block is required" unless block
60
+
61
+ @mutex.synchronize do
62
+ @callbacks[event_type] << block
63
+ end
64
+
65
+ self
66
+ end
67
+
68
+ # Start polling for device changes in a background thread.
69
+ #
70
+ # @param interval [Float] polling interval in seconds
71
+ # @return [Thread]
72
+ def start_event_monitoring(interval: 1.0)
73
+ @mutex.synchronize do
74
+ return @monitor_thread if @monitor_thread&.alive?
75
+
76
+ @snapshot = build_snapshot(enumerate_visible_devices)
77
+ @monitoring = true
78
+ @monitor_thread = Thread.new do
79
+ monitor_devices(interval)
80
+ end
81
+ end
82
+ end
83
+
84
+ # Stop the event-monitoring thread.
85
+ #
86
+ # @return [nil]
87
+ def stop_event_monitoring
88
+ thread = @mutex.synchronize do
89
+ @monitoring = false
90
+ thread = @monitor_thread
91
+ @monitor_thread = nil
92
+ thread
93
+ end
94
+
95
+ thread&.join unless thread == Thread.current
96
+ nil
97
+ end
98
+
99
+ private
100
+
101
+ def enumerate_visible_devices
102
+ Array(@backend.enumerate_devices).filter_map do |attributes|
103
+ next if blocked_device?(attributes)
104
+
105
+ Device.new(attributes)
106
+ end
107
+ end
108
+
109
+ def blocked_device?(attributes)
110
+ @config.blocked_classes.include?(attributes[:device_class])
111
+ end
112
+
113
+ def build_snapshot(devices)
114
+ devices.each_with_object({}) do |device, snapshot|
115
+ snapshot[device_identifier(device)] = device
116
+ end
117
+ end
118
+
119
+ def device_identifier(device)
120
+ attrs = device.to_h
121
+ [
122
+ attrs[:vendor_id],
123
+ attrs[:product_id],
124
+ attrs[:serial_number],
125
+ attrs[:bus_number],
126
+ attrs[:device_address]
127
+ ]
128
+ end
129
+
130
+ def monitor_devices(interval)
131
+ loop do
132
+ break unless monitoring?
133
+
134
+ current_devices = enumerate_visible_devices
135
+ current_snapshot = build_snapshot(current_devices)
136
+ dispatch_snapshot_changes(current_snapshot)
137
+ @mutex.synchronize do
138
+ @snapshot = current_snapshot
139
+ end
140
+ sleep interval
141
+ rescue StandardError
142
+ sleep interval
143
+ end
144
+ end
145
+
146
+ def dispatch_snapshot_changes(current_snapshot)
147
+ previous_snapshot = @mutex.synchronize { @snapshot.dup }
148
+
149
+ (current_snapshot.keys - previous_snapshot.keys).each do |identifier|
150
+ dispatch(:connect, current_snapshot.fetch(identifier))
151
+ end
152
+
153
+ (previous_snapshot.keys - current_snapshot.keys).each do |identifier|
154
+ dispatch(:disconnect, previous_snapshot.fetch(identifier))
155
+ end
156
+ end
157
+
158
+ def dispatch(event_type, device)
159
+ callbacks = @mutex.synchronize { @callbacks[event_type].dup }
160
+ event = ConnectionEvent.new(type: event_type, device: device)
161
+ callbacks.each { |callback| callback.call(event) }
162
+ end
163
+
164
+ def monitoring?
165
+ @mutex.synchronize { @monitoring }
166
+ end
167
+ end
168
+ end