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.
@@ -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 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)
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···· │ 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
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 is 0x0003 and reaches the wire as "0300":
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
- # The mask selects which ports the state word applies to; the library
39
- # always sends FFFF, i.e. all sixteen.
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 all or specified ports
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 { _set(_get ^ mask(ports, :all), commit: commit) }
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 all or specified ports
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 { _set(_get | mask(ports, :all), commit: commit) }
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 all or specified ports
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 { _set(_get & ~mask(ports, :all), commit: commit) }
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
- # Normalize
114
- keys = dataset.keys
115
- if (keys - PORTS).empty?
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
- val = _get
160
+ wanted = normalize(dataset, default)
161
+ val = _get
153
162
 
154
- dataset.compact.each do |k,v|
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
- val = _get
178
- h = PORTS.reduce({}) {|acc, obj|
179
- acc.merge(obj => (val & (1 << (obj-1))).positive?)
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 port states from the flash memory
202
- def 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
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
- def reset
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
- def check_ports(ports)
233
- ports.each do |p|
234
- unless PORTS.include?(p)
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(ports, empty = :none)
241
- case empty
242
- when :none
243
- when :all
244
- ports = PORTS if ports.empty?
245
- else raise ArgumentError
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(ports)
249
- ports.reduce(0) {|acc, obj| acc | (1 << (obj-1)) }
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.size != 8
470
+ elsif data.empty? || !data.match?(/\A(?:\h\h)+\z/)
258
471
  raise Error, "unexpected reply: #{data.inspect}"
259
472
  end
260
473
 
261
- [ data ].pack('H4').unpack1('v')
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
- def _set(v, commit: false)
265
- dataset = ([v].pack('v').unpack1('H*') + 'ffff').upcase
266
- action(commit ? 'FP' : 'SP', @password, dataset,
267
- secrets: [ @password ]).then { self }
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
- # 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
487
+ def encode(v, width)
488
+ (width / 2).times.map {|i| format('%02X', (v >> (8 * i)) & 0xff) }
489
+ .join
490
+ end
277
491
 
278
- UART.open @line, SPEED do |serial|
279
- flock(serial)
280
- begin
281
- @serial = serial
282
- yield serial
283
- ensure
284
- @serial = nil
285
- end
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 |serial|
526
+ session do
310
527
  @debug&.puts "<-- #{redact(cmd, secrets)}"
311
528
  serial.write "#{cmd}\r"
312
529
  if reply
313
- serial.read.chomp.tap do |data|
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
@@ -1,3 +1,3 @@
1
1
  module ExSYS
2
- VERSION = '0.6' # Version
2
+ VERSION = '1.0' # Version
3
3
  end