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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebdc824ab993ec59f3e5ba32fbef04a461e3f0354b8ea8723a17fa6125ba19a1
4
- data.tar.gz: 783826b2949fc55f1f8b98c34aca2f39d79f9fe445acbe3863b33e1435f123e1
3
+ metadata.gz: 97086399976474a8e6d5c41ca473a6b2bd385dfe65bc1a1655301510b93ce68d
4
+ data.tar.gz: f14819b8e580dcec1374b6ea1360628b3980c6dd7a8ea7aeb6f87ee406de312b
5
5
  SHA512:
6
- metadata.gz: '059792265ac5e663899a421176034d49bf555c61c5a3ca160f62cb5e53054fbb05e1b1fd5d13016f1003d0da9b9a033f1c20af17615e0852d506a0ce7031b52d'
7
- data.tar.gz: 825f0ba1eab46d7fc3517954050d68a0b43bab0ce34d0cc05cfdf1696d87a2d2679a0b878d2691719dd04e393e0750dbee6dabab97fd55a34639df9ac502a264
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 runs from 1 to 16, and that range is fixed:
38
- this gem targets the 16-port model, so on a smaller hub the commands
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
- | `restore` | Load the port state back from flash |
78
- | `reset` | Reset the hub; port power is *not* maintained |
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 all ports, then switch off ports 4, 5 and 6
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
- ExSYS::ManagedUSB::PORTS.each do |p|
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
- Switching is a read-modify-write, and the library holds the serial line
145
- -- locked -- across the whole exchange, so two processes driving the
146
- same hub cannot lose each other's changes. The wire protocol is
147
- documented in the `ExSYS::ManagedUSB` class comment.
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', 'Run verbosely'
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(*ARGV.map(&:to_i), commit: $opts[:commit])
71
+ $hub.on(*ports.call, commit: $opts[:commit])
72
+ verbose.call
53
73
 
54
74
  when 'off'
55
- $hub.off(*ARGV.map(&:to_i), commit: $opts[:commit])
75
+ $hub.off(*ports.call, commit: $opts[:commit])
76
+ verbose.call
56
77
 
57
78
  when 'toggle'
58
- $hub.toggle(*ARGV.map(&:to_i), commit: $opts[:commit])
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
- $hub.reset
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
- $hub.restore
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