exsys 0.5 → 1.0

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.
@@ -3,17 +3,77 @@ require "uart"
3
3
  module ExSYS
4
4
 
5
5
  # Control a ExSYS Managed USB hub
6
+ #
7
+ # The hub is commanded over a serial line, not over USB. Each command
8
+ # is a short ASCII frame closed by CR, and the hub answers G on success
9
+ # or Exx on error. GP is the exception: it needs no password, and it
10
+ # answers the port state directly.
11
+ #
12
+ # ?Q describe the hub GP read the port state
13
+ # SP set it, in RAM FP set it, in RAM and flash
14
+ # WP write RAM to flash CP change the password
15
+ # RD restore factory defaults RH reset the hub (no reply)
16
+ #
17
+ # A frame carries the whole 16-port state, so changing one port is a
18
+ # read-modify-write: GP to read it, then SP to put it back.
19
+ #
20
+ # ┌────┬──────────┬──────────┐
21
+ # │ SP │ pass···· │ 0300FFFF │
22
+ # └─┬──┴────┬─────┴────┬─────┘
23
+ # │ │ └─────── port state, low byte first, hub's width
24
+ # │ └────────────────── password, 8 chars (· = pad space)
25
+ # └────────────────────────── command, 2 chars
26
+ #
27
+ # The fields go out concatenated, with no separator: the frame above
28
+ # is written as "SPpass 0300FFFF\r".
29
+ #
30
+ # The state word is wider than the ports the hub has. A 16-port unit
31
+ # answers eight hex digits, four bytes, and the ports it does not have
32
+ # read as 1. A client therefore writes back what it read rather than
33
+ # padding, because on a 32-port hub padding with FFFF is not padding
34
+ # at all: it is a command to power ports 17 to 32.
35
+ #
36
+ # Port n is bit n-1 of the state word, and the word is sent low byte
37
+ # first, so ports 1 and 2 on, on a hub whose other ports read 1,
38
+ # reaches the wire as "0300FFFF". The low half of that:
39
+ #
40
+ # port 8 7 6 5 4 3 2 1 16 15 14 13 12 11 10 9
41
+ # bit 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0
42
+ # └───── low byte 03 ───┘ └──── high byte 00 ───┘
43
+ #
44
+ # How many ports a hub has is its own to say: ?Q reports it, and
45
+ # {#ports} is that list.
6
46
  class ManagedUSB
7
47
  SPEED = 9600 # @!visibility private
8
48
  PASSWORD = 'pass'.freeze # @!visibility private
9
49
  PORTS = 1.upto(16).to_a.freeze # @!visibility private
50
+ ALL = :all # every port, said explicitly
10
51
  TRUE_LIST = [ 1, :on, :ON, :true, :TRUE, :t, :T, true ].freeze # @!visibility private
11
52
  FALSE_LIST = [ 0, :off, :OFF, :false, :FALSE, :f, :F, false ].freeze # @!visibility private
12
-
53
+
54
+ # Raised by #flock when the platform won't lock a character device
55
+ # Key under which each thread keeps its open lines, one per hub.
56
+ # `private` does not apply to constants, so it lives here with the
57
+ # rest rather than pretending to be scoped.
58
+ SESSIONS = :exsys_managed_usb_sessions # @!visibility private
59
+
60
+ # Only the errors that mean "this platform will not lock this kind
61
+ # of file". A bad descriptor or a bad operation is a bug here, and
62
+ # swallowing it would run unlocked while reporting success -- the
63
+ # very outcome the lock exists to prevent -- so those propagate.
64
+ LOCK_ERRORS = [ Errno::EOPNOTSUPP, Errno::ENOTSUP, Errno::ENOLCK,
65
+ NotImplementedError ].freeze # @!visibility private
66
+
13
67
  # Error handling class
14
68
  class Error < StandardError
15
69
  end
16
70
 
