ruby-blinkstick 0.2.0 → 0.3.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.
@@ -8,18 +8,35 @@ module Blinkstick
8
8
  # stick (nothing is cached). A stick that is garbage collected without
9
9
  # {#close} still releases its OS handle, but call {#close} (or use the
10
10
  # block form of {Blinkstick.open}) rather than relying on that.
11
+ #
12
+ # A stick that is unplugged and plugged back in is found again by its
13
+ # serial on the next call, which repaints the last frame written before
14
+ # carrying on. Only when that fails does a call raise {DisconnectedError}.
11
15
  class Stick
16
+ include Eeprom
17
+ include Effects
18
+ include Handover
19
+ include Lighting
20
+ include Reconnect
21
+
12
22
  # @return [Info] the identity of this stick
13
23
  attr_reader :info
14
24
 
15
25
  # @api private
16
26
  # @param handle [#write_feature, #read_feature, #close] an open backend handle
17
27
  # @param info [Info]
18
- def initialize(handle, info)
28
+ # @param reopen [#call, nil] returns a fresh handle for this stick or
29
+ # raises an {Error}; nil means the stick never reconnects
30
+ def initialize(handle, info, reopen: nil)
19
31
  @handle = handle
20
32
  @info = info
33
+ @reopen = reopen
34
+ @reconnected = false
21
35
  @mutex = Mutex.new
22
36
  @closed = false
37
+ @limit = 255
38
+ @inverse = false
39
+ @last = nil
23
40
  ObjectSpace.define_finalizer(self, self.class.releaser(handle))
24
41
  end
25
42
 
@@ -44,9 +61,9 @@ module Blinkstick
44
61
  # @raise [ClosedError] after {#close}
45
62
  # @raise [BusyError] when another program holds the stick mid-transfer
46
63
  # @raise [PermissionError] when the system refuses access mid-transfer
47
- # @raise [DisconnectedError] when the stick stops responding
64
+ # @raise [DisconnectedError] when the stick stops responding and reconnecting to it fails
48
65
  def colour=(colour)
49
- write(Report.frame([Colour.parse(colour)] * info.model.leds))
66
+ write_frame([Colour.parse(colour)] * info.model.leds)
50
67
  end
51
68
 
52
69
  # Sets one LED, leaving the others as they are.
@@ -59,11 +76,10 @@ module Blinkstick
59
76
  # @raise [ClosedError] after {#close}
60
77
  # @raise [BusyError] when another program holds the stick mid-transfer
61
78
  # @raise [PermissionError] when the system refuses access mid-transfer
62
- # @raise [DisconnectedError] when the stick stops responding
79
+ # @raise [DisconnectedError] when the stick stops responding and reconnecting to it fails
63
80
  def []=(index, colour)
64
- parsed = Colour.parse(colour)
65
81
  check_index(index)
66
- write(Report.led(index, parsed))
82
+ write_led(index, Colour.parse(colour))
67
83
  colour
68
84
  end
69
85
 
@@ -76,7 +92,7 @@ module Blinkstick
76
92
  # @raise [ClosedError] after {#close}
77
93
  # @raise [BusyError] when another program holds the stick mid-transfer
78
94
  # @raise [PermissionError] when the system refuses access mid-transfer
79
- # @raise [DisconnectedError] when the stick stops responding
95
+ # @raise [DisconnectedError] when the stick stops responding and reconnecting to it fails
80
96
  def frame=(colours)
81
97
  list = Array.try_convert(colours)
82
98
  raise TypeError, "frame must be an Array of colours, got #{colours.class}" if list.nil?
@@ -86,33 +102,7 @@ module Blinkstick
86
102
  raise ArgumentError, "a #{model.name} has #{model.leds} LEDs, got #{list.size} colours"
87
103
  end
88
104
 
89
- write(Report.frame(list.map { |colour| Colour.parse(colour) }))
90
- end
91
-
92
- # Reads every LED back from the stick. Nothing is cached: each call asks
93
- # the stick.
94
- #
95
- # @return [Array<Colour>] frozen, one per LED of the model
96
- # @raise [ClosedError] after {#close}
97
- # @raise [BusyError] when another program holds the stick mid-transfer
98
- # @raise [PermissionError] when the system refuses access mid-transfer
99
- # @raise [DisconnectedError] when the stick stops responding
100
- def frame
101
- Report.decode_frame(read(Report::FRAME_ID, Report::FRAME_SIZE), info.model.leds)
102
- end
103
-
104
- # Reads one LED back from the stick, by reading the whole frame.
105
- #
106
- # @param index [Integer] LED index, 0 to one less than the model's LED count (a Nano takes 0 or 1, a Square 0 to 7)
107
- # @return [Colour]
108
- # @raise [IndexError] before anything is sent, when index is not an Integer within the model's LEDs
109
- # @raise [ClosedError] after {#close}
110
- # @raise [BusyError] when another program holds the stick mid-transfer
111
- # @raise [PermissionError] when the system refuses access mid-transfer
112
- # @raise [DisconnectedError] when the stick stops responding
113
- def [](index)
114
- check_index(index)
115
- frame.fetch(index)
105
+ write_frame(list.map { |colour| Colour.parse(colour) })
116
106
  end
