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/lib/exsys/managed-usb.rb
CHANGED
|
@@ -9,50 +9,71 @@ module ExSYS
|
|
|
9
9
|
# or Exx on error. GP is the exception: it needs no password, and it
|
|
10
10
|
# answers the port state directly.
|
|
11
11
|
#
|
|
12
|
-
# GP read the port state
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# RH reset the hub (no reply)
|
|
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
16
|
#
|
|
17
17
|
# A frame carries the whole 16-port state, so changing one port is a
|
|
18
18
|
# read-modify-write: GP to read it, then SP to put it back.
|
|
19
19
|
#
|
|
20
|
-
#
|
|
21
|
-
# │ SP │ pass···· │
|
|
22
|
-
#
|
|
23
|
-
# │ │
|
|
24
|
-
# │
|
|
25
|
-
#
|
|
26
|
-
# └───────────────────────────── command, 2 chars
|
|
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
|
|
27
26
|
#
|
|
28
27
|
# The fields go out concatenated, with no separator: the frame above
|
|
29
28
|
# is written as "SPpass 0300FFFF\r".
|
|
30
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
|
+
#
|
|
31
36
|
# 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
|
|
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:
|
|
33
39
|
#
|
|
34
40
|
# port 8 7 6 5 4 3 2 1 16 15 14 13 12 11 10 9
|
|
35
41
|
# bit 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0
|
|
36
42
|
# └───── low byte 03 ───┘ └──── high byte 00 ───┘
|
|
37
43
|
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
44
|
+
# How many ports a hub has is its own to say: ?Q reports it, and
|
|
45
|
+
# {#ports} is that list.
|
|
40
46
|
class ManagedUSB
|
|
41
47
|
SPEED = 9600 # @!visibility private
|
|
42
48
|
PASSWORD = 'pass'.freeze # @!visibility private
|
|
43
49
|
PORTS = 1.upto(16).to_a.freeze # @!visibility private
|
|
50
|
+
ALL = :all # every port, said explicitly
|
|
44
51
|
TRUE_LIST = [ 1, :on, :ON, :true, :TRUE, :t, :T, true ].freeze # @!visibility private
|
|
45
52
|
FALSE_LIST = [ 0, :off, :OFF, :false, :FALSE, :f, :F, false ].freeze # @!visibility private
|
|
46
53
|
|
|
47
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.
|
|
48
64
|
LOCK_ERRORS = [ Errno::EOPNOTSUPP, Errno::ENOTSUP, Errno::ENOLCK,
|
|
49
|
-
Errno::EINVAL, Errno::EBADF,
|
|
50
65
|
NotImplementedError ].freeze # @!visibility private
|
|
51
66
|
|
|
52
67
|
# Error handling class
|
|
53
68
|
class Error < StandardError
|
|
54
69
|
end
|
|
55
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
|
+
|
|
56
77
|
# Initialize object.
|
|
57
78
|
#
|
|
58
79
|
# @param line [String] Serial line
|
|
@@ -67,27 +88,49 @@ class ManagedUSB
|
|
|
67
88
|
@line = line
|
|
68
89
|
@password = password.ljust(8)
|
|
69
90
|
@debug = debug
|
|
91
|
+
@width = 8 # until the hub says otherwise
|
|
70
92
|
end
|
|
71
93
|
|
|
72
|
-
# Toggle
|
|
94
|
+
# Toggle the given ports
|
|
73
95
|
#
|
|
96
|
+
# @param ports [Integer,:all] ports to invert, or ALL for every
|
|
97
|
+
# one. An empty list is an error.
|
|
74
98
|
# @param commit [Boolean] Commit to flash memory
|
|
75
99
|
def toggle(*ports, commit: false)
|
|
76
|
-
session
|
|
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
|
|
77
106
|
end
|
|
78
107
|
|
|
79
|
-
# Turn on
|
|
80
|
-
#
|
|
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.
|
|
81
112
|
# @param commit [Boolean] Commit to flash memory
|
|
82
113
|
def on(*ports, commit: false)
|
|
83
|
-
session
|
|
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
|
|
84
120
|
end
|
|
85
121
|
|
|
86
|
-
# Turn off
|
|
87
|
-
#
|
|
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.
|
|
88
126
|
# @param commit [Boolean] Commit to flash memory
|
|
89
127
|
def off(*ports, commit: false)
|
|
90
|
-
session
|
|
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
|
|
91
134
|
end
|
|
92
135
|
|
|
93
136
|
# Set state for the specified ports
|
|
@@ -110,48 +153,14 @@ class ManagedUSB
|
|
|
110
153
|
# @param default [Boolean,nil] Default value to use if unspecified
|
|
111
154
|
# @param commit [Boolean] Commit to flash memory
|
|
112
155
|
def set(dataset, default = nil, commit: false)
|
|
113
|
-
#
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
dataset = dataset.transform_values do |v|
|
|
117
|
-
case v
|
|
118
|
-
when * TRUE_LIST then true
|
|
119
|
-
when *FALSE_LIST then false
|
|
120
|
-
when nil
|
|
121
|
-
else raise ArgumentError
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
elsif (keys - [:on, :off]).empty?
|
|
125
|
-
on = Array(dataset[:on ])
|
|
126
|
-
off = Array(dataset[:off])
|
|
127
|
-
|
|
128
|
-
check_ports(on + off)
|
|
129
|
-
|
|
130
|
-
unless (on & off).empty?
|
|
131
|
-
raise ArgumentError, "on/off overlap"
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
dataset = {}
|
|
135
|
-
dataset.merge!(on .to_h {|k| [k, true ] })
|
|
136
|
-
dataset.merge!(off.to_h {|k| [k, false ] })
|
|
137
|
-
else
|
|
138
|
-
raise ArgumentError
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
# Fill unspecified
|
|
142
|
-
unless default.nil?
|
|
143
|
-
(PORTS - dataset.keys).each do |k|
|
|
144
|
-
dataset.merge!(k => default)
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
|
|
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.
|
|
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.
|
|
151
159
|
session do
|
|
152
|
-
|
|
160
|
+
wanted = normalize(dataset, default)
|
|
161
|
+
val = _get
|
|
153
162
|
|
|
154
|
-
|
|
163
|
+
wanted.each do |k, v|
|
|
155
164
|
flg = 1 << (k-1)
|
|
156
165
|
if v
|
|
157
166
|
then val |= flg
|
|
@@ -163,6 +172,82 @@ class ManagedUSB
|
|
|
163
172
|
end
|
|
164
173
|
end
|
|
165
174
|
|
|
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}"
|
|
196
|
+
end
|
|
197
|
+
{ :id => $1, :ports => $3.to_i, :firmware => $4, :raw => raw }
|
|
198
|
+
end
|
|
199
|
+
|
|
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
|
|
240
|
+
end
|
|
241
|
+
end
|
|
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
|
+
|
|
166
251
|
# Get hub current state for all ports
|
|
167
252
|
#
|
|
168
253
|
# Return value depend of the asked type (default: ports)
|
|
@@ -174,10 +259,12 @@ class ManagedUSB
|
|
|
174
259
|
#
|
|
175
260
|
# @param type [:ports, :on_off, :on, :off] Type of returned value
|
|
176
261
|
def get(type = :ports)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
262
|
+
h = session do
|
|
263
|
+
v = _get
|
|
264
|
+
ports.reduce({}) {|acc, obj|
|
|
265
|
+
acc.merge(obj => (v & (1 << (obj-1))).positive?)
|
|
266
|
+
}
|
|
267
|
+
end
|
|
181
268
|
|
|
182
269
|
case type
|
|
183
270
|
when :ports
|
|
@@ -198,11 +285,41 @@ class ManagedUSB
|
|
|
198
285
|
end
|
|
199
286
|
end
|
|
200
287
|
|
|
201
|
-
# Restore
|
|
202
|
-
|
|
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
|
|
203
308
|
action('RD', @password, secrets: [ @password ]).then { self }
|
|
204
309
|
end
|
|
205
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.
|
|
316
|
+
def restore
|
|
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.'
|
|
321
|
+
end
|
|
322
|
+
|
|
206
323
|
# Save the port states to the flash memory
|
|
207
324
|
def commit
|
|
208
325
|
action('WP', @password, secrets: [ @password ]).then { self }
|
|
@@ -210,8 +327,18 @@ class ManagedUSB
|
|
|
210
327
|
|
|
211
328
|
# Perform a hub reset action
|
|
212
329
|
#
|
|
330
|
+
# Refuses without +confirm: true+, for the same reason
|
|
331
|
+
# {#factory_reset} does: every port loses power while it runs.
|
|
332
|
+
#
|
|
213
333
|
# @note power is not maintained accros a reset
|
|
214
|
-
|
|
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
|
|
215
342
|
action('RH', @password,
|
|
216
343
|
reply: false, secrets: [ @password ]).then { self }
|
|
217
344
|
end
|
|
@@ -227,26 +354,112 @@ class ManagedUSB
|
|
|
227
354
|
self
|
|
228
355
|
end
|
|
229
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
|
+
|
|
230
390
|
private
|
|
231
391
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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)
|
|
235
445
|
raise ArgumentError, "invalid port: #{p.inspect}"
|
|
236
446
|
end
|
|
237
447
|
end
|
|
238
448
|
end
|
|
239
449
|
|
|
240
|
-
def mask(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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)"
|
|
246
458
|
end
|
|
459
|
+
list = ports if list == [ ALL ]
|
|
247
460
|
|
|
248
|
-
check_ports(
|
|
249
|
-
|
|
461
|
+
check_ports(list)
|
|
462
|
+
list.reduce(0) {|acc, obj| acc | (1 << (obj-1)) }
|
|
250
463
|
end
|
|
251
464
|
|
|
252
465
|
def _get
|
|
@@ -254,36 +467,40 @@ class ManagedUSB
|
|
|
254
467
|
|
|
255
468
|
if (data.size == 3) && (data[0] == 'E')
|
|
256
469
|
raise Error, data[1..-1]
|
|
257
|
-
elsif data.
|
|
470
|
+
elsif data.empty? || !data.match?(/\A(?:\h\h)+\z/)
|
|
258
471
|
raise Error, "unexpected reply: #{data.inspect}"
|
|
259
472
|
end
|
|
260
473
|
|
|
261
|
-
|
|
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)
|
|
262
479
|
end
|
|
263
480
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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) }
|
|
268
485
|
end
|
|
269
486
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
# command rather than reopening in between.
|
|
275
|
-
def session
|
|
276
|
-
return yield @serial if @serial
|
|
487
|
+
def encode(v, width)
|
|
488
|
+
(width / 2).times.map {|i| format('%02X', (v >> (8 * i)) & 0xff) }
|
|
489
|
+
.join
|
|
490
|
+
end
|
|
277
491
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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.
|
|
497
|
+
def _set(v, commit: false)
|
|
498
|
+
if (v >> (@width * 4)).positive?
|
|
499
|
+
raise Error, "hub reports #{port_count} ports but answers a " \
|
|
500
|
+
"#{@width * 4}-bit state word"
|
|
286
501
|
end
|
|
502
|
+
action(commit ? 'FP' : 'SP', @password, encode(v, @width),
|
|
503
|
+
secrets: [ @password ]).then { self }
|
|
287
504
|
end
|
|
288
505
|
|
|
289
506
|
# Take an exclusive lock on the serial line, keeping concurrent
|
|
@@ -306,11 +523,18 @@ class ManagedUSB
|
|
|
306
523
|
|
|
307
524
|
def action(*cmds, reply: true, check: true, secrets: [])
|
|
308
525
|
cmd = cmds.join
|
|
309
|
-
session do
|
|
526
|
+
session do
|
|
310
527
|
@debug&.puts "<-- #{redact(cmd, secrets)}"
|
|
311
528
|
serial.write "#{cmd}\r"
|
|
312
529
|
if reply
|
|
313
|
-
|
|
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|
|
|
314
538
|
@debug&.puts "--> #{data}"
|
|
315
539
|
if check && data[0] != 'G'
|
|
316
540
|
raise Error, data[1..-1]
|
data/lib/exsys/version.rb
CHANGED