tribble-control 0.4.4
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/DESIGN.md +871 -0
- data/LICENSE +21 -0
- data/README.md +541 -0
- data/examples/tribble.conf +208 -0
- data/examples/vbus-check +140 -0
- data/exe/tribble-control +14 -0
- data/lib/tribble-control/cli/connect.rb +282 -0
- data/lib/tribble-control/cli/flash.rb +102 -0
- data/lib/tribble-control/cli/reset.rb +39 -0
- data/lib/tribble-control/cli/serial.rb +60 -0
- data/lib/tribble-control/cli/usb.rb +104 -0
- data/lib/tribble-control/cli.rb +1230 -0
- data/lib/tribble-control/hub/exsys.rb +248 -0
- data/lib/tribble-control/hub/usb.rb +418 -0
- data/lib/tribble-control/hub.rb +115 -0
- data/lib/tribble-control/platform.rb +271 -0
- data/lib/tribble-control/tally.rb +91 -0
- data/lib/tribble-control/version.rb +8 -0
- data/lib/tribble-control.rb +32 -0
- data/man/man1/tribble-control.1 +1483 -0
- data/tribble-control.gemspec +99 -0
- metadata +192 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#
|
|
4
|
+
# The ExSYS 16-port managed hub, over the FT232 serial line wired
|
|
5
|
+
# inside it. The exsys gem speaks the frames; this class says which
|
|
6
|
+
# hub, and answers the Hub interface with it.
|
|
7
|
+
#
|
|
8
|
+
require 'exsys'
|
|
9
|
+
require 'exsys/managed-usb'
|
|
10
|
+
|
|
11
|
+
require_relative '../hub'
|
|
12
|
+
|
|
13
|
+
module TribbleControl
|
|
14
|
+
class Hub
|
|
15
|
+
|
|
16
|
+
class ExSYS < Hub
|
|
17
|
+
# The gem's class, named once. Inside this class the bare
|
|
18
|
+
# constant ExSYS is this class, not the gem's module.
|
|
19
|
+
GEM = ::ExSYS::ManagedUSB
|
|
20
|
+
|
|
21
|
+
# The USB id of a hub's control adapter, for the messages that name
|
|
22
|
+
# it. Asked of the gem rather than written out: it is a fact about
|
|
23
|
+
# the hardware, the gem is what knows it, and a literal here would
|
|
24
|
+
# go on saying 0403:6001 after the gem had stopped looking for that.
|
|
25
|
+
CTRL_ID = "#{GEM::CTRL_VENDOR}:#{GEM::CTRL_PRODUCT}".freeze
|
|
26
|
+
|
|
27
|
+
# Every FTDI 0403:6001 the host has, as the gem reports them:
|
|
28
|
+
# { device:, serial:, usb_path: }. Reports; decides nothing. The
|
|
29
|
+
# gem's own errors -- a host it cannot look on -- become ours.
|
|
30
|
+
def self.available = guard { GEM.available }
|
|
31
|
+
|
|
32
|
+
# The hub +named+ names, or the one hub the host has.
|
|
33
|
+
#
|
|
34
|
+
# Three shapes of name, told apart by what they look like, no two
|
|
35
|
+
# of which can be confused:
|
|
36
|
+
#
|
|
37
|
+
# /dev/ttyUSB1 a '/' in it, so a device node, used as given --
|
|
38
|
+
# the same rule --openocd uses to tell a path from
|
|
39
|
+
# a name to look up
|
|
40
|
+
# 1-1.2.4.4 the USB path shape (GEM::USB_PATH), so the
|
|
41
|
+
# adapter in that socket
|
|
42
|
+
# AL03GD7X anything else, so an FT232 serial number
|
|
43
|
+
#
|
|
44
|
+
# The node is the worst of the three to write down. The number in
|
|
45
|
+
# /dev/ttyUSB1 is not the hub's, and not the USB device number
|
|
46
|
+
# either: it is the usbserial layer's own index, and it is the
|
|
47
|
+
# LOWEST ONE FREE when the adapter is probed (ttyU on FreeBSD,
|
|
48
|
+
# allocated the same way). So it depends on what else was attached
|
|
49
|
+
# first, and it is reused -- unplug the adapter holding ttyUSB0 and
|
|
50
|
+
# the next thing to attach becomes ttyUSB0. Two hubs can therefore
|
|
51
|
+
# swap names across a reboot, or while the machine is up, and every
|
|
52
|
+
# configuration naming them that way is then pointed at the other bench.
|
|
53
|
+
#
|
|
54
|
+
# The other two are both stable, and they answer different
|
|
55
|
+
# questions. A serial stays with the ADAPTER: move the hub to
|
|
56
|
+
# another socket or another machine and its serial goes with it. A
|
|
57
|
+
# USB path stays with the SOCKET: whatever is plugged in there
|
|
58
|
+
# answers to it, a replacement hub included. Naming one particular
|
|
59
|
+
# hub is the serial's job and is the usual want. The path is for
|
|
60
|
+
# the hub whose EEPROM carries no serial to be named by -- its only
|
|
61
|
+
# stable name -- and for a bench where the socket is the fixed
|
|
62
|
+
# thing. Both platforms report one, but each in its own numbering,
|
|
63
|
+
# so a path names a socket on the host that reported it and does
|
|
64
|
+
# not travel to another.
|
|
65
|
+
#
|
|
66
|
+
# Auto-detection is a guess, and it is only a safe guess while
|
|
67
|
+
# there is one candidate. The gem reports every FTDI 0403:6001 on
|
|
68
|
+
# the host, which is a hub's control adapter and also every other
|
|
69
|
+
# FT232 attached -- the gem says so itself, and deliberately
|
|
70
|
+
# reports rather than decides, because telling them apart means
|
|
71
|
+
# opening the line and writing to it. With two, taking one would
|
|
72
|
+
# be a coin toss decided by enumeration order, on a command that
|
|
73
|
+
# might then switch somebody else's ports, so it refuses and lists
|
|
74
|
+
# what it found with the serials to choose between them.
|
|
75
|
+
def self.open(named, password: nil)
|
|
76
|
+
if named&.include?(File::SEPARATOR)
|
|
77
|
+
# A line named outright is used as given, and discovery is
|
|
78
|
+
# not required to succeed for that to work -- naming it is
|
|
79
|
+
# the escape hatch for a host discovery cannot answer on.
|
|
80
|
+
# It is still ASKED, quietly, because a line that IS a
|
|
81
|
+
# known candidate brings its USB path with it, and that is
|
|
82
|
+
# what --method usb needs; see #usb_path.
|
|
83
|
+
return new(named, ctrl: candidate_for(named), password: password)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
found = available
|
|
87
|
+
ctrl = if named
|
|
88
|
+
then match(named, found)
|
|
89
|
+
else lone(found)
|
|
90
|
+
end
|
|
91
|
+
new(ctrl[:device], ctrl: ctrl, password: password)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The one candidate +named+ names among +found+, or an error.
|
|
95
|
+
def self.match(named, found)
|
|
96
|
+
key, what = if GEM::USB_PATH.match?(named)
|
|
97
|
+
then [ :usb_path, 'at USB path' ]
|
|
98
|
+
else [ :serial, 'with serial' ]
|
|
99
|
+
end
|
|
100
|
+
match = found.select {|c| c[key] == named }
|
|
101
|
+
case match.size
|
|
102
|
+
when 1 then match.first
|
|
103
|
+
when 0
|
|
104
|
+
# A path that matched nothing on a host reporting no
|
|
105
|
+
# paths at all is a different mistake from a path that
|
|
106
|
+
# is simply not this one, and saying "not found" would
|
|
107
|
+
# send the reader hunting for a socket.
|
|
108
|
+
if key == :usb_path && found.none? {|c| c[:usb_path] }
|
|
109
|
+
raise Error, "no FTDI #{CTRL_ID} at USB path '#{named}':" \
|
|
110
|
+
' this host reports no USB path for any of' \
|
|
111
|
+
' its serial lines, so none can be named' \
|
|
112
|
+
' that way. Name the hub by the serial of' \
|
|
113
|
+
" its FT232 instead (#{seen(found)})"
|
|
114
|
+
end
|
|
115
|
+
raise Error, "no FTDI #{CTRL_ID} #{what} '#{named}' on this" \
|
|
116
|
+
" host (#{seen(found)}). A name with a '/' in" \
|
|
117
|
+
' it is taken as the path of a serial line, one' \
|
|
118
|
+
' shaped 1-1.2.4.4 as a USB path, and anything' \
|
|
119
|
+
' else as an FT232 serial number'
|
|
120
|
+
else
|
|
121
|
+
raise Error, "#{match.size} FTDI #{CTRL_ID} are #{what}" \
|
|
122
|
+
" '#{named}' (#{seen(found)}): name the line by" \
|
|
123
|
+
' path instead'
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The one candidate the host has, when it has exactly one.
|
|
128
|
+
def self.lone(found)
|
|
129
|
+
case found.size
|
|
130
|
+
when 1 then found.first
|
|
131
|
+
when 0
|
|
132
|
+
raise Error, 'unable to auto-detect the hub control line:' \
|
|
133
|
+
" no FTDI #{CTRL_ID} on this host. Name it" \
|
|
134
|
+
" with -d, or with a 'device =' line in the" \
|
|
135
|
+
' configuration'
|
|
136
|
+
else
|
|
137
|
+
raise Error, 'unable to auto-detect the hub control line:' \
|
|
138
|
+
" #{found.size} FTDI #{CTRL_ID} adapters on this" \
|
|
139
|
+
" host (#{seen(found)}). Name the one to drive" \
|
|
140
|
+
" with -d, or with a 'device =' line in the" \
|
|
141
|
+
' configuration'
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The candidate the host reports for a line named outright, or nil.
|
|
146
|
+
#
|
|
147
|
+
# Quietly: naming a line is the escape hatch for a host discovery
|
|
148
|
+
# cannot answer on -- a pty under test, a node discovery does not
|
|
149
|
+
# know -- so a discovery that fails here must not take the run with
|
|
150
|
+
# it. What is lost when it does is the USB path, and with it
|
|
151
|
+
# --method usb, which says so at the point it needs one.
|
|
152
|
+
def self.candidate_for(line)
|
|
153
|
+
available.find {|c| c[:device] == line }
|
|
154
|
+
rescue Error
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# What the host has, as a refusal lists it.
|
|
159
|
+
def self.seen(found)
|
|
160
|
+
return 'none found' if found.empty?
|
|
161
|
+
"found: #{found.map {|c| describe(c) }.join(', ')}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# One candidate, as an error message names it.
|
|
165
|
+
#
|
|
166
|
+
# The serial leads, that being what the reader is meant to copy
|
|
167
|
+
# into a configuration, and the USB path follows it in brackets where the
|
|
168
|
+
# host reports one -- for the adapter with no serial it is the only
|
|
169
|
+
# stable name there is, and a refusal is where somebody goes
|
|
170
|
+
# looking for it.
|
|
171
|
+
def self.describe(ctrl)
|
|
172
|
+
name = if ctrl[:serial]
|
|
173
|
+
then "#{ctrl[:serial]} on #{ctrl[:device]}"
|
|
174
|
+
else "#{ctrl[:device]}, which reports no serial"
|
|
175
|
+
end
|
|
176
|
+
ctrl[:usb_path] ? "#{name} [#{ctrl[:usb_path]}]" : name
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The gem's errors are the hub layer's to report, not to leak: it
|
|
180
|
+
# raises both for a hub that refuses a command (E01 on a wrong
|
|
181
|
+
# password) and for a host it cannot look for a hub on. Both are
|
|
182
|
+
# operator errors with nothing to debug, so both become Hub::Error
|
|
183
|
+
# carrying the same words.
|
|
184
|
+
def self.guard
|
|
185
|
+
yield
|
|
186
|
+
rescue GEM::Error => e
|
|
187
|
+
raise Error, e.message
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private_class_method :match, :lone, :seen, :describe
|
|
191
|
+
|
|
192
|
+
# +line+ is the serial line; +ctrl+ the candidate it was chosen
|
|
193
|
+
# from, when discovery knew it, which is where the USB path comes
|
|
194
|
+
# from. The line is not opened here: the gem opens it on first
|
|
195
|
+
# use and locks it for the duration of each call.
|
|
196
|
+
def initialize(line, ctrl: nil, password: nil)
|
|
197
|
+
super()
|
|
198
|
+
@line = line
|
|
199
|
+
@ctrl = ctrl
|
|
200
|
+
@gem = GEM.new(line, password)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The serial line, as it was named or found.
|
|
204
|
+
attr_reader :line
|
|
205
|
+
|
|
206
|
+
def to_s = @line
|
|
207
|
+
def ports = GEM::PORTS
|
|
208
|
+
def state = guard { @gem.get(:ports) }
|
|
209
|
+
|
|
210
|
+
def on(*list) = guard { @gem.on(*selection(list)) }
|
|
211
|
+
def off(*list) = guard { @gem.off(*selection(list)) }
|
|
212
|
+
def toggle(*list) = guard { @gem.toggle(*selection(list)) }
|
|
213
|
+
|
|
214
|
+
# One exchange: the gem reads the port word, applies the whole
|
|
215
|
+
# change and writes it back under a single hold of the line.
|
|
216
|
+
def set(changes, default = nil) = guard { @gem.set(changes, default) }
|
|
217
|
+
|
|
218
|
+
# Four internal banks of four, so a board on +port+ is at
|
|
219
|
+
# <root>.bank.slot. The root is the control adapter's own path
|
|
220
|
+
# less its last two components: the FT232 is wired at the last
|
|
221
|
+
# position of that internal tree, <root>.4.4, which is also why a
|
|
222
|
+
# board must never be put on port 16 -- --method usb would compute
|
|
223
|
+
# the adapter's own path for it. See THE HUB in the manual.
|
|
224
|
+
#
|
|
225
|
+
# nil when there is nothing to work it out from -- a line named
|
|
226
|
+
# outright that discovery does not know, a host that reports no
|
|
227
|
+
# USB path for it, or an adapter plugged straight into a root port
|
|
228
|
+
# and therefore not inside a hub at all.
|
|
229
|
+
def usb_path(port)
|
|
230
|
+
return nil unless (root = self.usb_root)
|
|
231
|
+
bank = ((port - 1) / 4) + 1
|
|
232
|
+
slot = ((port - 1) % 4) + 1
|
|
233
|
+
"#{root}.#{bank}.#{slot}"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
private
|
|
237
|
+
|
|
238
|
+
def guard(&) = self.class.guard(&)
|
|
239
|
+
|
|
240
|
+
def usb_root
|
|
241
|
+
parts = @ctrl&.dig(:usb_path)&.split('.')
|
|
242
|
+
return nil if parts.nil? || parts.size < 3
|
|
243
|
+
parts[0..-3].join('.')
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#
|
|
4
|
+
# Any USB hub that switches its own ports, driven through usbconfig(8).
|
|
5
|
+
#
|
|
6
|
+
# Nothing here is an agreement with one manufacturer: what is spoken is
|
|
7
|
+
# USB chapter 11, the hub class, which every hub answers -- the hub
|
|
8
|
+
# descriptor says how many ports there are, GET_STATUS says whether one
|
|
9
|
+
# is powered, and SET_FEATURE/CLEAR_FEATURE(PORT_POWER) switches it.
|
|
10
|
+
# The ExSYS backend needs a gem because the ExSYS hub is switched over
|
|
11
|
+
# a serial line wired beside the bus; this one needs none, because the
|
|
12
|
+
# switching is on the bus itself.
|
|
13
|
+
#
|
|
14
|
+
# FreeBSD only for now: usbconfig(8) is FreeBSD's, and Linux has no
|
|
15
|
+
# command that issues an arbitrary control request to a hub.
|
|
16
|
+
#
|
|
17
|
+
require 'open3'
|
|
18
|
+
require 'rbconfig'
|
|
19
|
+
|
|
20
|
+
require_relative '../hub'
|
|
21
|
+
require_relative '../platform'
|
|
22
|
+
|
|
23
|
+
module TribbleControl
|
|
24
|
+
class Hub
|
|
25
|
+
|
|
26
|
+
class USB < Hub
|
|
27
|
+
# Both by absolute path: this program switches benches, and what it
|
|
28
|
+
# runs must not depend on whoever's PATH it was started with.
|
|
29
|
+
USBCONFIG = '/usr/sbin/usbconfig'
|
|
30
|
+
SYSCTL = '/sbin/sysctl'
|
|
31
|
+
|
|
32
|
+
# The two names that are not a serial. A ugen name is what
|
|
33
|
+
# usbconfig -d takes; a USB path is what the configuration already uses
|
|
34
|
+
# to place a board.
|
|
35
|
+
UGEN = /\Augen\d+\.\d+\z/
|
|
36
|
+
USB_PATH = /\A\d+-\d+(?:\.\d+)*\z/
|
|
37
|
+
|
|
38
|
+
# The hub descriptor, by bDescriptorType: the wValue that asks for
|
|
39
|
+
# it, how many bytes it is, and -- THE TRAP -- which bit of
|
|
40
|
+
# wPortStatus then means "powered". A USB 2 hub (0x29) reports
|
|
41
|
+
# power in 0x0100; a SuperSpeed hub (0x2a) reports it in 0x0200 and
|
|
42
|
+
# puts the link state in bits 5-8, so reading a SuperSpeed port
|
|
43
|
+
# with the USB 2 bit answers "unpowered" for a port that is fine.
|
|
44
|
+
# Which descriptor the hub ANSWERS is therefore what decides how
|
|
45
|
+
# its status word is read, and is remembered for that.
|
|
46
|
+
DESCRIPTORS = {
|
|
47
|
+
0x29 => { :value => '0x2900', :length => '9', :power => 0x0100 },
|
|
48
|
+
0x2a => { :value => '0x2a00', :length => '12', :power => 0x0200 }
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
# wHubCharacteristics bits 1:0, as the candidate reports them.
|
|
52
|
+
# Reported and never acted on: a hub that says ganged may still
|
|
53
|
+
# switch per port (the Genesys part on this bench does), and a hub
|
|
54
|
+
# that says individual may still switch nothing. The read-back
|
|
55
|
+
# after a switch is what knows, so nothing is refused on this.
|
|
56
|
+
SWITCHING = [ :ganged, :individual, :none, :none ].freeze
|
|
57
|
+
|
|
58
|
+
# PORT_POWER, the only port feature this backend touches.
|
|
59
|
+
PORT_POWER = '0x0008'
|
|
60
|
+
|
|
61
|
+
# What 'off' does to the socket, which the hub cannot be asked.
|
|
62
|
+
# See Hub#vbus?: a plain hub takes the port off the bus, and only
|
|
63
|
+
# one with a power switch wired to VBUS cuts the supply.
|
|
64
|
+
SWITCHES = { :link => false, :vbus => true }.freeze
|
|
65
|
+
|
|
66
|
+
# A class method so that a test can stand on another host, and
|
|
67
|
+
# because this is the one fact about the platform the backend has.
|
|
68
|
+
def self.freebsd? = RbConfig::CONFIG['host_os'].match?(/^freebsd/)
|
|
69
|
+
|
|
70
|
+
# How a command is run: a callable (*argv) -> merged stdout+stderr.
|
|
71
|
+
#
|
|
72
|
+
# Everything this class runs goes through one of these, so a test
|
|
73
|
+
# hands it a fake host instead of a real one, and nothing in the
|
|
74
|
+
# methods below has to know what Open3 is.
|
|
75
|
+
def self.runner
|
|
76
|
+
lambda do |*argv|
|
|
77
|
+
Open3.capture2e(*argv).first
|
|
78
|
+
rescue Errno::ENOENT
|
|
79
|
+
raise Error, "cannot run #{argv.first}: it is not installed" \
|
|
80
|
+
' on this host, so no USB hub can be switched here'
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Every hub on this host that is not a root hub, as
|
|
85
|
+
#
|
|
86
|
+
# { device: 'ugen1.4', serial: 'AC0528515619', usb_path: '1-1.1',
|
|
87
|
+
# ports: 4, switching: :individual,
|
|
88
|
+
# desc: 'vendor 0x0451 product 0x8142' }
|
|
89
|
+
#
|
|
90
|
+
# Root hubs are left out because they are the controller: their
|
|
91
|
+
# %location is empty and their parent is a usbusN, and a port of
|
|
92
|
+
# theirs has no PORT_POWER to clear. Offering one as a candidate
|
|
93
|
+
# would offer a hub every command against it then failed on.
|
|
94
|
+
#
|
|
95
|
+
# The descriptor is read per hub rather than guessed, because it is
|
|
96
|
+
# what says how many ports a candidate has -- and the port count is
|
|
97
|
+
# half of what a refusal has to print for the reader to tell two
|
|
98
|
+
# identical hubs apart. A hub that will not answer one is listed
|
|
99
|
+
# all the same, with no port count: discovery is a listing, and one
|
|
100
|
+
# odd hub must not stop the others from being named. Choosing that
|
|
101
|
+
# hub is what is refused, in #initialize, where the descriptor is
|
|
102
|
+
# read for real.
|
|
103
|
+
def self.available(run: nil)
|
|
104
|
+
run ||= self.runner
|
|
105
|
+
tree = Platform::FreeBSD.parse_usb_tree(run.call(SYSCTL, '-e', 'dev.uhub'))
|
|
106
|
+
tree.filter_map {|name, dev|
|
|
107
|
+
next unless name.start_with?('uhub')
|
|
108
|
+
loc = dev[:'%location']
|
|
109
|
+
next unless loc.is_a?(Hash) && (ugen = loc[:ugen])
|
|
110
|
+
desc = begin
|
|
111
|
+
self.descriptor(ugen, run)
|
|
112
|
+
rescue Error
|
|
113
|
+
{ :ports => nil, :switching => :unknown }
|
|
114
|
+
end
|
|
115
|
+
serial = dev.dig(:'%pnpinfo', :sernum).to_s
|
|
116
|
+
{ :device => ugen,
|
|
117
|
+
:serial => serial.empty? ? nil : serial,
|
|
118
|
+
:usb_path => Platform::FreeBSD.usb_path(dev, tree),
|
|
119
|
+
:ports => desc[:ports],
|
|
120
|
+
:switching => desc[:switching],
|
|
121
|
+
# The tail of %desc is the class, the revision and the
|
|
122
|
+
# bus address, and the address changes on every replug:
|
|
123
|
+
# only the head names the part.
|
|
124
|
+
:desc => dev[:'%desc'].to_s.split(',').first }
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The hub +named+ names, or the one switchable hub the host has.
|
|
129
|
+
#
|
|
130
|
+
# Three shapes of name, told apart by what they look like, no two
|
|
131
|
+
# of which can be confused:
|
|
132
|
+
#
|
|
133
|
+
# ugen1.4 the ugen shape, so that device, used as given
|
|
134
|
+
# 1-1.1 the USB path shape, so the hub in that socket
|
|
135
|
+
# AC0528515619 anything else, so the hub's serial number
|
|
136
|
+
#
|
|
137
|
+
# The ugen name is the worst of the three to write down, and it is
|
|
138
|
+
# here for the same reason /dev/ttyUSB1 is in Hub::ExSYS.open: it
|
|
139
|
+
# is the escape hatch for a hub discovery cannot answer for. The
|
|
140
|
+
# number in it is enumeration order -- ugen1.4 is the fourth device
|
|
141
|
+
# the second controller attached -- so a replug renumbers it, and a
|
|
142
|
+
# configuration naming a hub that way points at whatever attached in its
|
|
143
|
+
# place. The other two are stable: a serial follows the HUB, a USB
|
|
144
|
+
# path follows the SOCKET, and each names one host's numbering.
|
|
145
|
+
#
|
|
146
|
+
# Auto-detection is only safe while there is one candidate. Two
|
|
147
|
+
# hubs on a host and no name is a coin toss decided by enumeration
|
|
148
|
+
# order, and driving the wrong one raises nothing anywhere: the
|
|
149
|
+
# ports exist, the requests succeed, and the boards that go dark
|
|
150
|
+
# are on the other bench. It refuses and lists them instead.
|
|
151
|
+
def self.open(named, switch: :link, run: nil)
|
|
152
|
+
unless self.freebsd?
|
|
153
|
+
raise Error, 'the usb hub backend runs on FreeBSD only for now:' \
|
|
154
|
+
' it switches ports with usbconfig(8) hub-class' \
|
|
155
|
+
' requests, and no other host here has usbconfig'
|
|
156
|
+
end
|
|
157
|
+
run ||= self.runner
|
|
158
|
+
if named && UGEN.match?(named)
|
|
159
|
+
# Used as given, and discovery is not required to succeed
|
|
160
|
+
# for that to work. It is still ASKED, quietly, because a
|
|
161
|
+
# device that IS a known candidate brings its USB path and
|
|
162
|
+
# its serial with it, and the path is what --method usb
|
|
163
|
+
# needs; see #usb_path.
|
|
164
|
+
return new(named, hub: self.candidate_for(named, run),
|
|
165
|
+
switch: switch, run: run)
|
|
166
|
+
end
|
|
167
|
+
found = self.available(run: run)
|
|
168
|
+
hub = named ? self.match(named, found) : self.lone(found)
|
|
169
|
+
new(hub[:device], hub: hub, switch: switch, run: run)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The one candidate +named+ names among +found+, or an error.
|
|
173
|
+
def self.match(named, found)
|
|
174
|
+
key, what = if USB_PATH.match?(named)
|
|
175
|
+
then [ :usb_path, 'at USB path' ]
|
|
176
|
+
else [ :serial, 'with serial' ]
|
|
177
|
+
end
|
|
178
|
+
match = found.select {|c| c[key] == named }
|
|
179
|
+
case match.size
|
|
180
|
+
when 1 then match.first
|
|
181
|
+
when 0
|
|
182
|
+
raise Error, "no USB hub #{what} '#{named}' on this host" \
|
|
183
|
+
" (#{seen(found)}). A name shaped ugen1.4 is" \
|
|
184
|
+
' taken as a device, one shaped 1-1.1 as a USB' \
|
|
185
|
+
' path, and anything else as a hub serial number'
|
|
186
|
+
else
|
|
187
|
+
raise Error, "#{match.size} USB hubs are #{what} '#{named}'" \
|
|
188
|
+
" (#{seen(found)}): name the hub by its ugen" \
|
|
189
|
+
' name instead'
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The one switchable hub the host has, when it has exactly one.
|
|
194
|
+
def self.lone(found)
|
|
195
|
+
case found.size
|
|
196
|
+
when 1 then found.first
|
|
197
|
+
when 0
|
|
198
|
+
raise Error, 'unable to auto-detect the hub: this host has no' \
|
|
199
|
+
' USB hub below a root hub, and a root hub has no' \
|
|
200
|
+
" switchable port. Name the hub with -d, or with" \
|
|
201
|
+
" a 'device =' line in the configuration"
|
|
202
|
+
else
|
|
203
|
+
raise Error, 'unable to auto-detect the hub:' \
|
|
204
|
+
" #{found.size} USB hubs on this host" \
|
|
205
|
+
" (#{seen(found)}). Name the one to drive with" \
|
|
206
|
+
" -d, or with a 'device =' line in the configuration"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The candidate the host reports for a device named outright, or nil.
|
|
211
|
+
#
|
|
212
|
+
# Quietly, as in Hub::ExSYS.candidate_for: naming a device is the
|
|
213
|
+
# escape hatch for a host discovery cannot answer on, so a
|
|
214
|
+
# discovery that fails here must not take the run with it. What is
|
|
215
|
+
# lost when it does is the USB path, and with it --method usb,
|
|
216
|
+
# which says so at the point it needs one.
|
|
217
|
+
def self.candidate_for(device, run)
|
|
218
|
+
self.available(run: run).find {|c| c[:device] == device }
|
|
219
|
+
rescue Error
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# What the host has, as a refusal lists it. Semicolons between
|
|
224
|
+
# candidates, because each one has commas of its own.
|
|
225
|
+
def self.seen(found)
|
|
226
|
+
return 'none found' if found.empty?
|
|
227
|
+
"found: #{found.map {|c| describe(c) }.join('; ')}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# One candidate, as an error message names it: the serial leads,
|
|
231
|
+
# being what the reader is meant to copy into a configuration, then the
|
|
232
|
+
# device, the socket, and the port count -- which is the only thing
|
|
233
|
+
# that tells two of the same part apart when neither has a serial.
|
|
234
|
+
def self.describe(hub)
|
|
235
|
+
name = if hub[:serial]
|
|
236
|
+
then "#{hub[:serial]} on #{hub[:device]}"
|
|
237
|
+
else "#{hub[:device]}, which reports no serial"
|
|
238
|
+
end
|
|
239
|
+
name += " [#{hub[:usb_path]}]" if hub[:usb_path]
|
|
240
|
+
"#{name}, #{hub[:ports] || 'an unknown number of'} ports"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# The hub descriptor of +device+: which kind of hub it is, how many
|
|
244
|
+
# ports it has, and what it says about switching them.
|
|
245
|
+
#
|
|
246
|
+
# Two requests and not one, because the descriptor TYPE is half the
|
|
247
|
+
# answer and there is no way to ask which type a hub has: a USB 2
|
|
248
|
+
# hub answers 0x2900 and refuses 0x2a00, a SuperSpeed hub does the
|
|
249
|
+
# reverse. Whichever answered decides how the port status word is
|
|
250
|
+
# read from then on (see DESCRIPTORS).
|
|
251
|
+
def self.descriptor(device, run)
|
|
252
|
+
DESCRIPTORS.each do |type, d|
|
|
253
|
+
bytes = self.bytes(device,
|
|
254
|
+
self.request(device, run, '0xa0', '0x06',
|
|
255
|
+
d[:value], '0', d[:length]))
|
|
256
|
+
next if bytes.nil?
|
|
257
|
+
return { :type => type,
|
|
258
|
+
:ports => bytes[2],
|
|
259
|
+
:switching => SWITCHING[bytes[3] & 0x03] }
|
|
260
|
+
end
|
|
261
|
+
raise Error, "#{device} answers no hub descriptor, neither USB 2" \
|
|
262
|
+
' nor SuperSpeed, so it is not a hub this tool can' \
|
|
263
|
+
' switch'
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# One usbconfig request, as the text between its angle brackets:
|
|
267
|
+
# 'OK', 'ERROR', or the bytes that came back.
|
|
268
|
+
#
|
|
269
|
+
# Only the first bracketed group: usbconfig prints the payload
|
|
270
|
+
# again as ASCII after it, and that copy contains whatever the
|
|
271
|
+
# bytes happened to spell -- '<0x09 0x29 ...><)>' for a descriptor.
|
|
272
|
+
# A pattern that reached for the last group would parse that.
|
|
273
|
+
#
|
|
274
|
+
# The exit status is not consulted because it is not the answer:
|
|
275
|
+
# usbconfig exits 0 for a request the hub refused, and 0 for a
|
|
276
|
+
# device it could not even find. The printed text is the truth.
|
|
277
|
+
def self.request(device, run, *args)
|
|
278
|
+
out = run.call(USBCONFIG, '-d', device, 'do_request', *args)
|
|
279
|
+
if out.match?(/Permission denied|Operation not permitted/)
|
|
280
|
+
raise Error, "not allowed to drive #{device}: the ugen nodes" \
|
|
281
|
+
' are root:operator 0660, so switching a port' \
|
|
282
|
+
' needs membership of group operator (pw groupmod' \
|
|
283
|
+
' operator -m <user>, then log in again)'
|
|
284
|
+
end
|
|
285
|
+
payload = out[/REQUEST = <([^>]*)>/, 1]
|
|
286
|
+
if payload.nil?
|
|
287
|
+
raise Error, "#{device} answered no usbconfig request" \
|
|
288
|
+
" (#{out.to_s.lines.first.to_s.strip}): it is not" \
|
|
289
|
+
' a device this host can be asked about'
|
|
290
|
+
end
|
|
291
|
+
payload
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# The bytes of an answer, or nil for a request the hub refused.
|
|
295
|
+
def self.bytes(device, payload)
|
|
296
|
+
return nil if payload == 'ERROR'
|
|
297
|
+
payload.split.map {|b| Integer(b, 16) }
|
|
298
|
+
rescue ArgumentError
|
|
299
|
+
raise Error, "#{device} answered '#{payload}' where a usbconfig" \
|
|
300
|
+
' request should have brought bytes back'
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
private_class_method :match, :lone, :seen, :describe
|
|
304
|
+
|
|
305
|
+
# +device+ is the ugen name usbconfig is given; +hub+ the candidate
|
|
306
|
+
# it was chosen from, when discovery knew it, which is where the
|
|
307
|
+
# USB path comes from. The descriptor is read here and not per
|
|
308
|
+
# call: the port count is asked before the hub is opened for real
|
|
309
|
+
# (the configuration is checked against it) and it cannot change under
|
|
310
|
+
# us, while the port STATE can and is never remembered.
|
|
311
|
+
def initialize(device, hub: nil, switch: :link, run: nil)
|
|
312
|
+
super()
|
|
313
|
+
@device = device
|
|
314
|
+
@hub = hub || {}
|
|
315
|
+
@run = run || self.class.runner
|
|
316
|
+
@vbus = SWITCHES.fetch(switch) {
|
|
317
|
+
raise Error, "no such switch #{switch.inspect}: a hub's 'off'" \
|
|
318
|
+
' either takes the port off the bus (:link,' \
|
|
319
|
+
" the default) or cuts the socket (:vbus), and" \
|
|
320
|
+
' software cannot tell which this hub is' \
|
|
321
|
+
' wired for'
|
|
322
|
+
}
|
|
323
|
+
@desc = self.class.descriptor(@device, @run)
|
|
324
|
+
@ports = (1..@desc[:ports]).to_a
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# The ugen name, as it was named or found, and the ports the hub's
|
|
328
|
+
# descriptor says it has.
|
|
329
|
+
attr_reader :device
|
|
330
|
+
attr_reader :ports
|
|
331
|
+
|
|
332
|
+
def to_s = @device
|
|
333
|
+
def vbus? = @vbus
|
|
334
|
+
|
|
335
|
+
# One GET_STATUS per port: the hub has no request that reports them
|
|
336
|
+
# all, so a status of a 16-port hub is 16 exchanges.
|
|
337
|
+
def state = @ports.to_h {|port| [ port, powered?(port) ] }
|
|
338
|
+
|
|
339
|
+
def on(*list) = apply(selection(list), true)
|
|
340
|
+
def off(*list) = apply(selection(list), false)
|
|
341
|
+
|
|
342
|
+
# Read then invert, port by port, because there is no request that
|
|
343
|
+
# flips one: a port's own current state is the only thing that says
|
|
344
|
+
# what toggling it means.
|
|
345
|
+
def toggle(*list)
|
|
346
|
+
selection(list).each {|port| apply([ port ], !powered?(port)) }
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Where a board on +port+ is in this host's USB tree. A plain hub
|
|
350
|
+
# has no internal geometry to account for -- the ExSYS hub's 4-by-4
|
|
351
|
+
# tree is a fact about that hub and not about hubs -- so a port is
|
|
352
|
+
# one component below the hub's own path.
|
|
353
|
+
#
|
|
354
|
+
# nil when there is nothing to work it out from: a device named
|
|
355
|
+
# outright that discovery does not know, or a host that reports no
|
|
356
|
+
# path for it.
|
|
357
|
+
def usb_path(port)
|
|
358
|
+
root = @hub[:usb_path]
|
|
359
|
+
root && "#{root}.#{port}"
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
private
|
|
363
|
+
|
|
364
|
+
# Switch every port in +list+, and check that the hub did it.
|
|
365
|
+
#
|
|
366
|
+
# The read-back is not belt and braces, it is the only thing that
|
|
367
|
+
# knows. A hub that switches nothing -- ganged, or with no switch
|
|
368
|
+
# wired -- accepts CLEAR_FEATURE(PORT_POWER) and answers OK, and
|
|
369
|
+
# the port stays up. Without this, `off` would report success on a
|
|
370
|
+
# bench it had not touched, and a board would be flashed live. It
|
|
371
|
+
# is also why nothing is refused on wHubCharacteristics: the
|
|
372
|
+
# descriptor is a claim, this is the measurement.
|
|
373
|
+
def apply(list, powered)
|
|
374
|
+
list.each do |port|
|
|
375
|
+
feature(powered ? '0x03' : '0x01', port)
|
|
376
|
+
next if powered?(port) == powered
|
|
377
|
+
raise Error, "port #{port} of #{@device} did not switch" \
|
|
378
|
+
" #{powered ? 'on' : 'off'}: the hub accepted the" \
|
|
379
|
+
' request and the port still reports itself' \
|
|
380
|
+
" #{powered ? 'unpowered' : 'powered'}, so this" \
|
|
381
|
+
' hub does not switch that port'
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# SET_FEATURE (0x03) or CLEAR_FEATURE (0x01) of PORT_POWER.
|
|
386
|
+
def feature(request, port)
|
|
387
|
+
answer = self.class.request(@device, @run, '0x23', request,
|
|
388
|
+
PORT_POWER, port.to_s, '0')
|
|
389
|
+
return if answer == 'OK'
|
|
390
|
+
raise Error, "#{@device} refused to switch port #{port}:" \
|
|
391
|
+
" usbconfig answered '#{answer}'"
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Is the port powered, by the bit this kind of hub reports it in?
|
|
395
|
+
def powered?(port)
|
|
396
|
+
port_status(port).anybits?(DESCRIPTORS.fetch(@desc[:type])[:power])
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# wPortStatus, the first two of the four bytes GET_STATUS brings
|
|
400
|
+
# back, little-endian. The other two are wPortChange, which this
|
|
401
|
+
# backend never reads: it says what has happened since the last
|
|
402
|
+
# time somebody cleared it, and nothing here clears it.
|
|
403
|
+
def port_status(port)
|
|
404
|
+
bytes = self.class.bytes(@device,
|
|
405
|
+
self.class.request(@device, @run, '0xa3',
|
|
406
|
+
'0x00', '0x0000',
|
|
407
|
+
port.to_s, '4'))
|
|
408
|
+
if bytes.nil? || bytes.size < 2
|
|
409
|
+
raise Error, "#{@device} reports no status for port #{port}:" \
|
|
410
|
+
' the hub refused the request, which is what it' \
|
|
411
|
+
' answers for a port it has not got'
|
|
412
|
+
end
|
|
413
|
+
bytes[0] | (bytes[1] << 8)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
end
|
|
418
|
+
end
|