exsys 0.5 → 0.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f8a542193c2ef67c427b47ee5ee95c448ecb9f3aced7d1a763f4e3159340eb2
4
- data.tar.gz: 04da256b271c0c43d07e258c808eff8659c2b95ecc03d7424ec8101d46f5c126
3
+ metadata.gz: ebdc824ab993ec59f3e5ba32fbef04a461e3f0354b8ea8723a17fa6125ba19a1
4
+ data.tar.gz: 783826b2949fc55f1f8b98c34aca2f39d79f9fe445acbe3863b33e1435f123e1
5
5
  SHA512:
6
- metadata.gz: 6e2555919ac712c8b4d4064b4d4d9cd8e69d8aa74c3ce71dbb86be23deb6e20cf0be89907d7d41bf087010f139dec32dcdd6fd6b989ea21aad53a46e83721bf1
7
- data.tar.gz: da9630c153d83bbedc4f840707aa32ac90f2320d038e43ae01d8bfea62db52c090d2da9a39694545518e7b4829cf197a3487e64c4c052a9233cc4a8254154a07
6
+ metadata.gz: '059792265ac5e663899a421176034d49bf555c61c5a3ca160f62cb5e53054fbb05e1b1fd5d13016f1003d0da9b9a033f1c20af17615e0852d506a0ce7031b52d'
7
+ data.tar.gz: 825f0ba1eab46d7fc3517954050d68a0b43bab0ce34d0cc05cfdf1696d87a2d2679a0b878d2691719dd04e393e0750dbee6dabab97fd55a34639df9ac502a264
data/README.md CHANGED
@@ -1,44 +1,168 @@
1
+ # exsys
1
2
 
2
- Controlling a [ExSYS Managed USB hub][1] without being
3
- constrained by the official binary-only support.
3
+ Switch the ports of an [ExSYS managed USB hub][1] on and off, from the
4
+ shell or from Ruby, without being constrained by the vendor's
5
+ binary-only tool.
4
6
 