71
+ # Raised when the hub refuses a command outright, as older firmware
72
+ # does for ?Q. Distinct from {Error} so that a refusal can be
73
+ # worked around while a reply nobody can read still stops things.
74
+ class Unsupported < Error
75
+ end
76
+
17
77
  # Initialize object.
18
78
  #
19
79
  # @param line [String] Serial line
@@ -28,27 +88,49 @@ class ManagedUSB
28
88
  @line = line
29
89
  @password = password.ljust(8)
30
90
  @debug = debug
91
+ @width = 8 # until the hub says otherwise
31
92
  end
32
93
 
33
- # Toggle all or specified ports
94
+ # Toggle the given ports
34
95
  #
96
+ # @param ports [Integer,:all] ports to invert, or ALL for every
97
+ # one. An empty list is an error.
35
98
  # @param commit [Boolean] Commit to flash memory
36
99
  def toggle(*ports, commit: false)
37
- _set(_get ^ mask(ports, :all), commit: commit)
100
+ session do
101
+ # The mask first: it settles how many ports the hub
102
+ # has, and validates the list, before the hub is read.
103
+ m = mask(ports)
104
+ _set(_get ^ m, commit: commit)
105
+ end
38
106
  end
39
107
 
40
- # Turn on all or specified ports
41
- #
108
+ # Turn on the given ports
109
+ #
110
+ # @param ports [Integer,:all] ports to power, or ALL for every
111
+ # one. An empty list is an error.
42
112
  # @param commit [Boolean] Commit to flash memory
43
113
  def on(*ports, commit: false)
44
- _set(_get | mask(ports, :all), commit: commit)
114
+ session do
115
+ # The mask first: it settles how many ports the hub
116
+ # has, and validates the list, before the hub is read.
117
+ m = mask(ports)
118
+ _set(_get | m, commit: commit)
119
+ end
45
120
  end
46
121
 
47
- # Turn off all or specified ports
48
- #
122
+ # Turn off the given ports
123
+ #
124
+ # @param ports [Integer,:all] ports to unpower, or ALL for every
125
+ # one. An empty list is an error.
49
126
  # @param commit [Boolean] Commit to flash memory
50
127
  def off(*ports, commit: false)
51
- _set(_get & ~mask(ports, :all), commit: commit)
128
+ session do
129
+ # The mask first: it settles how many ports the hub
130
+ # has, and validates the list, before the hub is read.
131
+ m = mask(ports)
132
+ _set(_get & ~ m, commit: commit)
133
+ end
52
134
  end
53
135
 
54
136
  # Set state for the specified ports
@@ -71,53 +153,101 @@ class ManagedUSB
71
153
  # @param default [Boolean,nil] Default value to use if unspecified
72
154
  # @param commit [Boolean] Commit to flash memory
73
155
  def set(dataset, default = nil, commit: false)
74
- val = _get
156
+ # One session for the whole thing: normalising asks the hub how
157
+ # many ports it has, and that answer must come from the same
158
+ # held line as the read-modify-write it feeds.
159
+ session do
160
+ wanted = normalize(dataset, default)
161
+ val = _get
75
162
 
76
- # Normalize
77
- keys = dataset.keys
78
- if (keys - PORTS).empty?
79
- dataset = dataset.transform_values do |v|
80
- case v
81
- when * TRUE_LIST then true
82
- when *FALSE_LIST then false
83
- when nil
84
- else raise ArgumentError
163
+ wanted.each do |k, v|
164
+ flg = 1 << (k-1)
165
+ if v
166
+ then val |= flg
167
+ else val &= ~flg
85
168
  end
86
169
  end
87
- elsif (keys - [:on, :off]).empty?
88
- on = Array(dataset[:on ])
89
- off = Array(dataset[:off])
90
-
91
- unless (on & off).empty?
92
- raise ArgumentError, "on/off overlap"
93
- end
94
-
95
- dataset = {}
96
- dataset.merge!(on .to_h {|k| [k, true ] })
97
- dataset.merge!(off.to_h {|k| [k, false ] })
98
- else
99
- raise ArgumentError
170
+
171
+ _set(val, commit: commit)
100
172
  end