117
107
 
118
108
  # Reads the LED mode from the stick.
@@ -122,7 +112,7 @@ module Blinkstick
122
112
  # @raise [ClosedError] after {#close}
123
113
  # @raise [BusyError] when another program holds the stick mid-transfer
124
114
  # @raise [PermissionError] when the system refuses access mid-transfer
125
- # @raise [DisconnectedError] when the stick stops responding
115
+ # @raise [DisconnectedError] when the stick stops responding and reconnecting to it fails
126
116
  def mode
127
117
  Report.decode_mode(read(Report::MODE_ID, Report::MODE_SIZE))
128
118
  end
@@ -135,7 +125,7 @@ module Blinkstick
135
125
  # @raise [ClosedError] after {#close}
136
126
  # @raise [BusyError] when another program holds the stick mid-transfer
137
127
  # @raise [PermissionError] when the system refuses access mid-transfer
138
- # @raise [DisconnectedError] when the stick stops responding
128
+ # @raise [DisconnectedError] when the stick stops responding and reconnecting to it fails
139
129
  def info_block(number)
140
130
  Report.decode_info_block(read(Report.info_block_id(number), Report::INFO_BLOCK_SIZE))
141
131
  end
@@ -146,13 +136,14 @@ module Blinkstick
146
136
  # @raise [ClosedError] after {#close}
147
137
  # @raise [BusyError] when another program holds the stick mid-transfer
148
138
  # @raise [PermissionError] when the system refuses access mid-transfer
149
- # @raise [DisconnectedError] when the stick stops responding
139
+ # @raise [DisconnectedError] when the stick stops responding and reconnecting to it fails
150
140
  def off
151
141
  self.colour = Colour::OFF
152
142
  nil
153
143
  end
154
144
 
155
- # Releases the stick. Calling it again does nothing.
145
+ # Releases the stick. Calling it again does nothing, and it succeeds on a
146
+ # stick that was unplugged and not found again.
156
147
  #
157
148
  # @return [void]
158
149
  def close
@@ -161,7 +152,7 @@ module Blinkstick
161
152
 
162
153
  @closed = true
163
154
  ObjectSpace.undefine_finalizer(self)
164
- @handle.close
155
+ @handle&.close
165
156
  end
166
157
  nil
167
158
  end
@@ -191,39 +182,18 @@ module Blinkstick
191
182
  #
192
183
  # @return [String] at least size bytes, the first being report_id
193
184
  def read(report_id, size)
194
- transfer("reads") do
195
- reply = @handle.read_feature(report_id, size)
196
- unless reply.bytesize >= size && reply.getbyte(0) == report_id
197
- raise Backend::TransferFailed,
198
- "report #{report_id}: got #{reply.bytesize} of #{size} bytes, ID #{reply.getbyte(0).inspect}"
199
- end
200
-
201
- reply
202
- end
185
+ transfer("reads") { fetch(report_id, size) }
203
186
  end
204
187
 
205
- # Holds the lock across every attempt, so transfers from other threads
206
- # cannot land in between.
207
- def transfer(what, &)
208
- @mutex.synchronize do
209
- raise ClosedError, "#{info.serial} is closed" if @closed
210
-
211
- with_retries(what, &)
188
+ # One read attempt, checked. The caller holds the lock.
189
+ def fetch(report_id, size)
190
+ reply = @handle.read_feature(report_id, size)
191
+ unless reply.bytesize >= size && reply.getbyte(0) == report_id
192
+ raise Backend::TransferFailed,
193
+ "report #{report_id}: got #{reply.bytesize} of #{size} bytes, ID #{reply.getbyte(0).inspect}"
212
194
  end
213
- end
214
195
 
215
- # Runs the block up to Backend::ATTEMPTS times while it raises
216
- # TransferFailed. The caller must hold the lock.
217
- def with_retries(what)
218
- attempt = 0
219
- begin
220
- attempt += 1
221
- yield
222
- rescue Backend::TransferFailed
223
- retry if attempt < Backend::ATTEMPTS
224
-
225
- raise DisconnectedError, "#{info.serial} stopped responding: #{attempt} #{what} failed in a row"
226
- end
196
+ reply
227
197
  end
228
198
  end
229
199
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Blinkstick
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/blinkstick.rb CHANGED
@@ -5,7 +5,14 @@ require_relative "blinkstick/errors"
5
5
  require_relative "blinkstick/colour"