7
+ [![ExSYS EX-1526HMVS: managed 16-port USB 3.2 Gen 1 metal hub][hub]][1]
8
+
9
+
10
+ ## How it works
11
+
12
+ The hub is driven over a serial line, not over USB: switching a port is
13
+ a serial command, and the data path is not involved. Every command is
14
+ answered, and that reply is how the port state is read back and how a
15
+ refused command is reported.
16
+
17
+ ~~~text
18
+ ┌───────────────┐ ┌─────────────────────┐
19
+ │ │ │ │
20
+ │ host │ │ EX-1526HMVS │
21
+ │ │ USB 3.2 (data) │ │
22
+ │ │◂──────────────────────────▸│ UP-A / UP-B │
23
+ │ │ │ │
24
+ │ exsys-usb │ serial, 9600 8N1 │ │
25
+ │ (this gem) │◂──────────────────────────▸│ management (FTDI) │
26
+ │ │ │ │
27
+ │ │ │ 16 ports, 1 .. 16 │
28
+ └───────────────┘ └─────────────────────┘
29
+ ~~~
30
+
31
+ The serial line is `/dev/ttyU0` on FreeBSD, `/dev/ttyUSB0` on Linux.
32
+ You need read and write access to it: the device is usually owned by a
33
+ group such as `dialout` or `plugdev`, so check `ls -l` on it and add
34
+ yourself to that group rather than reaching for `sudo`.
35
+
36
+ The hub answers only to its password, `pass` unless it has been
37
+ changed. Port numbering runs from 1 to 16, and that range is fixed:
38
+ this gem targets the 16-port model, so on a smaller hub the commands
39
+ that take no port list would address ports that are not there.
40
+
41
+
42
+ ## Install
43
+
44
+ ~~~sh
45
+ gem install exsys
46
+ ~~~
47
+
48
+ The only runtime dependency is [uart][2], which pulls in `ruby-termios`
49
+ -- a C extension, so a compiler and the Ruby headers must be available
50
+ when it builds.
51
+
52
+ Tested on Ruby 3.1, 3.3 and 3.4, on FreeBSD and on Linux.
53
+
54
+
55
+ ## Command line
56
+
57
+ ~~~sh
58
+ dev=/dev/ttyU0
59
+ exsys-usb -d ${dev} on # All on
60
+ exsys-usb -d ${dev} off # All off
61
+ exsys-usb -d ${dev} on 1 2 # Only turn on port 1 and 2
62
+ exsys-usb -d ${dev} toggle 3, 5 # Toggle port 3 and 5
63
+ exsys-usb -d ${dev} set 3:on 5:off # Turn on port 3, turn off port 5
64
+ exsys-usb -d ${dev} -D false set 3:on # Turn on port 3, all others off
65
+ exsys-usb -d ${dev} -c on 1 # Turn on port 1, and save to flash
66
+ ~~~
67
+
68
+ ### Actions
69
+
70
+ | Action | Effect |
71
+ | :------------------ | :---------------------------------------------- |
72
+ | `on [PORT...]` | Power the listed ports, or every port |
73
+ | `off [PORT...]` | Unpower the listed ports, or every port |
74
+ | `toggle [PORT...]` | Invert the listed ports, or every port |
75
+ | `set PORT:STATE...` | Set the listed ports; `-D` decides the rest |
76
+ | `commit` | Save the current port state to flash |
77
+ | `restore` | Load the port state back from flash |
78
+ | `reset` | Reset the hub; port power is *not* maintained |
79
+
80
+ A port state in `set` is written `PORT:STATE`, where `STATE` is one of
81
+ `1`, `on`, `ON`, `true`, `TRUE`, `t`, `T` or their false counterparts
82
+ `0`, `off`, `OFF`, `false`, `FALSE`, `f`, `F`.
83
+
84
+ ### Options
85
+
86
+ | Option | Meaning |
87
+ | :-------------------- | :-------------------------------------------- |
88
+ | `-d`, `--device=DEV` | Serial line to the hub (required) |
89
+ | `-p`, `--password=STR`| Hub password; defaults to `pass` |
90
+ | `-c`, `--commit` | Also write the new state to flash |
91
+ | `-D`, `--default=BOOL`| State for the ports `set` does not name |
92
+ | `--debug[=FILE]` | Trace the serial exchange to stderr, or FILE |
93
+ | `-V`, `--version` | Print the library version |
94
+ | `-h`, `--help` | Print the usage |
95
+
96
+ The debug trace shows every frame sent and received, with the password
97
+ blanked out; when it is written to a file, that file is created
98
+ readable only by you.
99
+
100
+ ### Exit status
101
+
102
+ `0` when the command was carried out, `1` otherwise -- a malformed
103
+ argument, a port outside 1..16, an unreachable serial line, or a
104
+ command the hub refused. The error goes to stderr, so a script can
105
+ rely on the status:
5
106
 
6
- Executable
7
107
  ~~~sh
8
- dev=/dev/ttyU0
9
- exsys-hub -d ${dev} on # All on
10
- exsys-hub -d ${dev} off # All off
11
- exsys-hub -d ${dev} on 1 2 # Only turn on port 1 and 2
12
- exsys-hub -d ${dev} toggle 3, 5 # Toggle port 3 and 5
13
- exsys-hub -d ${dev} set 3:on 5:off # Turn on port 3, turn off port 5
14
- exsys-hub -d ${dev} -D false set 3:on # Turn on port 3, turn off all other ports
108
+ exsys-usb -d /dev/ttyU0 off 3 || echo "could not switch port 3 off"
15
109
  ~~~
16
110
 
17
- Library:
111
+
112
+ ## Library
18
113
 
19
114
  ~~~ruby
20
- # Instanciate hub (Linux: ttyUSB?, FreeBSD: ttyU?)
115
+ # Instantiate the hub (Linux: ttyUSB?, FreeBSD: ttyU?)
21
116
  # and enable debug output to stderr
22
117
  hub = ExSYS::ManagedUSB.new('/dev/ttyU0', debug: STDERR)
23
118
 
24
- # Chaining turning on all port, and swithing off ports 4,5,6
119
+ # Chain turning on all ports, then switch off ports 4, 5 and 6
25
120
  hub.on.off(4,5,6)
26
121
 
27
- # Perform sequential toggle of all individual ports
122
+ # Toggle each port in turn
28
123
  ExSYS::ManagedUSB::PORTS.each do |p|
29
124
  hub.toggle(p)
30
125
  end
31
126
 
