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 +4 -4
- data/CAPABILITIES.md +13 -13
- data/CHANGELOG.md +19 -0
- data/README.md +162 -11
- data/lib/blinkstick/backend/hidraw.rb +5 -0
- data/lib/blinkstick/backend/iokit.rb +8 -0
- data/lib/blinkstick/cancellation.rb +79 -0
- data/lib/blinkstick/colour.rb +38 -0
- data/lib/blinkstick/errors.rb +6 -2
- data/lib/blinkstick/model.rb +10 -2
- data/lib/blinkstick/names.rb +116 -0
- data/lib/blinkstick/report.rb +40 -0
- data/lib/blinkstick/stick/eeprom.rb +158 -0
- data/lib/blinkstick/stick/effects.rb +151 -0
- data/lib/blinkstick/stick/handover.rb +27 -0
- data/lib/blinkstick/stick/lighting.rb +120 -0
- data/lib/blinkstick/stick/reconnect.rb +126 -0
- data/lib/blinkstick/stick.rb +38 -68
- data/lib/blinkstick/version.rb +1 -1
- data/lib/blinkstick.rb +77 -14
- metadata +16 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 20e16b5618237efd0d8ce0ba62eeb126ef7e6121ea4266327139373d4d8f30d2
|
|
4
|
+
data.tar.gz: 0a5d0d90e43cb56aba4c0e49c61ff7092f8626aa9ae6ec278799497d18d3dbde
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 |
|
|
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.
|
|
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) |
|
|
31
|
-
| Mode 2, WS2812 |
|
|
32
|
-
| Mode 3, WS2812 mirror |
|
|
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.
|
|
34
|
-
| Set LED 0, or every LED in mode 3 (report 1) |
|
|
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 |
|
|
48
|
-
| Blink, pulse and morph |
|
|
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 |
|
|
53
|
-
| Inverse colours |
|
|
54
|
-
| Reconnect after unplug |
|
|
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
|
|
16
|
-
LED or the whole frame, reads LEDs, the mode and the info blocks back, and
|
|
17
|
-
macOS and Linux.
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
85
|
-
colour raises `Blinkstick::InvalidColourError`, which is an
|
|
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
|
-
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
data/lib/blinkstick/colour.rb
CHANGED
|
@@ -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
|
data/lib/blinkstick/errors.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/blinkstick/model.rb
CHANGED
|
@@ -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
|
-
|
|
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
|