6
6
  require_relative "blinkstick/model"
7
7
  require_relative "blinkstick/report"
8
+ require_relative "blinkstick/stick/eeprom"
9
+ require_relative "blinkstick/stick/effects"
10
+ require_relative "blinkstick/stick/handover"
11
+ require_relative "blinkstick/stick/lighting"
12
+ require_relative "blinkstick/stick/reconnect"
8
13
  require_relative "blinkstick/stick"
14
+ require_relative "blinkstick/names"
15
+ require_relative "blinkstick/cancellation"
9
16
 
10
17
  # Finds and drives BlinkStick Nano and Square USB LED sticks on macOS and Linux.
11
18
  #
@@ -21,29 +28,47 @@ module Blinkstick
21
28
  # Lists every attached BlinkStick, including models this gem cannot open
22
29
  # (their {Info#model} is {Model::UNKNOWN}).
23
30
  #
24
- # @return [Array<Info>]
31
+ # With +names: true+ each stick is opened briefly to read its name and then
32
+ # closed, so a stick another program holds shows as busy with an empty name.
33
+ # Unknown models are opened too.
34
+ #
35
+ # @param names [Boolean] read each stick's name
36
+ # @return [Array<Info>, Array<NamedInfo>] NamedInfo when +names+ is true
25
37
  # @raise [Error] when the OS cannot list devices
26
- def self.list
38
+ def self.list(names: false)
39
+ return list_named if names
40
+
27
41
  Backend.current.list.map { |device| info_for(device) }
28
42
  end
29
43
 
30
- # Opens a BlinkStick: the one with the given serial, or else the first one
31
- # found. If the first one found is an unknown model this raises, even when
32
- # a supported stick is also attached; pass a serial from {list} to choose.
44
+ # Opens a BlinkStick: the one with the given serial, the one with the given
45
+ # name, or else the first one found. If the first one found is an unknown
46
+ # model this raises, even when a supported stick is also attached; pass a
47
+ # serial from {list} to choose.
33
48
  #
34
49
  # Without a block the caller must {Stick#close} the stick. With a block,
35
50
  # like File.open, the stick is yielded, closed afterwards even if the
36
51
  # block raises, and the block's value is returned.
37
52
  #
53
+ # By name, every stick is opened in turn and its name compared byte for
54
+ # byte; sticks that cannot be opened or read are skipped, and the ones that
55
+ # do not match are closed.
56
+ #
38
57
  # @param serial [String, nil] for example "BS072777-3.0"
58
+ # @param name [String, nil] a name stored on the stick, see {Stick#name=}
39
59
  # @yieldparam stick [Stick]
40
60
  # @return [Stick, Object] the stick, or the block's value
41
- # @raise [NotFoundError] when no stick (or none with that serial) is attached
61
+ # @raise [ArgumentError] when both +serial+ and +name+ are given, or +name+ is empty
62
+ # @raise [TypeError] when +name+ is not a String
63
+ # @raise [NotFoundError] when no stick (or none with that serial or name) is attached;
64
+ # for a name the message says why each skipped stick was skipped
65
+ # @raise [DuplicateNameError] when more than one stick has that name
42
66
  # @raise [UnsupportedModelError] when the stick's model is unknown
43
67
  # @raise [BusyError] when another program has seized the stick
44
68
  # @raise [PermissionError] when the OS refuses access
45
- def self.open(serial: nil)
46
- stick = open_stick(serial)
69
+ def self.open(serial: nil, name: nil)
70
+ check_selectors(serial, name)
71
+ stick = name.nil? ? open_stick(serial) : open_named(name)
47
72
  return stick unless block_given?
48
73
 
49
74
  begin
@@ -56,17 +81,55 @@ module Blinkstick
56
81
  # Checks the model before opening, so an unknown stick is never opened.
57
82
  def self.open_stick(serial)
58
83
  backend = Backend.current
59
- device = find(backend.list, serial)
60
- info = info_for(device)
61
- raise UnsupportedModelError, "#{info.serial} is a model this gem does not support" unless info.model.supported?
84
+ build_stick(backend, find(backend.list, serial))
85
+ end
86
+
87
+ # The one place a Stick for a user is built, however it was found. Checks
88
+ # the model first, then takes the handle from the block (when the caller
89
+ # already holds one open) or opens the device, and gives the Stick a way
90
+ # to find the device again after an unplug.
91
+ def self.build_stick(backend, device)
92
+ info = supported_info(device)
93
+ handle = block_given? ? yield : backend.open(device)
94
+ Stick.new(handle, info, reopen: reopener(device.serial))
95
+ end
96
+
97
+ # A callable that relists through the current backend and opens the one
98
+ # stick with this serial, after the same model check {Blinkstick.open} makes. An
99
+ # empty serial, or one more than one stick shares, could reopen the wrong
100
+ # stick, so those never reconnect.
101
+ def self.reopener(serial)
102
+ return -> { raise DisconnectedError, "this stick has no serial, so it cannot be found again" } if serial.empty?
62
103
 