32
- # Set ports states for 1 and 2
127
+ # Set the state of ports 1 and 2
33
128
  hub.set({ 1 => true, 2 => false })
34
129
 
35
- # Set ports states for 1 and 2, forcing other ports to off
130
+ # Set ports 1 and 2, forcing every other port off
36
131
  hub.set({ 1 => true, 2 => true }, false)
37
132
  ~~~
38
133
 
134
+ Reading the state back has no command-line equivalent; it is a library
135
+ call, and answers in whichever shape suits the caller:
136
+
137
+ ~~~ruby
138
+ hub.get # => { 1 => true, 2 => false, ... }
139
+ hub.get(:on_off) # => { :on => [1, 3], :off => [2, 4, ...] }
140
+ hub.get(:on) # => [ 1, 3 ]
141
+ hub.get(:off) # => [ 2, 4, ... ]
142
+ ~~~
143
+
144
+ Switching is a read-modify-write, and the library holds the serial line
145
+ -- locked -- across the whole exchange, so two processes driving the
146
+ same hub cannot lose each other's changes. The wire protocol is
147
+ documented in the `ExSYS::ManagedUSB` class comment.
148
+
149
+
150
+ ## Tests
151
+
152
+ ~~~sh
153
+ rake test # or: ruby -Itest test/test_managed_usb.rb
154
+ ~~~
155
+
156
+ The suite drives the library and the executable against a model of the
157
+ hub's serial protocol (`test/support/`), so it needs neither a hub nor
158
+ the `uart`/`termios` gems.
39
159
 
40
160
 
161
+ ## License
41
162
 
163
+ MIT, see [LICENSE](LICENSE).
42
164
 
43
165
 
44
- [1]: https://www.exsys-shop.de/shopware/en/categories/hubsdocks/usb-hubs-managed/1263/managed-16-port-usb-3.2-gen-1-metal-hub-with-15kv-esd-surge-protection-din-rail?c=35
166
+ [1]: https://www.exsys.de/en/managed-16-port-usb-3.2-gen-1-metal-hub-with-15kv-esd-surge-protection-din-rail/EX-1526HMVS
167
+ [2]: https://rubygems.org/gems/uart
168
+ [hub]: https://www.exsys.de/thumbnail/df/a9/63/1716816684/EX-1526HMVS_-_Managed_16-Port_USB_3.2_Gen_1_Metall_HUB_15KV_ESD-1_800x800.jpg
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ require 'bundler'
2
+ require 'rake/testtask'
3
+ require 'yard'
4
+
5
+ Bundler::GemHelper.install_tasks
6
+
7
+ Rake::TestTask.new do |t|
8
+ t.test_files = FileList['test/test_*.rb']
9
+ t.verbose = true
10
+ t.warning = false
11
+ end
12
+
13
+ task :default => :test
14
+
15
+ YARD::Rake::YardocTask.new do |t|
16
+ t.files = [ 'lib/**/*.rb', 'ext/ucl.c' ]
17
+ t.options = [ '-m', 'markdown' ]
18
+ t.stats_options = [ '--list-undoc' ]
19
+ end
data/bin/exsys-usb CHANGED
@@ -23,26 +23,32 @@ parser = OptionParser.new do |op|
23
23
  exit
24
24
  end
25
25
  end
26
- parser.parse!(into: $opts)
27
26
 
28
- debug = if $opts.include?(:debug)
29
- if $opts[:debug].nil?
30
- then STDERR
31
- else File.open($opts[:debug], File::RDWR|File::CREAT)
32
- end
33
- end
34
- $hub = ExSYS::ManagedUSB.new($opts[:device], $opts[:password],
35
- debug: debug)
27
+ # Option parsing, debug file and hub creation all live inside the
28
+ # rescue, so that their failures are reported the same way as a hub
29
+ # failure instead of escaping as a backtrace.
30
+ begin
31
+ parser.parse!(into: $opts)
36
32
 
33
+ # Appended and kept private: the trace carries the hub password,
34
+ # and opening RDWR without truncating used to leave the tail of a
35
+ # longer previous session behind, reading as if it were hub traffic.
36
+ debug = if $opts.include?(:debug)
37
+ if $opts[:debug].nil?
38
+ then STDERR
39
+ else File.open($opts[:debug],
40
+ File::WRONLY|File::CREAT|File::APPEND, 0600)
41
+ end
42
+ end
43
+ $hub = ExSYS::ManagedUSB.new($opts[:device], $opts[:password],
44
+ debug: debug)
37
45
 
