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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbeec7298582de4220e66562a9fadfd994fc540c920b6af2da8ea0b91cbc7cee
4
- data.tar.gz: d016a0be08f195f84e43a1e663a7b70c49d8174d4a1fba2b1332cc70c6159c7b
3
+ metadata.gz: 20e16b5618237efd0d8ce0ba62eeb126ef7e6121ea4266327139373d4d8f30d2
4
+ data.tar.gz: 0a5d0d90e43cb56aba4c0e49c61ff7092f8626aa9ae6ec278799497d18d3dbde
5
5
  SHA512:
6
- metadata.gz: c66054616d8981e5c256a19a27f88c0e4bf6ee4046f86e3a554eea7b34cda60f9546a93cf19a9ba8e43723f888d4b6af88dd3948cf7bd9ded0e549d14d29f027
7
- data.tar.gz: a1d96c324d7793c6c4cf4a1193f97e07de2a2f162bd0aaff4a6589b95649fc76234b2d0c535b3467874a2fe0347b9198669194d5c3b92fd635bdc5fb0e08dd74
6
+ metadata.gz: 04b8b8e8a6ceea769d01c8c6bdfc0d6d2e054728c3168396fceeb91b283157adeaaad1a491409a75526d0167634fe8523d33bcb0abe82f5709d9c32b6a9d3fe9
7
+ data.tar.gz: c4b50c14b36758a465fc7b34884a320634689dc6b63dbb0a07fc4666d14854aff9b6f752c2c83023296bde721ac7a23e77326a5fde25425f6ea7de0e80a1cfbb
data/CAPABILITIES.md CHANGED
@@ -16,22 +16,22 @@ Features built into the BlinkStick firmware.
16
16
  |---|---|---|---|---|---|
17
17
  | List attached sticks | yes | yes | `Blinkstick.list` | 0.1.0 | Enumerate every attached stick |
18
18
  | Open the first stick or one by serial | yes | yes | `Blinkstick.open`, `Blinkstick.open(serial:)` | 0.1.0 | Open the first stick found, or a stick by its serial |
19
- | Name a stick and open it by name | no | no | | not yet | Store a name, read it back, open and list sticks by name. Stored in info block 1 |
19
+ | Name a stick and open it by name | yes | yes | `stick.name`, `stick.name =`, `Blinkstick.open(name:)`, `Blinkstick.list(names: true)` | 0.3.0 | Stored in info block 1. Writes EEPROM. Two sticks with one name raise `DuplicateNameError` |
20
20
  | Several sticks open at once | yes | yes | one `Stick` per `Blinkstick.open` | 0.1.0 | |
21
- | Serial and firmware version | yes | yes | `info.serial`, `info.version` | 0.1.0 | Read from the device info |
21
+ | Serial and firmware version | yes | yes | `info.serial`, `info.version`, `info.release` | 0.1.0 | Read from the device info. `info.release` (the USB release number) since 0.3.0 |
22
22
  | Manufacturer and product strings | yes | yes | `info.manufacturer`, `info.product` | 0.1.0 | Read from the device info |
23
23
  | Set all LEDs | yes | yes | `stick.colour =`, `stick.off` | 0.1.0 | Set every LED to one colour, or turn them off |
24
24
  | Set one LED | yes | yes | `stick[i] = colour` | 0.2.0 | Set a single LED by index, using report 5 |
25
25
  | Set every LED at once | yes | yes | `stick.frame = colours` | 0.2.0 | Write a whole frame in one report |
26
26
  | Read LED values back | yes | yes | `stick.frame`, `stick[i]` | 0.2.0 | Read the whole frame or a single LED |
27
- | Info blocks 1 and 2 | yes | yes | `stick.info_block(n)` | 0.2.0 | 32 bytes each, stored in EEPROM. Read only; writing comes later |
27
+ | Info blocks 1 and 2 | yes | yes | `stick.info_block(n)`, `stick.write_info_block(n, bytes)` | 0.2.0 | 32 bytes each, stored in EEPROM. Reading since 0.2.0, writing (reports 2 and 3) since 0.3.0. Writes checked on a Nano and a Square on macOS and Linux by the manual EEPROM check |
28
28
  | Set one LED directly (report 5) | yes | yes | `stick[i] = colour` | 0.2.0 | `[5, channel, index, R, G, B]`, RGB order (the frame is GRB). Works in modes 2 and 3. The channel byte is ignored and the index is not range checked |
29
29
  | Read device mode (report 4) | yes | yes | `stick.mode` | 0.2.0 | `[4, mode]`. Both read mode 2 |
