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.
@@ -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