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.
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.