30
- | Set device mode (report 4) | no | no | | not yet | Stored in EEPROM, blocks about 50 ms, skipped when the mode is unchanged, takes effect without a replug. 2 to 3 to 2 verified on both |
31
- | Mode 2, WS2812 | no | no | | not yet | The mode both are in, and the one every LED feature here needs. Probably the factory default |
32
- | Mode 3, WS2812 mirror | no | no | | not yet | Changes only report 1, whose colour goes to every LED. Reports 5 and 6 behave as in mode 2 |
33
- | Modes 0 and 1, normal and inverse | n/a | n/a | | n/a | For the BlinkStick Pro's RGB outputs. Upstream reports a Nano in mode 1 going dark until replugged. Setting the mode refuses them |
34
- | Set LED 0, or every LED in mode 3 (report 1) | no | no | | not yet | `[1, R, G, B]`, RGB order. Used by other BlinkStick software. Setting LED 0 or all LEDs does the same job in either mode |
30
+ | Set device mode (report 4) | yes | yes | `stick.mode =` | 0.3.0 | Stored in EEPROM, waits for the stick to answer again (12 to 15 ms seen, at most 250 ms), skipped when the mode is unchanged, takes effect without a replug. 2 to 3 to 2 checked on a Nano and a Square on macOS and Linux by the manual EEPROM check |
31
+ | Mode 2, WS2812 | yes | yes | `stick.mode = :ws2812` | 0.3.0 | The mode both are in, and the one every LED feature here needs. Probably the factory default |
32
+ | Mode 3, WS2812 mirror | yes | yes | `stick.mode = :ws2812_mirror` | 0.3.0 | Changes only report 1, whose colour goes to every LED. Reports 5 and 6 behave as in mode 2 |
33
+ | Modes 0 and 1, normal and inverse | n/a | n/a | | n/a | For the BlinkStick Pro's RGB outputs. Upstream reports a Nano in mode 1 going dark until replugged. `stick.mode =` refuses them with `ArgumentError` |
34
+ | Set LED 0, or every LED in mode 3 (report 1) | yes | yes | `stick[0] =`, `stick.colour =` | n/a | `[1, R, G, B]`, RGB order. Used by other BlinkStick software. Report 1 is never sent: here `stick[0] =` and `stick.colour =` do the same job in either mode |
35
35
  | Report 10 | n/a | unknown | | no | Square only, 2 bytes, reads 00 00. Undocumented; waiting on the manufacturer |
36
36
  | LED count setting (report 0x81) | n/a | n/a | | n/a | BlinkStick Flex only |
37
37
  | 16, 32 and 64 LED frames (reports 7 to 9) | n/a | n/a | | n/a | For longer strips |
@@ -44,14 +44,14 @@ Features this gem adds in software. They work the same on every supported device
44
44
  |---|---|---|---|
45
45
  | Model detection | `info.model` | 0.1.0 | From the serial and USB release number |
46
46
  | Colour from hex or `r,g,b` | `Colour.parse` | 0.1.0 | Parse a colour from hex, with or without the `#`, or `r,g,b` |
47
- | Brightness limit | | not yet | Cap the brightness of every colour written |
48
- | Blink, pulse and morph | | not yet | Cancellable |
47
+ | Brightness limit | `stick.brightness_limit`, `stick.brightness_limit =` | 0.3.0 | Cap the brightness of every colour written from then on. Per `Stick`, not stored on the device |
48
+ | Blink, pulse and morph | `stick.blink`, `stick.pulse`, `stick.morph`, `Blinkstick::Cancellation` | 0.3.0 | Cancellable. Return `true` when finished and `false` when cancelled |
49
49
  | CSS colour names | `Colour.parse` | 0.1.0 | All 148 from CSS Color Level 4 |
50
50
  | List CSS colour names | `Colour.names` | 0.1.0 | Sorted, for shell completion |
51
51
  | Colour as hex | `Colour#hex`, `Colour#to_s` | 0.1.0 | Lower case `#rrggbb` |
52
- | Random colour | | not yet | Any colour, or a vivid one |
53
- | Inverse colours | | not yet | Invert a colour or the whole frame. Not the firmware inverse mode |
54
- | Reconnect after unplug | | not yet | Automatic, repaints the last frame |
52
+ | Random colour | `Colour.random`, `Colour.random(vivid: true)` | 0.3.0 | Any colour, or a vivid one |
53
+ | Inverse colours | `Colour#inverse`, `stick.inverse =`, `stick.inverse?` | 0.3.0 | Invert a colour or the whole frame. Not the firmware inverse mode |
54
+ | Reconnect after unplug | automatic | 0.3.0 | Repaints the last frame. Raises `DisconnectedError` when it fails. Never moves onto a different stick |
55
55
 
56
56
  ## Platforms
57
57
 
