exsys 1.0 → 1.2
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 +100 -17
- data/Rakefile +16 -1
- data/bin/exsys-usb +24 -0
- data/lib/exsys/discovery.rb +332 -0
- data/lib/exsys/managed-usb.rb +59 -15
- data/lib/exsys/version.rb +1 -1
- data/lib/exsys.rb +1 -0
- data/test/test_discovery.rb +270 -0
- data/test/test_managed_usb.rb +78 -4
- 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: a4be13bb30c8ab3697d13b8b3c9f92fd398ffbd2d1f3cbd817470b3da313e521
|
|
4
|
+
data.tar.gz: 2b0059f98f7d2efe29789fdeb362faf6e4c63a5218cbc57d22593d978d42153f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2438a668ebc64b1236ea4d3001f4c78c4682ca3754d7214c6e50d48aa55163ca78cec57496e1ab51af010079dc2e89cf8f5127bdd8be5240d85268cf1a2946df
|
|
7
|
+
data.tar.gz: 4e0005f569dff091a466f44161836ba256304450b5c7499461e7e3758fa00425e167a5a988e13cca4c8967c943483d8916983adfbf0682472ee02d73580fecd0
|
data/README.md
CHANGED
|
@@ -33,6 +33,9 @@ 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
40
|
changed. Port numbering starts at 1 and runs to however many ports
|
|
38
41
|
the hub reports having: the gem asks it, rather than assuming sixteen.
|
|
@@ -58,7 +61,7 @@ dev=/dev/ttyU0
|
|
|
58
61
|
exsys-usb -d ${dev} on # All on
|
|
59
62
|
exsys-usb -d ${dev} off # All off
|
|
60
63
|
exsys-usb -d ${dev} on 1 2 # Only turn on port 1 and 2
|
|
61
|
-
exsys-usb -d ${dev} toggle 3
|
|
64
|
+
exsys-usb -d ${dev} toggle 3 5 # Toggle port 3 and 5
|
|
62
65
|
exsys-usb -d ${dev} set 3:on 5:off # Turn on port 3, turn off port 5
|
|
63
66
|
exsys-usb -d ${dev} -D false set 3:on # Turn on port 3, all others off
|
|
64
67
|
exsys-usb -d ${dev} -c on 1 # Turn on port 1, and save to flash
|
|
@@ -73,6 +76,7 @@ exsys-usb -d ${dev} -c on 1 # Turn on port 1, and save to flash
|
|
|
73
76
|
| `toggle [PORT...]` | Invert the listed ports, or every port |
|
|
74
77
|
| `set PORT:STATE...` | Set the listed ports; `-D` decides the rest |
|
|
75
78
|
| `status [PORT...]` | Report the ports, one `N on` / `N off` per line |
|
|
79
|
+
| `discover` | List the lines that could be a hub |
|
|
76
80
|
| `query` | What the hub says it is: id, ports, firmware |
|
|
77
81
|
| `commit` | Save the current port state to flash |
|
|
78
82
|
| `factory-reset` | Factory reset; refuses without `--yes` |
|
|
@@ -95,17 +99,18 @@ A port state in `set` is written `PORT:STATE`, where `STATE` is one of
|
|
|
95
99
|
|
|
96
100
|
### Options
|
|
97
101
|
|
|
98
|
-
| Option
|
|
99
|
-
|
|
|
100
|
-
| `-d`, `--device=DEV`
|
|
101
|
-
|
|
|
102
|
-
| `-
|
|
103
|
-
| `-
|
|
104
|
-
| `-
|
|
105
|
-
| `-
|
|
106
|
-
| `--
|
|
107
|
-
|
|
|
108
|
-
| `-
|
|
102
|
+
| Option | Meaning |
|
|
103
|
+
| :------------------------ | :------------------------------------------- |
|
|
104
|
+
| `-d`, `--device=DEV` | Serial line to the hub (required, but see |
|
|
105
|
+
| | `discover`) |
|
|
106
|
+
| `-p`, `--password=STRING` | Hub password; defaults to `pass` |
|
|
107
|
+
| `-c`, `--commit` | Also write the new state to flash |
|
|
108
|
+
| `-y`, `--yes` | Mean a destructive action |
|
|
109
|
+
| `-v`, `--[no-]verbose` | Report the port states after a change |
|
|
110
|
+
| `-D`, `--default=BOOLEAN` | State for the ports `set` does not name |
|
|
111
|
+
| `--debug[=FILE]` | Trace the serial exchange to stderr, or FILE |
|
|
112
|
+
| `-V`, `--version` | Print the library version |
|
|
113
|
+
| `-h`, `--help` | Print the usage |
|
|
109
114
|
|
|
110
115
|
The debug trace shows every frame sent and received, with the password
|
|
111
116
|
blanked out; when it is written to a file, that file is created
|
|
@@ -123,8 +128,8 @@ exsys-usb -d ${dev} -v on 3 # switch, then report
|
|
|
123
128
|
### Exit status
|
|
124
129
|
|
|
125
130
|
`0` when the command was carried out, `1` otherwise -- a malformed
|
|
126
|
-
argument, a port
|
|
127
|
-
command the hub refused. The error goes to stderr, so a script can
|
|
131
|
+
argument, a port the hub does not have, an unreachable serial line, or
|
|
132
|
+
a command the hub refused. The error goes to stderr, so a script can
|
|
128
133
|
rely on the status:
|
|
129
134
|
|
|
130
135
|
~~~sh
|
|
@@ -132,6 +137,82 @@ exsys-usb -d /dev/ttyU0 off 3 || echo "could not switch port 3 off"
|
|
|
132
137
|
~~~
|
|
133
138
|
|
|
134
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` with `dev.uhub` -- the adapters and the tree above them --
|
|
211
|
+
through `sysctl` on FreeBSD; any other platform raises
|
|
212
|
+
rather than answering an empty list, an empty list being a claim that
|
|
213
|
+
nothing is attached.
|
|
214
|
+
|
|
215
|
+
|
|
135
216
|
## Library
|
|
136
217
|
|
|
137
218
|
~~~ruby
|
|
@@ -168,8 +249,8 @@ hub.get(:off) # => [ 2, 4, ... ]
|
|
|
168
249
|
you say every port. An empty list is refused rather than read as
|
|
169
250
|
"all": `hub.off(*ports)` with an empty `ports` is the very same call as
|
|
170
251
|
`hub.off`, so a computed list that came back empty would otherwise
|
|
171
|
-
switch
|
|
172
|
-
there still means every port.
|
|
252
|
+
switch every port on the hub. The command line is unaffected -- naming
|
|
253
|
+
no port there still means every port.
|
|
173
254
|
|
|
174
255
|
Switching is a read-modify-write, and the library holds the serial
|
|
175
256
|
line -- locked -- across the whole exchange, so two processes driving
|
|
@@ -207,7 +288,9 @@ new one; nothing in the library can notice the swap.
|
|
|
207
288
|
above. The keyword is required: it is the one operation here that
|
|
208
289
|
nothing undoes, and the one most easily reached by misunderstanding.
|
|
209
290
|
It was called `restore` up to 0.6; the old name now raises rather than
|
|
210
|
-
run.
|
|
291
|
+
run. The hub's password goes back to `pass`, and the object follows it
|
|
292
|
+
there, so it stays usable afterwards -- but a later run of the tool
|
|
293
|
+
against that hub must drop its `-p`.
|
|
211
294
|
|
|
212
295
|
Sessions nest, so the methods above stay correct when called inside
|
|
213
296
|
one, and a session belongs to the thread that opened it: another thread
|
data/Rakefile
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
require 'bundler'
|
|
2
|
+
require 'rake/clean'
|
|
2
3
|
require 'rake/testtask'
|
|
3
4
|
require 'yard'
|
|
4
5
|
|
|
5
6
|
Bundler::GemHelper.install_tasks
|
|
6
7
|
|
|
8
|
+
# What a build leaves behind.
|
|
9
|
+
#
|
|
10
|
+
# CLEAN is the intermediate that is rebuilt from the sources every
|
|
11
|
+
# time: yard's object database. CLOBBER adds the products themselves
|
|
12
|
+
# -- rendered documentation, packaged gems -- which `rake yard` and
|
|
13
|
+
# `rake build` put back.
|
|
14
|
+
#
|
|
15
|
+
# Every entry is a path this repository's own tasks write, and every
|
|
16
|
+
# one of them is in .gitignore. Nothing is listed by a wildcard that
|
|
17
|
+
# could reach further than that: clobber is a delete, and a list that
|
|
18
|
+
# grows to match somebody's files is how a clean target eats work.
|
|
19
|
+
CLEAN.include('.yardoc', '_yardoc')
|
|
20
|
+
CLOBBER.include('doc', 'rdoc', 'pkg', 'coverage', '*.gem')
|
|
21
|
+
|
|
7
22
|
Rake::TestTask.new do |t|
|
|
8
23
|
t.test_files = FileList['test/test_*.rb']
|
|
9
24
|
t.verbose = true
|
|
@@ -13,7 +28,7 @@ end
|
|
|
13
28
|
task :default => :test
|
|
14
29
|
|
|
15
30
|
YARD::Rake::YardocTask.new do |t|
|
|
16
|
-
t.files = [ 'lib/**/*.rb'
|
|
31
|
+
t.files = [ 'lib/**/*.rb' ]
|
|
17
32
|
t.options = [ '-m', 'markdown' ]
|
|
18
33
|
t.stats_options = [ '--list-undoc' ]
|
|
19
34
|
end
|
data/bin/exsys-usb
CHANGED
|
@@ -94,6 +94,30 @@ begin
|
|
|
94
94
|
when 'status'
|
|
95
95
|
report.call(ARGV.map(&:to_i))
|
|
96
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
|
+
|
|
97
121
|
when 'query'
|
|
98
122
|
q = $hub.query
|
|
99
123
|
puts "id: #{q[:id]}"
|
|
@@ -0,0 +1,332 @@
|
|
|
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
|
+
# No ttyname, no candidate. An adapter whose tty is
|
|
224
|
+
# not named yet has no line to hand anybody, and the
|
|
225
|
+
# answer this used to give was the WORST one available:
|
|
226
|
+
# '/dev/tty' + '' is /dev/tty, the caller's own
|
|
227
|
+
# controlling terminal, which a caller taking it for a
|
|
228
|
+
# hub would open and write command frames to.
|
|
229
|
+
return nil if Discovery.nonempty(dev[:ttyname]).nil?
|
|
230
|
+
{ :device => '/dev/tty' + dev[:ttyname].to_s,
|
|
231
|
+
:serial => Discovery.nonempty(pnp[:sernum]),
|
|
232
|
+
:usb_path => self.usb_path(dev, tree) }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# The adapter's place in the USB tree, built by walking it.
|
|
236
|
+
#
|
|
237
|
+
# There is no /sys/bus/usb here and nothing states a path,
|
|
238
|
+
# but every piece of one is in the sysctl tree. A device's
|
|
239
|
+
# %location gives the bus and the port it occupies on its
|
|
240
|
+
# parent, and its %parent names that parent -- always a
|
|
241
|
+
# uhub, up to the root hub, whose own %location is empty
|
|
242
|
+
# and whose parent is the usbus. Collecting the ports on
|
|
243
|
+
# the way up and reversing them is the path:
|
|
244
|
+
#
|
|
245
|
+
# uftdi0 port=4 parent=uhub5 ┐
|
|
246
|
+
# uhub5 port=4 parent=uhub4 │ 1-1.1.4.4
|
|
247
|
+
# uhub4 port=1 parent=uhub2 │
|
|
248
|
+
# uhub2 port=1 parent=uhub0 ┘ (root: stop)
|
|
249
|
+
#
|
|
250
|
+
# The shape is Linux's, and the numbering is this host's.
|
|
251
|
+
# FreeBSD counts buses from 0 and Linux from 1, and neither
|
|
252
|
+
# orders its controllers for the other's benefit, so the
|
|
253
|
+
# same socket is not the same string on the two systems. A
|
|
254
|
+
# path names a socket on THIS host; see {USB_PATH}.
|
|
255
|
+
# A walk that does not REACH the root hub answers nil.
|
|
256
|
+
# Running off the end of the tree -- a parent nothing read
|
|
257
|
+
# describes -- leaves the ports collected so far, which
|
|
258
|
+
# read as a whole path and are not one: stopping one hub
|
|
259
|
+
# short of the root turns 1-1.1.4.4 into 1-4, a path that
|
|
260
|
+
# exists, names a socket, and is the wrong one.
|
|
261
|
+
def self.usb_path(dev, tree)
|
|
262
|
+
bus = nil
|
|
263
|
+
ports = []
|
|
264
|
+
rooted = false
|
|
265
|
+
seen = {}
|
|
266
|
+
while dev
|
|
267
|
+
loc = dev[:'%location']
|
|
268
|
+
unless loc.is_a?(Hash) && loc[:port]
|
|
269
|
+
rooted = true # a root hub occupies no port
|
|
270
|
+
break
|
|
271
|
+
end
|
|
272
|
+
bus ||= loc[:bus]
|
|
273
|
+
ports.unshift(loc[:port])
|
|
274
|
+
parent = dev[:'%parent'].to_s
|
|
275
|
+
# A tree is what this walks, and a %parent chain
|
|
276
|
+
# that returns to a device already on the way up is
|
|
277
|
+
# not one. No kernel prints that, but this parses
|
|
278
|
+
# whatever it is handed, and the answer without the
|
|
279
|
+
# guard is not a wrong path -- it is an unbounded
|
|
280
|
+
# loop, which on a bench tool is a command that
|
|
281
|
+
# never returns and never says why.
|
|
282
|
+
break if seen[parent]
|
|
283
|
+
seen[parent] = true
|
|
284
|
+
dev = tree[parent]
|
|
285
|
+
end
|
|
286
|
+
return nil unless rooted && bus && !ports.empty?
|
|
287
|
+
"#{bus}-#{ports.join('.')}"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# sysctl -e output, as { "uftdi0" => { key => value } }.
|
|
291
|
+
#
|
|
292
|
+
# Keyed by the device's name and not by its unit number:
|
|
293
|
+
# two branches are read at once, %parent names a parent
|
|
294
|
+
# that way, and unit numbers repeat across drivers.
|
|
295
|
+
#
|
|
296
|
+
# The two keys holding a list of their own -- %pnpinfo and
|
|
297
|
+
# %location -- are split into a hash of their own, since
|
|
298
|
+
# what is wanted is inside them.
|
|
299
|
+
def self.parse(output)
|
|
300
|
+
output.lines.map(&:chomp).reduce({}) {|acc, l|
|
|
301
|
+
k, v = l.split('=', 2)
|
|
302
|
+
next acc if k.nil?
|
|
303
|
+
dev, i, sk = k.split('.')[1..]
|
|
304
|
+
# No unit number in it -- dev.uhub.%parent -- so
|
|
305
|
+
# it describes the driver and not a device.
|
|
306
|
+
next acc if sk.nil? || i !~ /\A\d+\z/
|
|
307
|
+
if [ '%pnpinfo', '%location' ].include?(sk)
|
|
308
|
+
# Not every token in one of these is a pair:
|
|
309
|
+
# dev.acpi_timer.0.%pnpinfo is the bare word
|
|
310
|
+
# 'unknown'. Nothing outside a USB branch is
|
|
311
|
+
# read today, but a parser that dies on one
|
|
312
|
+
# driver's wording would take the hub with it.
|
|
313
|
+
v = Shellwords.shellsplit(v.to_s)
|
|
314
|
+
.filter_map {|e|
|
|
315
|
+
k2, v2 = e.split('=', 2)
|
|
316
|
+
[ k2.to_sym, v2 ] if v2
|
|
317
|
+
}.to_h
|
|
318
|
+
end
|
|
319
|
+
acc.merge("#{dev}#{i}" => { sk.to_sym => v }) {
|
|
320
|
+
|_k, o, n| o.merge(n)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def self.read(*keys)
|
|
326
|
+
Discovery.run(SYSCTL, '-e', *keys)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
end
|
data/lib/exsys/managed-usb.rb
CHANGED
|
@@ -48,6 +48,8 @@ class ManagedUSB
|
|
|
48
48
|
PASSWORD = 'pass'.freeze # @!visibility private
|
|
49
49
|
PORTS = 1.upto(16).to_a.freeze # @!visibility private
|
|
50
50
|
ALL = :all # every port, said explicitly
|
|
51
|
+
# The shapes {#get} will answer in; see it for what each one is.
|
|
52
|
+
TYPES = [ :ports, :on_off, :on, :off ].freeze
|
|
51
53
|
TRUE_LIST = [ 1, :on, :ON, :true, :TRUE, :t, :T, true ].freeze # @!visibility private
|
|
52
54
|
FALSE_LIST = [ 0, :off, :OFF, :false, :FALSE, :f, :F, false ].freeze # @!visibility private
|
|
53
55
|
|
|
@@ -135,15 +137,15 @@ class ManagedUSB
|
|
|
135
137
|
|
|
136
138
|
# Set state for the specified ports
|
|
137
139
|
#
|
|
138
|
-
# Port specification can have one of the
|
|
140
|
+
# Port specification can have one of the following format
|
|
139
141
|
#
|
|
140
142
|
# 1. hash of port values: { 1 => :on, 2 => :off, ...}
|
|
141
143
|
# 2. hash of port states: { :on => [1, 3], :off => 4 }
|
|
142
144
|
#
|
|
143
145
|
# In the case 1. the state values can be specified by
|
|
144
|
-
#
|
|
145
|
-
# * True: 1, :on, :ON, :true, :TRUE, true
|
|
146
|
-
# * False: 0, :off, :OFF, :false, :FALSE, false
|
|
146
|
+
#
|
|
147
|
+
# * True: 1, :on, :ON, :true, :TRUE, :t, :T, true
|
|
148
|
+
# * False: 0, :off, :OFF, :false, :FALSE, :f, :F, false
|
|
147
149
|
#
|
|
148
150
|
# The port states that are not specified will acquire the
|
|
149
151
|
# value specified by the default parameter (nil being the
|
|
@@ -258,7 +260,16 @@ class ManagedUSB
|
|
|
258
260
|
# * off : [ 1, 2, 3, ... ]
|
|
259
261
|
#
|
|
260
262
|
# @param type [:ports, :on_off, :on, :off] Type of returned value
|
|
263
|
+
# @raise [ArgumentError] for a type outside {TYPES}
|
|
261
264
|
def get(type = :ports)
|
|
265
|
+
# Checked before the line is opened. An unknown type is a
|
|
266
|
+
# caller's typo and nothing the hub can answer, so spending a
|
|
267
|
+
# GP and a ?Q on it before saying so helps nobody.
|
|
268
|
+
unless TYPES.include?(type)
|
|
269
|
+
raise ArgumentError, "unknown type: #{type.inspect} " \
|
|
270
|
+
"(expected one of #{TYPES.inspect})"
|
|
271
|
+
end
|
|
272
|
+
|
|
262
273
|
h = session do
|
|
263
274
|
v = _get
|
|
264
275
|
ports.reduce({}) {|acc, obj|
|
|
@@ -281,7 +292,10 @@ class ManagedUSB
|
|
|
281
292
|
when :off
|
|
282
293
|
h.reject {|_,v| v }.keys
|
|
283
294
|
else
|
|
284
|
-
|
|
295
|
+
# Unreachable: TYPES is checked on the way in. Here so
|
|
296
|
+
# that a type added to that list and not to this case says
|
|
297
|
+
# so, rather than answering nil.
|
|
298
|
+
raise Error, "no reader for #{type.inspect}"
|
|
285
299
|
end
|
|
286
300
|
end
|
|
287
301
|
|
|
@@ -297,6 +311,8 @@ class ManagedUSB
|
|
|
297
311
|
# power-on by itself. Confirmed against the vendor's own cusba
|
|
298
312
|
# tool, whose /D issues the same RD command and documents it as
|
|
299
313
|
# "restore to factory default settings".
|
|
314
|
+
# @note The password this object holds follows the hub's back to
|
|
315
|
+
# {PASSWORD}, so it stays usable afterwards.
|
|
300
316
|
# @param confirm [Boolean] must be true; the keyword is the point
|
|
301
317
|
# @raise [ArgumentError] when not confirmed
|
|
302
318
|
def factory_reset(confirm: false)
|
|
@@ -305,7 +321,15 @@ class ManagedUSB
|
|
|
305
321
|
'factory_reset drops every port and resets the ' \
|
|
306
322
|
'password, and nothing undoes it; pass confirm: true'
|
|
307
323
|
end
|
|
308
|
-
action('RD', @password, secrets: [ @password ])
|
|
324
|
+
action('RD', @password, secrets: [ @password ])
|
|
325
|
+
# RD puts the hub's password back to the default, so the one
|
|
326
|
+
# this object was holding is now the wrong one. Forgetting it
|
|
327
|
+
# here is what keeps the NEXT command from being refused by a
|
|
328
|
+
# hub that did exactly what it was told: without this, every
|
|
329
|
+
# later call on a hub that had a password raises E01, and
|
|
330
|
+
# nothing on the wire says why.
|
|
331
|
+
@password = PASSWORD.ljust(8)
|
|
332
|
+
self
|
|
309
333
|
end
|
|
310
334
|
|
|
311
335
|
# @deprecated Renamed to {#factory_reset} in 1.0.
|
|
@@ -407,13 +431,18 @@ class ManagedUSB
|
|
|
407
431
|
def normalize(dataset, default)
|
|
408
432
|
keys = dataset.keys
|
|
409
433
|
if (keys - ports).empty?
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
434
|
+
# to_h rather than transform_values, so that a value that
|
|
435
|
+
# is not a state can name the port it was given for: with
|
|
436
|
+
# sixteen of them, the offending value alone is not enough
|
|
437
|
+
# to find the typo by.
|
|
438
|
+
dataset = dataset.to_h do |k, v|
|
|
439
|
+
[ k, case v
|
|
440
|
+
when * TRUE_LIST then true
|
|
441
|
+
when *FALSE_LIST then false
|
|
442
|
+
when nil
|
|
443
|
+
else raise ArgumentError,
|
|
444
|
+
"not a port state: #{k} => #{v.inspect}"
|
|
445
|
+
end ]
|
|
417
446
|
end
|
|
418
447
|
elsif (keys - [:on, :off]).empty?
|
|
419
448
|
on = Array(dataset[:on ])
|
|
@@ -428,7 +457,9 @@ class ManagedUSB
|
|
|
428
457
|
dataset = on .to_h {|k| [k, true ] }
|
|
429
458
|
.merge(off.to_h {|k| [k, false ] })
|
|
430
459
|
else
|
|
431
|
-
raise ArgumentError
|
|
460
|
+
raise ArgumentError,
|
|
461
|
+
'dataset is neither { port => state } nor ' \
|
|
462
|
+
"{ :on/:off => ports }: #{keys.inspect}"
|
|
432
463
|
end
|
|
433
464
|
|
|
434
465
|
unless default.nil?
|
|
@@ -537,7 +568,20 @@ class ManagedUSB
|
|
|
537
568
|
(serial.gets("\n") || '').chomp.tap do |data|
|
|
538
569
|
@debug&.puts "--> #{data}"
|
|
539
570
|
if check && data[0] != 'G'
|
|
540
|
-
|
|
571
|
+
# Exx is the hub saying no, and xx is what it
|
|
572
|
+
# said. Anything else is NOT a code, and must
|
|
573
|
+
# not be reported as one: data[1..-1] of ''
|
|
574
|
+
# -- which is what a silent hub and a timed-out
|
|
575
|
+
# read both look like -- is nil, and an Error
|
|
576
|
+
# raised with nil carries the class name as its
|
|
577
|
+
# message and nothing else. Of a one-character
|
|
578
|
+
# reply it is '', an error with no message at
|
|
579
|
+
# all. Either way the operator is told the
|
|
580
|
+
# command failed and not one thing more, at
|
|
581
|
+
# exactly the moment the line went quiet.
|
|
582
|
+
code = data.match(/\AE(\S+)\z/)
|
|
583
|
+
raise Error, code ? code[1]
|
|
584
|
+
: "unexpected reply: #{data.inspect}"
|
|
541
585
|
end
|
|
542
586
|
end
|
|
543
587
|
end
|
data/lib/exsys/version.rb
CHANGED
data/lib/exsys.rb
CHANGED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
require_relative 'helper'
|
|
2
|
+
|
|
3
|
+
# Finding the hub's serial line, per platform.
|
|
4
|
+
#
|
|
5
|
+
# The reading and the parsing are kept apart in Discovery precisely so
|
|
6
|
+
# that this runs on a machine with nothing attached: what is asserted
|
|
7
|
+
# here is the parsing, against the output the two tools produce, and
|
|
8
|
+
# not that this host has a hub on it.
|
|
9
|
+
class TestDiscovery < Minitest::Test
|
|
10
|
+
D = ExSYS::ManagedUSB::Discovery
|
|
11
|
+
|
|
12
|
+
# sysctl -e dev.uftdi dev.uhub.
|
|
13
|
+
#
|
|
14
|
+
# The uhub half is captured verbatim from a real FreeBSD host --
|
|
15
|
+
# two controllers, a Genesys hub on one and a chain of two TI hubs
|
|
16
|
+
# on the other -- because the walk that builds a USB path out of it
|
|
17
|
+
# is the part worth testing against something nobody invented.
|
|
18
|
+
#
|
|
19
|
+
# The uftdi half is written: three adapters, one hanging off the
|
|
20
|
+
# deepest hub, one whose EEPROM carries no serial, and an FT230X,
|
|
21
|
+
# which uftdi drives too and which is not what is wanted.
|
|
22
|
+
UHUB = <<~'SYSCTL'
|
|
23
|
+
dev.uhub.%parent=
|
|
24
|
+
dev.uhub.0.%location=
|
|
25
|
+
dev.uhub.0.%parent=usbus1
|
|
26
|
+
dev.uhub.1.%location=
|
|
27
|
+
dev.uhub.1.%parent=usbus0
|
|
28
|
+
dev.uhub.2.%location=bus=1 hubaddr=1 port=1 devaddr=3 interface=0 ugen=ugen1.3
|
|
29
|
+
dev.uhub.2.%parent=uhub0
|
|
30
|
+
dev.uhub.3.%location=bus=0 hubaddr=1 port=2 devaddr=2 interface=0 ugen=ugen0.2
|
|
31
|
+
dev.uhub.3.%parent=uhub1
|
|
32
|
+
dev.uhub.4.%location=bus=1 hubaddr=3 port=1 devaddr=4 interface=0 ugen=ugen1.4
|
|
33
|
+
dev.uhub.4.%parent=uhub2
|
|
34
|
+
dev.uhub.5.%location=bus=1 hubaddr=4 port=4 devaddr=5 interface=0 ugen=ugen1.5
|
|
35
|
+
dev.uhub.5.%parent=uhub4
|
|
36
|
+
SYSCTL
|
|
37
|
+
|
|
38
|
+
UFTDI = <<~'SYSCTL'
|
|
39
|
+
dev.uftdi.0.%desc=FTDI FT232R USB UART
|
|
40
|
+
dev.uftdi.0.%driver=uftdi
|
|
41
|
+
dev.uftdi.0.%location=bus=1 hubaddr=5 port=4 devaddr=9 interface=0 ugen=ugen1.9
|
|
42
|
+
dev.uftdi.0.%pnpinfo=vendor=0x0403 product=0x6001 devclass=0x00 devsubclass=0x00 devproto=0x00 sernum="AL03GD7X" release=0x0600 mode=host intclass=0xff intsubclass=0xff intprotocol=0xff
|
|
43
|
+
dev.uftdi.0.%parent=uhub5
|
|
44
|
+
dev.uftdi.0.ttyname=U0
|
|
45
|
+
dev.uftdi.0.ttyports=1
|
|
46
|
+
dev.uftdi.1.%desc=FTDI FT232R USB UART
|
|
47
|
+
dev.uftdi.1.%driver=uftdi
|
|
48
|
+
dev.uftdi.1.%location=bus=0 hubaddr=2 port=3 devaddr=7 interface=0 ugen=ugen0.7
|
|
49
|
+
dev.uftdi.1.%pnpinfo=vendor=0x0403 product=0x6001 devclass=0x00 devsubclass=0x00 devproto=0x00 sernum="" release=0x0600 mode=host intclass=0xff intsubclass=0xff intprotocol=0xff
|
|
50
|
+
dev.uftdi.1.%parent=uhub3
|
|
51
|
+
dev.uftdi.1.ttyname=U1
|
|
52
|
+
dev.uftdi.1.ttyports=1
|
|
53
|
+
dev.uftdi.2.%desc=FTDI FT230X Basic UART
|
|
54
|
+
dev.uftdi.2.%driver=uftdi
|
|
55
|
+
dev.uftdi.2.%pnpinfo=vendor=0x0403 product=0x6015 devclass=0x00 devsubclass=0x00 devproto=0x00 sernum="DT04H6789" release=0x1000 mode=host intclass=0xff intsubclass=0xff intprotocol=0xff
|
|
56
|
+
dev.uftdi.2.%parent=uhub3
|
|
57
|
+
dev.uftdi.2.ttyname=U2
|
|
58
|
+
dev.uftdi.2.ttyports=1
|
|
59
|
+
SYSCTL
|
|
60
|
+
|
|
61
|
+
# udevadm info -q property --export, for one FT232R.
|
|
62
|
+
UDEVADM = <<~'UDEV'
|
|
63
|
+
DEVNAME='/dev/ttyUSB0'
|
|
64
|
+
DEVPATH='/devices/pci0000:00/0000:00:14.0/usb1/1-1/1-1.2/1-1.2.4/1-1.2.4.4/1-1.2.4.4:1.0/ttyUSB0/tty/ttyUSB0'
|
|
65
|
+
ID_BUS='usb'
|
|
66
|
+
ID_MODEL='FT232R_USB_UART'
|
|
67
|
+
ID_MODEL_ID='6001'
|
|
68
|
+
ID_SERIAL_SHORT='A50285BI'
|
|
69
|
+
ID_VENDOR_ID='0403'
|
|
70
|
+
MAJOR='188'
|
|
71
|
+
SUBSYSTEM='tty'
|
|
72
|
+
UDEV
|
|
73
|
+
|
|
74
|
+
def freebsd(text = UFTDI + UHUB)
|
|
75
|
+
tree = D::FreeBSD.parse(text)
|
|
76
|
+
tree.filter_map {|name, dev|
|
|
77
|
+
next unless name.start_with?('uftdi')
|
|
78
|
+
D::FreeBSD.candidate(dev, tree)
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def test_freebsd_reports_the_line_the_serial_and_the_path
|
|
83
|
+
assert_equal({ :device => '/dev/ttyU0',
|
|
84
|
+
:serial => 'AL03GD7X',
|
|
85
|
+
:usb_path => '1-1.1.4.4' }, freebsd.first)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Nothing on FreeBSD states a path: it is walked, %parent by
|
|
89
|
+
# %parent, and the ports collected on the way up. uftdi0 sits on
|
|
90
|
+
# port 4 of uhub5, which is on port 4 of uhub4, which is on port 1
|
|
91
|
+
# of uhub2, which is on port 1 of the root hub of bus 1.
|
|
92
|
+
def test_the_path_is_walked_up_to_the_root_hub
|
|
93
|
+
tree = D::FreeBSD.parse(UFTDI + UHUB)
|
|
94
|
+
assert_equal '1-1.1.4', D::FreeBSD.usb_path(tree['uhub5'], tree)
|
|
95
|
+
assert_equal '1-1.1', D::FreeBSD.usb_path(tree['uhub4'], tree)
|
|
96
|
+
assert_equal '0-2', D::FreeBSD.usb_path(tree['uhub3'], tree)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A root hub occupies no port on anything: its %location is empty.
|
|
100
|
+
def test_a_root_hub_has_no_path_of_its_own
|
|
101
|
+
tree = D::FreeBSD.parse(UFTDI + UHUB)
|
|
102
|
+
assert_nil D::FreeBSD.usb_path(tree['uhub0'], tree)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Without the hubs there is nothing to walk. The half-built
|
|
106
|
+
# answer is the dangerous one: the adapter's own port alone reads
|
|
107
|
+
# as a whole path -- 1-4 rather than 1-1.1.4.4 -- and names a real
|
|
108
|
+
# socket that is not this one.
|
|
109
|
+
def test_a_walk_that_never_reaches_the_root_is_nil
|
|
110
|
+
assert_nil freebsd(UFTDI).first[:usb_path]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# An unprogrammed EEPROM answers sernum="", and nothing may be
|
|
114
|
+
# identified by an empty string.
|
|
115
|
+
# The case the path exists for: no serial to be named by, and the
|
|
116
|
+
# socket is then the only stable name it has.
|
|
117
|
+
def test_an_empty_serial_is_nil_and_the_path_is_still_there
|
|
118
|
+
assert_equal({ :device => '/dev/ttyU1',
|
|
119
|
+
:serial => nil,
|
|
120
|
+
:usb_path => '0-2.3' }, freebsd[1])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# uftdi drives every FTDI part, not only the one the hub uses.
|
|
124
|
+
def test_another_ftdi_part_is_not_a_candidate
|
|
125
|
+
refute_includes freebsd.map {|c| c[:device] }, '/dev/ttyU2'
|
|
126
|
+
assert_equal 2, freebsd.size
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# dev.uhub.%parent has no unit number in it and must not become a
|
|
130
|
+
# device. Two branches are read at once, so the key is the name.
|
|
131
|
+
def test_the_driver_wide_key_is_not_taken_for_a_device
|
|
132
|
+
keys = D::FreeBSD.parse(UFTDI + UHUB).keys
|
|
133
|
+
assert_includes keys, 'uftdi0'
|
|
134
|
+
assert_includes keys, 'uhub5'
|
|
135
|
+
refute_includes keys, 'uhub'
|
|
136
|
+
assert_equal 9, keys.size
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Not every token in a %pnpinfo is a key=value pair --
|
|
140
|
+
# dev.acpi_timer.0.%pnpinfo is the bare word 'unknown' -- and a
|
|
141
|
+
# parser that died on one driver's wording would take the hub
|
|
142
|
+
# with it.
|
|
143
|
+
def test_a_field_that_is_not_a_pair_does_not_stop_the_parse
|
|
144
|
+
tree = D::FreeBSD.parse(<<~'SYSCTL')
|
|
145
|
+
dev.uftdi.0.%pnpinfo=unknown vendor=0x0403 product=0x6001 sernum="AL03GD7X"
|
|
146
|
+
dev.uftdi.0.ttyname=U0
|
|
147
|
+
SYSCTL
|
|
148
|
+
assert_equal '0x0403', tree['uftdi0'][:'%pnpinfo'][:vendor]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# '/dev/tty' + '' is /dev/tty -- the caller's own controlling
|
|
152
|
+
# terminal. An adapter whose tty is not named yet has no line to
|
|
153
|
+
# offer, and offering that one would have a caller open the
|
|
154
|
+
# operator's screen and write hub command frames into it.
|
|
155
|
+
def test_an_adapter_with_no_tty_yet_is_not_a_candidate
|
|
156
|
+
t = D::FreeBSD.parse(<<~SYSCTL)
|
|
157
|
+
dev.uhub.0.%location=
|
|
158
|
+
dev.uhub.0.%parent=usbus1
|
|
159
|
+
dev.uftdi.0.%location=bus=1 hubaddr=1 port=2 devaddr=3
|
|
160
|
+
dev.uftdi.0.%parent=uhub0
|
|
161
|
+
dev.uftdi.0.%pnpinfo=vendor=0x0403 product=0x6001 sernum="AL03GD7X"
|
|
162
|
+
SYSCTL
|
|
163
|
+
assert_nil D::FreeBSD.candidate(t['uftdi0'], t)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# A %parent chain that comes back on itself is not a tree. No
|
|
167
|
+
# kernel prints one; this parses whatever it is handed, and the
|
|
168
|
+
# answer without a guard is not a wrong path but a command that
|
|
169
|
+
# never returns.
|
|
170
|
+
def test_a_parent_chain_that_loops_terminates
|
|
171
|
+
t = D::FreeBSD.parse(<<~SYSCTL)
|
|
172
|
+
dev.uhub.1.%location=bus=1 hubaddr=1 port=1 devaddr=2
|
|
173
|
+
dev.uhub.1.%parent=uhub2
|
|
174
|
+
dev.uhub.2.%location=bus=1 hubaddr=2 port=2 devaddr=3
|
|
175
|
+
dev.uhub.2.%parent=uhub1
|
|
176
|
+
SYSCTL
|
|
177
|
+
assert_nil D::FreeBSD.usb_path(t['uhub1'], t)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def test_nothing_attached_is_an_empty_list_not_an_error
|
|
181
|
+
assert_empty freebsd('')
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def test_linux_reports_the_line_the_serial_and_the_path
|
|
185
|
+
assert_equal({ :device => '/dev/ttyUSB0',
|
|
186
|
+
:serial => 'A50285BI',
|
|
187
|
+
:usb_path => '1-1.2.4.4' },
|
|
188
|
+
D::Linux.candidate(D::Linux.parse(UDEVADM)))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Every hub on the way to the device matches the shape too, so it
|
|
192
|
+
# is the last match that is the adapter -- not 1-1, not 1-1.2.
|
|
193
|
+
def test_the_usb_path_is_the_device_not_a_hub_above_it
|
|
194
|
+
assert_equal '1-1.2.4.4', D::Linux.usb_path(
|
|
195
|
+
'/devices/pci0000:00/usb1/1-1/1-1.2/1-1.2.4/1-1.2.4.4/' \
|
|
196
|
+
'1-1.2.4.4:1.0/ttyUSB0/tty/ttyUSB0')
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def test_a_devpath_with_no_usb_component_has_no_path
|
|
200
|
+
assert_nil D::Linux.usb_path('/devices/platform/serial8250/ttyS0')
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The published shape, so that a caller can tell a path typed by a
|
|
204
|
+
# human from a serial number.
|
|
205
|
+
def test_what_a_usb_path_looks_like
|
|
206
|
+
assert_match ExSYS::ManagedUSB::USB_PATH, '1-1.2.4.4'
|
|
207
|
+
assert_match ExSYS::ManagedUSB::USB_PATH, '2-3'
|
|
208
|
+
refute_match ExSYS::ManagedUSB::USB_PATH, 'AL03GD7X'
|
|
209
|
+
refute_match ExSYS::ManagedUSB::USB_PATH, '1-1.2.4.4:1.0'
|
|
210
|
+
refute_match ExSYS::ManagedUSB::USB_PATH, '/dev/ttyUSB0'
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def test_linux_skips_an_adapter_that_is_not_the_right_part
|
|
214
|
+
props = D::Linux.parse(UDEVADM.sub("'6001'", "'6015'"))
|
|
215
|
+
assert_nil D::Linux.candidate(props)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def test_linux_unquotes_the_export_format
|
|
219
|
+
assert_equal '/dev/ttyUSB0', D::Linux.parse(UDEVADM)[:DEVNAME]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The public name delegates, so that a caller has one thing to call.
|
|
223
|
+
def test_available_is_what_the_platform_answered
|
|
224
|
+
found = [ { :device => '/dev/ttyU0', :serial => 'AL03GD7X',
|
|
225
|
+
:usb_path => nil } ]
|
|
226
|
+
D.stub(:available, found) do
|
|
227
|
+
assert_equal found, ExSYS::ManagedUSB.available
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# A platform with no way to look says so. An empty list would read
|
|
232
|
+
# as "no hub attached", which is a different thing and a lie.
|
|
233
|
+
def test_a_platform_we_cannot_look_on_is_an_error
|
|
234
|
+
RbConfig::CONFIG.stub(:[], 'solaris2.11') do
|
|
235
|
+
e = assert_raises(ExSYS::ManagedUSB::Error) { D.available }
|
|
236
|
+
assert_match(/no hub discovery for this platform/, e.message)
|
|
237
|
+
assert_match(/solaris2\.11/, e.message)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def test_a_missing_tool_is_an_error_too
|
|
242
|
+
e = assert_raises(ExSYS::ManagedUSB::Error) {
|
|
243
|
+
D.missing('/sbin/sysctl', Errno::ENOENT.new('/sbin/sysctl'))
|
|
244
|
+
}
|
|
245
|
+
assert_match(%r{cannot look for a hub: /sbin/sysctl}, e.message)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Regression, and the reason Discovery.run exists. This reader
|
|
249
|
+
# once ran `sysctl ... 2>/dev/null` in a backtick; the redirection
|
|
250
|
+
# made Ruby hand the string to /bin/sh, which reports a missing
|
|
251
|
+
# binary itself as exit 127 with no output, so Errno::ENOENT never
|
|
252
|
+
# reached the rescue and a host with no sysctl answered "no hub
|
|
253
|
+
# attached" -- the exact lie the comment there warns against.
|
|
254
|
+
#
|
|
255
|
+
# The status cannot stand in for it either: sysctl exits 1 for an
|
|
256
|
+
# unknown oid, which is the legitimate empty answer.
|
|
257
|
+
def test_a_reader_that_is_not_installed_raises_rather_than_answering_none
|
|
258
|
+
e = assert_raises(ExSYS::ManagedUSB::Error) {
|
|
259
|
+
D.run('/nonexistent/sysctl', '-e', 'dev.uftdi')
|
|
260
|
+
}
|
|
261
|
+
assert_match(%r{cannot look for a hub: /nonexistent/sysctl},
|
|
262
|
+
e.message)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# ... and a reader that IS installed, asked for an oid this host
|
|
266
|
+
# has not got, answers nothing at all rather than raising.
|
|
267
|
+
def test_an_oid_that_does_not_exist_is_an_empty_answer
|
|
268
|
+
assert_empty D.run('/usr/bin/env', 'true')
|
|
269
|
+
end
|
|
270
|
+
end
|
data/test/test_managed_usb.rb
CHANGED
|
@@ -134,10 +134,25 @@ class TestManagedUSB < Minitest::Test
|
|
|
134
134
|
assert_match(/overlap/, err.message)
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
+
# Each refusal names what it refused. These used to be bare
|
|
138
|
+
# ArgumentErrors, whose message was the class name and which left a
|
|
139
|
+
# caller with sixteen ports to find the typo among by hand.
|
|
137
140
|
def test_set_refuses_a_mixed_or_unknown_notation
|
|
138
|
-
assert_raises(ArgumentError) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
+
err = assert_raises(ArgumentError) {
|
|
142
|
+
@usb.set({ :on => [ 1 ], 2 => :off })
|
|
143
|
+
}
|
|
144
|
+
assert_match(/neither/, err.message)
|
|
145
|
+
assert_match(/:on/, err.message)
|
|
146
|
+
|
|
147
|
+
err = assert_raises(ArgumentError) { @usb.set({ :bogus => [ 1 ] }) }
|
|
148
|
+
assert_match(/:bogus/, err.message)
|
|
149
|
+
|
|
150
|
+
err = assert_raises(ArgumentError) {
|
|
151
|
+
@usb.set({ 1 => true, 7 => :perhaps })
|
|
152
|
+
}
|
|
153
|
+
assert_match(/not a port state/, err.message)
|
|
154
|
+
assert_match(/7/, err.message)
|
|
155
|
+
assert_match(/:perhaps/, err.message)
|
|
141
156
|
end
|
|
142
157
|
|
|
143
158
|
## get ###############################################################
|
|
@@ -178,8 +193,17 @@ class TestManagedUSB < Minitest::Test
|
|
|
178
193
|
assert_equal [ 3, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 ], @usb.get(:on)
|
|
179
194
|
end
|
|
180
195
|
|
|
196
|
+
# Refused by name, and before the line is opened: an unknown type
|
|
197
|
+
# is a typo and nothing the hub can answer, so it used to cost a GP
|
|
198
|
+
# and a ?Q and then raise a bare ArgumentError whose message was
|
|
199
|
+
# the class name.
|
|
181
200
|
def test_get_refuses_an_unknown_type
|
|
182
|
-
|
|
201
|
+
@usb.port_count # the one-off ?Q, out of the way
|
|
202
|
+
@hub.log.clear
|
|
203
|
+
err = assert_raises(ArgumentError) { @usb.get(:bogus) }
|
|
204
|
+
assert_match(/unknown type/, err.message)
|
|
205
|
+
assert_match(/:bogus/, err.message)
|
|
206
|
+
assert_empty @hub.log, 'a typo must not reach the hub'
|
|
183
207
|
end
|
|
184
208
|
|
|
185
209
|
## Flash and reset ###################################################
|
|
@@ -353,6 +377,24 @@ class TestManagedUSB < Minitest::Test
|
|
|
353
377
|
assert_equal FakeHub::DEFAULT_PASSWORD, @hub.password
|
|
354
378
|
end
|
|
355
379
|
|
|
380
|
+
# Regression: RD puts the hub's password back to the default, and
|
|
381
|
+
# this object went on holding the old one. Every later command was
|
|
382
|
+
# then refused by the hub that had just done what it was told --
|
|
383
|
+
# E01, with nothing on the wire saying why. Only reachable on a hub
|
|
384
|
+
# whose password is NOT the default, which is why the test above,
|
|
385
|
+
# running on a default-password hub, could not see it.
|
|
386
|
+
def test_factory_reset_forgets_the_password_the_hub_dropped
|
|
387
|
+
UART.hub = hub = FakeHub.new(password: 's3cret'.ljust(8))
|
|
388
|
+
usb = ExSYS::ManagedUSB.new('/dev/null', 's3cret')
|
|
389
|
+
|
|
390
|
+
usb.factory_reset(confirm: true)
|
|
391
|
+
usb.on(2)
|
|
392
|
+
|
|
393
|
+
assert_equal [ 2 ], hub.ports_on
|
|
394
|
+
assert_equal 'SPpass 0200FFFF', hub.log.last,
|
|
395
|
+
'the frame must carry the password the hub now has'
|
|
396
|
+
end
|
|
397
|
+
|
|
356
398
|
def test_on_with_commit_writes_through_to_flash
|
|
357
399
|
@usb.on(3, commit: true)
|
|
358
400
|
@usb.on(4)
|
|
@@ -440,6 +482,38 @@ class TestManagedUSB < Minitest::Test
|
|
|
440
482
|
assert_raises(ExSYS::ManagedUSB::Error) { @usb.get }
|
|
441
483
|
end
|
|
442
484
|
|
|
485
|
+
# Regression, and the same defect as the one above on the other
|
|
486
|
+
# path: a command whose reply is CHECKED -- commit, reset,
|
|
487
|
+
# factory_reset, password, and the SP behind every switch -- read
|
|
488
|
+
# the reply as an Exx code without first establishing it was one.
|
|
489
|
+
# data[1..-1] of '' is nil, and an Error raised with nil carries the
|
|
490
|
+
# class name as its message: a silent hub, which is also what a
|
|
491
|
+
# timed-out read looks like, reported itself as
|
|
492
|
+
# 'exsys-usb: ExSYS::ManagedUSB::Error' and nothing more.
|
|
493
|
+
def test_a_silent_hub_says_so_on_a_checked_command
|
|
494
|
+
@hub.silent = true
|
|
495
|
+
err = assert_raises(ExSYS::ManagedUSB::Error) { @usb.commit }
|
|
496
|
+
assert_match(/unexpected reply/, err.message)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# ... and of a one-character reply it is '', an error with no
|
|
500
|
+
# message at all. Anything that is not an Exx is reported as what
|
|
501
|
+
# arrived, not as a code the hub never sent.
|
|
502
|
+
def test_a_reply_too_short_to_be_a_code_is_not_read_as_one
|
|
503
|
+
@hub.garbage = 'X'
|
|
504
|
+
err = assert_raises(ExSYS::ManagedUSB::Error) { @usb.commit }
|
|
505
|
+
assert_match(/unexpected reply/, err.message)
|
|
506
|
+
assert_match(/"X"/, err.message)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# The Exx path itself still answers with the code alone, which is
|
|
510
|
+
# what the wrong-password test above reads.
|
|
511
|
+
def test_a_refusal_on_a_checked_command_is_still_just_its_code
|
|
512
|
+
@hub.garbage = 'E42'
|
|
513
|
+
err = assert_raises(ExSYS::ManagedUSB::Error) { @usb.commit }
|
|
514
|
+
assert_equal '42', err.message
|
|
515
|
+
end
|
|
516
|
+
|
|
443
517
|
## The serial line ###################################################
|
|
444
518
|
|
|
445
519
|
# Regression: a read-modify-write used to close the line between the
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: exsys
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: '1.
|
|
4
|
+
version: '1.2'
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stéphane D'Alu
|
|
@@ -81,11 +81,13 @@ files:
|
|
|
81
81
|
- bin/exsys-usb
|
|
82
82
|
- exsys.gemspec
|
|
83
83
|
- lib/exsys.rb
|
|
84
|
+
- lib/exsys/discovery.rb
|
|
84
85
|
- lib/exsys/managed-usb.rb
|
|
85
86
|
- lib/exsys/version.rb
|
|
86
87
|
- test/helper.rb
|
|
87
88
|
- test/support/fake_hub.rb
|
|
88
89
|
- test/support/uart.rb
|
|
90
|
+
- test/test_discovery.rb
|
|
89
91
|
- test/test_exsys_usb.rb
|
|
90
92
|
- test/test_managed_usb.rb
|
|
91
93
|
- test/test_readme.rb
|