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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b64ad35673afb17f7d07e21cd404043cf832637c9247a6697a08148ca781977e
4
- data.tar.gz: fd871f3815d45d51731a686cda177a28d1fb8315c72dd9a299da91bfeee1a091
3
+ metadata.gz: 97086399976474a8e6d5c41ca473a6b2bd385dfe65bc1a1655301510b93ce68d
4
+ data.tar.gz: f14819b8e580dcec1374b6ea1360628b3980c6dd7a8ea7aeb6f87ee406de312b
5
5
  SHA512:
6
- metadata.gz: 0ba2590f1165d88b24642f02a27b14ce5855f42c793e46ad1d2a697ebe6c795eeddb34ffb8e0d411988e920740de7bc189839545446107bdc850332c906d19a7
7
- data.tar.gz: 6c9f2cdf259a245308884fba34601a3c7040c656659472b99d0c8fed03435bc8233bb5aad21eaf87970eb2503aabdce611a62d37448a2f74e177be15d65b43be
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
@@ -1,3 +1,3 @@
1
1
  module ExSYS
2
- VERSION = '1.0' # Version
2
+ VERSION = '1.1' # Version
3
3
  end
data/lib/exsys.rb CHANGED
@@ -3,3 +3,4 @@ end
3
3
 
4
4
  require_relative 'exsys/version'
5
5
  require_relative 'exsys/managed-usb'
6
+ require_relative 'exsys/discovery'
@@ -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.0'
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