data/CHANGELOG.md CHANGED
@@ -6,6 +6,25 @@ All notable changes to this project are recorded here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-10-29
10
+
11
+ ### Added
12
+
13
+ - Names: `Stick#name` and `Stick#name=` (stored in info block 1), `Blinkstick.open(name:)` and `Blinkstick.list(names: true)`, which returns `NamedInfo` values. `DuplicateNameError` is raised when two sticks share a name.
14
+ - `Stick#write_info_block` writes info block 1 or 2 (EEPROM).
15
+ - `Stick#mode=` sets the mode to `:ws2812` or `:ws2812_mirror` (EEPROM), and skips the write when the stick is already in it. After a write it waits until the stick answers with the new mode (it stops answering for 12 to 15 ms after a mode write), raising `DisconnectedError` after 250 ms. The Pro modes are refused.
16
+ - `Stick#brightness_limit` and `Stick#brightness_limit=` cap the brightness of every colour written.
17
+ - `Stick#inverse?`, `Stick#inverse=` and `Colour#inverse` flip colours for LEDs wired so 255 is off.
18
+ - `Colour.random` and `Colour.random(vivid: true)`.
19
+ - `Stick#blink`, `Stick#pulse`, `Stick#morph` and `Blinkstick::Cancellation`. Effects return `true` when they finish and `false` when cancelled.
20
+ - `Info#release`, the USB release number the model is worked out from. It is the last member and defaults to `nil`, so building an `Info` the 0.2.0 way still works.
21
+ - Reconnect after an unplug: a stick that stops answering is found again by its serial and the last frame is repainted. A stick whose serial is shared with another attached stick never reconnects.
22
+
23
+ ### Changed
24
+
25
+ - `Stick#[]=` checks the index before the colour, so a call with both wrong raises `IndexError`. The first `[]=` on a fresh `Stick` reads the frame once.
26
+ - `DisconnectedError` now means the stick stopped responding and reconnecting to it failed, not just that three attempts failed.
27
+
9
28
  ## [0.2.0] - 2026-10-28
10
29
 
11
30
  ### Added
data/README.md CHANGED
@@ -12,11 +12,12 @@ of BlinkStick.
12
12
 
13
13
  ## Status
14
14
 
15
- This gem finds a BlinkStick Nano or Square, opens it, sets every LED to a colour, sets one
16
- LED or the whole frame, reads LEDs, the mode and the info blocks back, and turns them off, on
17
- macOS and Linux. Effects, setting the mode and writing the info blocks are still to come. The
18
- per LED, frame and reading calls arrive in 0.2.0. [CAPABILITIES.md](CAPABILITIES.md) lists
19
- what is supported and the Ruby method for each feature.
15
+ This gem finds a BlinkStick Nano or Square, opens it by serial or by name, sets every LED to a
16
+ colour, sets one LED or the whole frame, reads LEDs, the mode and the info blocks back, and
17
+ turns them off, on macOS and Linux. It can also name a stick, write the info blocks, set the
18
+ mode, limit the brightness, invert the output, pick random colours, run cancellable blink,
19
+ pulse and morph effects, and reconnect after an unplug. [CAPABILITIES.md](CAPABILITIES.md)
20
+ lists what is supported and the Ruby method for each feature.
20
21
 
21
22
  ## Install
22
23
 
@@ -50,7 +51,7 @@ require "blinkstick"
50
51
  Blinkstick.list
51
52
  # => [#<data Blinkstick::Info serial="BS072777-3.0", version="3.0",
52
53
  # manufacturer="Agile Innovative Ltd", product="BlinkStick",
53
- # model=#<data Blinkstick::Model name="Nano", leds=2>>]
54
+ # model=#<data Blinkstick::Model name="Nano", leds=2>, release=514>]
54
55
 
55
56
  Blinkstick.open do |stick| # the first stick; the block form closes it, like File.open
56
57
  stick.colour = :cornflowerblue # set every LED
@@ -61,6 +62,12 @@ Blinkstick.open do |stick| # the first stick; the block form closes it,
61
62
  stick.off # every LED off
62
63
  end
63
64
 
65
+ Blinkstick.open(name: "desk") do |stick| # a stick you have named, see "Writing to the stick's memory"
66
+ stick.brightness_limit = 128 # every colour written from now on is scaled to half
67
+ stick.inverse = true # for LEDs wired so 255 is off; set it before setting LEDs
68
+ stick.colour = Blinkstick::Colour.random(vivid: true) # any fully saturated hue
69
+ end
70
+
64
71
  stick = Blinkstick.open(serial: "BS072777-3.0") # without a block, you close it
65
72
  stick.info # => Blinkstick::Info
66
73
  stick.close
@@ -68,6 +75,8 @@ stick.closed? # => true
68
75
 
69
76
  Blinkstick::Colour.parse("255,136,0").hex # => "#ff8800"
70
77
  Blinkstick::Colour.names.first(3) # => ["aliceblue", "antiquewhite", "aqua"]
78
+ Blinkstick::Colour.parse("red").inverse # => #<data Blinkstick::Colour red=0, green=255, blue=255>
79
+ Blinkstick::Colour.random # each channel picked from 0 to 255
71
80
  ```
72
81
 
73
82
  A colour can be a CSS colour name (a string or symbol, any case), hex (`#rgb` or `#rrggbb`,
@@ -81,8 +90,9 @@ needs exactly one colour per LED, so a Nano takes 2 and a Square 8. A wrong coun
81
90
  before anything is sent.
82
91
 
83
92
  Failures raise subclasses of `Blinkstick::Error`: `NotFoundError`, `BusyError`,