63
- Stick.new(backend.open(device), info)
104
+ lambda do
105
+ backend = Backend.current
106
+ device = only_device(backend.list, serial)
107
+ supported_info(device)
108
+ backend.open(device)
109
+ end
110
+ end
111
+
112
+ # The one device with this serial, for {reopener}.
113
+ def self.only_device(devices, serial)
114
+ matches = devices.select { |device| device.serial == serial }
115
+ raise NotFoundError, "no BlinkStick with serial #{serial.inspect}" if matches.empty?
116
+ raise DisconnectedError, "serial #{serial.inspect} is ambiguous: #{matches.size} sticks have it" if matches.size > 1
117
+
118
+ matches.first
119
+ end
120
+
121
+ # The model check, made before anything is opened.
122
+ def self.supported_info(device)
123
+ info_for(device).tap do |info|
124
+ raise UnsupportedModelError, "#{info.serial} is a model this gem does not support" unless info.model.supported?
125
+ end
64
126
  end
65
127
 
66
128
  def self.find(devices, serial)
67
129
  return devices.first || raise(NotFoundError, "no BlinkStick attached") if serial.nil?
68
130
 
69
- devices.find { |device| device.serial == serial } || raise(NotFoundError, "no BlinkStick with serial #{serial}")
131
+ devices.find { |device| device.serial == serial } ||
132
+ raise(NotFoundError, "no BlinkStick with serial #{serial.inspect}")
70
133
  end
71
134
 
72
135
  def self.info_for(device)
@@ -74,5 +137,5 @@ module Blinkstick
74
137
  release: device.release)
75
138
  end
76
139
 
77
- private_class_method :open_stick, :find, :info_for
140
+ private_class_method :open_stick, :build_stick, :reopener, :only_device, :supported_info, :find, :info_for
78
141
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-blinkstick
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keith Bawden
@@ -29,10 +29,12 @@ dependencies:
29
29
  - - ">="
30
30
  - !ruby/object:Gem::Version
31
31
  version: 1.17.3
32
- description: Find, open and set the colour of BlinkStick Nano and Square USB LEDs
33
- from Ruby, on macOS and Linux. Lists attached sticks, opens one by serial, sets
34
- every LED from hex, r,g,b or CSS colour names, and turns them off. Nothing to install
35
- but the gem. Unofficial, and not affiliated with Agile Innovative.
32
+ description: Find, open and drive BlinkStick Nano and Square USB LEDs from Ruby, on
33
+ macOS and Linux. Sets every LED or one LED from hex, r,g,b or CSS colour names,
34
+ reads them back, names a stick and opens it by name, writes the info blocks and
35
+ mode, limits brightness, runs cancellable blink, pulse and morph effects, and reconnects
36
+ after an unplug. Nothing to install but the gem. Unofficial, and not affiliated
37
+ with Agile Innovative.
36
38
  email:
37
39
  - keith@arrix.com.au
38
40
  executables: []
@@ -47,12 +49,19 @@ files:
47
49
  - lib/blinkstick/backend.rb
48
50
  - lib/blinkstick/backend/hidraw.rb
49
51
  - lib/blinkstick/backend/iokit.rb
52
+ - lib/blinkstick/cancellation.rb
50
53
  - lib/blinkstick/colour.rb
51
54
  - lib/blinkstick/colour_names.rb
52
55
  - lib/blinkstick/errors.rb
53
56
  - lib/blinkstick/model.rb
57
+ - lib/blinkstick/names.rb
54
58
  - lib/blinkstick/report.rb
55
59
  - lib/blinkstick/stick.rb
60
+ - lib/blinkstick/stick/eeprom.rb
61
+ - lib/blinkstick/stick/effects.rb
62
+ - lib/blinkstick/stick/handover.rb
63
+ - lib/blinkstick/stick/lighting.rb
64
+ - lib/blinkstick/stick/reconnect.rb
56
65
  - lib/blinkstick/version.rb
57
66
  - udev/60-blinkstick.rules
58
67
  homepage: https://github.com/bawdo/ruby-blinkstick
@@ -79,6 +88,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
79
88
  requirements: []
80
89
  rubygems_version: 3.6.9
81
90
  specification_version: 4
82
- summary: Find and light BlinkStick Nano and Square USB LEDs from Ruby, on macOS and
83
- Linux.
91
+ summary: 'Drive BlinkStick Nano and Square USB LEDs from Ruby: names, effects and
92
+ reconnect.'
84
93
  test_files: []