38
- begin
39
46
  case action = ARGV.shift
40
47
  when nil
41
48
  puts parser
42
49
  exit
43
50
 
44
51
  when 'on'
45
- puts ARGV.map(&:to_i).join(',')
46
52
  $hub.on(*ARGV.map(&:to_i), commit: $opts[:commit])
47
53
 
48
54
  when 'off'
@@ -70,7 +76,11 @@ begin
70
76
 
71
77
  when 'restore'
72
78
  $hub.restore
79
+
80
+ else
81
+ raise "unknown action: #{action}"
73
82
  end
74
- #rescue => e
75
- # $stderr.puts "#{parser.program_name}: #{e}"
83
+ rescue => e
84
+ $stderr.puts "#{parser.program_name}: #{e}"
85
+ exit 1
76
86
  end
data/exsys.gemspec CHANGED
@@ -18,13 +18,15 @@ Gem::Specification.new do |s|
18
18
  s.authors = [ "Stéphane D'Alu" ]
19
19
  s.email = [ 'stephane.dalu@insa-lyon.fr' ]
20
20
 
21
- s.files = %w[ README.md exsys.gemspec ] +
22
- Dir['lib/**/*.rb']
21
+ s.files = %w[ README.md exsys.gemspec Rakefile ] +
22
+ Dir['lib/**/*.rb'] +
23
+ Dir['test/**/*.rb']
23
24
 
24
25
  s.bindir = 'bin'
25
26
  s.executables << 'exsys-usb'
26
27
 
27
28
  s.add_dependency 'uart'
29
+ s.add_development_dependency 'minitest', '~>5'
28
30
  s.add_development_dependency 'yard', '~>0'
29
31
  s.add_development_dependency 'rake', '~>13'
30
32
  end
@@ -3,13 +3,52 @@ 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
+ # GP read the port state SP set it, in RAM
13
+ # WP write RAM to flash FP set it, in RAM and flash
14
+ # RD restore RAM from flash CP change the password
15
+ # 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···· │ 0300 │ FFFF │
22
+ # └─┬──┴────┬─────┴──┬───┴──┬───┘
23
+ # │ │ │ └───── port mask, 4 hex, always FFFF
24
+ # │ │ └──────────── port state, 4 hex, low byte first
25
+ # │ └───────────────────── password, 8 chars (· = pad space)
26
+ # └───────────────────────────── command, 2 chars
27
+ #
28
+ # The fields go out concatenated, with no separator: the frame above
29
+ # is written as "SPpass 0300FFFF\r".
30
+ #
31
+ # Port n is bit n-1 of the state word, and the word is sent low byte
32
+ # first, so ports 1 and 2 on is 0x0003 and reaches the wire as "0300":
33
+ #
34
+ # port 8 7 6 5 4 3 2 1 16 15 14 13 12 11 10 9
35
+ # bit 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0
36
+ # └───── low byte 03 ───┘ └──── high byte 00 ───┘
37
+ #
38
+ # The mask selects which ports the state word applies to; the library
39
+ # always sends FFFF, i.e. all sixteen.
6
40
  class ManagedUSB
7
41
  SPEED = 9600 # @!visibility private
8
42
  PASSWORD = 'pass'.freeze # @!visibility private
9
43
  PORTS = 1.upto(16).to_a.freeze # @!visibility private
10
44
  TRUE_LIST = [ 1, :on, :ON, :true, :TRUE, :t, :T, true ].freeze # @!visibility private
11
45
  FALSE_LIST = [ 0, :off, :OFF, :false, :FALSE, :f, :F, false ].freeze # @!visibility private
12
-
46
+
47
+ # Raised by #flock when the platform won't lock a character device
48
+ LOCK_ERRORS = [ Errno::EOPNOTSUPP, Errno::ENOTSUP, Errno::ENOLCK,
49
+ Errno::EINVAL, Errno::EBADF,
50
+ NotImplementedError ].freeze # @!visibility private
51
+
13
52
  # Error handling class
14
53
  class Error < StandardError
15
54
  end