84
- `PermissionError`, `UnsupportedModelError`, `ClosedError` and `DisconnectedError`. A bad
85
- colour raises `Blinkstick::InvalidColourError`, which is an `ArgumentError`.
93
+ `PermissionError`, `UnsupportedModelError`, `ClosedError`, `DisconnectedError` and
94
+ `DuplicateNameError`. A bad colour raises `Blinkstick::InvalidColourError`, which is an
95
+ `ArgumentError`.
86
96
 
87
97
  ### Reading a stick
88
98
 
@@ -105,9 +115,150 @@ end
105
115
  trailing NUL bytes removed, as a frozen binary String. Any other number raises
106
116
  `ArgumentError`. The bytes are whatever any tool last wrote there, so treat them as
107
117
  untrusted input.
108
- - Nothing in this release writes EEPROM: the mode and info blocks are read only.
109
- - A read that keeps failing raises `Blinkstick::DisconnectedError`, and a read after `close`
110
- raises `ClosedError`, as writes do.
118
+ - A read that keeps failing makes the stick try to reconnect (see "Reconnecting"), and raises
119
+ `Blinkstick::DisconnectedError` if that fails too. A read after `close` raises `ClosedError`,
120
+ as writes do.
121
+
122
+ ### Brightness limit and inverse
123
+
124
+ ```ruby
125
+ stick.brightness_limit = 128 # an Integer from 0 to 255; 255, the default, is no limit
126
+ stick.brightness_limit # => 128
127
+ stick.colour = :white # sent as 128, 128, 128
128
+ stick.frame.first # => #<data Blinkstick::Colour red=128, green=128, blue=128>
129
+
130
+ stick.inverse = true # sends 255 minus each channel, after the limit
131
+ stick.inverse? # => true
132
+ ```
133
+
134
+ - The limit scales every channel of every colour written from then on, as `v * limit / 255`
135
+ with integer division: `colour=`, `[]=`, `frame=`, `off` and the effects. It does not repaint
136
+ what is already lit, and reads return what the stick holds, so they show the limited values.
137
+ - `inverse` is for LEDs wired so that 255 is off. Reads flip back, so `frame` and `stick[i]`
138
+ return the colours you wrote. It does not repaint either, so set it before setting LEDs. It is
139
+ not the firmware inverse mode (mode 1), which this gem refuses.
140
+ - Both belong to the `Stick` object. They are not stored on the device, and a second `Stick` on
141
+ the same serial starts with no limit and no inverse.
142
+
143
+ ### Random colours
144
+
145
+ ```ruby
146
+ Blinkstick::Colour.random # each channel picked from 0 to 255
147
+ Blinkstick::Colour.random(vivid: true) # one of the 1530 fully saturated hues
148
+ ```
149
+
150
+ These use Ruby's `Random`, so they are for pretty lights and nothing security related. Pass
151
+ `random:` (anything with a `rand` method) to make them repeatable in tests.
152
+
153
+ ### Writing to the stick's memory
154
+
155
+ A stick keeps a name, two 32 byte info blocks and its LED mode in EEPROM. **EEPROM wears out
156
+ with writes**, so set these once and never in a loop. `name=`, `write_info_block` and `mode=`
157
+ write EEPROM; the other calls here only read. An EEPROM call is retried like any transfer, so
158
+ on a flaky connection one call can send the same write up to six times (three attempts, a
159
+ reconnect, three more), as go-blinkstick does. That is one more reason not to call these in a
160
+ loop.
161
+
162
+ ```ruby
163
+ Blinkstick.open do |stick|
164
+ stick.name = "desk" # stored in info block 1
165
+ stick.name # => "desk"
166
+ stick.write_info_block(2, "my notes") # padded with zeros to 32 bytes
167
+ stick.info_block(2) # => "my notes"
168
+ stick.mode = :ws2812_mirror # reads first, writes only if the mode differs
169
+ end
170
+
171
+ Blinkstick.open(name: "desk") { |stick| stick.colour = :green }
172
+
173
+ Blinkstick.list(names: true)
174
+ # => [#<data Blinkstick::NamedInfo info=#<data Blinkstick::Info ...>, name="desk", busy=false>]
175
+ ```
176
+
177
+ - A name is a String of valid UTF-8 with no NUL and at most 32 bytes. `""` clears it. A wrong
178
+ type raises `TypeError` and a bad value raises `ArgumentError`, before anything is sent.
179
+ Names are held in info block 1, as the official tools and the Go library do, so writing
180
+ block 1 changes the name.
181
+ - `Blinkstick.open(name:)` opens each stick in turn, reads its name and keeps the one that
182
+ matches byte for byte (the others are closed). A stick that cannot be opened or read is
183
+ skipped. No match raises `NotFoundError` whose message says why each stick was skipped, and
184
+ two matches raise `DuplicateNameError` naming their serials. `serial:` and `name:` together,
185
+ or an empty name, raise `ArgumentError`. A match that is a model this gem does not support
186
+ raises `UnsupportedModelError`.
187
+ - `Blinkstick.list(names: true)` opens every stick briefly to read its name. A stick it cannot
188
+ open (another program has seized it, say) comes back with `busy?` true and an empty name.
189
+ `Blinkstick.list` with no argument opens nothing.
190
+ - `write_info_block(number, bytes)` takes block 1 or 2 and a String of at most 32 bytes, written
191
+ byte for byte whatever its encoding. Trailing NUL bytes read back as nothing, because reads
192
+ strip them. A bad number or too many bytes raises `ArgumentError`, and a non String raises
193
+ `TypeError`.
194
+ - `stick.mode =` takes `:ws2812` (or 2) or `:ws2812_mirror` (or 3). In mirror mode `colour=`
195
+ still lights every LED, and reports 5 and 6 behave as in mode 2. The change needs no replug.
196
+ The stick stops answering for a moment after a mode write (typically 12 to 15 ms), so
197
+ `mode=` waits until it answers with the new mode, raising `DisconnectedError` if it has not
198
+ within 250 ms. Modes 0 and 1 are for the BlinkStick Pro: they raise `ArgumentError`, as
199
+ they can leave a Nano dark until it is replugged.
200
+ - Names and info block bytes are whatever any tool last wrote, so they may not be valid UTF-8
201
+ and may hold control characters. They come back as they are. Treat them as untrusted input,
202
+ and sanitise them before printing them to a terminal.
203
+
204
+ ### Effects
205
+
206
+ ```ruby
207
+ stick.blink(:red, period: 1.0, repeats: 3) # on for 0.5 s, off for 0.5 s, three times
208
+ stick.pulse(:blue, duration: 2.0) # off, fade up to blue, fade down to off
209
+ stick.morph(:green, duration: 1.5) # fade from what the stick shows now to green
210
+ ```
211
+
212
+ All three block the calling thread until they finish. Times are in seconds. Colours are
213
+ anything `Colour.parse` takes, checked before anything is sent. `blink` and `pulse` take
214
+ `repeats:` (an Integer of 1 or more) and end with the LEDs off, and `morph` ends on its colour.
215
+ `morph` fades in 20 ms steps, counted as the Go library counts them (0.58 s is 29 steps), and
216
+ a duration under one step jumps straight to the colour. The brightness limit applies once, so
217
+ a fade to full red with a limit of 128 ends at 128.
218
+
219
+ To stop an effect from another thread, give it a `Blinkstick::Cancellation`:
220
+
221
+ ```ruby
222
+ cancel = Blinkstick::Cancellation.new
223
+ worker = Thread.new { stick.blink(:red, period: 1.0, repeats: 60, cancel: cancel) }
224
+
225
+ sleep 3
226
+ cancel.cancel # wakes the effect straight away; calling it again does nothing
227
+ worker.value # => false
228
+ cancel.cancelled? # => true
229
+ ```
230
+
231
+ - An effect returns `true` when it runs to the end and `false` when it was cancelled. A
232
+ cancelled effect leaves the LEDs as they are, so turn them off yourself if you want that.
233
+ - With no `cancel:` the effect just pauses with plain timed waits.
234
+ - One `Cancellation` can be shared by several effects. Its `wait(seconds)` returns `true` as
235
+ soon as it is cancelled and `false` after `seconds`.
236
+ - Each write takes the stick's lock on its own, so other calls can run in the middle of an
237
+ effect. Two effects on one stick at the same time interleave their frames, and the gem does
238
+ not stop you.
239
+ - Do not call a `Cancellation`'s methods from a `trap` handler: they take a Mutex, and Ruby
240
+ refuses that inside a trap. Set a flag in the handler and cancel from a normal thread instead.
241
+
242
+ ### Reconnecting
243
+
244
+ If a stick is unplugged, or stops answering for three attempts, the `Stick` closes its handle,
245
+ lists the attached sticks again and opens the one with its serial. When that works it repaints
246
+ the last frame it wrote, then repeats the call that failed. Plug the stick back in and the next
247
+ call just works, with the LEDs as you left them.
248
+
249
+ - The remembered frame is what the gem last wrote (after the brightness limit, before inverse).
250
+ `colour=`, `frame=`, `off` and the effects replace it, and `[]=` updates one entry. Nothing
251
+ is repainted if nothing has been written yet, or if the call that failed was itself a frame
252
+ write (it is simply retried).
253
+ - The first `[]=` on a `Stick` that has written nothing reads the frame from the stick once, so
254
+ the entries it does not touch are kept.
255
+ - It tries once per call. If the stick is missing, cannot be opened or the repaint fails, the
256
+ call raises `DisconnectedError`, and the next call tries again. `close` on a disconnected
257
+ stick succeeds.
258
+ - A `Stick` never reconnects onto a different stick. If more than one attached stick has its
259
+ serial, it raises `DisconnectedError` rather than guess. (A stick with an empty serial, which
260
+ Linux reports when there is no `HID_UNIQ`, is an unknown model and cannot be opened at all.)
261
+ - `BusyError`, `PermissionError` and `ClosedError` are raised as they are, without reconnecting.
111
262
 
