exsys 1.0 → 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 +81 -1
- data/bin/exsys-usb +24 -0
- data/lib/exsys/discovery.rb +314 -0
- data/lib/exsys/version.rb +1 -1
- data/lib/exsys.rb +1 -0
- data/test/test_discovery.rb +241 -0
- 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,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.
|
|
@@ -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` |
|
|
@@ -97,7 +101,8 @@ A port state in `set` is written `PORT:STATE`, where `STATE` is one of
|
|
|
97
101
|
|
|
98
102
|
| Option | Meaning |
|
|
99
103
|
| :-------------------- | :-------------------------------------------- |
|
|
100
|
-
| `-d`, `--device=DEV` | Serial line to the hub (required
|
|
104
|
+
| `-d`, `--device=DEV` | Serial line to the hub (required, but see |
|
|
105
|
+
| | `discover`) |
|
|
101
106
|
| `-p`, `--password=STR`| Hub password; defaults to `pass` |
|
|
102
107
|
| `-c`, `--commit` | Also write the new state to flash |
|
|
103
108
|
| `-y`, `--yes` | Mean a destructive action |
|
|
@@ -132,6 +137,81 @@ 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` 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
|
+
|
|
135
215
|
## Library
|
|
136
216
|
|
|
137
217
|
~~~ruby
|
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,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
|
data/lib/exsys/version.rb
CHANGED
data/lib/exsys.rb
CHANGED
|
@@ -0,0 +1,241 @@
|
|
|
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
|
+
def test_nothing_attached_is_an_empty_list_not_an_error
|
|
152
|
+
assert_empty freebsd('')
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def test_linux_reports_the_line_the_serial_and_the_path
|
|
156
|
+
assert_equal({ :device => '/dev/ttyUSB0',
|
|
157
|
+
:serial => 'A50285BI',
|
|
158
|
+
:usb_path => '1-1.2.4.4' },
|
|
159
|
+
D::Linux.candidate(D::Linux.parse(UDEVADM)))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Every hub on the way to the device matches the shape too, so it
|
|
163
|
+
# is the last match that is the adapter -- not 1-1, not 1-1.2.
|
|
164
|
+
def test_the_usb_path_is_the_device_not_a_hub_above_it
|
|
165
|
+
assert_equal '1-1.2.4.4', D::Linux.usb_path(
|
|
166
|
+
'/devices/pci0000:00/usb1/1-1/1-1.2/1-1.2.4/1-1.2.4.4/' \
|
|
167
|
+
'1-1.2.4.4:1.0/ttyUSB0/tty/ttyUSB0')
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def test_a_devpath_with_no_usb_component_has_no_path
|
|
171
|
+
assert_nil D::Linux.usb_path('/devices/platform/serial8250/ttyS0')
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The published shape, so that a caller can tell a path typed by a
|
|
175
|
+
# human from a serial number.
|
|
176
|
+
def test_what_a_usb_path_looks_like
|
|
177
|
+
assert_match ExSYS::ManagedUSB::USB_PATH, '1-1.2.4.4'
|
|
178
|
+
assert_match ExSYS::ManagedUSB::USB_PATH, '2-3'
|
|
179
|
+
refute_match ExSYS::ManagedUSB::USB_PATH, 'AL03GD7X'
|
|
180
|
+
refute_match ExSYS::ManagedUSB::USB_PATH, '1-1.2.4.4:1.0'
|
|
181
|
+
refute_match ExSYS::ManagedUSB::USB_PATH, '/dev/ttyUSB0'
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def test_linux_skips_an_adapter_that_is_not_the_right_part
|
|
185
|
+
props = D::Linux.parse(UDEVADM.sub("'6001'", "'6015'"))
|
|
186
|
+
assert_nil D::Linux.candidate(props)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def test_linux_unquotes_the_export_format
|
|
190
|
+
assert_equal '/dev/ttyUSB0', D::Linux.parse(UDEVADM)[:DEVNAME]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The public name delegates, so that a caller has one thing to call.
|
|
194
|
+
def test_available_is_what_the_platform_answered
|
|
195
|
+
found = [ { :device => '/dev/ttyU0', :serial => 'AL03GD7X',
|
|
196
|
+
:usb_path => nil } ]
|
|
197
|
+
D.stub(:available, found) do
|
|
198
|
+
assert_equal found, ExSYS::ManagedUSB.available
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A platform with no way to look says so. An empty list would read
|
|
203
|
+
# as "no hub attached", which is a different thing and a lie.
|
|
204
|
+
def test_a_platform_we_cannot_look_on_is_an_error
|
|
205
|
+
RbConfig::CONFIG.stub(:[], 'solaris2.11') do
|
|
206
|
+
e = assert_raises(ExSYS::ManagedUSB::Error) { D.available }
|
|
207
|
+
assert_match(/no hub discovery for this platform/, e.message)
|
|
208
|
+
assert_match(/solaris2\.11/, e.message)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def test_a_missing_tool_is_an_error_too
|
|
213
|
+
e = assert_raises(ExSYS::ManagedUSB::Error) {
|
|
214
|
+
D.missing('/sbin/sysctl', Errno::ENOENT.new('/sbin/sysctl'))
|
|
215
|
+
}
|
|
216
|
+
assert_match(%r{cannot look for a hub: /sbin/sysctl}, e.message)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Regression, and the reason Discovery.run exists. This reader
|
|
220
|
+
# once ran `sysctl ... 2>/dev/null` in a backtick; the redirection
|
|
221
|
+
# made Ruby hand the string to /bin/sh, which reports a missing
|
|
222
|
+
# binary itself as exit 127 with no output, so Errno::ENOENT never
|
|
223
|
+
# reached the rescue and a host with no sysctl answered "no hub
|
|
224
|
+
# attached" -- the exact lie the comment there warns against.
|
|
225
|
+
#
|
|
226
|
+
# The status cannot stand in for it either: sysctl exits 1 for an
|
|
227
|
+
# unknown oid, which is the legitimate empty answer.
|
|
228
|
+
def test_a_reader_that_is_not_installed_raises_rather_than_answering_none
|
|
229
|
+
e = assert_raises(ExSYS::ManagedUSB::Error) {
|
|
230
|
+
D.run('/nonexistent/sysctl', '-e', 'dev.uftdi')
|
|
231
|
+
}
|
|
232
|
+
assert_match(%r{cannot look for a hub: /nonexistent/sysctl},
|
|
233
|
+
e.message)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# ... and a reader that IS installed, asked for an oid this host
|
|
237
|
+
# has not got, answers nothing at all rather than raising.
|
|
238
|
+
def test_an_oid_that_does_not_exist_is_an_empty_answer
|
|
239
|
+
assert_empty D.run('/usr/bin/env', 'true')
|
|
240
|
+
end
|
|
241
|
+
end
|
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.1'
|
|
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
|