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.
@@ -0,0 +1,1230 @@
1
+ #
2
+ # The command line: global options, the configuration, and the machinery
3
+ # every command uses to reach a board (each_device, openocd).
4
+ #
5
+ require 'optparse'
6
+ require 'shellwords'
7
+ require 'forwardable'
8
+ require 'open3'
9
+ require 'ucl'
10
+ require 'tty/logger'
11
+ require 'parallel'
12
+
13
+ require_relative 'version'
14
+ require_relative 'platform'
15
+ require_relative 'hub/exsys'
16
+
17
+ module TribbleControl
18
+
19
+ class CLI
20
+ # An operator's error: CLI.run prints its message alone and exits 1.
21
+ class Error < StandardError
22
+ end
23
+
24
+ # Where a command's options are stored, for a command whose options
25
+ # may be given more than once.
26
+ #
27
+ # OptionParser's into: assigns, so an option given twice keeps only
28
+ # the second. For the keys a command lists in Repeatable, the values
29
+ # are added to the ones already there instead: '--tally twr --tally
30
+ # A3=none' means both, and dropping the first would change which
31
+ # tally every other board gets without a word.
32
+ class Accumulator
33
+ def initialize(opts, keys)
34
+ @opts = opts
35
+ @keys = keys
36
+ end
37
+
38
+ def []=(key, value)
39
+ @opts[key] = if @keys.include?(key)
40
+ then Array(@opts[key]) + Array(value)
41
+ else value
42
+ end
43
+ end
44
+ end
45
+
46
+ # The base of every command. A subclass is found by CLI.commands
47
+ # and run with the CLI it was parsed by.
48
+ class Command
49
+ extend Forwardable
50
+
51
+ def_delegators :@cli, :hub, :tty, :conf, :openocd, :each_device,
52
+ :port_list, :switchable, :offable, :offable?,
53
+ :devices, :tally, :warn_link_only
54
+
55
+ # Derive command name from class
56
+ def self.cmdname
57
+ if self.const_defined?(:NAME)
58
+ self::NAME
59
+ else
60
+ self.name.split('::')[-1]
61
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
62
+ .gsub(/([a-z\d])([A-Z])/, '\1-\2')
63
+ .downcase
64
+ end
65
+ end
66
+
67
+ def initialize(cli)
68
+ @cli = cli
69
+ end
70
+ end
71
+
72
+ # The configuration block gathering everything that keeps a port powered.
73
+ #
74
+ # Two rules, one key. 'undeclared' says whether a port the file
75
+ # does not mention is protected -- yes, the default, is what keeps
76
+ # a power feed out of reach of a bare 'usb off' -- and 'ports' and
77
+ # 'nodes' name the ones protected whichever way that falls, which
78
+ # is how a port that IS declared is kept powered anyway.
79
+ #
80
+ # 'ports' takes port numbers and 'nodes' the names of configuration
81
+ # entries, which is the same protection said two ways. A name is
82
+ # the better one where there is an entry to name: it survives the
83
+ # board moving socket, and it cannot go on protecting port 12 after
84
+ # port 12 became something else. 'ports' remains for what has no
85
+ # entry -- a power feed nothing drives, which is most of what ends
86
+ # up here.
87
+ #
88
+ # One block, so the whole policy is read in one place, under the
89
+ # word 'usb status' prints and the manual gives a section to.
90
+ PROTECT_KEY = 'protect'
91
+ PROTECT_UNDECLARED_KEY = 'undeclared'
92
+ PROTECT_PORTS_KEY = 'ports'
93
+ PROTECT_NODES_KEY = 'nodes'
94
+ PROTECT_KEYS = [ PROTECT_UNDECLARED_KEY, PROTECT_PORTS_KEY,
95
+ PROTECT_NODES_KEY ].freeze
96
+
97
+ # Top-level keys that belong inside 'protect', each with the line to
98
+ # write instead. Left to the device pass, 'reserved' would be refused
99
+ # as "configuration entry 'reserved' has no port" -- and the port
100
+ # line that asks for would load the file with every port it named
101
+ # switchable.
102
+ PROTECT_FORMER = {
103
+ 'reserved' => "#{PROTECT_KEY} { #{PROTECT_PORTS_KEY} = [ ... ] }",
104
+ 'undeclared' => "#{PROTECT_KEY} { #{PROTECT_UNDECLARED_KEY} = yes|no }"
105
+ }.freeze
106
+
107
+ # Shared settings a device can inherit, and the key that asks for
108
+ # them.
109
+ #
110
+ # A bench is usually a handful of boards of two or three kinds, and
111
+ # what a kind is -- which probe, which chip, which transport, what
112
+ # speed its console runs at -- is the same on every one of them.
113
+ # Written per device that is four identical lines eleven times, and
114
+ # the reader has to compare them to find the one that differs. A
115
+ # type says it once.
116
+ #
117
+ # One level only: a type is a block of settings, not a thing that
118
+ # can itself have a type. Identity is not inheritable either --
119
+ # see TYPE_FORBIDDEN.
120
+ TYPES_KEY = 'types'
121
+ TYPE_KEY = 'type'
122
+
123
+ # What a type may not carry. Both name one particular board: a
124
+ # port is where a single board is plugged in, and a serial is one
125
+ # physical probe. A type that set either would be saying that
126
+ # every board of that kind is the same board.
127
+ TYPE_FORBIDDEN = [ 'port', 'serial' ].freeze
128
+
129
+ # Top-of-file configuration key: which hub this file describes.
130
+ #
131
+ # A configuration is one bench, and a bench is one hub, so the file
132
+ # that says which board is on which port is the right place to say
133
+ # which hub those ports belong to. Without it, a host with two hubs
134
+ # plugged in has to be told twice -- -d for the line, -C for the
135
+ # map -- and the two are then free to disagree: -C says bench two
136
+ # and -d, or the auto-detection, says the first FTDI adapter the
137
+ # host happens to enumerate. With it, -C alone selects a bench.
138
+ #
139
+ # -d still wins, for the one-off: a hub that has moved, or a line
140
+ # reached through something other than the usual node.
141
+ #
142
+ # What the value may be depends on the kind of hub: see
143
+ # Hub::ExSYS.open and Hub::USB.open, and why a device node or a ugen
144
+ # name is the wrong thing to write in a file that gets deployed.
145
+ DEVICE_KEY = 'device'
146
+
147
+ # Top-of-file configuration key: which KIND of hub the file describes.
148
+ #
149
+ # 'exsys', the default, is the ExSYS managed hub over its FT232
150
+ # line; 'usb' is a standard hub with per-port power switching,
151
+ # reached through the host's own USB stack. See Hub::KINDS. The
152
+ # kind cannot be read off the DEVICE_KEY value -- a USB path names
153
+ # an FT232's socket for the one and the hub itself for the other --
154
+ # so it has to be said.
155
+ HUB_KEY = 'hub'
156
+ HUB_DEFAULT = 'exsys'
157
+
158
+ # Top-of-file configuration key, for hub = usb only: what that hub's
159
+ # switch does. 'link', the default, takes the port off the bus and
160
+ # leaves the board powered; 'vbus' cuts the socket's power.
161
+ # Software cannot tell the two apart -- the device vanishes and
162
+ # returns either way -- so the operator says which, having watched
163
+ # a board's LED during 'usb off'. See Hub#vbus?. The ExSYS hub
164
+ # always cuts power, so the key is refused with it rather than
165
+ # ignored: a line that changes nothing is a line somebody will
166
+ # trust.
167
+ SWITCH_KEY = 'switch'
168
+ SWITCHES = [ :link, :vbus ].freeze
169
+
170
+ # Which tally reads the consoles. Recognised at the top of the
171
+ # file, where it sets the bench's default, and inside a device,
172
+ # where it overrides it for that board. See Tally.
173
+ TALLY_KEY = 'tally'
174
+ TALLY_DEFAULT = 'lines'
175
+
176
+ # Per-device configuration key: which hub port the board is on.
177
+ #
178
+ # Required on every device entry, and 'port = none' is how an entry
179
+ # says it is a record rather than a board on the bench. Such an
180
+ # entry stays in the file -- its serial is worth keeping, and so is
181
+ # the comment saying when it stopped enumerating -- but tribble-control
182
+ # leaves it out of #devices, so it is never selected, never switched
183
+ # and never flashed.
184
+ #
185
+ # The port is the whole of it, deliberately: there is no separate
186
+ # 'present' or 'enabled' key. Everything this tool can do to a board
187
+ # it does by hub port, so an entry without one is not addressable by
188
+ # definition, and a second key saying the same thing is a second key
189
+ # to disagree with the first. 'enabled' in particular would have
190
+ # read as "leave this port off", which is a different thing and one
191
+ # 'usb off' already does.
192
+ #
193
+ # Missing is an error rather than a synonym for none. Deleting the
194
+ # port line is exactly what happens when a port is reassigned to
195
+ # another board, and inferring "gone" from a line somebody forgot
196
+ # would drop a live board silently.
197
+ PORT_KEY = 'port'
198
+ PORT_NONE = [ nil, 'none', 'null', '-' ].freeze
199
+
200
+ # The configuration file used when -C is not given, looked for in
201
+ # the current directory only.
202
+ DEFAULT_CONFIG = 'tribble-control.conf'
203
+
204
+ Defaults = { :'warm-up' => 5,
205
+ :openocd => '/usr/bin/openocd' }
206
+ GlobalParser = OptionParser.new do |opts|
207
+ opts.banner = "Usage: #{opts.program_name} ACTION"
208
+
209
+ opts.separator ''
210
+ opts.separator 'Global options:'
211
+
212
+ opts.on '-d', '--device=DEV', 'Which hub: its serial number, a',
213
+ ' USB path, or its device node if',
214
+ ' it has a / in it'
215
+ opts.on '--hub=KIND', Hub::KINDS.keys,
216
+ 'Which kind of hub: exsys (default) or usb'
217
+ opts.on '-p', '--password=STRING', 'ExSYS hub password'
218
+ opts.on '-C', '--config=FILE',
219
+ 'Configuration file (default: ./tribble-control.conf)'
220
+ opts.on '-m', '--method=TYPE', [ 'power', 'usb', 'serial' ],
221
+ 'Device selection method',
222
+ ' Available: power, usb, serial'
223
+ opts.on '-W', '--warm-up=SECONDS', Integer,
224
+ 'Warm-up delay after power on'
225
+ opts.on '--openocd=PATH', 'openocd path'
226
+ opts.on '-r', '--require=FILE', Array,
227
+ 'Ruby file(s) to load first, for the tallies',
228
+ ' they register (comma-separated, repeatable)'
229
+ opts.on '-F', '--force', 'Switch protected ports too'
230
+ opts.on '--debug[=FILE]', 'Show debug output, and copy',
231
+ 'the whole log to FILE if given'
232
+ opts.on '-v', '--[no-]verbose', 'Run verbosely'
233
+ opts.on '-V', '--version', 'Version' do
234
+ puts "tribble-control : #{TribbleControl::VERSION}"
235
+ puts "ExSYS library : #{ExSYS::VERSION}"
236
+ exit
237
+ end
238
+
239
+
240
+ opts.separator ''
241
+ opts.separator 'Informative options:'
242
+ opts.on '-h', '--help', "Show this message" do
243
+ puts opts
244
+ puts ''
245
+ puts 'Commands:'
246
+ CLI.commands.each_value {|klass|
247
+ puts format(' %-12s %s', klass.cmdname, klass::DESCRIPTION)
248
+ }
249
+ puts ''
250
+ puts "See '#{opts.program_name} CMD --help'" \
251
+ " for more information on a specific command"
252
+ puts "See '#{opts.program_name} --man'" \
253
+ " for the manual"
254
+ puts ''
255
+ exit
256
+ end
257
+ opts.on '--man', "Show the manual" do
258
+ CLI.show_manual
259
+ exit
260
+ end
261
+ end
262
+
263
+ # Where the manual lives: a file shipped beside the code.
264
+ #
265
+ # man/man1/ rather than man/: that shape is a MANPATH entry as it
266
+ # stands, so `MANPATH=<gem>/man man tribble-control` works on an
267
+ # installed gem without anything having to be copied anywhere.
268
+ MANUAL = File.expand_path('../../man/man1/tribble-control.1', __dir__)
269
+
270
+ # How to turn that page into text, in the order they are tried.
271
+ # mandoc first: it reads the UTF-8 the diagrams are drawn in without
272
+ # being told to, and it is what the BSDs ship. groff needs -Kutf8
273
+ # to do the same, and a bare nroff is the last resort -- on a host
274
+ # whose locale is not UTF-8 it will mangle the box drawing, which is
275
+ # still better than refusing to print the manual.
276
+ RENDERERS = [ %w[mandoc -Tutf8],
277
+ %w[groff -Kutf8 -Tutf8 -mandoc],
278
+ %w[nroff -mandoc] ].freeze
279
+
280
+ # groff and nroff mark bold with ANSI colour escapes by default,
281
+ # mandoc with backspace overstrike. Overstrike is the one a bare
282
+ # `less` renders as bold rather than printing raw, and the one that
283
+ # strips back to plain text in a single substitution, so ask the
284
+ # groff family for it and keep all three renderers alike.
285
+ RENDER_ENV = { 'GROFF_NO_SGR' => '1' }.freeze
286
+
287
+ # The rendered page, or nil when nothing on this host can render it.
288
+ def self.render_manual
289
+ RENDERERS.each do |cmd|
290
+ begin
291
+ out, status = Open3.capture2(RENDER_ENV, *cmd, MANUAL)
292
+ rescue Errno::ENOENT
293
+ next # that renderer is not installed
294
+ end
295
+ return out if status.success? && !out.empty?
296
+ end
297
+ nil
298
+ end
299
+
300
+ # Display the manual, paging it when the output is a terminal. A
301
+ # missing file or a host with no renderer says so, rather than dying
302
+ # on Errno::ENOENT from somewhere inside the pager.
303
+ def self.show_manual
304
+ unless File.readable?(MANUAL)
305
+ raise CLI::Error, "no manual found (#{MANUAL})"
306
+ end
307
+ unless (text = self.render_manual)
308
+ raise CLI::Error, 'no manual page renderer found (tried' \
309
+ " #{RENDERERS.map(&:first).join(', ')}):" \
310
+ " read #{MANUAL} directly"
311
+ end
312
+ pager = ENV['PAGER'] || 'less'
313
+ if $stdout.tty? && !pager.empty?
314
+ begin
315
+ IO.popen(pager, 'w') {|io| io.write(text) }
316
+ return
317
+ rescue Errno::ENOENT, Errno::EPIPE
318
+ # No such pager, or the reader quit early: fall through.
319
+ end
320
+ end
321
+ # Not a terminal: strip the backspace overstrike nroff uses for
322
+ # bold and underline, so that a pipe, a file or a grep sees the
323
+ # words themselves. A pager gets it unstripped, above, because
324
+ # that is what it renders as bold.
325
+ $stdout.write(text.gsub(/.\x08/, '').gsub(/\e\[[0-9;]*m/, ''))
326
+ rescue Errno::EPIPE
327
+ # Output closed (head, a quit pager): nothing left to say.
328
+ end
329
+
330
+ PROGNAME = GlobalParser.program_name
331
+
332
+ def self.commands
333
+ CLI::Command.subclasses.to_h {|k| [ k.cmdname, k ] }
334
+ end
335
+
336
+ # Find the command class corresponding to the name.
337
+ def self.find_command_class(name)
338
+ self.commands.find {|n,_k| n == name }&.last
339
+ end
340
+
341
+ # Run the command line.
342
+ #
343
+ # Commands that report per-device success (flash, reset, serial,
344
+ # connect) return false if any device failed; that becomes exit
345
+ # status 1. Hub::Error is caught here because the hub layer is ours
346
+ # to report on, not to leak: a backend raises it both for a hub that
347
+ # refuses a command (E01 on a wrong password) and for a host it
348
+ # cannot look for a hub on. Both are operator errors with nothing
349
+ # to debug, and a library caller of CLI.run gets the line, not a
350
+ # backtrace.
351
+ def self.run(argv = ARGV)
352
+ self.new.parse(argv).run.tap {|ok| exit 1 if ok == false }
353
+ rescue OptionParser::InvalidArgument, CLI::Error, Hub::Error => e
354
+ warn "#{PROGNAME}: #{e}"
355
+ exit 1
356
+ end
357
+
358
+ # The hub, once parse has settled which one: the object every
359
+ # command switches ports through. See Hub.
360
+ attr_reader :hub
361
+ attr_reader :tty
362
+ attr_reader :conf
363
+
364
+ # The hub's control line, once parse has settled which it is: what
365
+ # -d named, or what the configuration's 'device' line named, or the one
366
+ # the host was found to have.
367
+ attr_reader :device
368
+
369
+ def initialize
370
+ @device = nil
371
+ @hub_kind = HUB_DEFAULT
372
+ @switch = nil
373
+ @entries = nil
374
+ @protect_ports = []
375
+ @protect_nodes = []
376
+ @protect_undeclared = true
377
+ @tally_default = TALLY_DEFAULT
378
+ @types = {}
379
+ # :info; --debug replaces it with a :debug logger (see #parse),
380
+ # which is what shows the openocd command lines.
381
+ @tty = TTY::Logger.new do |config|
382
+ config.level = :info
383
+ end
384
+ end
385
+
386
+ # A port number, decimal whatever it looks like.
387
+ #
388
+ # Integer() reads a leading 0 as octal: '010' would be port 8 and
389
+ # '08' an ArgumentError, on the command line and in a quoted
390
+ # 'protect { ports = [ "010" ] }' alike. UCL already hands back an
391
+ # unquoted 010 as 10, so only a string goes through base 10.
392
+ def self.port_number(v)
393
+ v.is_a?(String) ? Integer(v, 10) : Integer(v)
394
+ end
395
+
396
+ # A board's name and port, from either. KeyError when neither is
397
+ # in the configuration.
398
+ def name_port(id)
399
+ case id
400
+ when /^\d+$/
401
+ port = Integer(id, 10)
402
+ if (name = @entries.find {|_k,v| v.dig('port') == port }&.first)
403
+ [ name, port ]
404
+ end
405
+ else
406
+ name = id
407
+ # Say which of the two it is. "id not found" for a name that
408
+ # is in the file, and deliberately so, sends the reader to
409
+ # look for a typo that is not there.
410
+ if @entries&.key?(id) && !self.present?(id)
411
+ raise Error, "device '#{id}' is not on the bench: the" \
412
+ " configuration gives it #{PORT_KEY} = none"
413
+ end
414
+ if (port = @entries.find {|k,_v| k == id }&.last&.dig('port'))
415
+ [ name, port ]
416
+ end
417
+ end.tap do |v|
418
+ raise KeyError, "id not found (#{id}:#{id.class})" if v.nil?
419
+ end
420
+ end
421
+
422
+ # Translate a port/name to a device serial number
423
+ def serial(id)
424
+ return nil if @entries.nil?
425
+ case id
426
+ when Integer
427
+ @entries.find {|_k,v| v.dig('port') == id }&.last&.dig('serial')
428
+ when String
429
+ @entries.find {|k,_v| k == id }&.last&.dig('serial')
430
+ else raise "unsupported id (#{id})"
431
+ end
432
+ end
433
+
434
+ # Read an arbitrary key of a device (by port number or name)
435
+ #
436
+ # The default applies when the key is ABSENT, not when it is falsey:
437
+ # 'power_cycle = false' means false. A key that is there means what
438
+ # it says.
439
+ def attribute(id, key, default = nil)
440
+ return default if @entries.nil?
441
+ entry = case id
442
+ when Integer then @entries.find {|_k,v| v.dig('port') == id }&.last
443
+ when String then @entries.find {|k,_v| k == id }&.last
444
+ else raise "unsupported id (#{id})"
445
+ end
446
+ return default if entry.nil?
447
+ return entry[key] if entry.key?(key)
448
+
449
+ # Then the type, if it named one. The entry wins: a type is
450
+ # what a kind of board has in common, and a device that says
451
+ # otherwise is saying it about itself.
452
+ if (name = entry[TYPE_KEY])
453
+ type = @types[name.to_s]
454
+ return type[key] if type&.key?(key)
455
+ end
456
+
457
+ default
458
+ end
459
+
460
+ # openocd interface script for a board, without the .cfg: cmsis-dap
461
+ # (a DAPLink probe, the default) or jlink (a J-Link OB, as on the
462
+ # DWM1001-DEV)
463
+ def interface(id)
464
+ self.attribute(id, 'interface', 'cmsis-dap').to_s
465
+ end
466
+
467
+ # openocd target script for a board, without the .cfg.
468
+ #
469
+ # The whole bench is nRF52 today, which is why that is the default
470
+ # and no existing configuration has to say so. It is a key rather than a
471
+ # constant because a board of another family is a configuration edit, not
472
+ # a patch: openocd ships a target script for each, and which one a
473
+ # board needs is a property of the board, exactly like interface=.
474
+ def target(id)
475
+ self.attribute(id, 'target', 'nrf52').to_s
476
+ end
477
+
478
+ # The SWD/JTAG transport openocd selects, or nil for 'none': select
479
+ # nothing and let the interface script decide.
480
+ #
481
+ # Making target= a key and leaving this one a constant would have
482
+ # been half a fix: a chip reached over JTAG takes the right target
483
+ # script and then fails on a transport it does not have.
484
+ #
485
+ # Read the way work_area is: every spelling of none in any case, and
486
+ # UCL's null, mean none; anything that is not a name -- a boolean, a
487
+ # number, an empty string -- is refused rather than handed to openocd
488
+ # as 'transport select false'.
489
+ def transport(id)
490
+ v = self.attribute(id, 'transport', 'swd')
491
+ return nil if PORT_NONE.include?(v.is_a?(String) ? v.downcase : v)
492
+ unless v.is_a?(String) && !v.strip.empty?
493
+ raise Error, "configuration entry '#{id}' has transport = #{v.inspect}," \
494
+ ' which is neither a transport nor none'
495
+ end
496
+ v
497
+ end
498
+
499
+ # Target RAM openocd may borrow for its flash algorithms, or 'none'
500
+ # to say nothing and let the target script choose.
501
+ #
502
+ # 16 KB is nothing to an nRF52840 and more than some parts have in
503
+ # total, so it is a property of the chip rather than of this tool.
504
+ # Written as openocd wants it, in hex.
505
+ def work_area(id)
506
+ v = self.attribute(id, 'work_area', 0x4000)
507
+ return nil if PORT_NONE.include?(v.is_a?(String) ? v.downcase : v)
508
+ begin
509
+ Integer(v)
510
+ rescue TypeError, ArgumentError
511
+ raise Error, "configuration entry '#{id}' has work_area = #{v.inspect}," \
512
+ ' which is neither a size nor none'
513
+ end
514
+ end
515
+
516
+ # Console baud rate of a board (230400 on the bench's MDK firmware,
517
+ # 115200 on the stock DWM1001-DEV devicetree)
518
+ def baud(id)
519
+ Integer(self.attribute(id, 'baud', 230_400))
520
+ end
521
+
522
+ # Which tally reads this board's console: the configuration's key for the
523
+ # board, else the file's own default, else counting lines.
524
+ def tally(id)
525
+ self.attribute(id, TALLY_KEY, @tally_default).to_s
526
+ end
527
+
528
+ # Does the configuration ask for a power cycle of this board at +moment+?
529
+ # The power_cycle key names one moment or a list of them, e.g.
530
+ # after-flash (the only one anything acts on today).
531
+ def power_cycle?(id, moment)
532
+ Array(self.attribute(id, 'power_cycle', [])).map(&:to_s)
533
+ .include?(moment.to_s)
534
+ end
535
+
536
+ # The hub port of an entry, or nil when it declares none.
537
+ def port_of(id)
538
+ v = self.attribute(id, PORT_KEY)
539
+ v = v.downcase if v.is_a?(String)
540
+ return nil if PORT_NONE.include?(v)
541
+ begin
542
+ self.class.port_number(v)
543
+ rescue TypeError, ArgumentError
544
+ raise Error, "configuration entry '#{id}' has #{PORT_KEY} = #{v.inspect}," \
545
+ " which is neither a port number nor none"
546
+ end
547
+ end
548
+
549
+ # Is there a board on the bench at this entry? See PORT_KEY.
550
+ def present?(id)
551
+ !self.port_of(id).nil?
552
+ end
553
+
554
+ # List of registered devices, absent ones left out.
555
+ #
556
+ # Everything that walks the bench goes through here, so leaving an
557
+ # absent board out in one place keeps it out of all of them: it is
558
+ # not switched, not flashed, not connected to, and not counted among
559
+ # the ports #switchable may power down.
560
+ def devices
561
+ (@entries&.keys || []).select {|n| self.present?(n) }
562
+ end
563
+
564
+ # Every entry, present or not. For looking a serial up, which is the
565
+ # reason an absent board is kept in the file at all.
566
+ def declared
567
+ @entries&.keys || []
568
+ end
569
+
570
+ # Translate a list of ids (port numbers or device names) to ports
571
+ def port_list(ids)
572
+ ids.map {|id|
573
+ port = case id
574
+ when /^\d+$/ then Integer(id, 10)
575
+ else
576
+ if @entries.nil?
577
+ raise Error, "device name '#{id}' needs a configuration"
578
+ end
579
+ self.name_port(id).last
580
+ end
581
+ unless @hub.ports.include?(port)
582
+ raise Error, "port out of range (#{port})"
583
+ end
584
+ port
585
+ }
586
+ end
587
+
588
+ # The ports 'protect' names, by number and through its nodes.
589
+ #
590
+ # A node with 'port = none' contributes nothing -- there is no port
591
+ # to keep powered -- rather than raising. A retired board left in
592
+ # the file is what 'port = none' is for, and the configuration does not
593
+ # become broken because that board's name is also protected.
594
+ def protected_ports
595
+ @protect_ports + @protect_nodes.filter_map {|n|
596
+ self.name_port(n).last if self.present?(n)
597
+ }
598
+ end
599
+
600
+ # Ports tribble-control may power down. Ports the configuration does not
601
+ # mention are protected unless 'protect { undeclared = no }' says
602
+ # otherwise, and the ports 'protect' names are protected either
603
+ # way. That is what keeps the Raspberry Pi power feeds out of reach.
604
+ def switchable
605
+ if @entries.nil?
606
+ raise Error, 'no configuration: refusing to power down any port' \
607
+ ' (use -C FILE, or --force)'
608
+ end
609
+ base = if @protect_undeclared
610
+ then self.devices.map {|n| name_port(n).last }
611
+ else @hub.ports
612
+ end
613
+ (base - self.protected_ports).tap {|l|
614
+ raise Error, 'configuration leaves no switchable port' if l.empty?
615
+ }
616
+ end
617
+
618
+ # May this one port be powered down?
619
+ #
620
+ # offable() raises, which is what an explicit 'usb off' wants: the
621
+ # user named a port and deserves to be told it is protected. The
622
+ # two internal paths that switch a single port are not that. They
623
+ # cut a port as a STEP of something else the user asked for -- the
624
+ # turn-by-turn off of --method power, the cycle a board's
625
+ # power_cycle key asks for after a flash -- so a protected port is a
626
+ # reason to skip the step and say so, not to abort an operation that
627
+ # has already succeeded.
628
+ def offable?(port, force: @opts[:force])
629
+ force || self.switchable.include?(port)
630
+ end
631
+
632
+ # Vet a set of ports about to be powered down. An empty list means
633
+ # "every port we are allowed to touch", never "all 16".
634
+ def offable(ports = [], force: false)
635
+ if force
636
+ return ports.empty? ? @hub.ports : ports
637
+ end
638
+ allowed = self.switchable
639
+ return allowed if ports.empty?
640
+ if (bad = ports - allowed).any?
641
+ raise Error, "refusing to power down port(s) #{bad.join(' ')}:" \
642
+ ' protected by the configuration' \
643
+ ' (use --force)'
644
+ end
645
+ ports
646
+ end
647
+
648
+ # Say so when powering down takes ports off the bus and no more.
649
+ #
650
+ # A hub whose switch cuts the link (switch = link, the default for
651
+ # hub = usb) makes a board vanish from the host exactly as a power
652
+ # cut would, so every command that powers down still works --
653
+ # selection by 'power' included, since openocd sees one probe
654
+ # either way. What does not happen is the board restarting. Said
655
+ # once per command, next to the ports, because the symptom of not
656
+ # knowing is a board that "was power-cycled" and kept its state.
657
+ def warn_link_only(ports)
658
+ return if @hub.vbus?
659
+
660
+ @tty&.warn "#{@hub} cuts the link, not the power: the board(s)" \
661
+ " on port(s) #{Array(ports).join(' ')} stay powered" \
662
+ " (#{SWITCH_KEY} = link)"
663
+ end
664
+
665
+ def each_device(ids, &block)
666
+ return to_enum(:each_device, ids) unless block
667
+
668
+ unless @opts.include?(:config)
669
+ raise Error, "a configuration is required: -C FILE, or" \
670
+ " ./tribble-control.conf"
671
+ end
672
+
673
+ name_port_list = (ids.empty? ? self.devices : ids)
674
+ .to_h {|id| self.name_port(id) }
675
+
676
+ # An empty selection is refused, not carried through. It is
677
+ # reachable -- no device named, on a configuration whose every
678
+ # entry says 'port = none' -- and every method below would do
679
+ # worse than nothing with it: --method power would cut the whole
680
+ # bench and loop over no board.
681
+ #
682
+ # Every other splat into the hub in this program is safe by
683
+ # construction -- offable() either returns a non-empty list or
684
+ # raises, and 'usb on' tests for empty itself before choosing
685
+ # between on() and on(*ports) -- so this is the one place that
686
+ # needed the guard.
687
+ if name_port_list.empty?
688
+ raise Error, if ids.empty?
689
+ 'no device selected: the configuration declares' \
690
+ ' none that is on the bench (every entry' \
691
+ " says #{PORT_KEY} = none)"
692
+ else
693
+ 'no device selected'
694
+ end
695
+ end
696
+
697
+ @tty&.info "Devices : #{name_port_list.keys.join(' ')}"
698
+
699
+ case @opts[:method]
700
+ when 'serial'
701
+ unless name_port_list.all? {|_n,p| self.serial(p) }
702
+ raise 'Device without serial' \
703
+ ' (select another method)'
704
+ end
705
+
706
+ @tty&.info 'Ensuring ports are powered up'
707
+ @hub.on(*name_port_list.values)
708
+ sleep(@opts[:'warm-up'])
709
+
710
+ @tty&.info "Parallelizing jobs"
711
+ # in_threads, not the default in_processes: forked children
712
+ # return their results to their own copy of the accumulator,
713
+ # so Flash#run's .all?(&:itself) would fold over [] and exit 0
714
+ # however many boards failed. The work is openocd under
715
+ # Open3.capture2e, which releases the GVL, so threads keep the
716
+ # parallelism.
717
+ Parallel.map(name_port_list.keys,
718
+ in_threads: [ name_port_list.size, 1 ].max) do |name|
719
+ block.call(name, serial: self.serial(name),
720
+ interface: self.interface(name),
721
+ target: self.target(name),
722
+ transport: self.transport(name),
723
+ work_area: self.work_area(name))
724
+ end
725
+
726
+ when 'usb'
727
+ @tty&.info 'Ensuring ports are powered up'
728
+
729
+ # @hub.on(*name_port_list.values)
730
+ name_port_list.each_value do |port|
731
+ @hub.on(port)
732
+ end
733
+ sleep(@opts[:'warm-up'])
734
+
735
+ name_port_list.map do |name, port|
736
+ unless (usb = @hub.usb_path(port))
737
+ raise Error, 'cannot place the hub in the USB tree, so' \
738
+ " --method usb cannot address a board:" \
739
+ " this host reports no USB path for" \
740
+ " #{@hub}. Use --method serial, or" \
741
+ ' --method power'
742
+ end
743
+ # The serial goes too, when the configuration has one.
744
+ #
745
+ # 'adapter usb location' does not select anything: measured
746
+ # on 2026-09-16 against openocd 0.12.0, the only release
747
+ # there is, with two CMSIS-DAP boards powered. Asked for
748
+ # A2's probe path, A1's probe path, either of their hub
749
+ # paths, and a location that does not exist at all, it
750
+ # answered with the same chip every time (FICR.DEVICEID
751
+ # 5765c939, A2). 'adapter serial' does select -- that is
752
+ # what 'flash' relies on -- so it is passed, and the location
753
+ # stays as a statement of intent. A board with no serial=
754
+ # reaches whichever probe openocd enumerates first.
755
+ block.call(name, usb: usb, serial: self.serial(name),
756
+ interface: self.interface(name),
757
+ target: self.target(name),
758
+ transport: self.transport(name),
759
+ work_area: self.work_area(name))
760
+ end
761
+
762
+ when 'power'
763
+ off_ports = self.offable(force: @opts[:force])
764
+
765
+ if @tty
766
+ @tty.warn "Ports #{off_ports.join(' ')} will be turned off" \
767
+ ' (hit Ctrl-C to abort)'
768
+ sleep(5)
769
+ end
770
+
771
+ @tty&.info "Turning off ports: #{off_ports.join(' ')}"
772
+ @hub.off(*off_ports)
773
+ self.warn_link_only(off_ports)
774
+ sleep(1)
775
+
776
+ name_port_list.map do |name, port|
777
+ @tty&.info "Selectively turning on device #{name}"
778
+ @hub.on(port)
779
+ sleep(@opts[:'warm-up'])
780
+ self.only_probe!(name)
781
+ block.call(name, interface: self.interface(name),
782
+ target: self.target(name),
783
+ transport: self.transport(name),
784
+ work_area: self.work_area(name))
785
+ ensure
786
+ if self.offable?(port)
787
+ @hub.off(port)
788
+ else
789
+ @tty&.warn "#{name}: leaving port #{port} powered, the" \
790
+ ' configuration protects it'
791
+ end
792
+ end
793
+
794
+ else raise 'unsupported flashing method'
795
+ end
796
+ end
797
+
798
+ # Refuse unless the board just powered is the only probe in sight.
799
+ #
800
+ # --method power identifies a board by its being the only one
801
+ # powered, and passes openocd no serial. But it powers down only
802
+ # what it may: a probe on a protected port, or on one the
803
+ # configuration does not declare, stays up, and openocd then picks
804
+ # between two adapters by itself -- with two CMSIS-DAP probes it
805
+ # silently takes one (see the 'adapter usb location' measurement in
806
+ # each_device). So 'flash -m power fw.hex A1' wrote A1's firmware
807
+ # to whichever board that was, and reported A1 flashed. The serial
808
+ # command always checked this; now every command does. A probe
809
+ # that shows no CDC console is not seen, so this narrows the risk
810
+ # rather than closing it.
811
+ def only_probe!(name)
812
+ probes = Platform.probe_consoles.keys
813
+ return if probes.size <= 1
814
+ seen = probes.map {|p| p.to_s[0, 12] }.join(', ')
815
+ raise Error, "#{probes.size} probes are powered with only" \
816
+ " #{name}'s port switched on (#{seen})," \
817
+ ' so --method power cannot tell which is this board.' \
818
+ ' A probe on a protected or undeclared port does' \
819
+ ' this: use --method serial, or --force to power' \
820
+ ' those down too'
821
+ end
822
+
823
+ # The openocd binary, resolved and checked once, before a port is
824
+ # switched; otherwise a mistyped --openocd surfaces as Errno::ENOENT
825
+ # once per board, from inside the thread pool. A name with no
826
+ # separator in it is looked up in PATH, which is what makes
827
+ # --openocd=openocd work on a host that keeps it somewhere other
828
+ # than /usr/bin (FreeBSD: it is under /usr/local).
829
+ def openocd_path
830
+ @openocd_path ||= begin
831
+ path = @opts[:openocd].to_s
832
+ found = if path.include?(File::SEPARATOR)
833
+ path
834
+ else
835
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR)
836
+ .map {|dir| File.join(dir, path) }
837
+ .find {|p| File.file?(p) && File.executable?(p) }
838
+ end
839
+ unless found && File.file?(found) && File.executable?(found)
840
+ raise Error, "openocd not found at '#{path}'" \
841
+ ' (give it with --openocd=PATH)'
842
+ end
843
+ found
844
+ end
845
+ end
846
+
847
+ def openocd(*commands, usb: nil, serial: nil,
848
+ interface: 'cmsis-dap', target: 'nrf52',
849
+ transport: 'swd', work_area: 0x4000, &block)
850
+ cmd = [ self.openocd_path ]
851
+ if work_area
852
+ cmd += [ '-c', format('set WORKAREASIZE 0x%x', work_area) ]
853
+ end
854
+ cmd += [ '-c', "source [find interface/#{interface}.cfg]" ]
855
+ unless transport.nil? || PORT_NONE.include?(transport)
856
+ cmd += [ '-c', "transport select #{transport}" ]
857
+ end
858
+ cmd += [ '-c', "source [find target/#{target}.cfg]" ]
859
+ cmd += [ '-c', "adapter usb location #{usb}" ] if usb
860
+ cmd += [ '-c', "adapter serial #{serial}" ] if serial
861
+ cmd += commands.flat_map {|c| [ '-c', c ] }
862
+ cmd += [ '-c', 'shutdown' ]
863
+
864
+ @tty&.debug Shellwords.shelljoin(cmd)
865
+
866
+ output, pstatus = Open3.capture2e(*cmd)
867
+ ok = pstatus.exitstatus.zero?
868
+
869
+ block.call(ok, output) if block
870
+ ok
871
+ end
872
+
873
+ # Parse +argv+: global options, -r files, the command and its
874
+ # options, the configuration, then the hub. Returns self.
875
+ def parse(argv)
876
+ opts = {}.merge(Defaults)
877
+
878
+ # -r given twice loads both files: it is how each tally reaches
879
+ # the tool, and a second -r that silently dropped the first would
880
+ # surface only as an unknown tally, far from its cause.
881
+ GlobalParser.order!(argv, into: Accumulator.new(opts, [ :require ]))
882
+
883
+ # Before anything else: a tally the configuration names has to be
884
+ # registered by the time the configuration is read, and a file that
885
+ # will not load should say so before a port is touched.
886
+ #
887
+ # An empty name -- '-r a.rb,,b.rb', or '--require=' -- is refused
888
+ # here rather than reaching File.expand_path as nil, which said
889
+ # only "no implicit conversion of nil into String".
890
+ if opts.include?(:require) &&
891
+ (opts[:require].empty? || opts[:require].any? {|f| f.to_s.empty? })
892
+ raise Error, '-r: an empty file name (--require= or a doubled' \
893
+ ' comma)'
894
+ end
895
+ Array(opts[:require]).each do |file|
896
+ path = File.expand_path(file)
897
+ raise Error, "no such file to require: #{file}" \
898
+ unless File.file?(path)
899
+ begin
900
+ require path
901
+ rescue ScriptError, StandardError => e
902
+ raise Error, "loading #{file}: #{e.message}"
903
+ end
904
+ end
905
+
906
+ cmdname = argv.shift
907
+ raise Error, "command missing" if cmdname.nil?
908
+ cmdk = CLI.find_command_class(cmdname)
909
+ raise Error, "command '#{cmdname}' is not recognized" if cmdk.nil?
910
+
911
+ if cmdk.const_defined?(:Defaults)
912
+ opts.merge!(cmdk::Defaults) {|_k, o, _n| o }
913
+ end
914
+ if cmdk.const_defined?(:Parser)
915
+ into = if cmdk.const_defined?(:Repeatable)
916
+ then Accumulator.new(opts, cmdk::Repeatable)
917
+ else opts
918
+ end
919
+ cmdk::Parser.order!(argv, into: into)
920
+ end
921
+
922
+ if cmdk.const_defined?(:Methods)
923
+ if opts.include?(:method)
924
+ unless cmdk::Methods.include?(opts[:method])
925
+ raise "#{cmdname} only support the #{cmdk::Methods.join(', ')} selection"
926
+ end
927
+ else
928
+ opts[:method] = cmdk::Methods.first
929
+ end
930
+ end
931
+
932
+ # -C names the file outright. Without it, a configuration
933
+ # named 'tribble-control.conf' in the current directory is used
934
+ # as though it had been given; neither the home directory nor
935
+ # /etc is ever looked in. Given -C, the current directory is
936
+ # not consulted at all.
937
+ unless opts.include?(:config)
938
+ opts[:config] = DEFAULT_CONFIG if File.exist?(DEFAULT_CONFIG)
939
+ end
940
+ if opts.include?(:config)
941
+ file = opts[:config]
942
+ raise Error, "file #{file} doesn't exist" unless File.exist?(file)
943
+ raw = UCL.load_file(file)
944
+
945
+ # Top-level keys that belong in the 'protect' block, caught
946
+ # before the device pass can take them for boards.
947
+ if (former = raw.keys & PROTECT_FORMER.keys).any?
948
+ raise Error, former.map {|k|
949
+ "'#{k}' is no longer a configuration key:" \
950
+ " write #{PROTECT_FORMER[k]}"
951
+ }.join('; ')
952
+ end
953
+
954
+ # What may never lose power. Checked key by key because
955
+ # the whole point of the block is that a port listed in it
956
+ # stays on: a misspelled 'port = [ 13 ]' that was silently
957
+ # ignored would read as protection and be none, which is
958
+ # the one failure mode this file exists to prevent.
959
+ if raw.include?(PROTECT_KEY)
960
+ protect = raw[PROTECT_KEY]
961
+ unless protect.is_a?(Hash)
962
+ raise Error, "#{PROTECT_KEY} must be a block:" \
963
+ " #{PROTECT_KEY} { #{PROTECT_PORTS_KEY}" \
964
+ ' = [ 13, 14 ] }'
965
+ end
966
+ if (bad = protect.keys - PROTECT_KEYS).any?
967
+ raise Error, "#{PROTECT_KEY} has no" \
968
+ " #{bad.join(', ')} key; it takes" \
969
+ " #{PROTECT_KEYS.join(', ')}"
970
+ end
971
+ if protect.include?(PROTECT_UNDECLARED_KEY)
972
+ undeclared = protect[PROTECT_UNDECLARED_KEY]
973
+ unless [ true, false ].include?(undeclared)
974
+ raise Error, "#{PROTECT_KEY}." \
975
+ "#{PROTECT_UNDECLARED_KEY} must be" \
976
+ ' yes or no'
977
+ end
978
+ @protect_undeclared = undeclared
979
+ end
980
+ # flatten: UCL turns a key written twice in one block
981
+ # into an array of its values, so a file with two
982
+ # 'ports' lines means both, not a nested list nothing
983
+ # can compare against a port number.
984
+ @protect_ports = Array(protect[PROTECT_PORTS_KEY])
985
+ .flatten.map do |p|
986
+ self.class.port_number(p)
987
+ rescue ArgumentError, TypeError
988
+ raise Error, "#{PROTECT_KEY}.#{PROTECT_PORTS_KEY} takes" \
989
+ " port numbers; '#{p}' is not one"
990
+ end
991
+ @protect_nodes = Array(protect[PROTECT_NODES_KEY])
992
+ .flatten.map(&:to_s)
993
+ end
994
+
995
+ if raw.include?(TALLY_KEY)
996
+ @tally_default = raw[TALLY_KEY].to_s
997
+ end
998
+
999
+ # Which hub this file describes. A block or a list here is
1000
+ # a file saying one configuration covers two benches, which it
1001
+ # cannot: every port number in it belongs to one hub.
1002
+ #
1003
+ # Integer is accepted because UCL hands one back for an
1004
+ # unquoted all-digit serial. It is not fixed, because it
1005
+ # CANNOT be fixed here: UCL has already parsed 00760040233
1006
+ # as the number 760040233, and the leading zeros are gone
1007
+ # before this sees it. Such a serial is looked up without
1008
+ # its zeros, fails, and the refusal lists what the host
1009
+ # really has -- which is the moment to quote it. The docs
1010
+ # say to quote a serial for this reason.
1011
+ if raw.include?(DEVICE_KEY)
1012
+ dev = raw[DEVICE_KEY]
1013
+ unless [ String, Symbol, Integer ].any? {|k| dev.is_a?(k) }
1014
+ raise Error, "#{DEVICE_KEY} must name one hub: its" \
1015
+ " serial number, such as A50285BI, a USB" \
1016
+ " path, such as 1-1.2.4.4, or its device" \
1017
+ ' node, such as /dev/ttyUSB0. Quote a' \
1018
+ ' serial that is all digits'
1019
+ end
1020
+ @device = dev.to_s
1021
+ end
1022
+
1023
+ # Which kind of hub, and what its switch does. Both are
1024
+ # checked here against what exists, so a typo is refused
1025
+ # at load rather than met as a missing backend later.
1026
+ if raw.include?(HUB_KEY)
1027
+ @hub_kind = raw[HUB_KEY].to_s.downcase
1028
+ unless Hub::KINDS.key?(@hub_kind)
1029
+ raise Error, "#{HUB_KEY} must be one of" \
1030
+ " #{Hub::KINDS.keys.join(', ')}"
1031
+ end
1032
+ end
1033
+
1034
+ if raw.include?(SWITCH_KEY)
1035
+ @switch = raw[SWITCH_KEY].to_s.downcase.to_sym
1036
+ unless SWITCHES.include?(@switch)
1037
+ raise Error, "#{SWITCH_KEY} must be one of" \
1038
+ " #{SWITCHES.join(', ')}"
1039
+ end
1040
+ end
1041
+
1042
+ # The type definitions, lifted out before anything is
1043
+ # taken for a device.
1044
+ if raw.include?(TYPES_KEY)
1045
+ @types = raw[TYPES_KEY]
1046
+ unless @types.is_a?(Hash)
1047
+ raise Error, "#{TYPES_KEY} must be a block of named" \
1048
+ ' definitions'
1049
+ end
1050
+ @types.each do |name, defn|
1051
+ unless defn.is_a?(Hash)
1052
+ raise Error, "type '#{name}' is not a block of settings"
1053
+ end
1054
+ # A type that named a port or a serial would be
1055
+ # saying every board of its kind is one board.
1056
+ if (bad = defn.keys & TYPE_FORBIDDEN).any?
1057
+ raise Error, "type '#{name}' sets #{bad.join(', ')}," \
1058
+ ' which names one particular board and' \
1059
+ ' cannot be shared'
1060
+ end
1061
+ # One level: a type is settings, not a thing with a
1062
+ # type of its own.
1063
+ if defn.key?(TYPE_KEY)
1064
+ raise Error, "type '#{name}' has a #{TYPE_KEY} of" \
1065
+ ' its own; types do not nest'
1066
+ end
1067
+ end
1068
+ end
1069
+
1070
+ @entries = raw.reject {|k,_|
1071
+ [ PROTECT_KEY, TALLY_KEY,
1072
+ TYPES_KEY, DEVICE_KEY, HUB_KEY, SWITCH_KEY ].include?(k)
1073
+ }
1074
+
1075
+ # Every device says which port it is on, or says none. A
1076
+ # forgotten port line is a mistake worth a message, not a
1077
+ # board quietly dropped off the bench.
1078
+ @entries.each do |name, entry|
1079
+ unless entry.is_a?(Hash) && entry.key?(PORT_KEY)
1080
+ raise Error, "configuration entry '#{name}' has no #{PORT_KEY}." \
1081
+ " Give it the hub port, or" \
1082
+ " '#{PORT_KEY} = none' if the board is no" \
1083
+ ' longer on the bench'
1084
+ end
1085
+ # Normalised here, once, so port_of() is the only place
1086
+ # that knows what a port may look like: name_port(),
1087
+ # serial() and attribute() compare the stored value
1088
+ # against an Integer, which port = '7' would never equal.
1089
+ # nil means none.
1090
+ entry[PORT_KEY] = self.port_of(name)
1091
+ end
1092
+
1093
+ # A type nothing defines is an error rather than an entry
1094
+ # quietly falling back to the tool's defaults: a board that
1095
+ # asked for jlink and silently got cmsis-dap is a flash
1096
+ # through the wrong probe, reported as success.
1097
+ @entries.each do |name, entry|
1098
+ next unless (t = entry[TYPE_KEY])
1099
+ next if @types.key?(t.to_s)
1100
+ known = @types.keys.sort
1101
+ raise Error, "configuration entry '#{name}' has #{TYPE_KEY} =" \
1102
+ " #{t}, which #{TYPES_KEY} does not define" \
1103
+ " (known: #{known.empty? ? 'none' : known.join(', ')})"
1104
+ end
1105
+
1106
+ # Two boards on one port is a configuration that cannot be right,
1107
+ # and it is what a reassigned port leaves behind when the old
1108
+ # entry keeps its number: the tool would then flash, power or
1109
+ # connect to whichever of the two it happened to find first,
1110
+ # under the other one's interface and baud. Caught here, at
1111
+ # load, rather than by a board behaving oddly later.
1112
+ #
1113
+ # 'none' is exempt, that being its whole purpose: any number
1114
+ # of entries may declare no port.
1115
+ seen = Hash.new {|h, k| h[k] = [] }
1116
+ @entries.each_key {|name|
1117
+ port = self.port_of(name)
1118
+ seen[port] << name unless port.nil?
1119
+ }
1120
+ clash = seen.select {|_, names| names.size > 1 }
1121
+ unless clash.empty?
1122
+ detail = clash.sort.map {|port, names|
1123
+ "#{port} (#{names.map {|n| "'#{n}'" }.join(', ')})"
1124
+ }.join('; ')
1125
+ raise Error, "configuration assigns the same #{PORT_KEY} more" \
1126
+ " than once: #{detail}"
1127
+ end
1128
+
1129
+ # Every protected node names an entry. A name that matches
1130
+ # nothing is a typo, and a typo here protects nothing while
1131
+ # reading, in the file, exactly like protection.
1132
+ if (unknown = @protect_nodes - @entries.keys).any?
1133
+ raise Error, "#{PROTECT_KEY}.#{PROTECT_NODES_KEY} names" \
1134
+ " #{unknown.map {|n| "'#{n}'" }.join(', ')}," \
1135
+ ' which the configuration does not declare'
1136
+ end
1137
+ end
1138
+
1139
+
1140
+ # Which hub to drive. The kind first: --hub, else the configuration's
1141
+ # HUB_KEY line, else exsys. Then the name, in the order they
1142
+ # are trusted: -d on the command line, the configuration's own
1143
+ # DEVICE_KEY line, and only then the host. The backend's open
1144
+ # holds the policy -- one candidate may be taken, two may not
1145
+ # -- and the wording of each refusal; what is settled here is
1146
+ # only which name it is handed, and which settings. A setting
1147
+ # for the other kind of hub is refused rather than dropped.
1148
+ #
1149
+ # No lock of ours around the ExSYS hub: exsys holds an exclusive lock on the
1150
+ # serial line for the whole of each call, the read-modify-write
1151
+ # of an on/off included, and that covers every process touching
1152
+ # the hub rather than only the tribble-control ones a lock file of
1153
+ # ours could know about. Nothing here needs a lock spanning two
1154
+ # calls, and the one candidate -- the turn-by-turn cycling
1155
+ # of --method power -- must not hold the line across its sleeps.
1156
+ kind = opts.fetch(:hub, @hub_kind)
1157
+ settings = {}
1158
+ if opts.include?(:password)
1159
+ unless kind == 'exsys'
1160
+ raise Error, "-p/--password: a #{kind} hub has no password"
1161
+ end
1162
+ settings[:password] = opts[:password]
1163
+ end
1164
+ if @switch
1165
+ unless kind == 'usb'
1166
+ raise Error, "#{SWITCH_KEY} = #{@switch} applies to" \
1167
+ " #{HUB_KEY} = usb; the #{kind} hub always" \
1168
+ ' cuts power'
1169
+ end
1170
+ settings[:switch] = @switch
1171
+ end
1172
+ @hub = Hub.backend(kind).open(opts.fetch(:device, @device),
1173
+ **settings)
1174
+ @device = opts[:device] = @hub.to_s
1175
+
1176
+ # --debug[=FILE]: the log goes to the terminal and, given FILE,
1177
+ # to the file as well, so a capture can be kept without watching
1178
+ # it go past. Opened before anything is switched: an unwritable
1179
+ # path found after a bench has been powered down is found too
1180
+ # late.
1181
+ if opts.include?(:debug)
1182
+ outputs = [ $stderr ]
1183
+ if (file = opts[:debug])
1184
+ begin
1185
+ @debug_io = File.open(file, 'a')
1186
+ @debug_io.sync = true
1187
+ rescue SystemCallError => e
1188
+ raise Error, "cannot write the debug log to #{file}:" \
1189
+ " #{e.message}"
1190
+ end
1191
+ outputs << @debug_io
1192
+ end
1193
+ # A new logger, not configure() on the old one: tty-logger
1194
+ # 0.6 builds its handlers when the logger is constructed,
1195
+ # and #configure does not revisit the level.
1196
+ @tty = TTY::Logger.new do |config|
1197
+ config.level = :debug
1198
+ config.output = outputs
1199
+ end
1200
+ end
1201
+
1202
+ @argv = argv
1203
+ @opts = opts
1204
+ @cmdk = cmdk
1205
+ self
1206
+ end
1207
+
1208
+ def run
1209
+ return nil if @cmdk.nil?
1210
+
1211
+ # Only commands that select devices power anything on, so only
1212
+ # they have a selection method and a warm-up delay to report.
1213
+ # Before a port is switched, not from inside the thread pool
1214
+ # with every board already powered. Commands that only
1215
+ # sometimes need openocd -- connect, and only under --reset --
1216
+ # do not declare it, and check when they reach for it.
1217
+ self.openocd_path if @cmdk.const_defined?(:OPENOCD) && @cmdk::OPENOCD
1218
+
1219
+ if @cmdk.const_defined?(:Methods)
1220
+ tty&.info "Device selection using #{@opts[:method]}"
1221
+ tty&.info "A #{@opts[:'warm-up']} sec warm-up delay" \
1222
+ " will be applied after device power-on"
1223
+ end
1224
+
1225
+ @cmdk.new(self).run(@argv, **@opts)
1226
+ end
1227
+
1228
+ end
1229
+
1230
+ end