112
263
  ### Sharing a stick
113
264
 
@@ -55,10 +55,15 @@ module Blinkstick
55
55
  # @param size [Integer] bytes to ask for, including the ID byte
56
56
  # @return [String] binary String of the bytes the OS returned, the
57
57
  # report ID first (empty when it returned none)
58
+ # @raise [ArgumentError] unless size is a positive Integer and report_id is 0 to 255
58
59
  # @raise [ClosedError] after {#close}
59
60
  # @raise [DisconnectedError] when the stick is unplugged
60
61
  # @raise [TransferFailed] for any other failed transfer
61
62
  def read_feature(report_id, size)
63
+ unless size.is_a?(Integer) && size.positive? && report_id.is_a?(Integer) && report_id.between?(0, 255)
64
+ raise ArgumentError, "need report ID 0 to 255 and positive size, got #{report_id.inspect}, #{size.inspect}"
65
+ end
66
+
62
67
  transfer do
63
68
  buffer = ("\x00".b * size)
64
69
  buffer.setbyte(0, report_id)
@@ -217,10 +217,12 @@ module Blinkstick
217
217
  # @param report_id [Integer] the report to ask for
218
218
  # @param size [Integer] the report's length, including the ID byte
219
219
  # @return [String] binary, the bytes IOKit returned, report ID first
