tribble-control 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/DESIGN.md +871 -0
- data/LICENSE +21 -0
- data/README.md +541 -0
- data/examples/tribble.conf +208 -0
- data/examples/vbus-check +140 -0
- data/exe/tribble-control +14 -0
- data/lib/tribble-control/cli/connect.rb +282 -0
- data/lib/tribble-control/cli/flash.rb +102 -0
- data/lib/tribble-control/cli/reset.rb +39 -0
- data/lib/tribble-control/cli/serial.rb +60 -0
- data/lib/tribble-control/cli/usb.rb +104 -0
- data/lib/tribble-control/cli.rb +1230 -0
- data/lib/tribble-control/hub/exsys.rb +248 -0
- data/lib/tribble-control/hub/usb.rb +418 -0
- data/lib/tribble-control/hub.rb +115 -0
- data/lib/tribble-control/platform.rb +271 -0
- data/lib/tribble-control/tally.rb +91 -0
- data/lib/tribble-control/version.rb +8 -0
- data/lib/tribble-control.rb +32 -0
- data/man/man1/tribble-control.1 +1483 -0
- data/tribble-control.gemspec +99 -0
- metadata +192 -0
|
@@ -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
|