tribble-control 0.4.4
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 +7 -0
- data/DESIGN.md +871 -0
- data/LICENSE +21 -0
- data/README.md +541 -0
- data/examples/tribble.conf +208 -0
- data/examples/vbus-check +140 -0
- data/exe/tribble-control +14 -0
- data/lib/tribble-control/cli/connect.rb +282 -0
- data/lib/tribble-control/cli/flash.rb +102 -0
- data/lib/tribble-control/cli/reset.rb +39 -0
- data/lib/tribble-control/cli/serial.rb +60 -0
- data/lib/tribble-control/cli/usb.rb +104 -0
- data/lib/tribble-control/cli.rb +1230 -0
- data/lib/tribble-control/hub/exsys.rb +248 -0
- data/lib/tribble-control/hub/usb.rb +418 -0
- data/lib/tribble-control/hub.rb +115 -0
- data/lib/tribble-control/platform.rb +271 -0
- data/lib/tribble-control/tally.rb +91 -0
- data/lib/tribble-control/version.rb +8 -0
- data/lib/tribble-control.rb +32 -0
- data/man/man1/tribble-control.1 +1483 -0
- data/tribble-control.gemspec +99 -0
- metadata +192 -0
data/DESIGN.md
ADDED
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
# tribble-control — design notes
|
|
2
|
+
|
|
3
|
+
For someone changing `tribble-control`: adding a board family, a debug
|
|
4
|
+
probe, a host platform, a command or a tally. The man page says what
|
|
5
|
+
the tool does and README.md says how to run it; this file says how the
|
|
6
|
+
pieces fit and where they are meant to give.
|
|
7
|
+
|
|
8
|
+
The one sentence the rest of this file elaborates: **the subject is a
|
|
9
|
+
USB hub.** Anything that is knowledge of a particular chip, a
|
|
10
|
+
particular probe or a particular firmware is pushed out of the code and
|
|
11
|
+
into the configuration, or into a file the configuration names.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## The shape of a run
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
exe/tribble-control argv in; one rescue turns an exception into a line
|
|
18
|
+
│
|
|
19
|
+
▾
|
|
20
|
+
CLI#parse -r files, then the configuration, then the hub object
|
|
21
|
+
│
|
|
22
|
+
▾
|
|
23
|
+
CLI#run resolves openocd if the command declares OPENOCD
|
|
24
|
+
│
|
|
25
|
+
▾
|
|
26
|
+
Command#run(argv, **opts)
|
|
27
|
+
│
|
|
28
|
+
├──▸ each_device(ids) {|name, **hopts| ... }
|
|
29
|
+
│ serial : the selected ports on; boards in parallel
|
|
30
|
+
│ usb : the selected ports on; boards in sequence
|
|
31
|
+
│ power : one board powered at a time; bench left off;
|
|
32
|
+
│ refused if a second probe console is up
|
|
33
|
+
│
|
|
34
|
+
├──▸ openocd(*cmds, **hopts) flash, reset, connect --reset
|
|
35
|
+
├──▸ Platform.usb_to_tty connect
|
|
36
|
+
│ Platform.serial_to_tty
|
|
37
|
+
├──▸ Platform.usb_to_serial serial
|
|
38
|
+
│ Platform.probe_consoles serial, and every -m power
|
|
39
|
+
└──▸ hub.on / hub.off / hub.state every switch, through Hub
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Everything that touches hardware is below `each_device`. Everything
|
|
43
|
+
that decides *which* hardware is above it, in the configuration layer, and is
|
|
44
|
+
settled before the hub object exists — which is why most of the test
|
|
45
|
+
suite never reaches a hub.
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
## Layout
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
exe/tribble-control the executable; it only calls CLI.run
|
|
52
|
+
lib/tribble-control.rb what `require 'tribble-control'` loads
|
|
53
|
+
lib/tribble-control/
|
|
54
|
+
version.rb the one place the version number lives
|
|
55
|
+
platform.rb Linux/FreeBSD ways of finding the boards
|
|
56
|
+
hub.rb what any hub answers: ports, state, on, off
|
|
57
|
+
hub/exsys.rb the ExSYS hub, over the exsys gem
|
|
58
|
+
hub/usb.rb any hub that switches its own ports, via usbconfig
|
|
59
|
+
tally.rb the seam where firmware knowledge goes
|
|
60
|
+
cli.rb options, configuration, each_device, openocd
|
|
61
|
+
cli/*.rb one file per command (usb, flash, ...)
|
|
62
|
+
man/man1/tribble-control.1 the manual (mdoc), rendered by --man
|
|
63
|
+
examples/tribble.conf a configuration to copy and edit
|
|
64
|
+
examples/vbus-check the LED recipe as a script: cut, hold, restore, ask
|
|
65
|
+
test/test_*.rb minitest: everything that needs no hub
|
|
66
|
+
test/support/fake_hub.rb a pty speaking the ExSYS hub's real frames
|
|
67
|
+
test/support/fake_usbconfig.rb a host answering sysctl and usbconfig
|
|
68
|
+
test/test-tribble-control the regression suite, against a deployed copy
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
## The configuration is the only model of the bench
|
|
73
|
+
|
|
74
|
+
`CLI#parse` reads the file with UCL, lifts out the six keys that are
|
|
75
|
+
not devices — `device`, `hub`, `switch`, `protect`, `tally`, `types` —
|
|
76
|
+
and treats everything else as a device entry. The file itself comes
|
|
77
|
+
from `-C`, or, failing that, from `./tribble-control.conf` in the
|
|
78
|
+
current directory if one exists there; neither path is consulted when
|
|
79
|
+
the other supplies the file.
|
|
80
|
+
|
|
81
|
+
A setting is then resolved by `attribute(id, key, default)`:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
attribute(id, key, default)
|
|
85
|
+
|
|
86
|
+
the device's own entry has the key? ──yes──▸ its value
|
|
87
|
+
│
|
|
88
|
+
no
|
|
89
|
+
▾
|
|
90
|
+
the entry names a type, and that
|
|
91
|
+
type has the key? ──yes──▸ the type's value
|
|
92
|
+
│
|
|
93
|
+
no
|
|
94
|
+
▾
|
|
95
|
+
the default written in the code
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Three rules hold this up, and each exists because its absence produced
|
|
99
|
+
a wrong flash reported as a success:
|
|
100
|
+
|
|
101
|
+
* **Present means present.** The lookup tests `key?`, not
|
|
102
|
+
truthiness, so a key explicitly set to `false` is not the same as a
|
|
103
|
+
key that is missing.
|
|
104
|
+
* **The entry wins.** A type is what a *kind* of board has in
|
|
105
|
+
common; a device that says otherwise is saying it about itself.
|
|
106
|
+
* **One level.** A type is a block of settings, not a thing that can
|
|
107
|
+
itself have a type, and it may not set `port` or `serial` — both
|
|
108
|
+
name one particular board.
|
|
109
|
+
|
|
110
|
+
What is refused at load rather than discovered later:
|
|
111
|
+
|
|
112
|
+
* **An entry with no `port` key.** Deleting that line is exactly what
|
|
113
|
+
reassigning a port to another board does, so reading "gone" into a
|
|
114
|
+
line somebody forgot would drop a live board silently.
|
|
115
|
+
* **Two entries on the same port.** The tool would reach whichever it
|
|
116
|
+
found first, under the other one's interface and baud.
|
|
117
|
+
* **A `type =` nothing defines.** A board that asked for jlink and
|
|
118
|
+
silently got cmsis-dap is a flash through the wrong probe, reported
|
|
119
|
+
as a success.
|
|
120
|
+
* **A type setting `port` or `serial`.** Both name one particular
|
|
121
|
+
board, so a type that set either would be saying that every board of
|
|
122
|
+
that kind is the same board.
|
|
123
|
+
* **A tally name nothing registered.** A run that forgot its `-r`
|
|
124
|
+
would otherwise capture a whole bench and report nothing but line
|
|
125
|
+
counts, which reads as a firmware saying nothing.
|
|
126
|
+
* **A key `protect` does not have, or an `undeclared` that is not a
|
|
127
|
+
boolean.** The block decides what may be powered down, so a line
|
|
128
|
+
in it that is quietly ignored reads as protection and is none —
|
|
129
|
+
`port = [ 13 ]` for `ports` being the way to write that.
|
|
130
|
+
* **A `protect` `nodes` entry the configuration does not declare.** Same
|
|
131
|
+
reason, one step further: a misspelled board name protects nothing
|
|
132
|
+
and looks in the file exactly like a protected board.
|
|
133
|
+
* **A top-level `reserved` or `undeclared`.** The two keys the
|
|
134
|
+
`protect` block replaced are refused by name, with the line to
|
|
135
|
+
write instead. Left to the device pass they would be refused as
|
|
136
|
+
entries with no port — true, and no help — and a configuration that then
|
|
137
|
+
gave them one would load with every port they named switchable.
|
|
138
|
+
* **A `device` that is a block or a list.** A configuration is one bench,
|
|
139
|
+
and one bench is one hub; a file naming two would be a file whose
|
|
140
|
+
port numbers mean two different things.
|
|
141
|
+
|
|
142
|
+
`port` is normalised to an Integer, or to `nil` for `none`, once at
|
|
143
|
+
load — `port_of` is the only place that has to know what a port may
|
|
144
|
+
look like. Write a configuration with `port = '8'` and it is an Integer by
|
|
145
|
+
the time anything reads it.
|
|
146
|
+
|
|
147
|
+
`port = none` is how an entry says it is a record rather than a board:
|
|
148
|
+
its serial is worth keeping, and so is the comment saying when it left.
|
|
149
|
+
Such an entry is dropped from `#devices`, which is the single filter
|
|
150
|
+
every walk of the bench goes through, so it is never selected, never
|
|
151
|
+
switched, never flashed, and never counted among the ports that may be
|
|
152
|
+
powered down. `#declared` is the unfiltered list, for looking a serial
|
|
153
|
+
up — which is the reason the entry is in the file at all.
|
|
154
|
+
|
|
155
|
+
### What must not lose power is one block
|
|
156
|
+
|
|
157
|
+
`protect` holds the whole power-down policy: `undeclared` for the ports
|
|
158
|
+
the file does not mention, `ports` and `nodes` for the ones it names.
|
|
159
|
+
|
|
160
|
+
One key because *protected* is the word the tool already uses — `usb
|
|
161
|
+
status` prints `(protected)`, the manual gives it a section, and the
|
|
162
|
+
refusals say the configuration protects a port. As two top-level keys,
|
|
163
|
+
`undeclared` and `reserved`, that word named neither of them, and the
|
|
164
|
+
reader had to find two lines to know what a bench refuses to switch.
|
|
165
|
+
`undeclared` is a boolean rather than the old `protect`/`switch` pair
|
|
166
|
+
for the same reason: under a key called `protect`, a value called
|
|
167
|
+
`protect` says the same word twice.
|
|
168
|
+
|
|
169
|
+
`nodes` exists because the alternative is writing a port number twice.
|
|
170
|
+
A protected board named by number appears in its own entry and again in
|
|
171
|
+
`ports`, and the two disagree the moment it moves socket — silently,
|
|
172
|
+
because both numbers are still valid ports. A name resolves through
|
|
173
|
+
the entry, so one line says where the board is. `ports` stays for what
|
|
174
|
+
has no entry, though declaring the thing is usually better even when
|
|
175
|
+
nothing drives it: `usb status` then prints a name beside the protected
|
|
176
|
+
port instead of a dash. The cost is that protecting by name and
|
|
177
|
+
driving by name are one namespace — a declared power feed is not
|
|
178
|
+
switchable, but it can still be *selected*, and a command naming it
|
|
179
|
+
will try to reach a board that is not there.
|
|
180
|
+
|
|
181
|
+
The two keys the block replaced are refused by name rather than
|
|
182
|
+
accepted as aliases. An alias keeps two spellings of one rule alive
|
|
183
|
+
for a file that one tool reads on one bench, and the reader then has to
|
|
184
|
+
know both; the break costs one edit per configuration, paid once, loudly, and
|
|
185
|
+
before anything is switched.
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
## The hub is an interface, and the ExSYS hub is one of them
|
|
189
|
+
|
|
190
|
+
Every switch in the program goes through `TribbleControl::Hub`: `ports`,
|
|
191
|
+
`state`, `on`, `off`, `toggle`, `set`, `usb_path(port)` and `vbus?`,
|
|
192
|
+
plus `to_s` for the messages. `CLI#hub` holds the one instance, the
|
|
193
|
+
commands reach it through the `hub` delegate, and nothing above
|
|
194
|
+
`each_device` knows which kind it is.
|
|
195
|
+
|
|
196
|
+
Two backends answer it. `Hub::ExSYS` wraps `ExSYS::ManagedUSB`, and
|
|
197
|
+
everything in this section that is about the FT232, the gem, or the
|
|
198
|
+
4-by-4 geometry lives in that class and not in `CLI`. `Hub::USB`
|
|
199
|
+
(`lib/tribble-control/hub/usb.rb`) drives any hub that switches its own
|
|
200
|
+
ports, through `usbconfig` hub-class requests: the hub descriptor says
|
|
201
|
+
how many ports there are, `GET_STATUS` says whether one is powered, and
|
|
202
|
+
SET_FEATURE/CLEAR_FEATURE of PORT_POWER switches it. It needs no gem,
|
|
203
|
+
because the switching is on the bus itself rather than on a serial line
|
|
204
|
+
wired beside it, and it is FreeBSD-only for now — the refusal is in
|
|
205
|
+
`Hub::USB.open`, so nothing above it knows about platforms.
|
|
206
|
+
`Hub.backend(kind)` maps the configuration's `hub =` word to the class and
|
|
207
|
+
requires it on demand, so a host lacking what one backend needs still
|
|
208
|
+
runs the other.
|
|
209
|
+
|
|
210
|
+
Five decisions shape the second backend, and none of them is a fact
|
|
211
|
+
about hubs that the first one contradicts:
|
|
212
|
+
|
|
213
|
+
* **The kind is declared, never inferred.** `hub = exsys|usb` says
|
|
214
|
+
what the hub *is*, not which tool drives it on this host, so the
|
|
215
|
+
same configuration line keeps working the day a Linux implementation
|
|
216
|
+
lands. It cannot be read off `device =`: a USB path such as
|
|
217
|
+
`1-1.1` names an FT232's socket for the ExSYS hub and the hub
|
|
218
|
+
itself for a usb hub. Inferring the kind from the shape of the
|
|
219
|
+
name was rejected for exactly that, and so were `usbconfig` and
|
|
220
|
+
`freebsd` as names for the key — both name the driver rather than
|
|
221
|
+
the hardware.
|
|
222
|
+
* **`switch = link|vbus` is the operator's to declare.** Software
|
|
223
|
+
cannot tell whether a hub's power switch is wired to the socket: a
|
|
224
|
+
hub with none still reports the port unpowered and drops the link,
|
|
225
|
+
and the device vanishes and returns either way. Measured on the
|
|
226
|
+
dock's Genesys hub on 2026-09-18 — a flash drive detached and
|
|
227
|
+
re-enumerated on CLEAR/SET_FEATURE(PORT_POWER), while an
|
|
228
|
+
nRF52840-MDK's LED stayed lit through two five-second cuts, on the
|
|
229
|
+
USB 2 and on the USB 3 side of the same socket. So the operator
|
|
230
|
+
watches a board's LED through `usb off` and writes the answer down.
|
|
231
|
+
A mode built on `usbconfig power_off` was rejected: on FreeBSD 15
|
|
232
|
+
it only unconfigures the device and clears `PORT_ENABLE` on the
|
|
233
|
+
parent port, keeps VBUS, and needs root — the `USB_RE_ENUM_PWR_OFF`
|
|
234
|
+
branch of /usr/src/sys/dev/usb/usb_hub.c, and the
|
|
235
|
+
`priv_check(PRIV_DRIVER)` in the set-power-mode ioctl of
|
|
236
|
+
/usr/src/sys/dev/usb/usb_generic.c — so the hub-class request does
|
|
237
|
+
strictly more with less privilege.
|
|
238
|
+
* **Link mode works everywhere except where it cannot.** A board on
|
|
239
|
+
a port taken off the bus vanishes from the host exactly as a power
|
|
240
|
+
cut would, so `usb off/on/toggle/set` behave and `--method power`
|
|
241
|
+
still identifies a board by being the only one visible to openocd.
|
|
242
|
+
What does not happen is the board restarting, so every power-down
|
|
243
|
+
warns once naming the ports that stay powered (`CLI#warn_link_only`)
|
|
244
|
+
and the after-flash cycle is skipped with a warning
|
|
245
|
+
(`lib/tribble-control/cli/flash.rb`). Doing the cycle silently was
|
|
246
|
+
rejected: the board
|
|
247
|
+
would keep the very state the cycle exists to clear.
|
|
248
|
+
* **The read-back is the only measurement.** After every SET/CLEAR
|
|
249
|
+
the backend reads the port status back and raises if the power bit
|
|
250
|
+
did not follow. A hub that switches nothing accepts the request
|
|
251
|
+
and answers OK, so without this `off` would report success on a
|
|
252
|
+
bench it had not touched. It is also why nothing is refused on
|
|
253
|
+
`wHubCharacteristics`: the dock's Genesys hub declares ganged
|
|
254
|
+
switching and switches per port anyway, and the descriptor is a
|
|
255
|
+
claim where the read-back is a measurement. Refusing hubs that
|
|
256
|
+
declare ganged or no switching was rejected on that.
|
|
257
|
+
* **Membership of group `operator`, and not root.** The ugen nodes
|
|
258
|
+
are `root:operator` 0660, and the kernel's `usb_check_request`
|
|
259
|
+
(/usr/src/sys/dev/usb/usb_util.c) demands the driver privilege only for
|
|
260
|
+
SET_ADDRESS, SET_CONFIG and SET_INTERFACE; hub-class port feature
|
|
261
|
+
requests pass. The backend turns the kernel's "Permission denied"
|
|
262
|
+
into a message naming the group rather than passing it on.
|
|
263
|
+
|
|
264
|
+
The base class carries the last guard: a switching method handed an
|
|
265
|
+
empty list, or a port the hub does not have, raises `Hub::Error` before
|
|
266
|
+
a backend sees it. Every caller checks first — see the invariants —
|
|
267
|
+
so this is the net and not the trapeze, but a fourth path into the hub
|
|
268
|
+
that forgets is now stopped rather than read as "every port".
|
|
269
|
+
|
|
270
|
+
A backend answering less than the interface says so by name
|
|
271
|
+
(`NotImplementedError` naming the class and method) rather than as a
|
|
272
|
+
`NoMethodError` on an internal — the same rule `Platform` has for a
|
|
273
|
+
host that cannot implement a lookup. Its errors are `Hub::Error`,
|
|
274
|
+
which `CLI.run` prints as one line; `Hub::ExSYS` translates the gem's
|
|
275
|
+
own error class into it so the commands never see the gem's.
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
## Which hub, and why the configuration names it
|
|
279
|
+
|
|
280
|
+
`ExSYS::ManagedUSB.available` returns every FTDI 0403:6001 on the host
|
|
281
|
+
as `{ device:, serial:, usb_path: }`. `Hub::ExSYS.open` turns that
|
|
282
|
+
plus what was asked for into the one line the gem is handed:
|
|
283
|
+
|
|
284
|
+
```text
|
|
285
|
+
Hub::ExSYS.open(named)
|
|
286
|
+
named = -d, else the configuration's `device`, else nil
|
|
287
|
+
|
|
288
|
+
named has a '/' in it? ──yes──▸ the serial line itself,
|
|
289
|
+
│ used as given
|
|
290
|
+
no
|
|
291
|
+
▾
|
|
292
|
+
named looks like 1-1.2.4.4? ──yes──▸ the candidate in that
|
|
293
|
+
│ socket, or an error
|
|
294
|
+
no
|
|
295
|
+
▾
|
|
296
|
+
named at all? ──yes──▸ the candidate whose serial
|
|
297
|
+
│ it is, or an error listing
|
|
298
|
+
no what the host does have
|
|
299
|
+
▾
|
|
300
|
+
exactly one candidate? ──yes──▸ that one
|
|
301
|
+
│
|
|
302
|
+
no
|
|
303
|
+
▾
|
|
304
|
+
an error
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The three shapes cannot collide: a serial is never digits and dashes in
|
|
308
|
+
the USB-path shape, and neither of those contains a `/`. The pattern
|
|
309
|
+
is the gem's `ExSYS::ManagedUSB::USB_PATH`, published for exactly this
|
|
310
|
+
— a caller taking a name from a human should not have to invent it.
|
|
311
|
+
|
|
312
|
+
Two decisions are load-bearing:
|
|
313
|
+
|
|
314
|
+
* **Auto-detection refuses to guess between two.** That FTDI id is a
|
|
315
|
+
hub's control adapter and equally every other FT232 on the host, so
|
|
316
|
+
picking the first enumerated is a coin toss — and driving the wrong
|
|
317
|
+
hub raises nothing anywhere. The ports exist, the frames are
|
|
318
|
+
accepted, `usb status` answers, and the boards that go dark are on
|
|
319
|
+
the other bench. There is no later check that could catch it, which
|
|
320
|
+
is why the guess is refused rather than warned about.
|
|
321
|
+
* **The serial line is what a file should never carry.** The `1` in
|
|
322
|
+
`ttyUSB1` is neither the hub's number nor the USB device number: it
|
|
323
|
+
is the usbserial layer's index, and it is the lowest one free when
|
|
324
|
+
that adapter is probed. It therefore depends on what else attached
|
|
325
|
+
first, and it is reused — unplug the adapter holding `ttyUSB0` and
|
|
326
|
+
the next thing to attach takes `ttyUSB0`. Two hubs can swap names
|
|
327
|
+
across a reboot or while the machine is up, and every configuration
|
|
328
|
+
naming them that way then points at the other bench, silently.
|
|
329
|
+
* **A serial and a USB path are both stable, and not the same
|
|
330
|
+
promise.** A serial is in the FT232's EEPROM and follows the
|
|
331
|
+
adapter; a USB path is a position in the tree and follows the
|
|
332
|
+
socket, so a replacement hub inherits it. Naming one particular
|
|
333
|
+
hub is the serial's job and is the default advice. The path exists
|
|
334
|
+
for the case the serial cannot cover — an adapter whose EEPROM
|
|
335
|
+
carries none, which otherwise has no stable name at all. Both
|
|
336
|
+
platforms report one: Linux states it, and on FreeBSD the gem walks
|
|
337
|
+
it out of the sysctl tree. Each host numbers in its own way,
|
|
338
|
+
though, so a path names a socket on the machine that reported it.
|
|
339
|
+
A path named on a host that reports none at all is refused with
|
|
340
|
+
that said, rather than as a path that is merely absent — the two
|
|
341
|
+
send a reader looking in different places. A value with a `/` in it is taken as a path anyway — the
|
|
342
|
+
same rule `--openocd` uses — because a line reached some other way
|
|
343
|
+
still has to be nameable.
|
|
344
|
+
|
|
345
|
+
`Hub::USB.open` holds the same policy in its own shapes: a ugen name is
|
|
346
|
+
a device used as given, `1-1.1` is a USB path, anything else is a
|
|
347
|
+
serial, and the three cannot collide either. Auto-detection takes a
|
|
348
|
+
lone candidate and refuses two or more, listing each with its serial,
|
|
349
|
+
its ugen name, its USB path and its port count — the count being what
|
|
350
|
+
tells two of the same part apart when neither carries a serial. Root
|
|
351
|
+
hubs are never candidates: they are the controller, their `%location` is
|
|
352
|
+
empty and their parent is a usbusN, and a port of one has no PORT_POWER
|
|
353
|
+
to clear, so offering one would offer a hub every command against it
|
|
354
|
+
then failed on. Discovery reads `sysctl -e dev.uhub` and asks each
|
|
355
|
+
candidate for its hub descriptor, so a hub that will not answer one is
|
|
356
|
+
listed all the same with no port count — a listing must not be stopped
|
|
357
|
+
by one odd hub — and choosing that hub is what is refused.
|
|
358
|
+
|
|
359
|
+
The configuration is the place for it because a configuration is already one bench:
|
|
360
|
+
the command that says which configuration then says which hub, and that
|
|
361
|
+
is the only thing it has to say. `-d` stays for the one-off.
|
|
362
|
+
|
|
363
|
+
The split with the `exsys` gem is along the same line as everywhere
|
|
364
|
+
else: what a hub *is* belongs to the gem, what this bench *wants*
|
|
365
|
+
belongs here.
|
|
366
|
+
|
|
367
|
+
* The gem reports. `ExSYS::ManagedUSB.available` knows the FTDI id
|
|
368
|
+
because it knows the hub, knows how to ask a Linux or a FreeBSD
|
|
369
|
+
host, and knows that a candidate is not a hub — every FT232 on the
|
|
370
|
+
machine matches, and telling them apart means opening the line and
|
|
371
|
+
writing to it. So it lists, with serials, and decides nothing.
|
|
372
|
+
* This tool decides. `Hub::ExSYS.open` is where the policy and the
|
|
373
|
+
wording live: what `-d` means against what the configuration says, that
|
|
374
|
+
one candidate may be taken and two may not, and what to print when
|
|
375
|
+
it refuses. None of that is a fact about hubs; it is a fact about
|
|
376
|
+
this tool's promise not to switch the wrong bench. It sits in the
|
|
377
|
+
backend rather than in `CLI` because the shapes a name may take —
|
|
378
|
+
an FT232 serial, a socket, a serial line — are this hub's shapes,
|
|
379
|
+
and another kind of hub is named in other ways.
|
|
380
|
+
|
|
381
|
+
`Platform` keeps only the board-side lookups. It has its own
|
|
382
|
+
`udevadm`/`sysctl` plumbing for the probes and consoles, which is why
|
|
383
|
+
the two readings look alike and are nonetheless not shared: one is
|
|
384
|
+
about a bench's boards, the other about a product's control adapter,
|
|
385
|
+
and the gem must work for callers that have no bench at all.
|
|
386
|
+
|
|
387
|
+
### The floor is exsys 1.2
|
|
388
|
+
|
|
389
|
+
The gemspec requires `exsys` as `~> 1.2`, and this tool is exactly the
|
|
390
|
+
caller that floor is for. Under 1.1, a FreeBSD control adapter whose
|
|
391
|
+
tty was not named yet came back from `ExSYS::ManagedUSB.available` as
|
|
392
|
+
`:device` `/dev/tty` — the string `/dev/tty` plus an empty ttyname —
|
|
393
|
+
and `Hub::ExSYS.open` takes a lone candidate without asking, so `usb off`
|
|
394
|
+
on such a host would have opened the operator's own controlling
|
|
395
|
+
terminal and written SP frames at it. 1.2 reports no candidate at all
|
|
396
|
+
for an adapter whose tty is not named.
|
|
397
|
+
|
|
398
|
+
Two more things come with that floor. The gem's own walk up the
|
|
399
|
+
sysctl tree is bounded, so a `%parent` chain that loops ends the walk
|
|
400
|
+
rather than running forever; and only an `Exx` reply is read as a hub
|
|
401
|
+
code, so a line that has gone silent no longer raises an `Error`
|
|
402
|
+
carrying no message. The second one matters here because `CLI.run`
|
|
403
|
+
prints the message and nothing else: an error without one is the whole
|
|
404
|
+
of what the operator is told, at the moment the line went quiet.
|
|
405
|
+
|
|
406
|
+
The alternative was a guard here — `Hub::ExSYS.open` refusing a candidate
|
|
407
|
+
whose device is `/dev/tty` — and it was rejected. That would paper
|
|
408
|
+
over a gem defect inside one caller and leave every other caller of
|
|
409
|
+
the gem exposed, and discovery is the gem's half of the split above.
|
|
410
|
+
|
|
411
|
+
The requirement stays pessimistic on the series (`~> 1.2`, not
|
|
412
|
+
`>= 1.2`) for the reason the `ucl` pin gives: the failure mode of a
|
|
413
|
+
quiet semantic change in the hub layer is a port switched that should
|
|
414
|
+
not be.
|
|
415
|
+
|
|
416
|
+
The duplication above now runs to those guards as well, and stays
|
|
417
|
+
duplicated. `Platform`'s walk and the gem's discovery grew the same
|
|
418
|
+
two independently: an entry with no ttyname, which `probe_consoles`
|
|
419
|
+
drops, and a `%parent` chain that loops, which `usb_path` bounds with
|
|
420
|
+
a seen-set. That is the visible price of the split, and it was paid
|
|
421
|
+
knowingly — the gem must work for callers with no bench, and
|
|
422
|
+
`Platform`'s walk is about a bench's boards rather than about a
|
|
423
|
+
product's control adapter. Nothing merges them.
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
## each_device: the one path to a board
|
|
427
|
+
|
|
428
|
+
`each_device(ids) {|name, **hopts| ... }` resolves a selection into
|
|
429
|
+
boards, arranges for them to be reachable, and yields each one with the
|
|
430
|
+
keyword arguments that say how to reach it. What is in `hopts` depends
|
|
431
|
+
on the method:
|
|
432
|
+
|
|
433
|
+
| Method | `hopts` carries |
|
|
434
|
+
| :------- | :-------------------------------------- |
|
|
435
|
+
| `serial` | `serial:` plus the openocd four |
|
|
436
|
+
| `usb` | `usb:`, `serial:` plus the openocd four |
|
|
437
|
+
| `power` | the openocd four only |
|
|
438
|
+
|
|
439
|
+
The openocd four are `interface:`, `target:`, `transport:` and
|
|
440
|
+
`work_area:` — all configuration keys, all with defaults, none of them a
|
|
441
|
+
constant anywhere in the code. That is what makes a board of another
|
|
442
|
+
family a configuration edit rather than a patch.
|
|
443
|
+
|
|
444
|
+
`power` carries neither a serial nor a path because it does not need
|
|
445
|
+
one: it cuts every switchable port and brings up one board at a time,
|
|
446
|
+
so the board being addressed is the only board there is. That is also
|
|
447
|
+
why it is the fallback when a `serial =` is missing or wrong, and why
|
|
448
|
+
it leaves the bench powered off.
|
|
449
|
+
|
|
450
|
+
Two things about this method are load-bearing:
|
|
451
|
+
|
|
452
|
+
* **An empty selection is refused, never carried through.** It is
|
|
453
|
+
reachable — a configuration whose every entry says `port = none`, with no
|
|
454
|
+
board named on the command line — and each of the three branches
|
|
455
|
+
would otherwise do something worse than nothing with it: an empty
|
|
456
|
+
splat into the hub reads as *every port*, so selecting no board
|
|
457
|
+
would power all sixteen up and report success for the nought
|
|
458
|
+
boards it flashed, and `power` would cut the bench and leave it off.
|
|
459
|
+
* **`Parallel.map` runs `in_threads:`, not in processes.** The
|
|
460
|
+
default forks, and a forked child pushes its result into its own
|
|
461
|
+
copy of the accumulator; the parent's stays empty, and `[].all?` is
|
|
462
|
+
`true`, so `flash` and `reset` would exit 0 however many boards had
|
|
463
|
+
failed. The work is `Open3.capture2e` on openocd, which releases
|
|
464
|
+
the GVL for its whole duration, so threads keep the parallelism and
|
|
465
|
+
keep the results where they can be seen.
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
## The openocd command line
|
|
469
|
+
|
|
470
|
+
`CLI#openocd` builds one invocation per board, in this order:
|
|
471
|
+
|
|
472
|
+
```sh
|
|
473
|
+
openocd \
|
|
474
|
+
-c 'set WORKAREASIZE 0x<work_area>' # unless work_area = none
|
|
475
|
+
-c 'source [find interface/<interface>.cfg]' \
|
|
476
|
+
-c 'transport select <transport>' \ # unless transport = none
|
|
477
|
+
-c 'source [find target/<target>.cfg]' \
|
|
478
|
+
-c 'adapter usb location <usb>' \ # --method usb only
|
|
479
|
+
-c 'adapter serial <serial>' \ # whenever the configuration has one
|
|
480
|
+
-c '<each command the caller passed>' \
|
|
481
|
+
-c shutdown
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
`adapter usb location` is documentation of intent, not a selector:
|
|
485
|
+
openocd 0.12 reaches the same adapter whichever path it is given,
|
|
486
|
+
including one that does not exist. `adapter serial` is what actually
|
|
487
|
+
selects, which is why `--method usb` passes it too whenever the configuration
|
|
488
|
+
has one. A board with no `serial =`, on a bench with more than one
|
|
489
|
+
adapter powered, is a board chosen at random.
|
|
490
|
+
|
|
491
|
+
The binary is resolved once, by `openocd_path`, before any port is
|
|
492
|
+
switched. A command that always needs it says so with `OPENOCD =
|
|
493
|
+
true`; `connect` does not, and checks when it reaches for it under
|
|
494
|
+
`--reset`.
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
## Extension points
|
|
498
|
+
|
|
499
|
+
### A new board family or probe — the configuration first
|
|
500
|
+
|
|
501
|
+
Add the keys to the configuration, or to a `types` block. `interface` and
|
|
502
|
+
`target` are independent: the first is the openocd interface script
|
|
503
|
+
(which probe), the second its target script (which chip). Either is
|
|
504
|
+
any name openocd can find, without the `.cfg`.
|
|
505
|
+
|
|
506
|
+
| Key | Default | Meaning |
|
|
507
|
+
| :---------- | :---------- | :-------------------------------------------- |
|
|
508
|
+
| `interface` | `cmsis-dap` | openocd interface script |
|
|
509
|
+
| `target` | `nrf52` | openocd target script |
|
|
510
|
+
| `transport` | `swd` | `jtag`, or `none` to let the interface decide |
|
|
511
|
+
| `work_area` | `0x4000` | target RAM for flash algorithms, or `none` |
|
|
512
|
+
| `baud` | `230400` | console speed |
|
|
513
|
+
|
|
514
|
+
A chip and a probe openocd already knows need no code at all. The one
|
|
515
|
+
exception is a probe from a vendor this tool has never seen: add its USB
|
|
516
|
+
vendor id to `Platform::PROBE_VENDORS`. That list is what makes a serial
|
|
517
|
+
the key to a console: both probe families the tool knows — DAPLink
|
|
518
|
+
(`0d28`) and J-Link OB (`1366`) — present their console as a CDC
|
|
519
|
+
interface reporting the *probe's* own serial. Matching on the vendor
|
|
520
|
+
rather than on one probe firmware is deliberate.
|
|
521
|
+
|
|
522
|
+
### A new tally — a file, loaded with `-r`
|
|
523
|
+
|
|
524
|
+
What a board's console output *means* is not this tool's business: the
|
|
525
|
+
strings worth counting belong to whatever firmware happens to be on the
|
|
526
|
+
bench this month, and they change without a hub changing. So `connect`
|
|
527
|
+
knows only how to open a console, prefix its lines and print them, and
|
|
528
|
+
hands every line to a tally.
|
|
529
|
+
|
|
530
|
+
```ruby
|
|
531
|
+
TribbleControl::Tally.register(:twr) do |device|
|
|
532
|
+
MyTally.new(device)
|
|
533
|
+
end
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
`tally =` at the top of the configuration sets the bench's default and
|
|
537
|
+
`tally =` inside a device entry overrides it for that board, so one
|
|
538
|
+
capture can read two firmwares. `connect --tally NAME` and
|
|
539
|
+
`--tally DEV=NAME` override both for one run: the configuration describes
|
|
540
|
+
boards, and the same board carries different firmware from one campaign
|
|
541
|
+
to the next, so what is flashed is the run's to say. `Connect#tallies`
|
|
542
|
+
resolves and builds every board's tally before `--off` or any port is
|
|
543
|
+
switched, so a bad name stops the run before it has done anything; the
|
|
544
|
+
option is repeatable because `Connect::Repeatable` lists it, which makes
|
|
545
|
+
`CLI#parse` store into a `CLI::Accumulator` that appends where
|
|
546
|
+
OptionParser's `into:` would overwrite. The block is called once per board per
|
|
547
|
+
run, so a tally may keep whatever state it likes without sharing it.
|
|
548
|
+
What it returns must answer two messages:
|
|
549
|
+
|
|
550
|
+
| Message | Receives / returns |
|
|
551
|
+
| :--------- | :------------------------------------------------ |
|
|
552
|
+
| `#<<` | every line `connect` prints |
|
|
553
|
+
| `#summary` | the text of the `SUMMARY` line, or `nil` for none |
|
|
554
|
+
|
|
555
|
+
Two ship: `lines` counts lines, which is all a tool that knows nothing
|
|
556
|
+
about the firmware can honestly say, and `none` is a null object for a
|
|
557
|
+
capture that wants no summary at all — a null object rather than `nil`,
|
|
558
|
+
so `connect` has one kind of thing to talk to.
|
|
559
|
+
|
|
560
|
+
A name nothing registered is an error, not a silent fall back to
|
|
561
|
+
counting lines: a configuration asking for `twr` on a run that forgot `-r`
|
|
562
|
+
would otherwise capture a whole bench and report nothing but line
|
|
563
|
+
counts, which reads as a firmware saying nothing.
|
|
564
|
+
|
|
565
|
+
### A new host platform — a module under `Platform`
|
|
566
|
+
|
|
567
|
+
A platform is a module under `Platform` holding three `def self.` methods.
|
|
568
|
+
`Platform::Current` is chosen by a `case` on `RbConfig::CONFIG['host_os']`
|
|
569
|
+
at the foot of `platform.rb` — `/^linux-/` and `/^freebsd/` today, with
|
|
570
|
+
anything else raising — and the module-level `def self.x(...) = Current.x(...)`
|
|
571
|
+
forwarders below it are what the rest of the program calls. Adding a
|
|
572
|
+
platform is a module plus a branch in that `case`.
|
|
573
|
+
|
|
574
|
+
| Method | Answers | Today |
|
|
575
|
+
| :-------------------- | :---------------------- | :---- |
|
|
576
|
+
| `probe_consoles` | `{probe serial => tty}` | both |
|
|
577
|
+
| `usb_to_tty(path)` | USB path → console tty | both |
|
|
578
|
+
| `usb_to_serial(path)` | USB path → probe serial | both |
|
|
579
|
+
|
|
580
|
+
Finding the *hub* is not among them: that is the hub backend's
|
|
581
|
+
(`Hub::ExSYS.open`, through the exsys gem). What is here is finding
|
|
582
|
+
the *boards*.
|
|
583
|
+
|
|
584
|
+
Nor is the hub's geometry — which socket a port is — which used to be
|
|
585
|
+
a `Platform.port_to_usb` and is now `Hub#usb_path(port)`. It walks
|
|
586
|
+
nothing: for the ExSYS hub it is four internal banks of four under the
|
|
587
|
+
control adapter's own root, so a board on a port is at
|
|
588
|
+
`root.bank.slot`, and the root is the adapter's path less its last two
|
|
589
|
+
components. Geometry belongs to the hub, not to the host, and a new
|
|
590
|
+
platform module neither defines it nor needs to.
|
|
591
|
+
|
|
592
|
+
The last two are about USB topology, and the two platforms differ in
|
|
593
|
+
where that comes from. Linux states it: `/sys/bus/usb` has a directory
|
|
594
|
+
per device named by its path. FreeBSD states nothing of the kind, so
|
|
595
|
+
the two lookups walk it out of the sysctl tree — `%location` gives the
|
|
596
|
+
port a device occupies on its parent, `%parent` names that parent, and
|
|
597
|
+
the walk ends at a root hub, whose `%location` is empty. A walk that
|
|
598
|
+
does not *reach* a root answers nil rather than what it collected:
|
|
599
|
+
stopping one hub short turns `1-1.2.4.4` into `1-4`, which is not a
|
|
600
|
+
broken string but a different socket.
|
|
601
|
+
|
|
602
|
+
The one asymmetry left is that FreeBSD cannot see a device no driver
|
|
603
|
+
claimed — there is no `dev.ugen` — so such a device has no node, no
|
|
604
|
+
serial and no path. Every probe the bench carries attaches something
|
|
605
|
+
(a DAPLink is `umodem`, `umass` and `usbhid` at once), but a probe that
|
|
606
|
+
enumerates and attaches nothing is invisible there rather than
|
|
607
|
+
serial-less. `Platform.serial_to_tty` is built
|
|
608
|
+
on `probe_consoles` alone, needs no USB topology, and is therefore what
|
|
609
|
+
lets a host without `/sys` reach a console at all.
|
|
610
|
+
|
|
611
|
+
Two conventions for a platform that cannot implement all three — both
|
|
612
|
+
platforms do today, and FreeBSD did not until its topology was walked
|
|
613
|
+
rather than read, so a third is likely to arrive short again:
|
|
614
|
+
|
|
615
|
+
* **Stub, do not omit.** An undefined method arrives as
|
|
616
|
+
`undefined method 'usb_to_tty' for module ...`, which names an
|
|
617
|
+
internal and tells the reader nothing. Raise a `CLI::Error` that
|
|
618
|
+
says which piece is missing and which commands still work. Each
|
|
619
|
+
of the two topology methods has a second route to the same board
|
|
620
|
+
— `serial` addresses a probe by its serial and `power` by being
|
|
621
|
+
the only one on — so a platform missing both still runs every
|
|
622
|
+
command.
|
|
623
|
+
* **Use `private_class_method def self.…`** for helpers. A bare
|
|
624
|
+
`private` does nothing to a `def self.` singleton method, and a
|
|
625
|
+
helper defined as an instance method on a module whose every caller
|
|
626
|
+
is a `def self.` is simply unreachable.
|
|
627
|
+
|
|
628
|
+
### A new hub backend — a subclass of `Hub`
|
|
629
|
+
|
|
630
|
+
A backend is a subclass of `Hub` in a file under
|
|
631
|
+
`lib/tribble-control/hub/`, listed in `Hub::KINDS` under the word a
|
|
632
|
+
configuration's `hub =` line uses, and loaded on demand by
|
|
633
|
+
`Hub.backend(kind)`. What it has to answer:
|
|
634
|
+
|
|
635
|
+
| Method | Answers |
|
|
636
|
+
| :------------------ | :--------------------------------------------- |
|
|
637
|
+
| `ports` | every port, in order, numbered as the hub does |
|
|
638
|
+
| `state` | `{ port => true/false }`, for every port |
|
|
639
|
+
| `on` `off` `toggle` | switch the ports named |
|
|
640
|
+
| `usb_path(port)` | where a board on that port is in the USB tree |
|
|
641
|
+
| `to_s` | the hub as a message names it |
|
|
642
|
+
|
|
643
|
+
Two have defaults in the base class: `set` is an on and an off, which a
|
|
644
|
+
backend that can apply a whole configuration in one exchange overrides,
|
|
645
|
+
and `vbus?` answers true, the honest answer for a hub built to switch
|
|
646
|
+
VBUS. The rest raise `NotImplementedError` naming the class and the
|
|
647
|
+
method, so a backend answering less than the interface says so by name
|
|
648
|
+
rather than as a `NoMethodError` on an internal. Errors are
|
|
649
|
+
`Hub::Error` and carry a message, because `CLI.run` prints the message
|
|
650
|
+
and nothing else. The base class also holds the last guard: an empty
|
|
651
|
+
port list, or a port the hub has not got, raises before the backend
|
|
652
|
+
sees it.
|
|
653
|
+
|
|
654
|
+
Naming and refusal policy lives in the backend's own `open`, not in
|
|
655
|
+
`CLI`: which shapes a name may take, that one candidate may be taken
|
|
656
|
+
and two may not, and the wording of each refusal. None of that is a
|
|
657
|
+
fact about hubs — it is this tool's promise not to switch the wrong
|
|
658
|
+
bench — and the shapes differ by kind. A platform refusal belongs
|
|
659
|
+
there too; `Hub::USB.open` is where FreeBSD-only is said, so a host that
|
|
660
|
+
cannot run one backend still runs the other.
|
|
661
|
+
|
|
662
|
+
A backend is testable without hardware, and the two show the two ways.
|
|
663
|
+
`Hub::USB` takes an injected runner, so `test/support/fake_usbconfig.rb`
|
|
664
|
+
*is* the host: it answers `sysctl -e dev.uhub` with a tree in the real
|
|
665
|
+
format and each `do_request` with the exact text usbconfig prints,
|
|
666
|
+
angle brackets and the trailing ASCII copy included, and a hub built
|
|
667
|
+
with `honours: false` accepts a switch and does not switch, which is the
|
|
668
|
+
only way to drive the read-back check. `Hub::ExSYS` goes the other way,
|
|
669
|
+
against `test/support/fake_hub.rb` on a pty speaking the real frames.
|
|
670
|
+
A new backend picks whichever its transport allows; the policy tests
|
|
671
|
+
stand on the fake either way.
|
|
672
|
+
|
|
673
|
+
### A new command — a class under `CLI`
|
|
674
|
+
|
|
675
|
+
Subclass `CLI::Command` in a file under `lib/tribble-control/cli/`, and
|
|
676
|
+
**require it from `lib/tribble-control.rb`**: `CLI.commands` finds
|
|
677
|
+
commands by asking `CLI::Command` for its subclasses, so a command file
|
|
678
|
+
that is never required is a command the tool does not have. (That is
|
|
679
|
+
also where the Ruby 3.1 floor comes from — `Class#subclasses`.)
|
|
680
|
+
|
|
681
|
+
| Constant | | What it does |
|
|
682
|
+
| :------------ | :------- | :------------------------------------------- |
|
|
683
|
+
| `DESCRIPTION` | required | the line `--help` prints |
|
|
684
|
+
| `NAME` | optional | overrides the name derived from the class |
|
|
685
|
+
| `Parser` | optional | `OptionParser` for its own options |
|
|
686
|
+
| `Defaults` | optional | fills options not already set |
|
|
687
|
+
| `Methods` | optional | accepted `--method` values; first is default |
|
|
688
|
+
| `OPENOCD` | optional | truthy → resolve openocd up front |
|
|
689
|
+
|
|
690
|
+
The name is derived from the class name unless `NAME` overrides it:
|
|
691
|
+
`CLI::BarBaz` becomes `bar-baz`. A `--method` the command does not list
|
|
692
|
+
is refused rather than ignored.
|
|
693
|
+
|
|
694
|
+
The instance gets `@cli`, and delegates the whole bench vocabulary to it:
|
|
695
|
+
|
|
696
|
+
| Call | Gives back |
|
|
697
|
+
| :------------------------ | :---------------------------------------------- |
|
|
698
|
+
| `hub` | the hub object: `Hub::ExSYS` or `Hub::USB` |
|
|
699
|
+
| `tty` | the logger (`TTY::Logger`), or `nil` |
|
|
700
|
+
| `openocd(*cmds, **hopts)` | `true` on success; a block gets `(ok, output)` |
|
|
701
|
+
| `each_device(ids, &b)` | as above; with no block, an Enumerator |
|
|
702
|
+
| `port_list(ids)` | names or numbers → port Integers |
|
|
703
|
+
| `devices` | every declared board that is on the bench |
|
|
704
|
+
| `switchable` | the ports that may be powered down |
|
|
705
|
+
| `offable(ports, force:)` | the vetted list, or raises |
|
|
706
|
+
| `offable?(port, force:)` | `true` or `false` |
|
|
707
|
+
| `tally(id)` | this board's tally name, from the configuration |
|
|
708
|
+
|
|
709
|
+
`conf` is delegated too and is vestigial: `@conf` is never assigned, so
|
|
710
|
+
it always answers `nil`. Do not build on it.
|
|
711
|
+
|
|
712
|
+
A command that reports per-device success returns `false` if any device
|
|
713
|
+
failed, which `CLI.run` turns into exit status 1. `connect` does the
|
|
714
|
+
same for a board whose console was not found or could not be read: it
|
|
715
|
+
prints `<NAME> ERROR: …` in place of that board's SUMMARY line.
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
## Invariants a change must not break
|
|
719
|
+
|
|
720
|
+
* **Powering down goes through the gate.** `offable(ports)` raises,
|
|
721
|
+
which is what an explicit `usb off` wants — the user named a port
|
|
722
|
+
and deserves to be told it is protected. `offable?(port)` answers
|
|
723
|
+
yes or no, which is what a step *inside* something else wants: the
|
|
724
|
+
turn-by-turn off of `--method power`, the cycle a board's
|
|
725
|
+
`power_cycle` key asks for after a flash. A protected port there
|
|
726
|
+
is a reason to skip the step and say so, not to abort an operation
|
|
727
|
+
that has already succeeded. Nothing calls `@hub.off` with a raw
|
|
728
|
+
port and no gate.
|
|
729
|
+
* **An empty list never means "every port".** `offable` either
|
|
730
|
+
returns a non-empty list or raises; `usb on` names every port
|
|
731
|
+
outright (`hub.on(*hub.ports)`) rather than passing an "all";
|
|
732
|
+
`each_device` guards its selection. Those are the three, and
|
|
733
|
+
`Hub#selection` is the net under them: a backend is never handed an
|
|
734
|
+
empty list, whatever a fourth path forgets.
|
|
735
|
+
* **The commands talk to `Hub`, not to a backend.** A method the
|
|
736
|
+
interface does not name is a method the next backend will not
|
|
737
|
+
have. Add it to `Hub` first, with a default or as a
|
|
738
|
+
`NotImplementedError`, and then to the backends.
|
|
739
|
+
* **Only `SP` is ever issued.** Port states are set for the here and
|
|
740
|
+
now, never written to the hub's flash, so nothing the tool does
|
|
741
|
+
survives a hub power cycle. `FP`, `WP`, `RD` and `RH` are not used
|
|
742
|
+
— the last two drop every port, protected ones included.
|
|
743
|
+
* **The version lives in `version.rb` alone.** The gemspec reads it
|
|
744
|
+
from there and `--version` prints the same constant, so a release
|
|
745
|
+
cannot have two numbers.
|
|
746
|
+
* **A port number is decimal.** `CLI.port_number` reads a string in
|
|
747
|
+
base 10 wherever a port is parsed — the command line, `port =`,
|
|
748
|
+
`protect.ports` — because `Integer('010')` is 8: a zero-padded port
|
|
749
|
+
once switched, and protected, the wrong socket.
|
|
750
|
+
* **`--method power` never lets openocd choose the adapter.** It
|
|
751
|
+
powers down only what it may, so a probe on a protected or
|
|
752
|
+
undeclared port stays up; `CLI#only_probe!` refuses the run when more
|
|
753
|
+
than one probe console is present once a board is powered.
|
|
754
|
+
* **A guess about which hub is never made when it could be wrong.**
|
|
755
|
+
Reaching the wrong hub is undetectable after the fact — every frame
|
|
756
|
+
is accepted and the boards that go dark are somebody else's — so
|
|
757
|
+
two candidates is an error, not a warning and not a default.
|
|
758
|
+
* **Failure is reported before the bench is disturbed.** A missing
|
|
759
|
+
openocd, an unwritable `--debug` file, an unreadable configuration, a
|
|
760
|
+
tally name nothing registered, a FIRMWARE that is not a readable file
|
|
761
|
+
and `connect --interactive` given more than one board are all found
|
|
762
|
+
before a port is switched. Finding out that a path is
|
|
763
|
+
unwritable after a bench has been powered down is finding out too
|
|
764
|
+
late.
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
## Traps
|
|
768
|
+
|
|
769
|
+
* **Port 16 is an ordinary socket, but do not use it.** The FT232
|
|
770
|
+
control adapter is wired inside the hub and enumerates at the last
|
|
771
|
+
position of the hub's internal tree, so `--method usb` would
|
|
772
|
+
compute that path for a board on port 16 and address the FT232.
|
|
773
|
+
* **An MDK puts its own hub in front of its DAPLink.** The probe
|
|
774
|
+
sits one level below the hub port there, while a J-Link sits on it;
|
|
775
|
+
`usb_to_serial` looks at both and takes the first that is a probe
|
|
776
|
+
with a serial.
|
|
777
|
+
* **openocd no longer prints a probe serial.** Neither the CMSIS-DAP
|
|
778
|
+
`Serial# =` line nor a J-Link `S/N`, at any debug level. The
|
|
779
|
+
serial comes from the USB descriptor the kernel already has, which
|
|
780
|
+
needs no SWD session and no powering the rest of the bench down.
|
|
781
|
+
* **`tty-logger` builds its handlers at construction.** `#configure`
|
|
782
|
+
does not revisit the level, so `--debug` replaces the logger rather
|
|
783
|
+
than reconfiguring it.
|
|
784
|
+
* **The power bit is not the same bit on both kinds of hub.** A USB
|
|
785
|
+
2 hub (descriptor 0x29) reports it in 0x0100 of wPortStatus; a
|
|
786
|
+
SuperSpeed hub (0x2a) reports it in 0x0200 and puts the link state
|
|
787
|
+
in bits 5-8, so reading a SuperSpeed port with the USB 2 bit
|
|
788
|
+
answers "unpowered" for a port that is fine. Which descriptor the
|
|
789
|
+
hub *answers* is what decides, and is remembered for that.
|
|
790
|
+
* **`usbconfig` exits 0 for a request the hub refused**, printing
|
|
791
|
+
`REQUEST = <ERROR>`, and exits 0 for a device it could not even
|
|
792
|
+
find. The printed text is the truth; the status says nothing.
|
|
793
|
+
* **A dock's hub chain may reset on its own.** The pair of TUSB8041
|
|
794
|
+
hubs on the dock this was written against detached and re-attached
|
|
795
|
+
every few minutes under test, taking every board with them. A hub
|
|
796
|
+
that flaps is a poor bench hub whatever its descriptor declares.
|
|
797
|
+
* **The LED test is the only proof that a hub switches VBUS.** The
|
|
798
|
+
read-back proves the hub did what it was told, not that the socket
|
|
799
|
+
lost power: a hub with no switch wired clears the bit, drops the
|
|
800
|
+
link, reports itself unpowered, and leaves the board running.
|
|
801
|
+
* **A DWM1001-DEV may need a power cycle after a flash.** It comes
|
|
802
|
+
out of the openocd sequence in a state where its DW1000 never
|
|
803
|
+
reports a transmission again; an SWD reset alone does not clear it.
|
|
804
|
+
That is what `power_cycle = after-flash` is for, and why a board
|
|
805
|
+
whose port is protected gets a warning saying plainly that it
|
|
806
|
+
missed the cycle — the symptom otherwise reads as a radio fault.
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
## Tests
|
|
810
|
+
|
|
811
|
+
Two suites, layered the way the code is:
|
|
812
|
+
|
|
813
|
+
| Suite | Covers | Needs |
|
|
814
|
+
| :---------------- | :------------------------------------------- | :-------- |
|
|
815
|
+
| `rake test` | the configuration layer and the hub exchange | nothing |
|
|
816
|
+
| `rake test:bench` | the whole tool, deployed | the bench |
|
|
817
|
+
|
|
818
|
+
`rake test` covers configuration parsing, types, tallies, hub selection and
|
|
819
|
+
the openocd command line, and drives the hub exchange itself against a
|
|
820
|
+
pty emulator speaking the real frames. Hub selection is tested against
|
|
821
|
+
a stubbed `ExSYS::ManagedUSB.available` under `Hub::ExSYS`, since what is being asserted
|
|
822
|
+
is the policy — which candidate is taken, and when none is — and not
|
|
823
|
+
the `udevadm`/`sysctl` reading that finds them, which the gem tests
|
|
824
|
+
against captured output of its own. The usb backend is proved the same
|
|
825
|
+
way and with no hub either: an injected runner stands in for the host,
|
|
826
|
+
so the descriptor reading, the port numbering, the link-mode warnings
|
|
827
|
+
and the read-back that catches a hub which accepts a switch and does
|
|
828
|
+
not switch are all asserted against `test/support/fake_usbconfig.rb`.
|
|
829
|
+
|
|
830
|
+
The split is not an accident of history: nearly everything worth
|
|
831
|
+
asserting is decided during configuration parsing, which happens before the
|
|
832
|
+
hub object is constructed, so it can be proved on the machine where the
|
|
833
|
+
code is written. A project whose only proof requires a lab in another
|
|
834
|
+
city cannot be checked by whoever is holding it.
|
|
835
|
+
|
|
836
|
+
The bench suite takes a second argument naming a different copy of the
|
|
837
|
+
tool, so it can be pointed at a deliberately broken one: a test that
|
|
838
|
+
has never been seen to fail is not evidence. What stays baked into it
|
|
839
|
+
is that bench's inventory — board names, a probe serial, a USB path,
|
|
840
|
+
the flash page the gate writes — and pointing the suite at another
|
|
841
|
+
bench means editing them. The power-cycle gate needs one thing of
|
|
842
|
+
that bench's configuration as well: a `protect { ports = [ ... ] }` line to
|
|
843
|
+
add the flashed board's port to. It says so and stops rather than
|
|
844
|
+
running a test that could only pass.
|
|
845
|
+
|
|
846
|
+
`rake lint` gates at zero rubocop offences and runs with `rake`. A
|
|
847
|
+
linter at zero is a gate; at several hundred it is a wall nobody reads.
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
## Releasing
|
|
851
|
+
|
|
852
|
+
* Bump `VERSION` in `lib/tribble-control/version.rb`. Nothing else
|
|
853
|
+
carries a number.
|
|
854
|
+
* `gem build` must run with the checkout as its working directory:
|
|
855
|
+
RubyGems resolves the manifest's paths against the cwd, not against
|
|
856
|
+
the gemspec.
|
|
857
|
+
* The manifest keeps the page at `man/man1/` inside the gem, which
|
|
858
|
+
makes the gem's `man/` a usable `MANPATH` entry with nothing copied
|
|
859
|
+
anywhere. See `rake man:install`.
|
|
860
|
+
* `allowed_push_host` is `https://rubygems.org`, with MFA required.
|
|
861
|
+
It was `none` while the gem drove one bench and was installed from a
|
|
862
|
+
checkout; that let a checkout build unreleased code under the last
|
|
863
|
+
release's number, which a version fetched from a registry cannot.
|
|
864
|
+
`rake release` is bundler's own: tag, push, publish. A published
|
|
865
|
+
version cannot be taken back, only yanked, and its number is spent.
|
|
866
|
+
* Runtime dependencies are bounded (`~>`, or `>= … < …` for
|
|
867
|
+
`parallel`): a quiet change in any of them is a port switched that
|
|
868
|
+
should not be, or a failed flash reported as success.
|
|
869
|
+
* `Gemfile.lock` is not committed. This is a library, and the bench
|
|
870
|
+
installs the built gem rather than a vendored bundle, so nothing
|
|
871
|
+
reads a lock.
|