@@ -34,21 +73,21 @@ class ManagedUSB
34
73
  #
35
74
  # @param commit [Boolean] Commit to flash memory
36
75
  def toggle(*ports, commit: false)
37
- _set(_get ^ mask(ports, :all), commit: commit)
76
+ session { _set(_get ^ mask(ports, :all), commit: commit) }
38
77
  end
39
78
 
40
79
  # Turn on all or specified ports
41
80
  #
42
81
  # @param commit [Boolean] Commit to flash memory
43
82
  def on(*ports, commit: false)
44
- _set(_get | mask(ports, :all), commit: commit)
83
+ session { _set(_get | mask(ports, :all), commit: commit) }
45
84
  end
46
85
 
47
86
  # Turn off all or specified ports
48
87
  #
49
88
  # @param commit [Boolean] Commit to flash memory
50
89
  def off(*ports, commit: false)
51
- _set(_get & ~mask(ports, :all), commit: commit)
90
+ session { _set(_get & ~mask(ports, :all), commit: commit) }
52
91
  end
53
92
 
54
93
  # Set state for the specified ports
@@ -71,8 +110,6 @@ class ManagedUSB
71
110
  # @param default [Boolean,nil] Default value to use if unspecified
72
111
  # @param commit [Boolean] Commit to flash memory
73
112
  def set(dataset, default = nil, commit: false)
74
- val = _get
75
-
76
113
  # Normalize
77
114
  keys = dataset.keys
78
115
  if (keys - PORTS).empty?
@@ -87,7 +124,9 @@ class ManagedUSB
87
124
  elsif (keys - [:on, :off]).empty?
88
125
  on = Array(dataset[:on ])
89
126
  off = Array(dataset[:off])
90
-
127
+
128
+ check_ports(on + off)
129
+
91
130
  unless (on & off).empty?
92
131
  raise ArgumentError, "on/off overlap"
93
132
  end
@@ -106,16 +145,22 @@ class ManagedUSB
106
145
  end
107
146
  end
108
147
 
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
148
+ # Compute value and apply, holding the line for the whole
149
+ # read-modify-write so a concurrent process cannot interleave
150
+ # its own update between the read and the write.
151
+ session do
152
+ val = _get
153
+
154
+ dataset.compact.each do |k,v|
155
+ flg = 1 << (k-1)
156
+ if v
157
+ then val |= flg
158
+ else val &= ~flg
159
+ end
115
160
  end
161
+
162
+ _set(val, commit: commit)
116
163
  end
117
-
118
- _set(val, commit: commit)
119
164
  end
120
165
 
121
166
  # Get hub current state for all ports
@@ -138,13 +183,16 @@ class ManagedUSB
138
183
  when :ports
139
184
  h
140
185
  when :on_off
