exsys 0.6 → 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.
- checksums.yaml +4 -4
- data/README.md +80 -12
- data/bin/exsys-usb +54 -7
- data/lib/exsys/managed-usb.rb +328 -104
- data/lib/exsys/version.rb +1 -1
- data/test/support/fake_hub.rb +52 -26
- data/test/support/uart.rb +23 -6
- data/test/test_exsys_usb.rb +100 -5
- data/test/test_managed_usb.rb +268 -18
- metadata +1 -1
data/test/support/fake_hub.rb
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
class FakeHub
|
|
8
8
|
DEFAULT_PASSWORD = 'pass'.ljust(8)
|
|
9
9
|
|
|
10
|
-
attr_reader :log, :opens, :locks, :line, :speed, :mode
|
|
11
|
-
attr_accessor :password, :silent, :lockable, :garbage
|
|
10
|
+
attr_reader :log, :opens, :locks, :line, :speed, :mode, :lock_path
|
|
11
|
+
attr_accessor :password, :silent, :lockable, :garbage, :lock_error,
|
|
12
|
+
:ident
|
|
12
13
|
|
|
13
14
|
# @param state [Integer] initial port bitmap
|
|
14
15
|
# @param path [String] file backing the state, so that separate
|
|
@@ -18,13 +19,19 @@ class FakeHub
|
|
|
18
19
|
# @param delay [Float] pause inside a write, widening the
|
|
19
20
|
# window a concurrent process could slip
|
|
20
21
|
# into
|
|
22
|
+
# @param ports [Integer] how many ports this hub has
|
|
23
|
+
# @param width [Integer] hex digits in its state word. Real hubs
|
|
24
|
+
# answer wider than their port count: a
|
|
25
|
+
# 16-port unit answers eight, and the ports
|
|
26
|
+
# it does not have read as 1.
|
|
21
27
|
def initialize(password: DEFAULT_PASSWORD, state: 0x0000,
|
|
28
|
+
ports: 16, width: 8,
|
|
22
29
|
path: nil, lock: nil, delay: 0)
|
|
23
30
|
@password = password
|
|
24
31
|
@state = state
|
|
25
32
|
@flash = state
|
|
26
33
|
@path = path
|
|
27
|
-
@
|
|
34
|
+
@lock_path = lock
|
|
28
35
|
@delay = delay
|
|
29
36
|
@log = []
|
|
30
37
|
@opens = 0
|
|
@@ -32,6 +39,10 @@ class FakeHub
|
|
|
32
39
|
@silent = false # hub answers nothing at all
|
|
33
40
|
@lockable = true # platform allows locking the line
|
|
34
41
|
@garbage = nil # hub answers this instead, when set
|
|
42
|
+
@lock_error = nil # raised by flock; for the propagation test
|
|
43
|
+
@ports = ports
|
|
44
|
+
@width = width
|
|
45
|
+
@ident = format('CENTOS0005%02dv02', ports) # nil = refuse ?Q
|
|
35
46
|
|
|
36
47
|
# Attach to the hub the file already describes, so that a test
|
|
37
48
|
# can inspect what its subprocesses did; seed it otherwise.
|
|
@@ -57,9 +68,20 @@ class FakeHub
|
|
|
57
68
|
# Ports currently powered, as a sorted list -- the oracle the tests
|
|
58
69
|
# compare against.
|
|
59
70
|
def ports_on
|
|
60
|
-
1.upto(
|
|
71
|
+
1.upto(@ports).select {|p| state & (1 << (p-1)) != 0 }
|
|
61
72
|
end
|
|
62
73
|
|
|
74
|
+
# The power-on state the hub would come back to.
|
|
75
|
+
def flash_ports
|
|
76
|
+
1.upto(@ports).select {|p| flash & (1 << (p-1)) != 0 }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Bits for ports this hub does not have. A real one reports them
|
|
80
|
+
# set, which is why a client must write back what it read.
|
|
81
|
+
def absent = ((1 << (@width * 4)) - 1) & ~real
|
|
82
|
+
|
|
83
|
+
def real = (1 << @ports) - 1
|
|
84
|
+
|
|
63
85
|
# Record how the line was opened, so a test can check the library
|
|
64
86
|
# asks for the device and the speed the hub actually needs.
|
|
65
87
|
def opened(line, speed, mode)
|
|
@@ -67,18 +89,13 @@ class FakeHub
|
|
|
67
89
|
@opens += 1
|
|
68
90
|
end
|
|
69
91
|
|
|
70
|
-
|
|
71
|
-
|
|
92
|
+
# Called by each opened line before it locks. The lock handle
|
|
93
|
+
# itself belongs to the line, not to the hub, so that two threads
|
|
94
|
+
# hold two of them and genuinely contend.
|
|
95
|
+
def lock_attempted
|
|
96
|
+
raise @lock_error if @lock_error # must NOT be swallowed
|
|
97
|
+
raise Errno::ENOTSUP unless @lockable # platform refusal
|
|
72
98
|
@locks += 1
|
|
73
|
-
return 0 if @lock.nil?
|
|
74
|
-
|
|
75
|
-
@lockfh = File.open(@lock, File::RDWR|File::CREAT, 0o600)
|
|
76
|
-
@lockfh.flock(mode)
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def unlock
|
|
80
|
-
@lockfh&.close
|
|
81
|
-
@lockfh = nil
|
|
82
99
|
end
|
|
83
100
|
|
|
84
101
|
# Answer one command, as the hub would.
|
|
@@ -99,9 +116,10 @@ class FakeHub
|
|
|
99
116
|
code = cmd[0, 2]
|
|
100
117
|
args = cmd[2..].to_s
|
|
101
118
|
|
|
102
|
-
# GP
|
|
103
|
-
# bare
|
|
104
|
-
return encode(@state
|
|
119
|
+
# GP and ?Q are the two commands the hub answers without a
|
|
120
|
+
# password, both with a bare payload rather than a G/E status.
|
|
121
|
+
return encode(@state | absent) if code == 'GP'
|
|
122
|
+
return @ident || 'E01' if code == '?Q'
|
|
105
123
|
|
|
106
124
|
return 'E01' unless args.start_with?(@password)
|
|
107
125
|
rest = args[@password.size..]
|
|
@@ -109,21 +127,29 @@ class FakeHub
|
|
|
109
127
|
sleep @delay if @delay.positive?
|
|
110
128
|
|
|
111
129
|
case code
|
|
112
|
-
when 'SP' then @state = decode(rest)
|
|
113
|
-
when 'FP' then @state = @flash = decode(rest)
|
|
130
|
+
when 'SP' then @state = decode(rest) & real ; 'G'
|
|
131
|
+
when 'FP' then @state = @flash = decode(rest) & real ; 'G'
|
|
114
132
|
when 'WP' then @flash = @state ; 'G'
|
|
115
|
-
when 'RD' then @state = @flash
|
|
133
|
+
when 'RD' then @state = @flash = 0
|
|
134
|
+
@password = DEFAULT_PASSWORD ; 'G'
|
|
116
135
|
when 'RH' then @state = @flash ; nil
|
|
117
136
|
when 'CP' then @password = rest ; 'G'
|
|
118
137
|
else 'E02'
|
|
119
138
|
end
|
|
120
139
|
end
|
|
121
140
|
|
|
122
|
-
# Little-endian
|
|
123
|
-
# because that is what a real hub answers -- observed on an
|
|
124
|
-
# 16-port unit, which replies to GP with
|
|
125
|
-
def encode(v)
|
|
126
|
-
|
|
141
|
+
# Little-endian, any width, spelled out rather than packed. Upper
|
|
142
|
+
# case because that is what a real hub answers -- observed on an
|
|
143
|
+
# ExSYS 16-port unit, which replies to GP with "C4FFFFFF".
|
|
144
|
+
def encode(v)
|
|
145
|
+
(@width / 2).times.map {|i| format('%02X', (v >> (8 * i)) & 0xff) }
|
|
146
|
+
.join
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def decode(str)
|
|
150
|
+
str.scan(/\h\h/).each_with_index
|
|
151
|
+
.sum {|byte, i| byte.to_i(16) << (8 * i) }
|
|
152
|
+
end
|
|
127
153
|
|
|
128
154
|
def load
|
|
129
155
|
return if @path.nil? || !File.exist?(@path)
|
data/test/support/uart.rb
CHANGED
|
@@ -18,12 +18,29 @@ module UART
|
|
|
18
18
|
def reset! = @hub = nil
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
# Stands in for the File that UART.open normally yields.
|
|
21
|
+
# Stands in for the File that UART.open normally yields. Each one
|
|
22
|
+
# carries its own lock handle, as a real open would, so that two of
|
|
23
|
+
# them contend instead of sharing one.
|
|
22
24
|
class Serial
|
|
23
|
-
def initialize(hub)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
def initialize(hub)
|
|
26
|
+
@hub = hub
|
|
27
|
+
@lock = hub.lock_path &&
|
|
28
|
+
File.open(hub.lock_path, File::RDWR|File::CREAT, 0o600)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def flock(mode)
|
|
32
|
+
@hub.lock_attempted
|
|
33
|
+
@lock ? @lock.flock(mode) : 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def write(cmd) = @reply = @hub.command(cmd.chomp("\r"))
|
|
37
|
+
|
|
38
|
+
# A real hub ends every reply CR LF -- seen in the hex off the
|
|
39
|
+
# wire -- so a read to the terminator takes the whole line.
|
|
40
|
+
def gets(_sep = "\n")
|
|
41
|
+
@reply.nil? ? nil : "#{@reply}\r\n"
|
|
42
|
+
end
|
|
43
|
+
def close = @lock&.close
|
|
27
44
|
end
|
|
28
45
|
|
|
29
46
|
def self.open(line, speed = 9600, mode = '8N1')
|
|
@@ -34,7 +51,7 @@ module UART
|
|
|
34
51
|
begin
|
|
35
52
|
yield serial
|
|
36
53
|
ensure
|
|
37
|
-
|
|
54
|
+
serial.close
|
|
38
55
|
end
|
|
39
56
|
end
|
|
40
57
|
end
|
data/test/test_exsys_usb.rb
CHANGED
|
@@ -50,19 +50,104 @@ class TestExsysUsb < Minitest::Test
|
|
|
50
50
|
assert_equal [ 3 ], hub.ports_on
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
def
|
|
53
|
+
def test_commit_saves_the_power_on_state
|
|
54
54
|
exsys_usb('on', '1')
|
|
55
55
|
exsys_usb('commit')
|
|
56
56
|
exsys_usb('on', '2')
|
|
57
|
-
|
|
58
|
-
assert_equal [ 1 ],
|
|
57
|
+
assert_equal [ 1, 2 ], hub.ports_on
|
|
58
|
+
assert_equal [ 1 ], hub.flash_ports
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def test_commit_flag_writes_through
|
|
62
62
|
exsys_usb('-c', 'on', '4')
|
|
63
63
|
exsys_usb('on', '5')
|
|
64
|
-
|
|
65
|
-
assert_equal [ 4 ],
|
|
64
|
+
assert_equal [ 4, 5 ], hub.ports_on
|
|
65
|
+
assert_equal [ 4 ], hub.flash_ports
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# restore issues RD, the hub's factory reset -- not the inverse of
|
|
69
|
+
# commit. Nothing in the protocol reloads the flashed state.
|
|
70
|
+
def test_restore_is_a_factory_reset
|
|
71
|
+
exsys_usb('on', '1')
|
|
72
|
+
exsys_usb('commit')
|
|
73
|
+
_, err, st = exsys_usb('--yes', 'factory-reset')
|
|
74
|
+
assert_equal 0, st.exitstatus, err
|
|
75
|
+
assert_empty hub.ports_on
|
|
76
|
+
assert_empty hub.flash_ports
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
## Reading the hub ##################################################
|
|
80
|
+
|
|
81
|
+
def test_status_lists_every_port
|
|
82
|
+
exsys_usb('on', '1', '3')
|
|
83
|
+
out, _, st = exsys_usb('status')
|
|
84
|
+
assert_equal 0, st.exitstatus
|
|
85
|
+
assert_equal 16, out.lines.size
|
|
86
|
+
assert_includes out.lines, "1 on\n"
|
|
87
|
+
assert_includes out.lines, "2 off\n"
|
|
88
|
+
assert_includes out.lines, "3 on\n"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def test_status_can_be_asked_about_named_ports
|
|
92
|
+
exsys_usb('on', '3')
|
|
93
|
+
out, _, = exsys_usb('status', '3', '4')
|
|
94
|
+
assert_equal "3 on\n4 off\n", out
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def test_status_refuses_a_port_the_hub_does_not_have
|
|
98
|
+
_, err, st = exsys_usb('status', '99')
|
|
99
|
+
assert_equal 1, st.exitstatus
|
|
100
|
+
assert_match(/invalid port: 99/, err)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def test_query_reports_what_the_hub_says_it_is
|
|
104
|
+
out, _, st = exsys_usb('query')
|
|
105
|
+
assert_equal 0, st.exitstatus
|
|
106
|
+
assert_match(/^id:\s+CENTOS$/, out)
|
|
107
|
+
assert_match(/^ports:\s+16$/, out)
|
|
108
|
+
assert_match(/^firmware:\s+v02$/, out)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
## Verbose ##########################################################
|
|
112
|
+
|
|
113
|
+
def test_verbose_reports_the_state_after_a_change
|
|
114
|
+
out, _, = exsys_usb('-v', 'on', '2')
|
|
115
|
+
assert_includes out.lines, "2 on\n"
|
|
116
|
+
assert_equal 16, out.lines.size
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def test_without_verbose_a_change_says_nothing
|
|
120
|
+
out, _, st = exsys_usb('on', '2')
|
|
121
|
+
assert_equal 0, st.exitstatus
|
|
122
|
+
assert_empty out
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
## Confirming the irreversible ######################################
|
|
126
|
+
|
|
127
|
+
def test_reset_refuses_without_yes
|
|
128
|
+
_, err, st = exsys_usb('reset')
|
|
129
|
+
assert_equal 1, st.exitstatus
|
|
130
|
+
assert_match(/--yes/, err)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def test_reset_runs_when_meant
|
|
134
|
+
_, err, st = exsys_usb('--yes', 'reset')
|
|
135
|
+
assert_equal 0, st.exitstatus, err
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def test_factory_reset_refuses_without_yes
|
|
139
|
+
exsys_usb('on', '1')
|
|
140
|
+
_, err, st = exsys_usb('factory-reset')
|
|
141
|
+
assert_equal 1, st.exitstatus
|
|
142
|
+
assert_match(/--yes/, err)
|
|
143
|
+
assert_equal [ 1 ], hub.ports_on, 'the hub must not have been touched'
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def test_factory_reset_runs_when_meant
|
|
147
|
+
exsys_usb('on', '1')
|
|
148
|
+
_, err, st = exsys_usb('--yes', 'factory-reset')
|
|
149
|
+
assert_equal 0, st.exitstatus, err
|
|
150
|
+
assert_empty hub.ports_on
|
|
66
151
|
end
|
|
67
152
|
|
|
68
153
|
## Exit status -- regression. Every failure used to be printed and
|
|
@@ -220,6 +305,16 @@ class TestExsysUsb < Minitest::Test
|
|
|
220
305
|
assert_equal [ 1, 2 ], hub.ports_on
|
|
221
306
|
end
|
|
222
307
|
|
|
308
|
+
# Typing the old name must not silently do nothing, nor quietly do
|
|
309
|
+
# the reset: it must say what the command actually is.
|
|
310
|
+
def test_the_old_restore_action_explains_itself
|
|
311
|
+
_, err, st = exsys_usb('restore')
|
|
312
|
+
assert_equal 1, st.exitstatus
|
|
313
|
+
assert_match(/renamed factory-reset/, err)
|
|
314
|
+
assert_match(/factory defaults/, err)
|
|
315
|
+
assert_equal [], hub.ports_on
|
|
316
|
+
end
|
|
317
|
+
|
|
223
318
|
## Packaging #########################################################
|
|
224
319
|
|
|
225
320
|
# Regression: the file carried a shebang but no execute bit, so a
|
data/test/test_managed_usb.rb
CHANGED
|
@@ -11,17 +11,37 @@ class TestManagedUSB < Minitest::Test
|
|
|
11
11
|
|
|
12
12
|
## Switching #########################################################
|
|
13
13
|
|
|
14
|
-
def
|
|
15
|
-
@usb.on
|
|
14
|
+
def test_all_powers_every_port
|
|
15
|
+
@usb.on(ExSYS::ManagedUSB::ALL)
|
|
16
16
|
assert_equal ExSYS::ManagedUSB::PORTS, @hub.ports_on
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def
|
|
20
|
-
@usb.on
|
|
21
|
-
@usb.off
|
|
19
|
+
def test_all_unpowers_every_port
|
|
20
|
+
@usb.on(:all)
|
|
21
|
+
@usb.off(:all)
|
|
22
22
|
assert_empty @hub.ports_on
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Regression: an empty list used to mean every port, so a caller
|
|
26
|
+
# splatting a computed list that came back empty switched all
|
|
27
|
+
# sixteen. on(*[]) and on() are the same call, so neither can be
|
|
28
|
+
# allowed through.
|
|
29
|
+
def test_an_empty_port_list_is_refused_not_taken_as_every_port
|
|
30
|
+
@usb.on(:all)
|
|
31
|
+
[ ->{ @usb.on(*[]) }, ->{ @usb.on },
|
|
32
|
+
->{ @usb.off(*[]) }, ->{ @usb.off },
|
|
33
|
+
->{ @usb.toggle(*[]) }, ->{ @usb.toggle } ].each do |op|
|
|
34
|
+
err = assert_raises(ArgumentError, &op)
|
|
35
|
+
assert_match(/no port given/, err.message)
|
|
36
|
+
end
|
|
37
|
+
assert_equal ExSYS::ManagedUSB::PORTS, @hub.ports_on,
|
|
38
|
+
'the hub must not have been touched'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_all_cannot_be_mixed_with_port_numbers
|
|
42
|
+
assert_raises(ArgumentError) { @usb.on(:all, 3) }
|
|
43
|
+
end
|
|
44
|
+
|
|
25
45
|
def test_on_and_off_are_restricted_to_the_named_ports
|
|
26
46
|
@usb.on(1, 2, 16)
|
|
27
47
|
assert_equal [ 1, 2, 16 ], @hub.ports_on
|
|
@@ -36,8 +56,8 @@ class TestManagedUSB < Minitest::Test
|
|
|
36
56
|
end
|
|
37
57
|
|
|
38
58
|
def test_switching_returns_self_so_calls_chain
|
|
39
|
-
assert_same @usb, @usb.on
|
|
40
|
-
@usb.on.off(4, 5, 6)
|
|
59
|
+
assert_same @usb, @usb.on(:all)
|
|
60
|
+
@usb.on(:all).off(4, 5, 6)
|
|
41
61
|
assert_equal ExSYS::ManagedUSB::PORTS - [ 4, 5, 6 ], @hub.ports_on
|
|
42
62
|
end
|
|
43
63
|
|
|
@@ -141,7 +161,7 @@ class TestManagedUSB < Minitest::Test
|
|
|
141
161
|
def test_get_on_off_always_carries_both_keys
|
|
142
162
|
assert_equal({ :on => [], :off => ExSYS::ManagedUSB::PORTS },
|
|
143
163
|
@usb.get(:on_off))
|
|
144
|
-
@usb.on
|
|
164
|
+
@usb.on(:all)
|
|
145
165
|
assert_equal({ :on => ExSYS::ManagedUSB::PORTS, :off => [] },
|
|
146
166
|
@usb.get(:on_off))
|
|
147
167
|
@usb.off(1)
|
|
@@ -151,6 +171,7 @@ class TestManagedUSB < Minitest::Test
|
|
|
151
171
|
# A real hub answers in upper case; accept either, since the reply
|
|
152
172
|
# is only ever fed to pack('H4'), which does not care.
|
|
153
173
|
def test_a_lower_case_reply_decodes_the_same
|
|
174
|
+
@usb.port_count # settle ?Q before garbling every reply
|
|
154
175
|
@hub.garbage = 'c4ffffff'
|
|
155
176
|
assert_equal [ 3, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 ], @usb.get(:on)
|
|
156
177
|
@hub.garbage = 'C4FFFFFF'
|
|
@@ -163,24 +184,191 @@ class TestManagedUSB < Minitest::Test
|
|
|
163
184
|
|
|
164
185
|
## Flash and reset ###################################################
|
|
165
186
|
|
|
166
|
-
def
|
|
187
|
+
def test_commit_saves_the_state_as_the_power_on_state
|
|
167
188
|
@usb.on(1)
|
|
168
189
|
@usb.commit
|
|
169
|
-
@
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
190
|
+
assert_equal [ 1 ], @hub.flash_ports
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# RD is the hub's "restore factory defaults", not the inverse of
|
|
194
|
+
# commit: there is no command that reloads the flashed state, the
|
|
195
|
+
# hub applies it at power-on by itself. Modelled from the vendor's
|
|
196
|
+
# own documentation -- running it against hardware would drop every
|
|
197
|
+
# port on the bench and reset the password.
|
|
198
|
+
## Describing the hub ###############################################
|
|
199
|
+
|
|
200
|
+
def test_query_reports_id_ports_and_firmware
|
|
201
|
+
assert_equal({ :id => 'CENTOS', :ports => 16, :firmware => 'v02',
|
|
202
|
+
:raw => 'CENTOS000516v02' }, @usb.query)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def test_query_needs_no_password
|
|
206
|
+
usb = ExSYS::ManagedUSB.new('/dev/null', 'wrong')
|
|
207
|
+
assert_equal 16, usb.query[:ports]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The hub is asked how many ports it has before it is read, not
|
|
211
|
+
# somewhere in the middle: a trace of any port operation reads the
|
|
212
|
+
# same way, and an invalid port is refused without touching it.
|
|
213
|
+
def test_the_query_leads_and_happens_once
|
|
214
|
+
@usb.on(1)
|
|
215
|
+
assert_equal %w[?Q GP SP], (@hub.log.map {|c| c[0, 2] })
|
|
216
|
+
|
|
217
|
+
@hub.log.clear
|
|
218
|
+
@usb.off(2)
|
|
219
|
+
assert_equal %w[GP SP], (@hub.log.map {|c| c[0, 2] })
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def test_an_invalid_port_is_refused_before_the_hub_is_read
|
|
223
|
+
assert_raises(ArgumentError) { @usb.on(99) }
|
|
224
|
+
assert_equal %w[?Q], (@hub.log.map {|c| c[0, 2] })
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def test_port_count_is_asked_once_and_remembered
|
|
228
|
+
3.times { @usb.port_count }
|
|
229
|
+
assert_equal 1, @hub.log.count('?Q')
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Firmware that does not know ?Q answers an error, and the count
|
|
233
|
+
# falls back to what the state word can address.
|
|
234
|
+
def test_port_count_falls_back_when_the_hub_refuses_the_query
|
|
235
|
+
@hub.ident = nil # answers E01
|
|
236
|
+
assert_equal ExSYS::ManagedUSB::PORTS.size, @usb.port_count
|
|
237
|
+
assert_match(/will not answer/, @dbg.string)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# A reply that arrives but cannot be read is a different thing from
|
|
241
|
+
# a refusal, and must not be guessed at: on a wider hub a guess of
|
|
242
|
+
# sixteen would leave its upper ports untouched while reporting
|
|
243
|
+
# success.
|
|
244
|
+
def test_an_unreadable_query_reply_does_not_become_a_guess
|
|
245
|
+
@hub.ident = 'CENTOS-garbled'
|
|
246
|
+
assert_raises(ExSYS::ManagedUSB::Error) { @usb.port_count }
|
|
247
|
+
assert_raises(ExSYS::ManagedUSB::Error) { @usb.on(:all) }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The firmware is whatever follows the count, not a bare number:
|
|
251
|
+
# a hub reporting v1.02 must not fall back to a guessed count.
|
|
252
|
+
def test_a_dotted_firmware_version_still_parses
|
|
253
|
+
@hub.ident = 'CENTOS000532v1.02'
|
|
254
|
+
assert_equal 32, @usb.query[:ports]
|
|
255
|
+
assert_equal 'v1.02', @usb.query[:firmware]
|
|
256
|
+
assert_equal 32, @usb.port_count
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def test_an_unparseable_query_reply_is_an_error
|
|
260
|
+
@hub.ident = 'wat'
|
|
261
|
+
err = assert_raises(ExSYS::ManagedUSB::Error) { @usb.query }
|
|
262
|
+
assert_match(/unexpected query reply/, err.message)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# ALL means every port the hub says it has. Confirmed against the
|
|
266
|
+
# vendor tool, which reads the same field and reports 4, 8, 16 or
|
|
267
|
+
# 32 ports from it.
|
|
268
|
+
def test_all_covers_exactly_the_ports_the_hub_reports
|
|
269
|
+
@hub.ident = 'CENTOS000504v02' # a hub with 4 ports
|
|
270
|
+
@usb.on(:all)
|
|
271
|
+
assert_equal [ 1, 2, 3, 4 ], @hub.ports_on
|
|
272
|
+
assert_equal [ 1, 2, 3, 4 ], @usb.ports
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def test_a_port_the_hub_does_not_have_is_refused
|
|
276
|
+
@hub.ident = 'CENTOS000504v02'
|
|
277
|
+
assert_raises(ArgumentError) { @usb.on(5) }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
## Hubs that are not sixteen ports ##################################
|
|
281
|
+
|
|
282
|
+
# Regression: the state word's upper half was written as a literal
|
|
283
|
+
# 'ffff' rather than carried back from the read. On a 16-port hub
|
|
284
|
+
# those bits are ports that do not exist and it went unnoticed; on
|
|
285
|
+
# a 32-port hub it powered ports 17..32 on every single operation.
|
|
286
|
+
def test_a_wider_hub_does_not_have_its_upper_ports_driven
|
|
287
|
+
UART.hub = wide = FakeHub.new(ports: 32, width: 8)
|
|
288
|
+
usb = ExSYS::ManagedUSB.new('/dev/null')
|
|
289
|
+
|
|
290
|
+
usb.on(1)
|
|
291
|
+
assert_equal [ 1 ], wide.ports_on
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def test_a_wider_hub_can_use_all_of_its_ports
|
|
295
|
+
UART.hub = wide = FakeHub.new(ports: 32, width: 8)
|
|
296
|
+
usb = ExSYS::ManagedUSB.new('/dev/null')
|
|
297
|
+
|
|
298
|
+
assert_equal 32, usb.port_count
|
|
299
|
+
usb.on(:all)
|
|
300
|
+
assert_equal (1..32).to_a, wide.ports_on
|
|
301
|
+
usb.off(20)
|
|
302
|
+
assert_equal (1..32).to_a - [ 20 ], wide.ports_on
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def test_a_narrower_hub_reports_only_the_ports_it_has
|
|
306
|
+
UART.hub = small = FakeHub.new(ports: 4, width: 8)
|
|
307
|
+
usb = ExSYS::ManagedUSB.new('/dev/null')
|
|
308
|
+
|
|
309
|
+
assert_equal [ 1, 2, 3, 4 ], usb.ports
|
|
310
|
+
usb.on(:all)
|
|
311
|
+
assert_equal [ 1, 2, 3, 4 ], small.ports_on
|
|
312
|
+
assert_equal({ 1 => true, 2 => true, 3 => true, 4 => true },
|
|
313
|
+
usb.get)
|
|
314
|
+
assert_raises(ArgumentError) { usb.on(5) }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# The ports it does not have read as 1, and must be written back
|
|
318
|
+
# that way rather than cleared.
|
|
319
|
+
def test_absent_ports_are_carried_back_untouched
|
|
320
|
+
UART.hub = small = FakeHub.new(ports: 4, width: 8)
|
|
321
|
+
usb = ExSYS::ManagedUSB.new('/dev/null')
|
|
322
|
+
|
|
323
|
+
usb.on(1)
|
|
324
|
+
# Little-endian: ports 1..8 in the first byte, so port 1 on with
|
|
325
|
+
# ports 5..32 absent and reading 1 is F1 FF FF FF.
|
|
326
|
+
assert_equal 'F1FFFFFF', small.log.last[-8..],
|
|
327
|
+
'absent ports written back as read'
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
## Factory reset #####################################################
|
|
331
|
+
|
|
332
|
+
def test_the_old_restore_name_is_a_tombstone
|
|
333
|
+
err = assert_raises(NoMethodError) { @usb.restore }
|
|
334
|
+
assert_match(/renamed factory_reset/, err.message)
|
|
335
|
+
assert_match(/not the inverse of commit/, err.message)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def test_factory_reset_refuses_unless_it_is_confirmed
|
|
339
|
+
@usb.on(:all)
|
|
340
|
+
err = assert_raises(ArgumentError) { @usb.factory_reset }
|
|
341
|
+
assert_match(/confirm: true/, err.message)
|
|
342
|
+
assert_equal ExSYS::ManagedUSB::PORTS, @hub.ports_on,
|
|
343
|
+
'the hub must not have been touched'
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def test_restore_is_a_factory_reset
|
|
347
|
+
@usb.on(1)
|
|
348
|
+
@usb.commit
|
|
349
|
+
@usb.on(:all)
|
|
350
|
+
@usb.factory_reset(confirm: true)
|
|
351
|
+
assert_empty @hub.ports_on, 'every port dropped'
|
|
352
|
+
assert_empty @hub.flash_ports, 'the power-on state went too'
|
|
353
|
+
assert_equal FakeHub::DEFAULT_PASSWORD, @hub.password
|
|
173
354
|
end
|
|
174
355
|
|
|
175
356
|
def test_on_with_commit_writes_through_to_flash
|
|
176
357
|
@usb.on(3, commit: true)
|
|
177
358
|
@usb.on(4)
|
|
178
|
-
@
|
|
179
|
-
assert_equal [ 3 ],
|
|
359
|
+
assert_equal [ 3, 4 ], @hub.ports_on
|
|
360
|
+
assert_equal [ 3 ], @hub.flash_ports
|
|
180
361
|
end
|
|
181
362
|
|
|
182
363
|
def test_reset_expects_no_reply
|
|
183
|
-
assert_same @usb, @usb.reset
|
|
364
|
+
assert_same @usb, @usb.reset(confirm: true)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def test_reset_refuses_unless_it_is_confirmed
|
|
368
|
+
@usb.on(:all)
|
|
369
|
+
err = assert_raises(ArgumentError) { @usb.reset }
|
|
370
|
+
assert_match(/confirm: true/, err.message)
|
|
371
|
+
refute_includes @hub.log.map {|c| c[0, 2] }, 'RH'
|
|
184
372
|
end
|
|
185
373
|
|
|
186
374
|
## Password ##########################################################
|
|
@@ -257,14 +445,16 @@ class TestManagedUSB < Minitest::Test
|
|
|
257
445
|
# Regression: a read-modify-write used to close the line between the
|
|
258
446
|
# GP and the SP, letting another process slip in between the two.
|
|
259
447
|
def test_read_modify_write_holds_the_line_open
|
|
448
|
+
@usb.port_count # the one-off ?Q, out of the way
|
|
449
|
+
@hub.log.clear
|
|
260
450
|
@usb.on(1)
|
|
261
|
-
assert_equal 1, @hub.opens
|
|
262
451
|
assert_equal 2, @hub.log.size # GP then SP, one session
|
|
452
|
+
assert_equal %w[GP SP], (@hub.log.map {|c| c[0, 2] })
|
|
263
453
|
end
|
|
264
454
|
|
|
265
455
|
def test_set_holds_the_line_open_too
|
|
266
456
|
@usb.set({ 1 => true })
|
|
267
|
-
assert_equal 1, @hub.opens
|
|
457
|
+
assert_equal 1, @hub.opens # ?Q, GP and SP all inside it
|
|
268
458
|
end
|
|
269
459
|
|
|
270
460
|
def test_the_line_is_opened_as_the_hub_expects
|
|
@@ -280,6 +470,66 @@ class TestManagedUSB < Minitest::Test
|
|
|
280
470
|
assert_equal 1, @hub.locks
|
|
281
471
|
end
|
|
282
472
|
|
|
473
|
+
# Regression: EBADF and EINVAL were swallowed alongside the genuine
|
|
474
|
+
# "this platform will not lock this" errors. Both mean a bug here,
|
|
475
|
+
# and swallowing one runs unlocked while reporting success.
|
|
476
|
+
def test_an_unexpected_lock_error_is_not_swallowed
|
|
477
|
+
@hub.lock_error = Errno::EBADF
|
|
478
|
+
assert_raises(Errno::EBADF) { @usb.on(1) }
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
## Sessions #########################################################
|
|
482
|
+
|
|
483
|
+
def test_session_yields_the_hub_and_returns_the_block_value
|
|
484
|
+
result = @usb.session {|h| assert_same @usb, h; :done }
|
|
485
|
+
assert_equal :done, result
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def test_one_session_holds_one_line_for_every_call_inside_it
|
|
489
|
+
@usb.session do
|
|
490
|
+
@usb.get
|
|
491
|
+
@usb.on(1)
|
|
492
|
+
@usb.off(1)
|
|
493
|
+
end
|
|
494
|
+
assert_equal 1, @hub.opens
|
|
495
|
+
assert_equal 1, @hub.locks
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def test_without_a_session_each_call_opens_its_own_line
|
|
499
|
+
@usb.get
|
|
500
|
+
@usb.on(1)
|
|
501
|
+
assert_equal 2, @hub.opens
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def test_a_session_releases_the_line_even_when_the_block_raises
|
|
505
|
+
assert_raises(RuntimeError) { @usb.session { raise 'boom' } }
|
|
506
|
+
@usb.on(1) # must open afresh, not reuse
|
|
507
|
+
assert_equal 2, @hub.opens
|
|
508
|
+
assert_equal [ 1 ], @hub.ports_on
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Regression: the open line was plain instance state, so a second
|
|
512
|
+
# thread either borrowed a line it held no lock on -- losing one
|
|
513
|
+
# thread's change -- or had it closed underneath it by the first
|
|
514
|
+
# thread's ensure, which surfaced as a NoMethodError on nil.
|
|
515
|
+
def test_two_threads_never_share_one_line
|
|
516
|
+
Dir.mktmpdir('exsys-threads') do |dir|
|
|
517
|
+
fake = FakeHub.new(path: File.join(dir, 'hub'),
|
|
518
|
+
lock: File.join(dir, 'lock'), delay: 0.2)
|
|
519
|
+
UART.hub = fake
|
|
520
|
+
usb = ExSYS::ManagedUSB.new('/dev/null')
|
|
521
|
+
|
|
522
|
+
a = Thread.new { usb.on(1) }
|
|
523
|
+
sleep 0.1 # B enters while A holds the line
|
|
524
|
+
b = Thread.new { usb.on(2) }
|
|
525
|
+
[ a, b ].each(&:join)
|
|
526
|
+
|
|
527
|
+
assert_equal 2, fake.opens, 'each thread opens its own line'
|
|
528
|
+
assert_equal 2, fake.locks, 'each thread takes its own lock'
|
|
529
|
+
assert_equal [ 1, 2 ], fake.ports_on
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
283
533
|
# A platform that will not lock a character device must not make the
|
|
284
534
|
# tool unusable; it degrades to unlocked and says so.
|
|
285
535
|
def test_an_unlockable_line_degrades_rather_than_failing
|