173
+ end
101
174
 
102
- # Fill unspecified
103
- unless default.nil?
104
- (PORTS - dataset.keys).each do |k|
105
- dataset.merge!(k => default)
106
- end
175
+ # Ask the hub to describe itself
176
+ #
177
+ # One of the two commands needing no password. The reply is a
178
+ # single string -- "CENTOS000516v02" on the 16-port model -- made
179
+ # of an identifier, four digits, the port count, and a firmware
180
+ # version. It carries no port states: those come from GP.
181
+ #
182
+ # The count is the two digits before the firmware, which is where
183
+ # the vendor's own tool reads it, checked against that tool for
184
+ # hubs reporting 4, 8, 16 and 32. The four digits before it are
185
+ # returned in :raw and nowhere else: what they mean is not known,
186
+ # and the vendor ignores them too.
187
+ #
188
+ # @return [Hash] :id, :ports, :firmware, and the :raw reply
189
+ def query
190
+ raw = action('?Q', check: false)
191
+ if (raw.size == 3) && (raw[0] == 'E')
192
+ raise Unsupported, "hub refused the query: #{raw[1..-1]}"
193
+ end
194
+ unless raw =~ /\A([A-Z]+)(\d*)(\d{2})(v\S*)\z/
195
+ raise Error, "unexpected query reply: #{raw.inspect}"
107
196
  end
197
+ { :id => $1, :ports => $3.to_i, :firmware => $4, :raw => raw }
198
+ end
108
199
 
109
- # Compute value
110
- dataset.compact.each do |k,v|
111
- flg = 1 << (k-1)
112
- if v
113
- then val |= flg
114
- else val &= ~flg
200
+ # Number of ports the hub says it has
201
+ #
202
+ # Asked once, before the first operation needing it, and then
203
+ # remembered. This is what {ALL} covers and what a port is checked
204
+ # against.
205
+ #
206
+ # @note Remembered for the life of this object, which outlasts any
207
+ # one connection: the line is opened per operation, not held. So
208
+ # an instance is bound to the hub it first asked. If the device
209
+ # is unplugged and another appears under the same name, build a
210
+ # new instance rather than reusing this one -- nothing here can
211
+ # notice the swap.
212
+ #
213
+ # Falls back to {PORTS}.size when the firmware is too old to answer
214
+ # {#query}. A reply that arrives but cannot be read raises
215
+ # instead: guessing low there would leave a wider hub's upper ports
216
+ # untouched while reporting success.
217
+ #
218
+ # @return [Integer]
219
+ def port_count
220
+ @port_count ||=
221
+ begin
222
+ n = query[:ports]
223
+ # PS64 in the vendor's own symbols: 64 ports is the
224
+ # most the protocol can express.
225
+ unless n.between?(1, 64)
226
+ raise Error, "hub reports #{n} ports"
227
+ end
228
+ n
229
+ rescue Unsupported
230
+ # Firmware too old to be asked. Sixteen is the only
231
+ # safe guess: it is what the state word addresses on
232
+ # every hub this gem has been run against. A reply
233
+ # that arrives but cannot be read is NOT this case and
234
+ # is left to raise -- guessing low there would leave a
235
+ # wider hub's upper ports untouched while reporting
236
+ # success.
237
+ @debug&.puts '!!! hub will not answer ?Q, assuming ' \
238
+ "#{PORTS.size} ports"
239
+ PORTS.size
115
240
  end
116
- end
117
-
118
- _set(val, commit: commit)
119
241
  end
120
242
 
243
+ # The ports this hub has, as a list
244
+ #
245
+ # From {#port_count}, so asked of the hub once and bound to this
246
+ # object for its lifetime.
247
+ #
248
+ # @return [Array<Integer>]
249
+ def ports = 1.upto(port_count).to_a
250
+
121
251
  # Get hub current state for all ports
