exsys 0.6 → 1.1
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 +4 -4
- data/README.md +161 -13
- data/bin/exsys-usb +78 -7
- data/lib/exsys/discovery.rb +314 -0
- data/lib/exsys/managed-usb.rb +328 -104
- data/lib/exsys/version.rb +1 -1
- data/lib/exsys.rb +1 -0
- data/test/support/fake_hub.rb +52 -26
- data/test/support/uart.rb +23 -6
- data/test/test_discovery.rb +241 -0
- data/test/test_exsys_usb.rb +100 -5
- data/test/test_managed_usb.rb +268 -18
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 97086399976474a8e6d5c41ca473a6b2bd385dfe65bc1a1655301510b93ce68d
|
|
4
|
+
data.tar.gz: f14819b8e580dcec1374b6ea1360628b3980c6dd7a8ea7aeb6f87ee406de312b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3f70740de8575d2e1982b890fd33f65bc200181e73f7854435233dc325642cefd9b10103e7edacf936f0fbae6b8de8329ab97a979072713a83f49bc17ea623f6
|
|
7
|
+
data.tar.gz: 57c4f9a96bb7cf8de062101b72720bc76350ef0098081bb036632a7f3672975630a8d8eb14aee2a1837f9e283dc0e13a1ec31fbc43a71882521ef6daa1851911
|
data/README.md
CHANGED
|
@@ -33,10 +33,12 @@ You need read and write access to it: the device is usually owned by a
|
|
|
33
33
|
group such as `dialout` or `plugdev`, so check `ls -l` on it and add
|
|
34
34
|
yourself to that group rather than reaching for `sudo`.
|
|
35
35
|
|
|
36
|
+
`exsys-usb discover` lists the lines that could be a hub, so you do not
|
|
37
|
+
have to guess which one it is. See [Finding the hub](#finding-the-hub).
|
|
38
|
+
|
|
36
39
|
The hub answers only to its password, `pass` unless it has been
|
|
37
|
-
changed. Port numbering
|
|
38
|
-
|
|
39
|
-
that take no port list would address ports that are not there.
|
|
40
|
+
changed. Port numbering starts at 1 and runs to however many ports
|
|
41
|
+
the hub reports having: the gem asks it, rather than assuming sixteen.
|
|
40
42
|
|
|
41
43
|
|
|
42
44
|
## Install
|
|
@@ -73,9 +75,23 @@ exsys-usb -d ${dev} -c on 1 # Turn on port 1, and save to flash
|
|
|
73
75
|
| `off [PORT...]` | Unpower the listed ports, or every port |
|
|
74
76
|
| `toggle [PORT...]` | Invert the listed ports, or every port |
|
|
75
77
|
| `set PORT:STATE...` | Set the listed ports; `-D` decides the rest |
|
|
78
|
+
| `status [PORT...]` | Report the ports, one `N on` / `N off` per line |
|
|
79
|
+
| `discover` | List the lines that could be a hub |
|
|
80
|
+
| `query` | What the hub says it is: id, ports, firmware |
|
|
76
81
|
| `commit` | Save the current port state to flash |
|
|
77
|
-
| `
|
|
78
|
-
| `reset` |
|
|
82
|
+
| `factory-reset` | Factory reset; refuses without `--yes` |
|
|
83
|
+
| `reset` | Reboot the hub; refuses without `--yes` |
|
|
84
|
+
|
|
85
|
+
> [!WARNING]
|
|
86
|
+
> `reset` and `factory-reset` both drop power to every port, so both
|
|
87
|
+
> refuse without `--yes`. `reset` reboots the whole hub; it is not the
|
|
88
|
+
> way to power-cycle one device, which is `off` then `on`.
|
|
89
|
+
>
|
|
90
|
+
> `factory-reset` is not the inverse of `commit`. It issues the hub's
|
|
91
|
+
> `RD` command: every port drops and the password goes back to `pass`.
|
|
92
|
+
> Nothing in the protocol reloads a saved state -- the hub applies it
|
|
93
|
+
> at power-on by itself.
|
|
94
|
+
|
|
79
95
|
|
|
80
96
|
A port state in `set` is written `PORT:STATE`, where `STATE` is one of
|
|
81
97
|
`1`, `on`, `ON`, `true`, `TRUE`, `t`, `T` or their false counterparts
|
|
@@ -85,9 +101,12 @@ A port state in `set` is written `PORT:STATE`, where `STATE` is one of
|
|
|
85
101
|
|
|
86
102
|
| Option | Meaning |
|
|
87
103
|
| :-------------------- | :-------------------------------------------- |
|
|
88
|
-
| `-d`, `--device=DEV` | Serial line to the hub (required
|
|
104
|
+
| `-d`, `--device=DEV` | Serial line to the hub (required, but see |
|
|
105
|
+
| | `discover`) |
|
|
89
106
|
| `-p`, `--password=STR`| Hub password; defaults to `pass` |
|
|
90
107
|
| `-c`, `--commit` | Also write the new state to flash |
|
|
108
|
+
| `-y`, `--yes` | Mean a destructive action |
|
|
109
|
+
| `-v`, `--verbose` | Report the port states after a change |
|
|
91
110
|
| `-D`, `--default=BOOL`| State for the ports `set` does not name |
|
|
92
111
|
| `--debug[=FILE]` | Trace the serial exchange to stderr, or FILE |
|
|
93
112
|
| `-V`, `--version` | Print the library version |
|
|
@@ -97,6 +116,15 @@ The debug trace shows every frame sent and received, with the password
|
|
|
97
116
|
blanked out; when it is written to a file, that file is created
|
|
98
117
|
readable only by you.
|
|
99
118
|
|
|
119
|
+
`status` prints one port per line, which greps and awks without
|
|
120
|
+
parsing:
|
|
121
|
+
|
|
122
|
+
~~~sh
|
|
123
|
+
exsys-usb -d ${dev} status # every port
|
|
124
|
+
exsys-usb -d ${dev} status 3 7 # just those two
|
|
125
|
+
exsys-usb -d ${dev} -v on 3 # switch, then report
|
|
126
|
+
~~~
|
|
127
|
+
|
|
100
128
|
### Exit status
|
|
101
129
|
|
|
102
130
|
`0` when the command was carried out, `1` otherwise -- a malformed
|
|
@@ -109,6 +137,81 @@ exsys-usb -d /dev/ttyU0 off 3 || echo "could not switch port 3 off"
|
|
|
109
137
|
~~~
|
|
110
138
|
|
|
111
139
|
|
|
140
|
+
## Finding the hub
|
|
141
|
+
|
|
142
|
+
The management side of the hub is an ordinary FTDI FT232, so the host
|
|
143
|
+
can be asked which serial lines are attached and what their serial
|
|
144
|
+
numbers are:
|
|
145
|
+
|
|
146
|
+
~~~sh
|
|
147
|
+
exsys-usb discover
|
|
148
|
+
~~~
|
|
149
|
+
|
|
150
|
+
~~~text
|
|
151
|
+
/dev/ttyUSB0 A50285BI 1-1.2.4.4
|
|
152
|
+
/dev/ttyUSB1 - 1-1.3
|
|
153
|
+
~~~
|
|
154
|
+
|
|
155
|
+
One line per adapter: the device to pass to `-d`, the FT232's own
|
|
156
|
+
serial number, and where it sits in the USB tree. A `-` is a name this
|
|
157
|
+
host cannot give — an EEPROM carrying no serial, or a topology that
|
|
158
|
+
could not be established. The same list from Ruby:
|
|
159
|
+
|
|
160
|
+
~~~ruby
|
|
161
|
+
ExSYS::ManagedUSB.available
|
|
162
|
+
# => [ { :device => "/dev/ttyUSB0", :serial => "A50285BI",
|
|
163
|
+
# :usb_path => "1-1.2.4.4" },
|
|
164
|
+
# { :device => "/dev/ttyUSB1", :serial => nil,
|
|
165
|
+
# :usb_path => "1-1.3" } ]
|
|
166
|
+
~~~
|
|
167
|
+
|
|
168
|
+
**A candidate is not a hub.** That FT232 is not an ExSYS part and
|
|
169
|
+
carries no ExSYS id, so this lists every FT232 on the host — a
|
|
170
|
+
USB-serial cable, a debug probe, a second hub — and nothing short of
|
|
171
|
+
opening the line and asking (`?Q`, the `query` action) tells them
|
|
172
|
+
apart. Opening an unknown line means writing to somebody else's
|
|
173
|
+
device, which is why this reports rather than decides. A program that
|
|
174
|
+
switches ports should not pick one silently when there is more than
|
|
175
|
+
one: the ports of an unrelated hub exist, accept the frames, and report
|
|
176
|
+
success.
|
|
177
|
+
|
|
178
|
+
**Write down a serial or a path, not the line.** The number in
|
|
179
|
+
`/dev/ttyUSB1` is neither the hub's nor the USB device number: it is
|
|
180
|
+
the usbserial (Linux) or ucom (FreeBSD) layer's own index, and it is
|
|
181
|
+
the lowest one free when that adapter is probed. So it depends on what
|
|
182
|
+
else attached first, and it is reused — unplug whatever holds
|
|
183
|
+
`ttyUSB0` and the next thing to attach takes `ttyUSB0`. Two hubs can
|
|
184
|
+
swap lines across a reboot, or while the machine is up.
|
|
185
|
+
|
|
186
|
+
The other two are stable, in different ways, and which one is wanted
|
|
187
|
+
depends on the question:
|
|
188
|
+
|
|
189
|
+
| Name | Stays with | Answers |
|
|
190
|
+
| :--------- | :------------------ | :------------------------------- |
|
|
191
|
+
| `:serial` | the adapter | "this particular hub" |
|
|
192
|
+
| `:usb_path`| the socket | "whatever is plugged in there" |
|
|
193
|
+
|
|
194
|
+
Move a hub to another port and its serial goes with it while its path
|
|
195
|
+
changes; swap in a replacement hub and the path is unchanged while the
|
|
196
|
+
serial is not. For naming one particular hub the serial is the answer.
|
|
197
|
+
The path is for a hub whose EEPROM carries no serial to be named by,
|
|
198
|
+
and for a bench where the socket is the fixed thing.
|
|
199
|
+
|
|
200
|
+
Both platforms report a path, by different means. Linux states it, in
|
|
201
|
+
`/sys`. FreeBSD states nothing of the kind, so it is walked out of the
|
|
202
|
+
sysctl tree: each device's `%location` gives the port it occupies on
|
|
203
|
+
its parent and `%parent` names that parent, so collecting the ports
|
|
204
|
+
from the adapter up to the root hub builds the same shape. The
|
|
205
|
+
numbering is each host's own, though — FreeBSD counts buses from 0 and
|
|
206
|
+
Linux from 1 — so a path names a socket on the machine that reported
|
|
207
|
+
it and does not travel to another.
|
|
208
|
+
|
|
209
|
+
Discovery reads `/sys/class/tty` through `udevadm` on Linux and
|
|
210
|
+
`dev.uftdi` through `sysctl` on FreeBSD; any other platform raises
|
|
211
|
+
rather than answering an empty list, an empty list being a claim that
|
|
212
|
+
nothing is attached.
|
|
213
|
+
|
|
214
|
+
|
|
112
215
|
## Library
|
|
113
216
|
|
|
114
217
|
~~~ruby
|
|
@@ -116,11 +219,11 @@ exsys-usb -d /dev/ttyU0 off 3 || echo "could not switch port 3 off"
|
|
|
116
219
|
# and enable debug output to stderr
|
|
117
220
|
hub = ExSYS::ManagedUSB.new('/dev/ttyU0', debug: STDERR)
|
|
118
221
|
|
|
119
|
-
# Chain turning on
|
|
120
|
-
hub.on.off(4,5,6)
|
|
222
|
+
# Chain turning on every port, then switch off ports 4, 5 and 6
|
|
223
|
+
hub.on(:all).off(4,5,6)
|
|
121
224
|
|
|
122
225
|
# Toggle each port in turn
|
|
123
|
-
|
|
226
|
+
hub.ports.each do |p|
|
|
124
227
|
hub.toggle(p)
|
|
125
228
|
end
|
|
126
229
|
|
|
@@ -141,10 +244,55 @@ hub.get(:on) # => [ 1, 3 ]
|
|
|
141
244
|
hub.get(:off) # => [ 2, 4, ... ]
|
|
142
245
|
~~~
|
|
143
246
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
247
|
+
`on`, `off` and `toggle` want an explicit port list, and `:all` is how
|
|
248
|
+
you say every port. An empty list is refused rather than read as
|
|
249
|
+
"all": `hub.off(*ports)` with an empty `ports` is the very same call as
|
|
250
|
+
`hub.off`, so a computed list that came back empty would otherwise
|
|
251
|
+
switch all sixteen. The command line is unaffected -- naming no port
|
|
252
|
+
there still means every port.
|
|
253
|
+
|
|
254
|
+
Switching is a read-modify-write, and the library holds the serial
|
|
255
|
+
line -- locked -- across the whole exchange, so two processes driving
|
|
256
|
+
the same hub cannot lose each other's changes.
|
|
257
|
+
|
|
258
|
+
A read-decide-write spans two calls, so it needs the line held across
|
|
259
|
+
both. Wrap them in a session:
|
|
260
|
+
|
|
261
|
+
~~~ruby
|
|
262
|
+
hub.session do
|
|
263
|
+
hub.on(1) unless hub.get[1]
|
|
264
|
+
end
|
|
265
|
+
~~~
|
|
266
|
+
|
|
267
|
+
The hub will also describe itself, over the same line and without a
|
|
268
|
+
password:
|
|
269
|
+
|
|
270
|
+
~~~ruby
|
|
271
|
+
hub.query # => { id: "CENTOS", ports: 16, firmware: "v02",
|
|
272
|
+
# raw: "CENTOS000516v02" }
|
|
273
|
+
hub.port_count # => 16, asked once and remembered
|
|
274
|
+
~~~
|
|
275
|
+
|
|
276
|
+
`:all` covers exactly those ports, and a port the hub does not have is
|
|
277
|
+
refused. The count is read from the same field the vendor's own tool
|
|
278
|
+
reads, checked against it for hubs reporting 4, 8, 16 and 32 ports.
|
|
279
|
+
|
|
280
|
+
It is asked once and kept for the life of the object, which outlasts
|
|
281
|
+
any one connection -- the serial line is opened per operation, not
|
|
282
|
+
held. So a hub object is bound to the hub it first asked. If the
|
|
283
|
+
device is unplugged and another appears under the same name, build a
|
|
284
|
+
new one; nothing in the library can notice the swap.
|
|
285
|
+
|
|
286
|
+
`hub.factory_reset(confirm: true)` issues `RD` and carries the warning
|
|
287
|
+
above. The keyword is required: it is the one operation here that
|
|
288
|
+
nothing undoes, and the one most easily reached by misunderstanding.
|
|
289
|
+
It was called `restore` up to 0.6; the old name now raises rather than
|
|
290
|
+
run.
|
|
291
|
+
|
|
292
|
+
Sessions nest, so the methods above stay correct when called inside
|
|
293
|
+
one, and a session belongs to the thread that opened it: another thread
|
|
294
|
+
opens, and locks, its own line. The wire protocol is documented in the
|
|
295
|
+
`ExSYS::ManagedUSB` class comment.
|
|
148
296
|
|
|
149
297
|
|
|
150
298
|
## Tests
|
data/bin/exsys-usb
CHANGED
|
@@ -4,7 +4,7 @@ require 'optparse'
|
|
|
4
4
|
require 'exsys'
|
|
5
5
|
require 'exsys/managed-usb'
|
|
6
6
|
|
|
7
|
-
$opts = { :commit => false, :default => nil }
|
|
7
|
+
$opts = { :commit => false, :default => nil, :yes => false }
|
|
8
8
|
parser = OptionParser.new do |op|
|
|
9
9
|
op.banner = "Usage: #{op.program_name} ACTION"
|
|
10
10
|
|
|
@@ -12,8 +12,9 @@ parser = OptionParser.new do |op|
|
|
|
12
12
|
op.on '-D', '--default=BOOLEAN', TrueClass, 'Default state if not specified'
|
|
13
13
|
op.on '-p', '--password=STRING', 'Hub password'
|
|
14
14
|
op.on '-c', '--commit', 'Commit change to flash memory'
|
|
15
|
+
op.on '-y', '--yes', 'Confirm a destructive action'
|
|
15
16
|
op.on '--debug[=FILE]', 'Debug output file'
|
|
16
|
-
op.on '-v', '--[no-]verbose', '
|
|
17
|
+
op.on '-v', '--[no-]verbose', 'Report port states after a change'
|
|
17
18
|
op.on '-V', '--version', 'Version' do
|
|
18
19
|
puts "ExSYS library : #{ExSYS::VERSION}"
|
|
19
20
|
exit
|
|
@@ -43,19 +44,40 @@ begin
|
|
|
43
44
|
$hub = ExSYS::ManagedUSB.new($opts[:device], $opts[:password],
|
|
44
45
|
debug: debug)
|
|
45
46
|
|
|
47
|
+
# Naming no port on the command line still means every port. The
|
|
48
|
+
# library wants that said outright rather than inferred from an
|
|
49
|
+
# empty list, so it is spelled out here.
|
|
50
|
+
ports = -> { ARGV.empty? ? [ ExSYS::ManagedUSB::ALL ]
|
|
51
|
+
: ARGV.map(&:to_i) }
|
|
52
|
+
|
|
53
|
+
# One line per port, "3 on", which greps and awks cleanly. Used by
|
|
54
|
+
# the status action and by --verbose after a change.
|
|
55
|
+
report = ->(only = []) {
|
|
56
|
+
state = $hub.get
|
|
57
|
+
(only.empty? ? $hub.ports : only).each do |p|
|
|
58
|
+
raise "invalid port: #{p}" unless state.key?(p)
|
|
59
|
+
puts "#{p} #{state[p] ? 'on' : 'off'}"
|
|
60
|
+
end
|
|
61
|
+
}
|
|
62
|
+
# Only meaningful after something changed.
|
|
63
|
+
verbose = -> { report.call if $opts[:verbose] }
|
|
64
|
+
|
|
46
65
|
case action = ARGV.shift
|
|
47
66
|
when nil
|
|
48
67
|
puts parser
|
|
49
68
|
exit
|
|
50
69
|
|
|
51
70
|
when 'on'
|
|
52
|
-
$hub.on(*
|
|
71
|
+
$hub.on(*ports.call, commit: $opts[:commit])
|
|
72
|
+
verbose.call
|
|
53
73
|
|
|
54
74
|
when 'off'
|
|
55
|
-
$hub.off(*
|
|
75
|
+
$hub.off(*ports.call, commit: $opts[:commit])
|
|
76
|
+
verbose.call
|
|
56
77
|
|
|
57
78
|
when 'toggle'
|
|
58
|
-
$hub.toggle(*
|
|
79
|
+
$hub.toggle(*ports.call, commit: $opts[:commit])
|
|
80
|
+
verbose.call
|
|
59
81
|
|
|
60
82
|
when 'set'
|
|
61
83
|
t = ExSYS::ManagedUSB::TRUE_LIST .to_h {|e| [ e.to_s, e ]}
|
|
@@ -67,15 +89,64 @@ begin
|
|
|
67
89
|
[$1.to_i, tf[$2]]
|
|
68
90
|
}
|
|
69
91
|
$hub.set(a, $opts[:default], commit: $opts[:commit])
|
|
92
|
+
verbose.call
|
|
70
93
|
|
|
94
|
+
when 'status'
|
|
95
|
+
report.call(ARGV.map(&:to_i))
|
|
96
|
+
|
|
97
|
+
# Which line to give -d. Needs no hub and no password: it asks the
|
|
98
|
+
# HOST what is attached, so it works before anything is known.
|
|
99
|
+
#
|
|
100
|
+
# Three columns, "line serial usb-path", '-' for one the host does
|
|
101
|
+
# not report -- the same one-record-per-line shape as status, for
|
|
102
|
+
# the same reason. What is listed is every FT232 on the host,
|
|
103
|
+
# which is not the same as every hub: see the library documentation
|
|
104
|
+
# for ExSYS::ManagedUSB.available.
|
|
105
|
+
when 'discover'
|
|
106
|
+
found = ExSYS::ManagedUSB.available
|
|
107
|
+
found.each do |c|
|
|
108
|
+
puts [ c[:device], c[:serial] || '-',
|
|
109
|
+
c[:usb_path] || '-' ].join(' ')
|
|
110
|
+
end
|
|
111
|
+
# On stderr, and not as a row: stdout here is a listing meant
|
|
112
|
+
# to be read by awk, and a sentence in it would be parsed as an
|
|
113
|
+
# adapter. An operator still wants to know the command ran and
|
|
114
|
+
# found nothing, which is what stderr is for.
|
|
115
|
+
if found.empty?
|
|
116
|
+
warn "#{parser.program_name}: no FTDI" \
|
|
117
|
+
" #{ExSYS::ManagedUSB::CTRL_VENDOR}:" \
|
|
118
|
+
"#{ExSYS::ManagedUSB::CTRL_PRODUCT} on this host"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
when 'query'
|
|
122
|
+
q = $hub.query
|
|
123
|
+
puts "id: #{q[:id]}"
|
|
124
|
+
puts "ports: #{q[:ports]}"
|
|
125
|
+
puts "firmware: #{q[:firmware]}"
|
|
126
|
+
|
|
71
127
|
when 'commit'
|
|
72
128
|
$hub.commit
|
|
73
129
|
|
|
74
130
|
when 'reset'
|
|
75
|
-
$
|
|
131
|
+
unless $opts[:yes]
|
|
132
|
+
raise 'reset reboots the hub, and every port loses power ' \
|
|
133
|
+
'while it does; pass --yes to mean it'
|
|
134
|
+
end
|
|
135
|
+
$hub.reset(confirm: true)
|
|
136
|
+
|
|
137
|
+
when 'factory-reset'
|
|
138
|
+
unless $opts[:yes]
|
|
139
|
+
raise 'factory-reset drops every port and resets the ' \
|
|
140
|
+
'password, and nothing undoes it; pass --yes to mean it'
|
|
141
|
+
end
|
|
142
|
+
$hub.factory_reset(confirm: true)
|
|
76
143
|
|
|
144
|
+
# A tombstone rather than an alias: anyone typing the old name is
|
|
145
|
+
# expecting the inverse of commit, which this never was.
|
|
77
146
|
when 'restore'
|
|
78
|
-
|
|
147
|
+
raise 'restore was renamed factory-reset: it restores the hub ' \
|
|
148
|
+
'to factory defaults, dropping every port and resetting ' \
|
|
149
|
+
'the password, and is not the inverse of commit'
|
|
79
150
|
|
|
80
151
|
else
|
|
81
152
|
raise "unknown action: #{action}"
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
require 'open3'
|
|
2
|
+
require 'rbconfig'
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
require_relative 'managed-usb'
|
|
6
|
+
|
|
7
|
+
module ExSYS
|
|
8
|
+
|
|
9
|
+
class ManagedUSB
|
|
10
|
+
|
|
11
|
+
# USB vendor and product of the hub's management adapter.
|
|
12
|
+
#
|
|
13
|
+
# It is not an ExSYS id. The management side of the hub is an
|
|
14
|
+
# ordinary FTDI FT232, so this pair matches the hub AND every other
|
|
15
|
+
# FT232 attached to the host: a USB-serial cable, a debug probe,
|
|
16
|
+
# a second hub. Nothing short of opening the line and asking it
|
|
17
|
+
# (?Q, see {#query}) tells them apart, and opening an unknown line
|
|
18
|
+
# means writing to somebody else's device.
|
|
19
|
+
#
|
|
20
|
+
# So {available} reports candidates, and choosing between them is
|
|
21
|
+
# the caller's -- see the note there.
|
|
22
|
+
CTRL_VENDOR = '0403'.freeze
|
|
23
|
+
CTRL_PRODUCT = '6001'.freeze
|
|
24
|
+
|
|
25
|
+
# What a USB path looks like: a bus, a dash, and the chain of hub
|
|
26
|
+
# ports leading to the device -- 1-1.2.4.4. Published so that a
|
|
27
|
+
# caller taking a device name from a human or a configuration file
|
|
28
|
+
# can tell one from a serial number without inventing the pattern
|
|
29
|
+
# again; the two cannot be confused, a serial never being digits
|
|
30
|
+
# and dashes in this shape.
|
|
31
|
+
USB_PATH = /\A\d+-\d+(?:\.\d+)*\z/
|
|
32
|
+
|
|
33
|
+
# Every serial line on this host that could be a managed hub.
|
|
34
|
+
#
|
|
35
|
+
# Each entry carries the names the host knows the adapter by:
|
|
36
|
+
#
|
|
37
|
+
# [ { :device => '/dev/ttyUSB0',
|
|
38
|
+
# :serial => 'AL03GD7X',
|
|
39
|
+
# :usb_path => '1-1.2.4.4' },
|
|
40
|
+
# { :device => '/dev/ttyUSB1',
|
|
41
|
+
# :serial => nil,
|
|
42
|
+
# :usb_path => '1-1.3' } ]
|
|
43
|
+
#
|
|
44
|
+
# `:device` is the line, as {#initialize} wants it.
|
|
45
|
+
#
|
|
46
|
+
# `:serial` is the FT232's own serial number, from its EEPROM, or
|
|
47
|
+
# nil for a chip carrying none -- an FT232R ships with one, an
|
|
48
|
+
# unprogrammed EEPROM is possible.
|
|
49
|
+
#
|
|
50
|
+
# `:usb_path` is where the adapter sits in the USB tree ({USB_PATH}),
|
|
51
|
+
# or nil where one cannot be established. Linux states it; FreeBSD
|
|
52
|
+
# does not, and it is walked out of the sysctl tree instead (see
|
|
53
|
+
# Discovery::FreeBSD.usb_path).
|
|
54
|
+
#
|
|
55
|
+
# The shape is the same on both and the NUMBERING is each host's
|
|
56
|
+
# own: FreeBSD counts buses from 0 and Linux from 1, and neither
|
|
57
|
+
# orders its controllers for the other's benefit. A path names a
|
|
58
|
+
# socket on the host that reported it, and does not travel.
|
|
59
|
+
#
|
|
60
|
+
# The two are stable in DIFFERENT ways, and which is wanted depends
|
|
61
|
+
# on the question. A serial follows the adapter: move the hub to
|
|
62
|
+
# another socket, another port, another machine, and its serial goes
|
|
63
|
+
# with it. A USB path follows the socket: whatever is plugged in
|
|
64
|
+
# there answers to it, including a replacement hub. For naming one
|
|
65
|
+
# particular hub the serial is the answer; the path is for a hub
|
|
66
|
+
# with no serial to be named by, and for a bench where the socket is
|
|
67
|
+
# the thing that is fixed.
|
|
68
|
+
#
|
|
69
|
+
# Prefer one of them over the line for anything written down. The number in
|
|
70
|
+
# /dev/ttyUSB1 is neither the hub's nor the USB device number: it is
|
|
71
|
+
# the usbserial (Linux) or ucom (FreeBSD) layer's own index, and it
|
|
72
|
+
# is the lowest one free when that adapter is probed. It therefore
|
|
73
|
+
# depends on what else attached first, and it is reused -- unplug
|
|
74
|
+
# whatever holds ttyUSB0 and the next thing to attach takes
|
|
75
|
+
# ttyUSB0. Two hubs can swap lines across a reboot, or while the
|
|
76
|
+
# machine is up. The serial cannot move.
|
|
77
|
+
#
|
|
78
|
+
# ONE candidate is not proof that it is a hub, and several are not
|
|
79
|
+
# a list of hubs: see {CTRL_VENDOR}. A caller that switches ports
|
|
80
|
+
# should therefore not pick one silently when there is more than
|
|
81
|
+
# one -- the ports of an unrelated hub exist, accept the frames,
|
|
82
|
+
# and report success.
|
|
83
|
+
#
|
|
84
|
+
# @return [Array<Hash>] one { :device, :serial } per FT232 found,
|
|
85
|
+
# in whatever order the host lists them
|
|
86
|
+
# @raise [Error] if this platform has no way to look, or the tool
|
|
87
|
+
# that does the looking is not installed
|
|
88
|
+
def self.available = Discovery.available
|
|
89
|
+
|
|
90
|
+
# Asking the host what is attached.
|
|
91
|
+
#
|
|
92
|
+
# Each platform answers by running the tool that already knows --
|
|
93
|
+
# udevadm on Linux, sysctl on FreeBSD -- and each keeps the running
|
|
94
|
+
# and the parsing apart, so that the parsing can be tested against
|
|
95
|
+
# captured output on a machine with nothing plugged in.
|
|
96
|
+
module Discovery # @!visibility private
|
|
97
|
+
|
|
98
|
+
def self.available
|
|
99
|
+
case RbConfig::CONFIG['host_os']
|
|
100
|
+
when /^linux/ then Linux.available
|
|
101
|
+
when /^freebsd/ then FreeBSD.available
|
|
102
|
+
else raise Error, 'no hub discovery for this platform' \
|
|
103
|
+
" (#{RbConfig::CONFIG['host_os']}):" \
|
|
104
|
+
' name the serial line instead'
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# An absent USB string descriptor arrives as an empty one, not
|
|
109
|
+
# as nothing: udevadm prints ID_SERIAL_SHORT='' and uftdi's
|
|
110
|
+
# pnpinfo sernum="". Both mean the EEPROM carries no serial,
|
|
111
|
+
# and nothing may be identified by ''.
|
|
112
|
+
def self.nonempty(str)
|
|
113
|
+
s = str.to_s
|
|
114
|
+
s.empty? ? nil : s
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# What a platform reader ran, when it is not there at all.
|
|
118
|
+
# Reported rather than swallowed: an empty list would read as
|
|
119
|
+
# "no hub attached", which is a different thing and a lie.
|
|
120
|
+
def self.missing(tool, error)
|
|
121
|
+
raise Error, "cannot look for a hub: #{tool} (#{error.message})"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Run a reader, and hand back only what it printed.
|
|
125
|
+
#
|
|
126
|
+
# Open3 with the arguments given SEPARATELY, and never a
|
|
127
|
+
# backtick: a backtick takes a single string, and a single
|
|
128
|
+
# string with a redirection or a metacharacter in it is run by
|
|
129
|
+
# /bin/sh -- which reports a missing binary ITSELF, as exit
|
|
130
|
+
# 127 and no output, so Errno::ENOENT never reaches Ruby and
|
|
131
|
+
# the rescue above becomes dead code. That is exactly how
|
|
132
|
+
# this once answered "no hub attached" on a host with no
|
|
133
|
+
# sysctl. Passing the arguments apart from the command runs
|
|
134
|
+
# it directly: no shell, no quoting, and a missing binary
|
|
135
|
+
# raises.
|
|
136
|
+
#
|
|
137
|
+
# stderr is dropped rather than redirected, for one message:
|
|
138
|
+
# with no FTDI ever attached the uftdi driver is not loaded,
|
|
139
|
+
# the oid does not exist, and sysctl says so on stderr while
|
|
140
|
+
# printing nothing. That is not an error -- it is the answer,
|
|
141
|
+
# an empty list -- and a library has no business writing it to
|
|
142
|
+
# the terminal. The exit status cannot tell the two apart
|
|
143
|
+
# either: sysctl exits 1 for an unknown oid, and 1 just the
|
|
144
|
+
# same when only one of several is unknown.
|
|
145
|
+
def self.run(tool, *args)
|
|
146
|
+
out, _err, _status = Open3.capture3(tool, *args)
|
|
147
|
+
out
|
|
148
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
149
|
+
self.missing(tool, e)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
module Linux
|
|
154
|
+
UDEVADM = '/usr/bin/udevadm'
|
|
155
|
+
|
|
156
|
+
def self.available
|
|
157
|
+
Dir['/sys/class/tty/ttyUSB*'].sort.filter_map {|path|
|
|
158
|
+
self.candidate(self.properties(path))
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# One candidate, or nil for an adapter that is not an FT232.
|
|
163
|
+
def self.candidate(props)
|
|
164
|
+
return nil unless props[:ID_VENDOR_ID] == CTRL_VENDOR &&
|
|
165
|
+
props[:ID_MODEL_ID] == CTRL_PRODUCT
|
|
166
|
+
{ :device => props[:DEVNAME],
|
|
167
|
+
:serial => Discovery.nonempty(props[:ID_SERIAL_SHORT]),
|
|
168
|
+
:usb_path => self.usb_path(props[:DEVPATH]) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The adapter's own place in the USB tree, out of the sysfs
|
|
172
|
+
# path the tty hangs off.
|
|
173
|
+
#
|
|
174
|
+
# DEVPATH carries the whole chain -- the bus, every hub
|
|
175
|
+
# between, the device, its interface, then the tty:
|
|
176
|
+
#
|
|
177
|
+
# .../usb1/1-1/1-1.2/1-1.2.4/1-1.2.4.4/1-1.2.4.4:1.0/
|
|
178
|
+
# ttyUSB0/tty/ttyUSB0
|
|
179
|
+
#
|
|
180
|
+
# Every hub on the way matches {USB_PATH} as well, so it is
|
|
181
|
+
# the LAST match that is the device itself; the interface
|
|
182
|
+
# component after it carries a ':' and matches nothing.
|
|
183
|
+
def self.usb_path(devpath)
|
|
184
|
+
devpath.to_s.split('/').grep(USB_PATH).last
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# udevadm's --export format: KEY='value' a line, the value
|
|
188
|
+
# quoted the way a shell would want it.
|
|
189
|
+
def self.parse(export)
|
|
190
|
+
export.lines.to_h {|l| l.split('=', 2) }
|
|
191
|
+
.transform_keys(&:to_sym)
|
|
192
|
+
.transform_values {|v| Shellwords.split(v).join(' ') }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def self.properties(path)
|
|
196
|
+
self.parse(Discovery.run(UDEVADM, 'info', '-q', 'property',
|
|
197
|
+
'--export', path))
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
module FreeBSD
|
|
203
|
+
SYSCTL = '/sbin/sysctl'
|
|
204
|
+
|
|
205
|
+
# The two branches read together, in one call: the adapters
|
|
206
|
+
# themselves, and every hub, which is what the walk from an
|
|
207
|
+
# adapter up to its bus passes through and nothing else.
|
|
208
|
+
OIDS = %w[dev.uftdi dev.uhub].freeze
|
|
209
|
+
|
|
210
|
+
def self.available
|
|
211
|
+
tree = self.parse(self.read(*OIDS))
|
|
212
|
+
tree.filter_map {|name, dev|
|
|
213
|
+
next unless name.start_with?('uftdi')
|
|
214
|
+
self.candidate(dev, tree)
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def self.candidate(dev, tree = {})
|
|
219
|
+
pnp = dev[:'%pnpinfo']
|
|
220
|
+
return nil unless pnp.is_a?(Hash)
|
|
221
|
+
return nil unless pnp[:vendor] == "0x#{CTRL_VENDOR}" &&
|
|
222
|
+
pnp[:product] == "0x#{CTRL_PRODUCT}"
|
|
223
|
+
{ :device => '/dev/tty' + dev[:ttyname].to_s,
|
|
224
|
+
:serial => Discovery.nonempty(pnp[:sernum]),
|
|
225
|
+
:usb_path => self.usb_path(dev, tree) }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The adapter's place in the USB tree, built by walking it.
|
|
229
|
+
#
|
|
230
|
+
# There is no /sys/bus/usb here and nothing states a path,
|
|
231
|
+
# but every piece of one is in the sysctl tree. A device's
|
|
232
|
+
# %location gives the bus and the port it occupies on its
|
|
233
|
+
# parent, and its %parent names that parent -- always a
|
|
234
|
+
# uhub, up to the root hub, whose own %location is empty
|
|
235
|
+
# and whose parent is the usbus. Collecting the ports on
|
|
236
|
+
# the way up and reversing them is the path:
|
|
237
|
+
#
|
|
238
|
+
# uftdi0 port=4 parent=uhub5 ┐
|
|
239
|
+
# uhub5 port=4 parent=uhub4 │ 1-1.1.4.4
|
|
240
|
+
# uhub4 port=1 parent=uhub2 │
|
|
241
|
+
# uhub2 port=1 parent=uhub0 ┘ (root: stop)
|
|
242
|
+
#
|
|
243
|
+
# The shape is Linux's, and the numbering is this host's.
|
|
244
|
+
# FreeBSD counts buses from 0 and Linux from 1, and neither
|
|
245
|
+
# orders its controllers for the other's benefit, so the
|
|
246
|
+
# same socket is not the same string on the two systems. A
|
|
247
|
+
# path names a socket on THIS host; see {USB_PATH}.
|
|
248
|
+
# A walk that does not REACH the root hub answers nil.
|
|
249
|
+
# Running off the end of the tree -- a parent nothing read
|
|
250
|
+
# describes -- leaves the ports collected so far, which
|
|
251
|
+
# read as a whole path and are not one: stopping one hub
|
|
252
|
+
# short of the root turns 1-1.1.4.4 into 1-4, a path that
|
|
253
|
+
# exists, names a socket, and is the wrong one.
|
|
254
|
+
def self.usb_path(dev, tree)
|
|
255
|
+
bus = nil
|
|
256
|
+
ports = []
|
|
257
|
+
rooted = false
|
|
258
|
+
while dev
|
|
259
|
+
loc = dev[:'%location']
|
|
260
|
+
unless loc.is_a?(Hash) && loc[:port]
|
|
261
|
+
rooted = true # a root hub occupies no port
|
|
262
|
+
break
|
|
263
|
+
end
|
|
264
|
+
bus ||= loc[:bus]
|
|
265
|
+
ports.unshift(loc[:port])
|
|
266
|
+
dev = tree[dev[:'%parent'].to_s]
|
|
267
|
+
end
|
|
268
|
+
return nil unless rooted && bus && !ports.empty?
|
|
269
|
+
"#{bus}-#{ports.join('.')}"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# sysctl -e output, as { "uftdi0" => { key => value } }.
|
|
273
|
+
#
|
|
274
|
+
# Keyed by the device's name and not by its unit number:
|
|
275
|
+
# two branches are read at once, %parent names a parent
|
|
276
|
+
# that way, and unit numbers repeat across drivers.
|
|
277
|
+
#
|
|
278
|
+
# The two keys holding a list of their own -- %pnpinfo and
|
|
279
|
+
# %location -- are split into a hash of their own, since
|
|
280
|
+
# what is wanted is inside them.
|
|
281
|
+
def self.parse(output)
|
|
282
|
+
output.lines.map(&:chomp).reduce({}) {|acc, l|
|
|
283
|
+
k, v = l.split('=', 2)
|
|
284
|
+
next acc if k.nil?
|
|
285
|
+
dev, i, sk = k.split('.')[1..]
|
|
286
|
+
# No unit number in it -- dev.uhub.%parent -- so
|
|
287
|
+
# it describes the driver and not a device.
|
|
288
|
+
next acc if sk.nil? || i !~ /\A\d+\z/
|
|
289
|
+
if [ '%pnpinfo', '%location' ].include?(sk)
|
|
290
|
+
# Not every token in one of these is a pair:
|
|
291
|
+
# dev.acpi_timer.0.%pnpinfo is the bare word
|
|
292
|
+
# 'unknown'. Nothing outside a USB branch is
|
|
293
|
+
# read today, but a parser that dies on one
|
|
294
|
+
# driver's wording would take the hub with it.
|
|
295
|
+
v = Shellwords.shellsplit(v.to_s)
|
|
296
|
+
.filter_map {|e|
|
|
297
|
+
k2, v2 = e.split('=', 2)
|
|
298
|
+
[ k2.to_sym, v2 ] if v2
|
|
299
|
+
}.to_h
|
|
300
|
+
end
|
|
301
|
+
acc.merge("#{dev}#{i}" => { sk.to_sym => v }) {
|
|
302
|
+
|_k, o, n| o.merge(n)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def self.read(*keys)
|
|
308
|
+
Discovery.run(SYSCTL, '-e', *keys)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
end
|