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 +4 -4
- data/README.md +141 -17
- data/Rakefile +19 -0
- data/bin/exsys-usb +23 -13
- data/exsys.gemspec +4 -2
- data/lib/exsys/managed-usb.rb +128 -32
- data/lib/exsys/version.rb +1 -1
- data/test/helper.rb +60 -0
- data/test/support/fake_hub.rb +139 -0
- data/test/support/uart.rb +40 -0
- data/test/test_exsys_usb.rb +230 -0
- data/test/test_managed_usb.rb +291 -0
- data/test/test_readme.rb +61 -0
- metadata +24 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ebdc824ab993ec59f3e5ba32fbef04a461e3f0354b8ea8723a17fa6125ba19a1
|
|
4
|
+
data.tar.gz: 783826b2949fc55f1f8b98c34aca2f39d79f9fe445acbe3863b33e1435f123e1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '059792265ac5e663899a421176034d49bf555c61c5a3ca160f62cb5e53054fbb05e1b1fd5d13016f1003d0da9b9a033f1c20af17615e0852d506a0ce7031b52d'
|
|
7
|
+
data.tar.gz: 825f0ba1eab46d7fc3517954050d68a0b43bab0ce34d0cc05cfdf1696d87a2d2679a0b878d2691719dd04e393e0750dbee6dabab97fd55a34639df9ac502a264
|
data/README.md
CHANGED
|
@@ -1,44 +1,168 @@
|
|
|
1
|
+
# exsys
|
|
1
2
|
|
|
2
|
-
|
|
3
|
-
constrained by the
|
|
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
|
|
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
|
-
|
|
111
|
+
|
|
112
|
+
## Library
|
|
18
113
|
|
|
19
114
|
~~~ruby
|
|
20
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
127
|
+
# Set the state of ports 1 and 2
|
|
33
128
|
hub.set({ 1 => true, 2 => false })
|
|
34
129
|
|
|
35
|
-
# Set ports
|
|
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]:
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
data/lib/exsys/managed-usb.rb
CHANGED
|
@@ -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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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 {|
|
|
193
|
+
h.select {|_,v| v }.keys
|
|
146
194
|
when :off
|
|
147
|
-
h.reject {|
|
|
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,
|
|
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
|
|
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
|
|
266
|
+
action(commit ? 'FP' : 'SP', @password, dataset,
|
|
267
|
+
secrets: [ @password ]).then { self }
|
|
208
268
|
end
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
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
|