122
252
  #
123
253
  # Return value depend of the asked type (default: ports)
@@ -129,42 +259,88 @@ class ManagedUSB
129
259
  #
130
260
  # @param type [:ports, :on_off, :on, :off] Type of returned value
131
261
  def get(type = :ports)
132
- val = _get
133
- h = PORTS.reduce({}) {|acc, obj|
134
- acc.merge(obj => (val & (1 << (obj-1))).positive?)
135
- }
262
+ h = session do
263
+ v = _get
264
+ ports.reduce({}) {|acc, obj|
265
+ acc.merge(obj => (v & (1 << (obj-1))).positive?)
266
+ }
267
+ end
136
268
 
137
269
  case type
138
270
  when :ports
139
271
  h
140
272
  when :on_off
141
- h.reduce({}) {|acc, (k,v)|
142
- acc.merge(v ? :on : :off => [ k ]) {|k,o,n| o + n }
273
+ # Seeded with both keys, so that a hub with all its ports
274
+ # in the same state still answers the documented shape
275
+ # instead of omitting the empty one.
276
+ h.reduce({ :on => [], :off => [] }) {|acc, (k,v)|
277
+ acc.merge(v ? :on : :off => [ k ]) {|_,o,n| o + n }
143
278
  }
144
279
  when :on
145
- h.select {|k,v| v }.keys
280
+ h.select {|_,v| v }.keys
146
281
  when :off
147
- h.reject {|k,v| v }.keys
282
+ h.reject {|_,v| v }.keys
148
283
  else
149
284
  raise ArgumentError
150
285
  end
151
286
  end
152
287
 
153
- # Restore port states from the flash memory
288
+ # Restore the hub to its factory defaults
289
+ #
290
+ # Refuses without +confirm: true+. This is the one operation here
291
+ # that cannot be undone, and the one a caller is most likely to
292
+ # reach by misunderstanding, so it asks to be meant.
293
+ #
294
+ # @note This is destructive, and is not the inverse of {#commit}:
295
+ # it drops every port and resets the password. Nothing in the
296
+ # protocol reloads the flashed state -- the hub applies it at
297
+ # power-on by itself. Confirmed against the vendor's own cusba
298
+ # tool, whose /D issues the same RD command and documents it as
299
+ # "restore to factory default settings".
300
+ # @param confirm [Boolean] must be true; the keyword is the point
301
+ # @raise [ArgumentError] when not confirmed
302
+ def factory_reset(confirm: false)
303
+ unless confirm
304
+ raise ArgumentError,
305
+ 'factory_reset drops every port and resets the ' \
306
+ 'password, and nothing undoes it; pass confirm: true'
307
+ end
308
+ action('RD', @password, secrets: [ @password ]).then { self }
309
+ end
310
+
311
+ # @deprecated Renamed to {#factory_reset} in 1.0.
312
+ #
313
+ # The old name read as the inverse of {#commit}, which it never
314
+ # was, so it is gone rather than aliased -- a caller holding that
315
+ # belief needs to be stopped, not quietly forwarded.
154
316
  def restore
155
- action('RD', @password).then { self }
317
+ raise NoMethodError,
318
+ 'restore was renamed factory_reset: RD restores the hub ' \
319
+ 'to factory defaults, dropping every port and resetting ' \
320
+ 'the password. It is not the inverse of commit.'
156
321
  end
157
322
 
158
323
  # Save the port states to the flash memory
159
324
  def commit
160
- action('WP', @password).then { self }
325
+ action('WP', @password, secrets: [ @password ]).then { self }
161
326
  end
162
327
 
163
328
  # Perform a hub reset action
164
329
  #
330
+ # Refuses without +confirm: true+, for the same reason
331
+ # {#factory_reset} does: every port loses power while it runs.
332
+ #
165
333
  # @note power is not maintained accros a reset
166
- def reset
167
- action('RH', @password, reply: false).then { self }
334
+ # @param confirm [Boolean] must be true; the keyword is the point
335
+ # @raise [ArgumentError] when not confirmed
336
+ def reset(confirm: false)
337
+ unless confirm
338
+ raise ArgumentError,
339
+ 'reset reboots the hub, and every port loses power ' \
340
+ 'while it does; pass confirm: true'
341
+ end
342
+ action('RH', @password,
343
+ reply: false, secrets: [ @password ]).then { self }
168
344
  end
169
345
 
170
346
  # Change the hub protection password
@@ -172,22 +348,118 @@ class ManagedUSB
172
348
  new = PASSWORD if new.nil?
173
349
  raise ArgumentError, 'password too long' if new.size > 8
174
350
  new_password = new.ljust(8)
175
- action('CP', @password, new_password)
351
+ action('CP', @password, new_password,
352
+ secrets: [ @password, new_password ])
176
353
  @password = new_password
177
354
  self
178
355
  end
179
356
 
357
+ # Hold the serial line, exclusively locked, for the whole block.
358
+ #
359
+ # Every switching method already does this around its own
360
+ # read-modify-write. Wrapping several calls in one session extends
361
+ # that to the sequence, which is what a read-decide-write needs if
362
+ # another process or thread is driving the same hub:
363
+ #
364
+ # hub.session do
365
+ # hub.off(*hub.get(:on))
366
+ # end
367
+ #
368
+ # Sessions nest: an inner one reuses the line the outer one holds,
369
+ # so the methods above stay correct when called inside one.
370
+ #
371
+ # A session belongs to the thread that opened it. Another thread
372
+ # opens, and locks, its own rather than borrowing this one.
373
+ #
374
+ # @yieldparam hub [ManagedUSB] this hub
375
+ # @return the value of the block
376
+ def session
377
+ return yield self if serial
378
+
379
+ UART.open @line, SPEED do |line|
380
+ flock(line)
381
+ begin
382
+ self.serial = line
383
+ yield self
384
+ ensure
385
+ self.serial = nil
386
+ end
387
+ end
388
+ end
389
+
180
390
  private
391
+
392
+ # The line this thread currently holds for this hub, if any.
393
+ #
394
+ # Scoped to the thread as well as to the hub, because the handle and
395
+ # its lock belong to whoever opened them: a second thread reusing
396
+ # this one would be writing down a line it holds no lock on.
397
+ def serial = (Thread.current[SESSIONS] ||= {})[self]
398
+
399
+ def serial=(line)
400
+ store = (Thread.current[SESSIONS] ||= {})
401
+ line.nil? ? store.delete(self) : store[self] = line
402
+ end
403
+
404
+ # Turn either accepted port-state notation into { port => bool },
405
+ # with the unlisted ports filled in when a default is given and
406
+ # dropped when it is not.
407
+ def normalize(dataset, default)
408
+ keys = dataset.keys
409
+ if (keys - ports).empty?
410
+ dataset = dataset.transform_values do |v|
411
+ case v
412
+ when * TRUE_LIST then true
413
+ when *FALSE_LIST then false
414
+ when nil
415
+ else raise ArgumentError
416
+ end
417
+ end
418
+ elsif (keys - [:on, :off]).empty?
419
+ on = Array(dataset[:on ])
420
+ off = Array(dataset[:off])
421
+
422
+ check_ports(on + off)
423
+
424
+ unless (on & off).empty?
425
+ raise ArgumentError, "on/off overlap"
426
+ end
427
+
428
+ dataset = on .to_h {|k| [k, true ] }
429
+ .merge(off.to_h {|k| [k, false ] })
430
+ else
431
+ raise ArgumentError
432
+ end
433
+
434
+ unless default.nil?
435
+ (ports - dataset.keys).each {|k| dataset[k] = default }
436
+ end
437
+
438
+ dataset.compact
439
+ end
440
+
441
+ def check_ports(list)
442
+ known = ports
443
+ list.each do |p|
444
+ unless known.include?(p)
445
+ raise ArgumentError, "invalid port: #{p.inspect}"
446
+ end
447
+ end
448
+ end
181
449
 
182
- def mask(ports, empty = :none)
183
- case empty
184
- when :none
185
- when :all
186
- ports = PORTS if ports.empty?
187
- else raise ArgumentError
450
+ def mask(list)
451
+ # An empty list is refused rather than taken to mean everything.
452
+ # A caller splatting a computed list cannot say "none": on(*[])
453
+ # and on() are the same call, so the convenience would silently
454
+ # switch every port whenever the list came back empty.
455
+ if list.empty?
456
+ raise ArgumentError,
457
+ "no port given (#{ALL.inspect} means every port)"
188
458
  end
189
-
190
- ports.reduce(0) {|acc, obj| acc |= 1 << (obj-1) }
459
+ list = ports if list == [ ALL ]
460
+
461
+ check_ports(list)
462
+ list.reduce(0) {|acc, obj| acc | (1 << (obj-1)) }
191
463
  end
192
464
 
193
465
  def _get
@@ -195,26 +467,74 @@ class ManagedUSB
195
467
 
196
468
  if (data.size == 3) && (data[0] == 'E')
197
469
  raise Error, data[1..-1]
198
- elsif data.size != 8
199
- raise Error
470
+ elsif data.empty? || !data.match?(/\A(?:\h\h)+\z/)
471
+ raise Error, "unexpected reply: #{data.inspect}"
200
472
  end
201
473
 
202
- [ data ].pack('H4').unpack1('v')
474
+ # The hub sets the width, and keeps it: a 16-port model answers
475
+ # eight hex digits, four bytes, of which only the low sixteen
476
+ # bits are ports it has. Whatever comes back is written back.
477
+ @width = data.size
478
+ decode(data)
479
+ end
480
+
481
+ # Little-endian byte order, any width.
482
+ def decode(hex)
483
+ hex.scan(/\h\h/).each_with_index
484
+ .sum {|byte, i| byte.to_i(16) << (8 * i) }
203
485
  end
204
486
 
487
+ def encode(v, width)
488
+ (width / 2).times.map {|i| format('%02X', (v >> (8 * i)) & 0xff) }
489
+ .join
490
+ end
491
+
492
+ # Always preceded by a {#_get} in the same session, which is what
493
+ # fixes the width and carries the bits above the hub's real ports
494
+ # back untouched. Those bits read as 1 on a hub that has fewer
495
+ # ports than its word is wide; writing them back as read is what
496
+ # keeps a wider hub from having its upper ports driven.
205
497
  def _set(v, commit: false)
206
- dataset = ([v].pack('v').unpack1('H*') + 'ffff').upcase
207
- action(commit ? 'FP' : 'SP', @password, dataset).then { self }
498
+ if (v >> (@width * 4)).positive?
499
+ raise Error, "hub reports #{port_count} ports but answers a " \
500
+ "#{@width * 4}-bit state word"
501
+ end
502
+ action(commit ? 'FP' : 'SP', @password, encode(v, @width),
503
+ secrets: [ @password ]).then { self }
208
504
  end
209
-
210
-
211
- def action(*cmds, reply: true, check: true)
505
+
506
+ # Take an exclusive lock on the serial line, keeping concurrent
507
+ # processes from interleaving their own read-modify-write.
508
+ #
509
+ # Not every platform locks a character device; where it is refused
510
+ # the operation carries on unlocked -- single-process use is
511
+ # unaffected, concurrent use stays racy -- and says so on the debug
512
+ # output rather than failing outright.
513
+ def flock(serial)
514
+ serial.flock(File::LOCK_EX)
515
+ rescue *LOCK_ERRORS => e
516
+ @debug&.puts "!!! serial line not lockable (#{e.class})"
517
+ end
518
+
519
+ # Blank out the passwords before a command reaches the debug output.
520
+ def redact(str, secrets)
521
+ secrets.reduce(str) {|acc, s| acc.gsub(s, '*' * s.size) }
522
+ end
523
+
524
+ def action(*cmds, reply: true, check: true, secrets: [])
212
525
  cmd = cmds.join
213
- UART.open @line, SPEED do |serial|
214
- @debug&.puts "<-- #{cmd}"
526
+ session do
527
+ @debug&.puts "<-- #{redact(cmd, secrets)}"
215
528
  serial.write "#{cmd}\r"
216
529
  if reply
217
- serial.read.chomp.tap do |data|
530
+ # To the line terminator, not to EOF. There is no EOF
531
+ # on a serial line: what ends a read is the uart gem's
532
+ # VTIME, half a second of silence, so reading to EOF
533
+ # spent that half second on every single command while
534
+ # the hub had already answered. A hub that says
535
+ # nothing still costs exactly that, and still yields
536
+ # the empty string the callers below expect.
537
+ (serial.gets("\n") || '').chomp.tap do |data|
218
538
  @debug&.puts "--> #{data}"
219
539
  if check && data[0] != 'G'
220
540
  raise Error, data[1..-1]
data/lib/exsys/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module ExSYS
2
- VERSION = 0.5 # Version
2
+ VERSION = '1.0' # Version
3
3
  end
data/test/helper.rb ADDED
@@ -0,0 +1,60 @@
1
+ require 'minitest/autorun'
2
+ require 'fileutils'
3
+ require 'stringio'
4
+ require 'open3'
5
+ require 'shellwords'
6
+ require 'tmpdir'
7
+
8
+ ROOT = File.expand_path('..', __dir__)
9
+
10
+ # test/support carries the `uart` double, and must come before the real
11
+ # gem so that nothing here ever touches a serial line.
12
+ $LOAD_PATH.unshift File.join(ROOT, 'test', 'support')
13
+ $LOAD_PATH.unshift File.join(ROOT, 'lib')
14
+
15
+ require 'exsys'
16
+
17
+ # Run the executable in a subprocess, against a file-backed FakeHub.
18
+ #
19
+ # Returns [stdout, stderr, status]; the hub's state is left in the file
20
+ # so the caller can assert on what the ports actually did.
21
+ module CLI
22
+ EXE = File.join(ROOT, 'bin', 'exsys-usb')
23
+
24
+ def exsys_usb(*args, env: {})
25
+ exsys_usb_raw([ '-d', '/dev/null', *args ], env: env)
26
+ end
27
+
28
+ # As above, but with nothing added to the arguments -- for running a
29
+ # command line exactly as some other artifact spells it.
30
+ def exsys_usb_raw(args, env: {})
31
+ Open3.capture3({ 'EXSYS_TEST_HUB' => hub_file }.merge(env),
32
+ RbConfig.ruby,
33
+ '-I', File.join(ROOT, 'test', 'support'),
34
+ '-I', File.join(ROOT, 'lib'),
35
+ EXE, *args)
36
+ end
37
+
38
+ # The hub the subprocesses share, as this test left it.
39
+ def hub = FakeHub.new(path: hub_file)
40
+
41
+ def hub_file = @hub_file ||= File.join(@tmp, 'hub')
42
+
43
+ # Put the shared hub into a known state -- a test needing the hub to
44
+ # hold a particular password calls this before running anything.
45
+ def seed_hub(password: FakeHub::DEFAULT_PASSWORD, state: 0x0000)
46
+ FileUtils.rm_f(hub_file)
47
+ FakeHub.new(path: hub_file, password: password.ljust(8), state: state)
48
+ end
49
+
50
+ def setup
51
+ super
52
+ @tmp = Dir.mktmpdir('exsys-test')
53
+ seed_hub
54
+ end
55
+
56
+ def teardown
57
+ FileUtils.remove_entry(@tmp) if @tmp
58
+ super
59
+ end
60
+ end