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.
- checksums.yaml +4 -4
- data/README.md +211 -19
- data/Rakefile +19 -0
- data/bin/exsys-usb +77 -20
- data/exsys.gemspec +4 -2
- data/lib/exsys/managed-usb.rb +399 -79
- data/lib/exsys/version.rb +1 -1
- data/test/helper.rb +60 -0
- data/test/support/fake_hub.rb +165 -0
- data/test/support/uart.rb +57 -0
- data/test/test_exsys_usb.rb +325 -0
- data/test/test_managed_usb.rb +541 -0
- data/test/test_readme.rb +61 -0
- metadata +24 -6
data/lib/exsys/managed-usb.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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 {|
|
|
280
|
+
h.select {|_,v| v }.keys
|
|
146
281
|
when :off
|
|
147
|
-
h.reject {|
|
|
282
|
+
h.reject {|_,v| v }.keys
|
|
148
283
|
else
|
|
149
284
|
raise ArgumentError
|
|
150
285
|
end
|
|
151
286
|
end
|
|
152
287
|
|
|
153
|
-
# Restore
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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(
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
@debug&.puts "<-- #{cmd}"
|
|
526
|
+
session do
|
|
527
|
+
@debug&.puts "<-- #{redact(cmd, secrets)}"
|
|
215
528
|
serial.write "#{cmd}\r"
|
|
216
529
|
if reply
|
|
217
|
-
|
|
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
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
|