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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # The hub, as the rest of the program sees it: switchable ports and
5
+ # nothing about how they are switched.
6
+ #
7
+ module TribbleControl
8
+
9
+ # What every kind of hub has to answer.
10
+ #
11
+ # The subject of this tool is a USB hub, of more than one kind. This
12
+ # class is the seam that puts each kind behind the same calls. A backend
13
+ # is a subclass answering the methods below, and the commands,
14
+ # each_device and the protections talk to nothing else.
15
+ #
16
+ # Ports are Integers numbered as the hub numbers them, from 1. A list
17
+ # handed to a switching method must be non-empty and name ports the
18
+ # hub has: an empty list never means "every port" anywhere in this
19
+ # program (see DESIGN.md), and the guard here is the last one rather
20
+ # than the first -- every caller checks before it gets this far, and a
21
+ # fourth path into the hub that forgets to is stopped here instead of
22
+ # powering a bench up or down.
23
+ #
24
+ # Reaching for a hub's own methods in a command -- anything a backend
25
+ # can do that this class does not name -- is the thing this class
26
+ # exists to stop. Add the method here, with a default or as a
27
+ # NotImplementedError, and then to the backends.
28
+ class Hub
29
+ # Anything the hub layer has to report: a hub that refuses a
30
+ # command, a host that cannot be looked for one on, a name that
31
+ # matches nothing or matches two. CLI.run prints the message and
32
+ # nothing else, so every one of these must carry one.
33
+ class Error < StandardError
34
+ end
35
+
36
+ # The kinds of hub there are, by the name a configuration's 'hub =' line
37
+ # uses, and the class answering for each. The configuration says what
38
+ # the hub IS -- an ExSYS managed hub, a standard hub with per-port
39
+ # power switching -- and not which tool drives it on this host, so
40
+ # the same line keeps working when another host learns to drive
41
+ # such a hub. A backend is required when it is asked for, so a
42
+ # host that lacks what one of them needs still runs the others.
43
+ KINDS = { 'exsys' => 'ExSYS', 'usb' => 'USB' }.freeze
44
+
45
+ # The class for a kind, loaded.
46
+ def self.backend(kind)
47
+ unless (klass = KINDS[kind.to_s])
48
+ raise Error, "unknown hub kind '#{kind}' (one of" \
49
+ " #{KINDS.keys.join(', ')})"
50
+ end
51
+ require_relative "hub/#{kind}"
52
+ const_get(klass)
53
+ end
54
+
55
+ # Every port this hub has, in order. Static, and asked before the
56
+ # hub is ever opened: the configuration is checked against it.
57
+ def ports = raise NotImplementedError, "#{self.class}#ports"
58
+
59
+ # The hub's own view of what is powered: { port => true/false },
60
+ # for every port in #ports.
61
+ def state = raise NotImplementedError, "#{self.class}#state"
62
+
63
+ def on(*) = raise NotImplementedError, "#{self.class}#on"
64
+ def off(*) = raise NotImplementedError, "#{self.class}#off"
65
+ def toggle(*) = raise NotImplementedError, "#{self.class}#toggle"
66
+
67
+ # Apply { port => true/false } and, unless +default+ is nil, put
68
+ # every port not named to +default+. A backend that can do the
69
+ # whole thing in one exchange with the hub overrides this; here it
70
+ # is an on and an off.
71
+ def set(changes, default = nil)
72
+ ons = changes.select {|_, v| v }.keys
73
+ offs = changes.reject {|_, v| v }.keys
74
+ unless default.nil?
75
+ (default ? ons : offs).concat(self.ports - changes.keys)
76
+ end
77
+ self.on(*ons) unless ons.empty?
78
+ self.off(*offs) unless offs.empty?
79
+ end
80
+
81
+ # Where a board on +port+ is in this host's USB tree, as a path in
82
+ # the shape ExSYS::ManagedUSB::USB_PATH describes (1-1.2.4.4), or
83
+ # nil when the hub cannot be placed in the tree. --method usb
84
+ # needs it; the other two methods need no topology at all.
85
+ def usb_path(port) = raise NotImplementedError, "#{self.class}#usb_path(#{port})"
86
+
87
+ # The hub as a message names it.
88
+ def to_s = raise NotImplementedError, "#{self.class}#to_s"
89
+
90
+ # Does 'off' remove power from the socket, or only take the port
91
+ # off the bus? Software cannot tell the two apart -- a hub with no
92
+ # power switch wired still reports the port unpowered and drops the
93
+ # link, and the device on it vanishes and returns either way -- so
94
+ # this is a fact a backend is told, or knows about its hardware.
95
+ # false means a board on a cut port keeps running, which is enough
96
+ # to select it by ('power' identifies a board by being the only
97
+ # one visible) and not enough to reboot it. The default is the
98
+ # honest one for a hub that was built to switch VBUS.
99
+ def vbus? = true
100
+
101
+ private
102
+
103
+ # A list a switching method was handed, vetted: non-empty, and
104
+ # every port one the hub has.
105
+ def selection(list)
106
+ raise Error, 'no port named: refusing to switch nothing' if list.empty?
107
+ if (bad = list - self.ports).any?
108
+ raise Error, "no such port on this hub: #{bad.join(' ')}" \
109
+ " (it has #{self.ports.first}-#{self.ports.last})"
110
+ end
111
+ list
112
+ end
113
+ end
114
+
115
+ end
@@ -0,0 +1,271 @@
1
+ #
2
+ # Host-specific ways of finding the boards: their probes, their
3
+ # consoles, and their place in the USB tree. Finding the HUB is each
4
+ # Hub backend's own (Hub::ExSYS.open, through the exsys gem), and so is
5
+ # its geometry, which socket a port is (Hub#usb_path).
6
+ #
7
+ require 'rbconfig'
8
+ require 'shellwords'
9
+
10
+ module TribbleControl
11
+
12
+ module Platform
13
+
14
+ # Vendor ids of the debug probes a bench carries: NXP/mbed for DAPLink
15
+ # (as on an nRF52840-MDK), SEGGER for a J-Link OB (as on a DWM1001-DEV).
16
+ # Both present their console as a CDC interface reporting the PROBE's
17
+ # own serial, which is what makes a serial the key to a console.
18
+ PROBE_VENDORS = %w[0d28 1366].freeze
19
+
20
+ module FreeBSD
21
+ # The sysctl branches that describe the USB bus.
22
+ #
23
+ # Not the whole dev tree: that is 78K of text here against 5K for
24
+ # these, and it is read afresh on every lookup rather than
25
+ # remembered, because --method power switches ports off and on
26
+ # underneath us and a remembered tree would describe a bench that
27
+ # has since changed.
28
+ #
29
+ # uftdi the hub's control adapter
30
+ # uhub every hub, which is what the walk up to the bus
31
+ # passes through and nothing else
32
+ # umodem a probe's CDC console -- the tty, and the serial
33
+ # usbhid a DAPLink's HID interface, and umass its drive:
34
+ # umass the same device under another driver, carrying the
35
+ # same serial, so a probe whose CDC did not attach is
36
+ # still answerable for
37
+ USB_OIDS = %w[dev.uftdi dev.uhub dev.umodem
38
+ dev.usbhid dev.umass].freeze
39
+
40
+ # Every debug probe's console, keyed by the probe's serial.
41
+ #
42
+ # The probe reports its own serial, the configuration already carries
43
+ # that serial to address the board for flashing, and umodem says
44
+ # which tty the probe's CDC interface became. No topology at all,
45
+ # which is what lets `connect --method serial` work anywhere.
46
+ def self.probe_consoles
47
+ self.usb_tree.filter_map {|name, dev|
48
+ next unless name.start_with?('umodem')
49
+ next unless self.probe?(dev)
50
+ # No ttyname, no console. '/dev/tty' + '' is /dev/tty --
51
+ # the controlling terminal -- so an entry whose tty is not
52
+ # named yet would map a probe's serial to the operator's own
53
+ # screen, for connect to read as if it were a board.
54
+ next if dev[:ttyname].to_s.empty?
55
+ [ dev.dig(:'%pnpinfo', :sernum), '/dev/tty' + dev[:ttyname].to_s ]
56
+ }.to_h
57
+ end
58
+
59
+ # The console of whatever is at that USB path, or nil.
60
+ #
61
+ # At the path or below it: an nRF52840-MDK puts its own hub on the
62
+ # socket and its DAPLink one level down, so the port leads to the
63
+ # hub and the tty belongs to the child.
64
+ def self.usb_to_tty(path, tree: nil)
65
+ raise ArgumentError if path.nil?
66
+ tree ||= self.usb_tree
67
+ dev = self.devices_at(path, tree).find {|_n, d| d[:ttyname] }&.last
68
+ dev && ('/dev/tty' + dev[:ttyname].to_s)
69
+ end
70
+
71
+ # The probe serial of whatever is at that USB path, or nil.
72
+ #
73
+ # Read from the descriptor the kernel already has, as on Linux, so
74
+ # it needs no SWD session and no powering the rest of the bench
75
+ # down. Several drivers may claim one probe -- an MDK's DAPLink is
76
+ # umodem, usbhid and umass at once -- and all of them report the
77
+ # device's serial, so the first that is a probe with one answers.
78
+ #
79
+ # The limit here that Linux does not have: a device NO driver
80
+ # claimed has no sysctl node at all, there being no dev.ugen, so it
81
+ # cannot be seen. A probe that enumerates and attaches nothing is
82
+ # invisible rather than serial-less.
83
+ def self.usb_to_serial(path, tree: nil)
84
+ raise ArgumentError if path.nil?
85
+ tree ||= self.usb_tree
86
+ self.devices_at(path, tree).each do |_name, dev|
87
+ next unless self.probe?(dev)
88
+ serial = dev.dig(:'%pnpinfo', :sernum).to_s
89
+ return serial unless serial.empty?
90
+ end
91
+ nil
92
+ end
93
+
94
+ # Is this one of the debug probes a bench carries?
95
+ private_class_method def self.probe?(dev)
96
+ vendor = dev.dig(:'%pnpinfo', :vendor).to_s.delete_prefix('0x')
97
+ PROBE_VENDORS.include?(vendor)
98
+ end
99
+
100
+ # Everything sitting at that USB path, or below it, shallowest
101
+ # first and then by name so that the answer does not depend on the
102
+ # order sysctl happened to print.
103
+ private_class_method def self.devices_at(path, tree)
104
+ tree.filter_map {|name, dev|
105
+ p = self.usb_path(dev, tree)
106
+ next unless p == path || p&.start_with?("#{path}.")
107
+ [ p, name, dev ]
108
+ }.sort_by {|p, name, _| [ p.count('.'), p, name ] }
109
+ .map {|_p, name, dev| [ name, dev ] }
110
+ end
111
+
112
+ # Where a device sits in the USB tree, as Linux would write it.
113
+ #
114
+ # FreeBSD states no such path, but every piece of one is in the
115
+ # sysctl tree: %location gives the bus and the port the device
116
+ # occupies on its parent, and %parent names that parent -- always a
117
+ # uhub, up to the root hub, whose own %location is empty.
118
+ #
119
+ # A walk that does not REACH the root answers nil rather than what
120
+ # it collected on the way: stopping one hub short turns 1-1.2.4.4
121
+ # into 1-4, which is not a broken string but a different socket.
122
+ #
123
+ # The exsys gem walks the same tree for its own device, and this is
124
+ # deliberately not that code: its walker is a documented internal
125
+ # of a gem that must stand alone, and a tool reaching into one is a
126
+ # tool that breaks on the next release.
127
+ # Public: Hub::USB walks the same tree, for the hub's own path.
128
+ def self.usb_path(dev, tree)
129
+ bus = nil
130
+ ports = []
131
+ rooted = false
132
+ seen = {}
133
+ while dev
134
+ loc = dev[:'%location']
135
+ unless loc.is_a?(Hash) && loc[:port]
136
+ rooted = true # a root hub occupies no port
137
+ break
138
+ end
139
+ bus ||= loc[:bus]
140
+ ports.unshift(loc[:port])
141
+ parent = dev[:'%parent'].to_s
142
+ # A %parent chain that returns to a device already on the
143
+ # way up is not a tree. No kernel prints one, but this
144
+ # parses whatever it is handed, and without the guard the
145
+ # answer is not a wrong path but an unbounded loop: a
146
+ # command that never returns and never says why.
147
+ break if seen[parent]
148
+ seen[parent] = true
149
+ dev = tree[parent]
150
+ end
151
+ return nil unless rooted && bus && !ports.empty?
152
+ "#{bus}-#{ports.join('.')}"
153
+ end
154
+
155
+ # Parse a sysctl -e dump into the shape below. Split from the
156
+ # reading of it so that the tests can feed a capture in and run on
157
+ # a host with no bench, no probe, and no sysctl at all.
158
+ def self.parse_usb_tree(output)
159
+ output.lines.reduce({}) {|acc, l|
160
+ k, v = l.chomp.split('=', 2)
161
+ next acc if k.nil?
162
+ dev, i, sk = k.split('.')[1..]
163
+ # No unit number in it -- dev.uhub.%parent -- so it
164
+ # describes the driver and not a device.
165
+ next acc if sk.nil? || i !~ /\A\d+\z/
166
+ if [ '%pnpinfo', '%location' ].include?(sk)
167
+ # Not every token in one of these is a pair: some
168
+ # drivers write a bare word, and a parser that died on
169
+ # one of them would take the whole bench with it.
170
+ v = Shellwords.shellsplit(v.to_s).filter_map {|e|
171
+ k2, v2 = e.split('=', 2)
172
+ [ k2.to_sym, v2 ] if v2
173
+ }.to_h
174
+ end
175
+ acc.merge("#{dev}#{i}" => { sk.to_sym => v }) {|_k, o, n|
176
+ o.merge(n)
177
+ }
178
+ }
179
+ end
180
+
181
+ # The USB branches of the sysctl tree, as
182
+ #
183
+ # { 'umodem0' => { :ttyname => 'U0', :'%parent' => 'uhub6',
184
+ # :'%location' => { :bus => '1', ... } } }
185
+ #
186
+ # Keyed by device name and not by unit number: several branches are
187
+ # read at once, %parent names a parent that way, and unit numbers
188
+ # repeat across drivers.
189
+ # Public: Hub::USB walks the same tree, to find the hubs in it.
190
+ def self.usb_tree
191
+ oids = USB_OIDS.map {|o| Shellwords.escape(o) }.join(' ')
192
+ self.parse_usb_tree(`/sbin/sysctl -e #{oids} 2>/dev/null`)
193
+ end
194
+ end
195
+
196
+ module Linux
197
+ # See FreeBSD.probe_consoles. Matched on the probe's vendor, not
198
+ # on one probe firmware: an MDK's DAPLink and a DWM1001-DEV's
199
+ # J-Link OB both report a serial and both become a ttyACM.
200
+ def self.probe_consoles
201
+ Dir['/sys/class/tty/ttyACM*']
202
+ .map {|path| self.udevadm_query(path) }
203
+ .select {|dev| PROBE_VENDORS.include?(dev[:ID_VENDOR_ID]) }
204
+ .to_h {|dev| [ dev[:ID_SERIAL_SHORT], dev[:DEVNAME] ] }
205
+ end
206
+
207
+ def self.usb_to_tty(path)
208
+ raise ArgumentError if path.nil?
209
+ if (dev_path = Dir["/sys/bus/usb/devices/#{path}/**/tty/ttyACM*"]&.first)
210
+ File.join('/dev', File.basename(dev_path))
211
+ end
212
+ end
213
+
214
+ # The probe's serial, from the USB descriptor the kernel already has.
215
+ #
216
+ # Not asked of openocd: 0.12.0, the current release, prints neither
217
+ # the CMSIS-DAP "Serial# =" line nor a J-Link "S/N", at any debug
218
+ # level. Reading the descriptor needs no SWD session, no openocd,
219
+ # and above all no powering the rest of the bench down to leave one
220
+ # adapter for openocd to find.
221
+ #
222
+ # An MDK puts its own hub in front of its DAPLink, so the probe sits
223
+ # one level below the hub port there while a J-Link sits on it; look
224
+ # at both, and take the first that is a probe with a serial.
225
+ def self.usb_to_serial(path)
226
+ raise ArgumentError if path.nil?
227
+ base = "/sys/bus/usb/devices/#{path}"
228
+ [ base, *Dir["#{base}.*"].sort ].each do |dev|
229
+ next unless File.file?("#{dev}/idVendor")
230
+ next unless PROBE_VENDORS.include?(File.read("#{dev}/idVendor").chomp)
231
+ next unless File.file?("#{dev}/serial")
232
+ serial = File.read("#{dev}/serial").chomp
233
+ return serial unless serial.empty?
234
+ end
235
+ nil
236
+ end
237
+
238
+ private_class_method def self.udevadm_query(path)
239
+ `/usr/bin/udevadm info -q property --export #{Shellwords.escape(path)}`
240
+ .lines.to_h {|l| l.split('=', 2) }
241
+ .transform_keys(&:to_sym)
242
+ .transform_values {|v| Shellwords.split(v).join(' ') }
243
+ end
244
+ end
245
+
246
+
247
+ Current = case RbConfig::CONFIG['host_os']
248
+ when /^linux-/ then Platform::Linux
249
+ when /^freebsd/ then Platform::FreeBSD
250
+ else raise 'Unsupported platform'
251
+ end
252
+
253
+
254
+ def self.probe_consoles(...) = Current.probe_consoles(...)
255
+
256
+ # The console of the board whose probe carries this serial, or nil.
257
+ #
258
+ # Works on both platforms and needs no USB topology, which is what lets
259
+ # `connect --method serial` reach a console on a host with no
260
+ # /sys/bus/usb.
261
+ def self.serial_to_tty(serial)
262
+ return nil if serial.nil?
263
+ self.probe_consoles[serial.to_s]
264
+ end
265
+
266
+ def self.usb_to_tty(...) = Current.usb_to_tty(...)
267
+ def self.usb_to_serial(...) = Current.usb_to_serial(...)
268
+
269
+ end
270
+
271
+ end
@@ -0,0 +1,91 @@
1
+ #
2
+ # What a board's console output means.
3
+ #
4
+ require_relative 'cli'
5
+
6
+ module TribbleControl
7
+
8
+ # A running count of what a board printed, and one line saying so.
9
+ #
10
+ # `connect` knows how to open a board's console, prefix its lines and
11
+ # print them. What a line MEANS -- a completed exchange, a failure, a
12
+ # banner, noise -- is the firmware's business, and firmware is not what
13
+ # this tool is about: the strings to look for change with every build
14
+ # of every project that ever sits on this hub, and none of them belong
15
+ # in a program whose subject is a USB hub.
16
+ #
17
+ # So they are not here. A tally is the seam: `connect` hands it every
18
+ # line it prints and asks it, once, for a summary. The one below
19
+ # counts lines, which is all a tool that knows nothing about the
20
+ # firmware can honestly say. Anything that knows more is a block,
21
+ # registered from a file loaded with -r/--require:
22
+ #
23
+ # TribbleControl::Tally.register(:twr) do |device|
24
+ # MyTally.new(device)
25
+ # end
26
+ #
27
+ # and chosen with `tally = twr` in the configuration, for the whole bench or
28
+ # for one board. The block is called once per board per run, so a
29
+ # tally may keep whatever state it likes without sharing it.
30
+ class Tally
31
+ @registry = {}
32
+
33
+ class << self
34
+ # Register a tally builder under +name+. The block is given a
35
+ # device name and must return an object answering #<<, which
36
+ # receives every line, and #summary, which returns the text of
37
+ # the SUMMARY line or nil for no summary at all.
38
+ def register(name, &block)
39
+ raise ArgumentError, 'a tally needs a block' if block.nil?
40
+ @registry[name.to_s] = block
41
+ end
42
+
43
+ def registered = @registry.keys.sort
44
+
45
+ # Build the tally called +name+ for the device +device+.
46
+ #
47
+ # An unknown name is an error rather than a silent fallback to
48
+ # counting lines: a configuration asking for 'twr' on a run that
49
+ # forgot -r would otherwise capture a whole bench and report
50
+ # nothing but line counts, which reads as a firmware saying
51
+ # nothing rather than as a missing file.
52
+ def build(name, device)
53
+ builder = @registry[name.to_s]
54
+ if builder.nil?
55
+ raise CLI::Error, "unknown tally '#{name}'" \
56
+ " (known: #{self.registered.join(', ')})." \
57
+ ' A tally other than the built-in ones' \
58
+ ' comes from a file given with --require'
59
+ end
60
+ builder.call(device)
61
+ end
62
+ end
63
+
64
+ def initialize(device)
65
+ @device = device
66
+ @lines = 0
67
+ end
68
+
69
+ def <<(_line)
70
+ @lines += 1
71
+ self
72
+ end
73
+
74
+ def summary = "lines=#{@lines}"
75
+ end
76
+
77
+ # The default: a board printed this many lines.
78
+ Tally.register(:lines) {|device| Tally.new(device) }
79
+
80
+ # Counts nothing, says nothing: for a capture that wants the lines and
81
+ # no SUMMARY at all. A null object rather than nil, so that `connect`
82
+ # has one kind of thing to talk to.
83
+ class NullTally
84
+ def initialize(device) ; @device = device ; end
85
+ def <<(_line) = self
86
+ def summary = nil
87
+ end
88
+
89
+ Tally.register(:none) {|device| NullTally.new(device) }
90
+
91
+ end
@@ -0,0 +1,8 @@
1
+ module TribbleControl
2
+
3
+ # Bumped by hand. The version lives here alone: the gemspec reads it
4
+ # from this file, and `tribble-control --version` prints the same
5
+ # constant, so a release cannot have two numbers.
6
+ VERSION = '0.4.4'.freeze
7
+
8
+ end
@@ -0,0 +1,32 @@
1
+ #
2
+ # tribble-control -- power, flash and monitor the boards plugged into a
3
+ # switchable USB hub: an ExSYS 16-port managed hub, or any hub that
4
+ # switches its own ports.
5
+ #
6
+ # tribble-control -C tribble.conf flash zephyr.hex A1 A3
7
+ # tribble-control -C tribble.conf connect --off C2 B2 | tee twr.log
8
+ #
9
+ # A hub port may feed something that must never lose power, so
10
+ # tribble-control refuses to switch off any port its 'protect' block
11
+ # names, and by default any port the configuration does not declare;
12
+ # -F/--force lifts both rules.
13
+ #
14
+ # Full documentation -- port map, device selection, recipes and traps --
15
+ # is in man/man1/tribble-control.1, and is displayed by
16
+ # `tribble-control --man`.
17
+ #
18
+ require_relative 'tribble-control/version'
19
+ require_relative 'tribble-control/platform'
20
+ require_relative 'tribble-control/hub'
21
+ require_relative 'tribble-control/hub/exsys'
22
+ require_relative 'tribble-control/cli'
23
+ require_relative 'tribble-control/tally'
24
+
25
+ # The commands, loaded for their side effect: CLI.commands finds them by
26
+ # asking CLI::Command for its subclasses, so a command file that is
27
+ # never required is a command the tool does not have.
28
+ require_relative 'tribble-control/cli/usb'
29
+ require_relative 'tribble-control/cli/serial'
30
+ require_relative 'tribble-control/cli/flash'
31
+ require_relative 'tribble-control/cli/reset'
32
+ require_relative 'tribble-control/cli/connect'