220
+ # @raise [ArgumentError] unless size is a positive Integer and report_id is 0 to 255
220
221
  # @raise [ClosedError] after {#close}
221
222
  # @raise [DisconnectedError] when the stick is unplugged
222
223
  # @raise [TransferFailed] for any other failed transfer
223
224
  def read_feature(report_id, size)
225
+ check_read_arguments(report_id, size)
224
226
  ensure_open
225
227
  buffer = FFI::MemoryPointer.new(:uint8, size)
226
228
  buffer.put_uint8(0, report_id)
@@ -252,6 +254,12 @@ module Blinkstick
252
254
  @serial || "the stick"
253
255
  end
254
256
 
257
+ def check_read_arguments(report_id, size)
258
+ return if size.is_a?(Integer) && size.positive? && report_id.is_a?(Integer) && report_id.between?(0, 255)
259
+
260
+ raise ArgumentError, "need report ID 0 to 255 and positive size, got #{report_id.inspect}, #{size.inspect}"
261
+ end
262
+
255
263
  def ensure_open
256
264
  raise ClosedError, "#{name} is closed" if @closed
257
265
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blinkstick
4
+ # A cooperative cancellation token for effects ({Stick#blink} and friends).
5
+ #
6
+ # Every pause in an effect is {#wait}, so {#cancel} wakes it at once. It
7
+ # uses only Mutex and ConditionVariable (no Thread#raise, Thread#kill or
8
+ # Timeout), so it is safe across threads and works under a fiber scheduler.
9
+ # One token may be shared by several effects. Do not call any of its
10
+ # methods from a signal trap handler; see {#cancel}.
11
+ #
12
+ # cancel = Blinkstick::Cancellation.new
13
+ # Thread.new { sleep 10; cancel.cancel }
14
+ class Cancellation
15
+ # The longest single condition wait, in seconds. ConditionVariable#wait
16
+ # raises RangeError for very long timeouts, so a longer pause is made of
17
+ # several waits (the loop works out what remains each time).
18
+ LONGEST_WAIT = 86_400
19
+ private_constant :LONGEST_WAIT
20
+
21
+ def initialize
22
+ @mutex = Mutex.new
23
+ @condition = ConditionVariable.new
24
+ @cancelled = false
25
+ end
26
+
27
+ # Cancels the token and wakes every waiter. Safe to call again.
28
+ #
29
+ # Do not call this from a signal trap handler: Mutex#synchronize raises
30
+ # ThreadError there. Set a flag in the trap and cancel from a thread that
31
+ # watches it.
32
+ #
33
+ # @return [nil]
34
+ # @raise [ThreadError] when called from a signal trap handler
35
+ def cancel
36
+ @mutex.synchronize do
37
+ @cancelled = true
38
+ @condition.broadcast
39
+ end
40
+ nil
41
+ end
42
+
43
+ # @return [Boolean] true once {#cancel} has been called
44
+ def cancelled?
45
+ @mutex.synchronize { @cancelled }
46
+ end
47
+
48
+ # Pauses for +seconds+, or until the token is cancelled, whichever is first.
49
+ #
50
+ # @param seconds [Numeric] a finite number, 0 or more
51
+ # @return [Boolean] true if cancelled (at once if it already was), false
52
+ # once +seconds+ have passed
53
+ # @raise [ArgumentError] when +seconds+ is not a finite Numeric of 0 or more
54
+ def wait(seconds)
55
+ check_seconds(seconds)
56
+ deadline = now + seconds
57
+ @mutex.synchronize do
58
+ until @cancelled
59
+ remaining = deadline - now
60
+ break if remaining <= 0
61
+
62
+ @condition.wait(@mutex, [remaining, LONGEST_WAIT].min)
63
+ end
64
+ @cancelled
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def check_seconds(seconds)
71
+ ok = seconds.is_a?(Numeric) && seconds.real? && seconds.to_f.finite? && seconds >= 0
72
+ raise ArgumentError, "seconds must be a finite number of 0 or more, got #{seconds.inspect}" unless ok
73
+ end
74
+
75
+ def now
76
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
77
+ end
78
+ end
79
+ end
@@ -43,6 +43,27 @@ module Blinkstick
43
43
  NAMES