141
- h.reduce({}) {|acc, (k,v)|
142
- acc.merge(v ? :on : :off => [ k ]) {|k,o,n| o + n }
186
+ # Seeded with both keys, so that a hub with all its ports
187
+ # in the same state still answers the documented shape
188
+ # instead of omitting the empty one.
189
+ h.reduce({ :on => [], :off => [] }) {|acc, (k,v)|
190
+ acc.merge(v ? :on : :off => [ k ]) {|_,o,n| o + n }
143
191
  }
144
192
  when :on
145
- h.select {|k,v| v }.keys
193
+ h.select {|_,v| v }.keys
146
194
  when :off
147
- h.reject {|k,v| v }.keys
195
+ h.reject {|_,v| v }.keys
148
196
  else
149
197
  raise ArgumentError
150
198
  end
@@ -152,19 +200,20 @@ class ManagedUSB
152
200
 
153
201
  # Restore port states from the flash memory
154
202
  def restore
155
- action('RD', @password).then { self }
203
+ action('RD', @password, secrets: [ @password ]).then { self }
156
204
  end
157
205
 
158
206
  # Save the port states to the flash memory
159
207
  def commit
160
- action('WP', @password).then { self }
208
+ action('WP', @password, secrets: [ @password ]).then { self }
161
209
  end
162
210
 
163
211
  # Perform a hub reset action
164
212
  #
165
213
  # @note power is not maintained accros a reset
166
214
  def reset
167
- action('RH', @password, reply: false).then { self }
215
+ action('RH', @password,
216
+ reply: false, secrets: [ @password ]).then { self }
168
217
  end
169
218
 
170
219
  # Change the hub protection password
@@ -172,12 +221,21 @@ class ManagedUSB
172
221
  new = PASSWORD if new.nil?
173
222
  raise ArgumentError, 'password too long' if new.size > 8
174
223
  new_password = new.ljust(8)
175
- action('CP', @password, new_password)
224
+ action('CP', @password, new_password,
225
+ secrets: [ @password, new_password ])
176
226
  @password = new_password
177
227
  self
178
228
  end
179
229
 
180
230
  private
231
+
232
+ def check_ports(ports)
233
+ ports.each do |p|
234
+ unless PORTS.include?(p)
235
+ raise ArgumentError, "invalid port: #{p.inspect}"
236
+ end
237
+ end
238
+ end
181
239
 
182
240
  def mask(ports, empty = :none)
183
241
  case empty
@@ -186,8 +244,9 @@ class ManagedUSB
186
244
  ports = PORTS if ports.empty?
187
245
  else raise ArgumentError
188
246
  end
189
-
190
- ports.reduce(0) {|acc, obj| acc |= 1 << (obj-1) }
247
+
248
+ check_ports(ports)
249
+ ports.reduce(0) {|acc, obj| acc | (1 << (obj-1)) }
191
250
  end
192
251
 
193
252
  def _get
@@ -196,7 +255,7 @@ class ManagedUSB
196
255
  if (data.size == 3) && (data[0] == 'E')
197
256
  raise Error, data[1..-1]
198
257
  elsif data.size != 8
199
- raise Error
258
+ raise Error, "unexpected reply: #{data.inspect}"
200
259
  end
201
260
 
202
261
  [ data ].pack('H4').unpack1('v')
@@ -204,14 +263,51 @@ class ManagedUSB
204
263
 
205
264
  def _set(v, commit: false)
206
265
  dataset = ([v].pack('v').unpack1('H*') + 'ffff').upcase
207
- action(commit ? 'FP' : 'SP', @password, dataset).then { self }
266
+ action(commit ? 'FP' : 'SP', @password, dataset,
267
+ secrets: [ @password ]).then { self }
208
268
  end
209
-
210
-
211
- def action(*cmds, reply: true, check: true)
212
- cmd = cmds.join
269
+
270
+ # Run a block with the serial line open and exclusively locked.
271
+ #
272
+ # Nested calls reuse the outer session, so that a read-modify-write
273
+ # wrapped in one holds the line across both the GP and the SP
274
+ # command rather than reopening in between.
275
+ def session
276
+ return yield @serial if @serial
277
+
213
278
  UART.open @line, SPEED do |serial|
214
- @debug&.puts "<-- #{cmd}"
279
+ flock(serial)
280
+ begin
281
+ @serial = serial
282
+ yield serial
283
+ ensure
284
+ @serial = nil
285
+ end
286
+ end
287
+ end
288
+
289
+ # Take an exclusive lock on the serial line, keeping concurrent
290
+ # processes from interleaving their own read-modify-write.
291
+ #
292
+ # Not every platform locks a character device; where it is refused
293
+ # the operation carries on unlocked -- single-process use is
294
+ # unaffected, concurrent use stays racy -- and says so on the debug
295
+ # output rather than failing outright.
296
+ def flock(serial)
297
+ serial.flock(File::LOCK_EX)
298
+ rescue *LOCK_ERRORS => e
299
+ @debug&.puts "!!! serial line not lockable (#{e.class})"
300
+ end
301
+
302
+ # Blank out the passwords before a command reaches the debug output.
303
+ def redact(str, secrets)
304
+ secrets.reduce(str) {|acc, s| acc.gsub(s, '*' * s.size) }
305
+ end
306
+
307
+ def action(*cmds, reply: true, check: true, secrets: [])
308
+ cmd = cmds.join
309
+ session do |serial|
310
+ @debug&.puts "<-- #{redact(cmd, secrets)}"
215
311
  serial.write "#{cmd}\r"
216
312
  if reply
217
313
  serial.read.chomp.tap do |data|
data/lib/exsys/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module ExSYS
2
- VERSION = 0.5 # Version
2
+ VERSION = '0.6' # 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