44
44
  end
45
45
 
46
+ # Picks a random colour.
47
+ #
48
+ # Without +vivid+ each channel is uniform from 0 to 255, so many results are
49
+ # muted. With +vivid+ the colour is one of the 1530 fully saturated hues:
50
+ # one channel is 255 and another is 0. Not suitable for anything security
51
+ # related.
52
+ #
53
+ # @param vivid [Boolean] pick only fully saturated hues
54
+ # @param random [#rand] the generator, anything responding to +rand(n)+
55
+ # @return [Colour]
56
+ def self.random(vivid: false, random: Random)
57
+ return hue(random.rand(Colour::HUES)) if vivid
58
+
59
+ new(red: random.rand(256), green: random.rand(256), blue: random.rand(256))
60
+ end
61
+
62
+ # @return [Colour] the colour with each channel replaced by 255 minus its value
63
+ def inverse
64
+ with(red: 255 - red, green: 255 - green, blue: 255 - blue)
65
+ end
66
+
46
67
  # @return [String] the colour as lower case "#rrggbb"
47
68
  def hex
48
69
  format("#%<r>02x%<g>02x%<b>02x", r: red, g: green, b: blue)
@@ -53,6 +74,20 @@ module Blinkstick
53
74
  class << self
54
75
  private
55
76
 
77
+ # Port of go-blinkstick's hue: step +step+ (0...HUES) of the wheel,
78
+ # running red, yellow, green, cyan, blue, magenta and back towards red.
79
+ def hue(step)
80
+ offset = step % 255
81
+ case step / 255
82
+ when 0 then new(red: 255, green: offset, blue: 0)
83
+ when 1 then new(red: 255 - offset, green: 255, blue: 0)
84
+ when 2 then new(red: 0, green: 255, blue: offset)
85
+ when 3 then new(red: 0, green: 255 - offset, blue: 255)
86
+ when 4 then new(red: offset, green: 0, blue: 255)
87
+ else new(red: 255, green: 0, blue: 255 - offset)
88
+ end
89
+ end
90
+
56
91
  def lookup(name)
57
92
  COLOUR_NAMES[name]
58
93
  end
@@ -97,6 +132,9 @@ module Blinkstick
97
132
  end
98
133
  end
99
134
 
135
+ # Number of fully saturated hues: six segments of 255 steps.
136
+ Colour::HUES = 6 * 255
137
+
100
138
  # Colour with every channel off.
101
139
  Colour::OFF = Colour.new(red: 0, green: 0, blue: 0)
102
140
  end
@@ -4,7 +4,7 @@ module Blinkstick
4
4
  # Base class for every error this gem raises on purpose.
5
5
  class Error < StandardError; end
6
6
 
7
- # Raised when no BlinkStick matches the requested serial or index.
7
+ # Raised when no BlinkStick is attached, or none matches the requested serial or name.
8
8
  class NotFoundError < Error; end
9
9
 
10
10
  # Raised when another program has seized the stick (macOS only).
@@ -19,9 +19,13 @@ module Blinkstick
19
19
  # Raised when using a device after it has been closed.
20
20
  class ClosedError < Error; end
21
21
 
22
- # Raised when the stick is unplugged or stops responding mid-use.
22
+ # Raised when the stick is unplugged or stops responding mid-use and
23
+ # reconnecting to it (by its serial) fails or cannot be tried.
23
24
  class DisconnectedError < Error; end
24
25
 
26
+ # Raised when more than one attached stick has the requested name.
27
+ class DuplicateNameError < Error; end
28
+
25
29
  # Raised when a value cannot be turned into a {Colour}.
26
30
  class InvalidColourError < ArgumentError; end
27
31
  end
@@ -33,7 +33,15 @@ module Blinkstick
33
33
  private_constant :MODELS
34
34
 
35
35
  # Identifies an attached BlinkStick. It holds no OS path or handle.
36
- Info = Data.define(:serial, :version, :manufacturer, :product, :model) do
36
+ # +release+ comes last and defaults to nil, so 0.2.0 code that builds an
37
+ # Info from its five members, by position or keyword, still works.
38
+ Info = Data.define(:serial, :version, :manufacturer, :product, :model, :release) do
39
+ # Data checks the other members (serial, version, manufacturer, product
40
+ # and model) as usual.
41
+ #
42
+ # @param release [Integer, nil] the USB bcdDevice value, nil when not known
43
+ def initialize(release: nil, **) = super
44
+
37
45
  # @param serial [String] for example "BS072777-3.0"
38
46
  # @param manufacturer [String]
39
47
  # @param product [String]
@@ -43,7 +51,7 @@ module Blinkstick
43
51
  _, _, version = serial.partition("-")
44
52
  major, = version.split(".", 2)
45
53
  new(serial: serial, version: version, manufacturer: manufacturer, product: product,
46
- model: MODELS.fetch([major, release], Model::UNKNOWN))
54
+ model: MODELS.fetch([major, release], Model::UNKNOWN), release: release)
47
55
  end
48
56
  end
49
57
  end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Finding sticks by the name stored on them.
4
+ module Blinkstick
5
+ # A stick's identity and the name stored on it, from {Blinkstick.list}
6
+ # with +names: true+. A stick that could not be opened or read is +busy+
7
+ # with an empty +name+.
8
+ #
9
+ # @!attribute [r] info
10
+ # @return [Info]
11
+ # @!attribute [r] name
12
+ # @return [String] "" when the stick has no name or could not be read
13
+ # @!attribute [r] busy
14
+ # @return [Boolean]
15
+ NamedInfo = Data.define(:info, :name, :busy) do
16
+ # @return [Boolean] true when the stick could not be opened or read
17
+ def busy?
18
+ busy
19
+ end
20
+ end
21
+
22
+ # One stick seen while looking for a name: its device, name and open Stick,
23
+ # or the Error that stopped it being read.
24
+ Reading = Data.define(:device, :name, :error, :stick)
25
+ private_constant :Reading
26
+
27
+ class << self
28
+ private
29
+
30
+ def check_selectors(serial, name)
31
+ raise ArgumentError, "pass serial: or name:, not both" unless serial.nil? || name.nil?
32
+ return if name.nil?
33
+ raise TypeError, "name must be a String, got #{name.class}" unless name.is_a?(String)
34
+ raise ArgumentError, "name must not be empty" if name.empty?
35
+ end
36
+
37
+ # Opens without the model check and without reconnecting: for reading a
38
+ # name, never handed to a caller as it is.
39
+ def open_any(backend, device)
40
+ info = info_for(device)
41
+ Stick.new(backend.open(device), info)
42
+ end
43
+
44
+ # Opens, reads the name and closes again, whatever happens in between.
45
+ def read_name(backend, device)
46
+ stick = open_any(backend, device)
47
+ begin
48
+ stick.name
49
+ ensure
50
+ stick.close
51
+ end
52
+ end
53
+
54
+ def list_named
55
+ backend = Backend.current
56
+ backend.list.map do |device|
57
+ NamedInfo.new(info: info_for(device), name: read_name(backend, device), busy: false)
58
+ rescue Error
59
+ NamedInfo.new(info: info_for(device), name: "", busy: true)
60
+ end
61
+ end
62
+
63
+ # Keeps every readable stick open until the match is known, then hands
64
+ # the match's handle to a new Stick and closes the rest.
65
+ def open_named(name)
66
+ backend = Backend.current
67
+ readings = []
68
+ begin
69
+ backend.list.each { |device| readings << try_keep(backend, device) }
70
+ match = single_match(name, readings)
71
+ build_stick(backend, match.device) { match.stick.detach }
72
+ ensure
73
+ readings.each { |reading| reading.stick&.close }
74
+ end
75
+ end
76
+
77
+ def single_match(name, readings)
78
+ matches = readings.select { |reading| reading.name&.b == name.b }
79
+ raise name_error(name, matches.map(&:device), readings) unless matches.size == 1
80
+
81
+ matches.first
82
+ end
83
+
84
+ # @return [Reading] with the stick still open, or with the Error when it
85
+ # could not be opened or read
86
+ def try_keep(backend, device)
87
+ stick = open_any(backend, device)
88
+ begin
89
+ Reading.new(device, stick.name, nil, stick).tap { stick = nil }
90
+ ensure
91
+ stick&.close
92
+ end
93
+ rescue Error => e
94
+ Reading.new(device, nil, e, nil)
95
+ end
96
+
97
+ def name_error(name, matches, readings)
98
+ matches.empty? ? not_found_name(name, readings) : duplicate_name(name, matches)
99
+ end
100
+
101
+ def duplicate_name(name, matches)
102
+ DuplicateNameError.new("#{matches.size} sticks are named #{name.inspect}: " \
103
+ "#{matches.map { |device| device.serial.inspect }.join(', ')}")
104
+ end
105
+
106
+ def not_found_name(name, readings)
107
+ skipped = readings.filter_map do |reading|
108
+ error = reading.error
109
+ "#{reading.device.serial.inspect} (#{error.class.name.split('::').last}: #{error.message})" if error
110
+ end
111
+ message = "no BlinkStick named #{name.inspect}"
112
+ message += "; skipped #{skipped.join(', ')}" unless skipped.empty?
113
+ NotFoundError.new(message)
114
+ end
115
+ end
116
+ end