ruby-blinkstick 0.1.0 → 0.2.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 +6 -6
- data/CHANGELOG.md +10 -0
- data/README.md +39 -4
- data/lib/blinkstick/backend/hidraw.rb +41 -8
- data/lib/blinkstick/backend/iokit.rb +40 -10
- data/lib/blinkstick/backend.rb +6 -3
- data/lib/blinkstick/report.rb +69 -0
- data/lib/blinkstick/stick.rb +135 -10
- data/lib/blinkstick/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dbeec7298582de4220e66562a9fadfd994fc540c920b6af2da8ea0b91cbc7cee
|
|
4
|
+
data.tar.gz: d016a0be08f195f84e43a1e663a7b70c49d8174d4a1fba2b1332cc70c6159c7b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c66054616d8981e5c256a19a27f88c0e4bf6ee4046f86e3a554eea7b34cda60f9546a93cf19a9ba8e43723f888d4b6af88dd3948cf7bd9ded0e549d14d29f027
|
|
7
|
+
data.tar.gz: a1d96c324d7793c6c4cf4a1193f97e07de2a2f162bd0aaff4a6589b95649fc76234b2d0c535b3467874a2fe0347b9198669194d5c3b92fd635bdc5fb0e08dd74
|
data/CAPABILITIES.md
CHANGED
|
@@ -21,12 +21,12 @@ Features built into the BlinkStick firmware.
|
|
|
21
21
|
| Serial and firmware version | yes | yes | `info.serial`, `info.version` | 0.1.0 | Read from the device info |
|
|
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
|
-
| Set one LED |
|
|
25
|
-
| Set every LED at once |
|
|
26
|
-
| Read LED values back |
|
|
27
|
-
| Info blocks 1 and 2 |
|
|
28
|
-
| Set one LED directly (report 5) |
|
|
29
|
-
| Read device mode (report 4) |
|
|
24
|
+
| Set one LED | yes | yes | `stick[i] = colour` | 0.2.0 | Set a single LED by index, using report 5 |
|
|
25
|
+
| Set every LED at once | yes | yes | `stick.frame = colours` | 0.2.0 | Write a whole frame in one report |
|
|
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 |
|
|
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
|
+
| Read device mode (report 4) | yes | yes | `stick.mode` | 0.2.0 | `[4, mode]`. Both read mode 2 |
|
|
30
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
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
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 |
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,16 @@ All notable changes to this project are recorded here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.0] - 2026-10-28
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `Stick#[]=` sets one LED and leaves the others as they are, using one report. A bad index raises `IndexError`.
|
|
14
|
+
- `Stick#frame=` sets every LED at once, one colour each. It needs exactly one colour per LED of the model, and nothing is sent if any colour is invalid.
|
|
15
|
+
- `Stick#frame` and `Stick#[]` read the LEDs back as `Colour` values. Reads always ask the stick, and a failing read raises `DisconnectedError` after three attempts.
|
|
16
|
+
- `Stick#mode` reads the device mode as `:rgb`, `:rgb_inverse`, `:ws2812` or `:ws2812_mirror`, or the raw byte for a mode the gem does not know.
|
|
17
|
+
- `Stick#info_block` reads info block 1 or 2, read only. The bytes are whatever any tool last wrote, so treat them as untrusted.
|
|
18
|
+
|
|
9
19
|
## [0.1.0] - 2026-10-28
|
|
10
20
|
|
|
11
21
|
### Added
|
data/README.md
CHANGED
|
@@ -12,10 +12,11 @@ of BlinkStick.
|
|
|
12
12
|
|
|
13
13
|
## Status
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
|
19
20
|
|
|
20
21
|
## Install
|
|
21
22
|
|
|
@@ -55,6 +56,8 @@ Blinkstick.open do |stick| # the first stick; the block form closes it,
|
|
|
55
56
|
stick.colour = :cornflowerblue # set every LED
|
|
56
57
|
stick.colour = "#ff8800"
|
|
57
58
|
stick.colour = [255, 136, 0]
|
|
59
|
+
stick[0] = :red # set one LED, the others stay as they are
|
|
60
|
+
stick.frame = Array.new(stick.info.model.leds) { |i| i.even? ? :red : :green } # every LED at once, one colour each
|
|
58
61
|
stick.off # every LED off
|
|
59
62
|
end
|
|
60
63
|
|
|
@@ -70,10 +73,42 @@ Blinkstick::Colour.names.first(3) # => ["aliceblue", "antiquewhite", "
|
|
|
70
73
|
A colour can be a CSS colour name (a string or symbol, any case), hex (`#rgb` or `#rrggbb`,
|
|
71
74
|
with or without the `#`), `"r,g,b"`, or an array of three integers.
|
|
72
75
|
|
|
76
|
+
`stick[i] = colour` sends one report and leaves the other LEDs alone. The index runs from 0
|
|
77
|
+
to one less than the model's LED count (a Nano takes 0 or 1, a Square 0 to 7), and a bad index raises
|
|
78
|
+
`IndexError`; negative indexes are refused, not counted from the end. `stick.frame = colours`
|
|
79
|
+
needs exactly one colour per LED, so a Nano takes 2 and a Square 8. A wrong count raises
|
|
80
|
+
`ArgumentError`, and something that is not an array raises `TypeError`. Every colour is checked
|
|
81
|
+
before anything is sent.
|
|
82
|
+
|
|
73
83
|
Failures raise subclasses of `Blinkstick::Error`: `NotFoundError`, `BusyError`,
|
|
74
84
|
`PermissionError`, `UnsupportedModelError`, `ClosedError` and `DisconnectedError`. A bad
|
|
75
85
|
colour raises `Blinkstick::InvalidColourError`, which is an `ArgumentError`.
|
|
76
86
|
|
|
87
|
+
### Reading a stick
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
Blinkstick.open do |stick|
|
|
91
|
+
stick.frame # => [#<data Blinkstick::Colour red=255, green=0, blue=0>, ...]
|
|
92
|
+
stick[0] # => #<data Blinkstick::Colour red=255, green=0, blue=0>
|
|
93
|
+
stick.mode # => :ws2812
|
|
94
|
+
stick.info_block(1) # => "" or up to 32 bytes, a binary String
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- `stick.frame` returns a frozen Array with one `Colour` per LED of the model (2 for a Nano,
|
|
99
|
+
8 for a Square), and `stick[i]` returns one of them.
|
|
100
|
+
- Reads always ask the stick. Nothing is cached, so you see what the stick shows now, even if
|
|
101
|
+
another program changed it.
|
|
102
|
+
- `stick.mode` is `:rgb`, `:rgb_inverse`, `:ws2812` or `:ws2812_mirror`. A byte the gem does
|
|
103
|
+
not know comes back as an Integer.
|
|
104
|
+
- `stick.info_block(1)` and `stick.info_block(2)` return the 32 byte user data blocks with
|
|
105
|
+
trailing NUL bytes removed, as a frozen binary String. Any other number raises
|
|
106
|
+
`ArgumentError`. The bytes are whatever any tool last wrote there, so treat them as
|
|
107
|
+
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.
|
|
111
|
+
|
|
77
112
|
### Sharing a stick
|
|
78
113
|
|
|
79
114
|
Sticks are shared on both platforms, so several programs (and several Ruby processes) can
|
|
@@ -20,6 +20,15 @@ module Blinkstick
|
|
|
20
20
|
(3 << 30) | (length << 16) | (0x48 << 8) | 0x06
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# The ioctl request number for HIDIOCGFEATURE of the given length:
|
|
24
|
+
# direction read/write (3), size, type 'H' (0x48) and number 7.
|
|
25
|
+
#
|
|
26
|
+
# @param length [Integer] report size in bytes, including the ID byte
|
|
27
|
+
# @return [Integer]
|
|
28
|
+
def self.get_feature_request(length)
|
|
29
|
+
(3 << 30) | (length << 16) | (0x48 << 8) | 0x07
|
|
30
|
+
end
|
|
31
|
+
|
|
23
32
|
# An open hidraw node.
|
|
24
33
|
class Handle
|
|
25
34
|
# @param io [#ioctl, #close] the open device node
|
|
@@ -37,15 +46,26 @@ module Blinkstick
|
|
|
37
46
|
# @raise [DisconnectedError] when the stick is unplugged
|
|
38
47
|
# @raise [TransferFailed] for any other failed transfer
|
|
39
48
|
def write_feature(report)
|
|
40
|
-
|
|
49
|
+
transfer { @io.ioctl(Hidraw.set_feature_request(report.bytesize), report.dup) }
|
|
50
|
+
end
|
|
41
51
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
# Reads a feature report from the stick.
|
|
53
|
+
#
|
|
54
|
+
# @param report_id [Integer] the report to ask for
|
|
55
|
+
# @param size [Integer] bytes to ask for, including the ID byte
|
|
56
|
+
# @return [String] binary String of the bytes the OS returned, the
|
|
57
|
+
# report ID first (empty when it returned none)
|
|
58
|
+
# @raise [ClosedError] after {#close}
|
|
59
|
+
# @raise [DisconnectedError] when the stick is unplugged
|
|
60
|
+
# @raise [TransferFailed] for any other failed transfer
|
|
61
|
+
def read_feature(report_id, size)
|
|
62
|
+
transfer do
|
|
63
|
+
buffer = ("\x00".b * size)
|
|
64
|
+
buffer.setbyte(0, report_id)
|
|
65
|
+
count = @io.ioctl(Hidraw.get_feature_request(size), buffer)
|
|
66
|
+
# IO#ioctl may grow the buffer, so trust its return value, not the length.
|
|
67
|
+
count.positive? ? buffer.byteslice(0, count) : "".b
|
|
68
|
+
end
|
|
49
69
|
end
|
|
50
70
|
|
|
51
71
|
# Releases the node. Calling it again does nothing, and it never raises.
|
|
@@ -64,6 +84,19 @@ module Blinkstick
|
|
|
64
84
|
|
|
65
85
|
private
|
|
66
86
|
|
|
87
|
+
# Runs one transfer, mapping the OS's errors onto ours.
|
|
88
|
+
def transfer
|
|
89
|
+
raise ClosedError, "#{name} is closed" if @closed
|
|
90
|
+
|
|
91
|
+
yield
|
|
92
|
+
rescue Errno::ENODEV, Errno::ENOENT, Errno::ESHUTDOWN => e
|
|
93
|
+
raise DisconnectedError, "#{name} was unplugged (#{e.class})"
|
|
94
|
+
rescue Errno::EACCES, Errno::EPERM => e
|
|
95
|
+
raise PermissionError, "#{name}: #{e.message}. #{UDEV_HINT}"
|
|
96
|
+
rescue SystemCallError => e
|
|
97
|
+
raise TransferFailed, "#{name}: #{e.message}"
|
|
98
|
+
end
|
|
99
|
+
|
|
67
100
|
def name
|
|
68
101
|
@serial || "the stick"
|
|
69
102
|
end
|
|
@@ -5,9 +5,10 @@ require_relative "../../blinkstick"
|
|
|
5
5
|
|
|
6
6
|
module Blinkstick
|
|
7
7
|
module Backend
|
|
8
|
-
# The macOS backend. It finds sticks with an IOHIDManager and
|
|
9
|
-
# feature reports with IOHIDDeviceSetReport
|
|
10
|
-
# CoreFoundation through ffi. Sticks are opened
|
|
8
|
+
# The macOS backend. It finds sticks with an IOHIDManager and moves
|
|
9
|
+
# feature reports with IOHIDDeviceSetReport and IOHIDDeviceGetReport,
|
|
10
|
+
# calling IOKit and CoreFoundation through ffi. Sticks are opened
|
|
11
|
+
# shared, never seized.
|
|
11
12
|
#
|
|
12
13
|
# CoreFoundation ownership follows Apple's Create rule: anything returned
|
|
13
14
|
# by a Create or Copy call is released here, and anything returned by a
|
|
@@ -57,6 +58,7 @@ module Blinkstick
|
|
|
57
58
|
attach_function :hid_device_open, :IOHIDDeviceOpen, %i[pointer uint32], :int
|
|
58
59
|
attach_function :hid_device_close, :IOHIDDeviceClose, %i[pointer uint32], :int
|
|
59
60
|
attach_function :hid_device_set_report, :IOHIDDeviceSetReport, %i[pointer int long buffer_in long], :int
|
|
61
|
+
attach_function :hid_device_get_report, :IOHIDDeviceGetReport, %i[pointer int long buffer_inout pointer], :int
|
|
60
62
|
|
|
61
63
|
UTF8 = 0x08000100 # kCFStringEncodingUTF8
|
|
62
64
|
SINT32 = 3 # kCFNumberSInt32Type
|
|
@@ -149,7 +151,8 @@ module Blinkstick
|
|
|
149
151
|
PROPERTY_KEYS = %w[VendorID ProductID VersionNumber Manufacturer Product SerialNumber].freeze
|
|
150
152
|
private_constant :OPEN_REASONS, :PROPERTY_KEYS
|
|
151
153
|
|
|
152
|
-
# Maps a failed IOHIDDeviceSetReport to the
|
|
154
|
+
# Maps a failed IOHIDDeviceSetReport or IOHIDDeviceGetReport to the
|
|
155
|
+
# error a handle raises.
|
|
153
156
|
#
|
|
154
157
|
# @param code [Integer] the IOReturn, signed or unsigned
|
|
155
158
|
# @return [Class] a {Blinkstick::Error} subclass
|
|
@@ -201,13 +204,30 @@ module Blinkstick
|
|
|
201
204
|
# @raise [DisconnectedError] when the stick is unplugged
|
|
202
205
|
# @raise [TransferFailed] for any other failed transfer
|
|
203
206
|
def write_feature(report)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return if code.zero?
|
|
207
|
+
ensure_open
|
|
208
|
+
check(Native.hid_device_set_report(@device, REPORT_TYPE_FEATURE, report.getbyte(0), report,
|
|
209
|
+
report.bytesize), "writing")
|
|
210
|
+
end
|
|
209
211
|
|
|
210
|
-
|
|
212
|
+
# Reads a feature report. The report ID stays as the buffer's first
|
|
213
|
+
# byte and the length cell starts at size, so the length IOKit hands
|
|
214
|
+
# back counts the ID (as hidapi's macOS get_report does for a non-zero
|
|
215
|
+
# ID). Only a run against real hardware confirms this.
|
|
216
|
+
#
|
|
217
|
+
# @param report_id [Integer] the report to ask for
|
|
218
|
+
# @param size [Integer] the report's length, including the ID byte
|
|
219
|
+
# @return [String] binary, the bytes IOKit returned, report ID first
|
|
220
|
+
# @raise [ClosedError] after {#close}
|
|
221
|
+
# @raise [DisconnectedError] when the stick is unplugged
|
|
222
|
+
# @raise [TransferFailed] for any other failed transfer
|
|
223
|
+
def read_feature(report_id, size)
|
|
224
|
+
ensure_open
|
|
225
|
+
buffer = FFI::MemoryPointer.new(:uint8, size)
|
|
226
|
+
buffer.put_uint8(0, report_id)
|
|
227
|
+
length = FFI::MemoryPointer.new(:long) # CFIndex
|
|
228
|
+
length.write_long(size)
|
|
229
|
+
check(Native.hid_device_get_report(@device, REPORT_TYPE_FEATURE, report_id, buffer, length), "reading")
|
|
230
|
+
buffer.read_bytes(length.read_long.clamp(0, size))
|
|
211
231
|
end
|
|
212
232
|
|
|
213
233
|
# Closes the device, then releases the reference the handle owns.
|
|
@@ -231,6 +251,16 @@ module Blinkstick
|
|
|
231
251
|
def name
|
|
232
252
|
@serial || "the stick"
|
|
233
253
|
end
|
|
254
|
+
|
|
255
|
+
def ensure_open
|
|
256
|
+
raise ClosedError, "#{name} is closed" if @closed
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def check(code, action)
|
|
260
|
+
return if code.zero?
|
|
261
|
+
|
|
262
|
+
raise IOKit.error_for(code), "#{name}: #{action} a report failed (#{IOKit.describe(code)})"
|
|
263
|
+
end
|
|
234
264
|
end
|
|
235
265
|
|
|
236
266
|
# @return [Array<Device>] every BlinkStick attached
|
data/lib/blinkstick/backend.rb
CHANGED
|
@@ -10,9 +10,12 @@ module Blinkstick
|
|
|
10
10
|
# A backend responds to `#list`, returning an Array of {Device}, and
|
|
11
11
|
# `#open(device)`, returning a handle. A handle responds to
|
|
12
12
|
# `#write_feature(report)`, where report is a binary String whose first
|
|
13
|
-
# byte is the report ID
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# byte is the report ID; to `#read_feature(report_id, size)`, returning a
|
|
14
|
+
# binary String whose first byte is the report ID and whose length is the
|
|
15
|
+
# number of bytes the OS returned (size includes the ID byte); and to
|
|
16
|
+
# `#close`, which may be called more than once. Handles raise
|
|
17
|
+
# {Blinkstick::Error} subclasses, or {TransferFailed} for a failure worth
|
|
18
|
+
# trying again, from either transfer.
|
|
16
19
|
#
|
|
17
20
|
# @api private
|
|
18
21
|
module Backend
|
data/lib/blinkstick/report.rb
CHANGED
|
@@ -26,5 +26,74 @@ module Blinkstick
|
|
|
26
26
|
bytes = [FRAME_ID, 0] + colours.flat_map { |c| [c.green, c.red, c.blue] }
|
|
27
27
|
bytes.fill(0, bytes.size...FRAME_SIZE).pack("C*").freeze
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
# Report ID of a single LED write.
|
|
31
|
+
LED_ID = 5
|
|
32
|
+
|
|
33
|
+
# Single LED report size in bytes: ID, channel, index, then R, G, B.
|
|
34
|
+
LED_SIZE = 6
|
|
35
|
+
|
|
36
|
+
# Builds report 5 for channel 0. Unlike the frame, the colour is R, G, B.
|
|
37
|
+
#
|
|
38
|
+
# @param index [Integer] LED index, 0 to 255
|
|
39
|
+
# @param colour [Colour]
|
|
40
|
+
# @return [String] frozen binary String of {LED_SIZE} bytes
|
|
41
|
+
# @raise [ArgumentError] unless index is an Integer from 0 to 255
|
|
42
|
+
def self.led(index, colour)
|
|
43
|
+
raise ArgumentError, "LED index must be an Integer from 0 to 255, got #{index.inspect}" unless
|
|
44
|
+
index.is_a?(Integer) && index.between?(0, 255)
|
|
45
|
+
|
|
46
|
+
[LED_ID, 0, index, colour.red, colour.green, colour.blue].pack("C*").freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Reads the first LEDs of a report 6 (G, R, B per slot).
|
|
50
|
+
#
|
|
51
|
+
# @param bytes [Array<Integer>, String] the {FRAME_SIZE} byte report
|
|
52
|
+
# @param count [Integer] LEDs to decode, at most {FRAME_LEDS}
|
|
53
|
+
# @return [Array<Colour>] frozen
|
|
54
|
+
def self.decode_frame(bytes, count)
|
|
55
|
+
bytes = bytes.bytes if bytes.is_a?(String)
|
|
56
|
+
Array.new(count) do |i|
|
|
57
|
+
green, red, blue = bytes[2 + (i * 3), 3]
|
|
58
|
+
Colour.new(red:, green:, blue:)
|
|
59
|
+
end.freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Report ID of the LED mode.
|
|
63
|
+
MODE_ID = 4
|
|
64
|
+
|
|
65
|
+
# Mode report size in bytes: ID, then the mode.
|
|
66
|
+
MODE_SIZE = 2
|
|
67
|
+
|
|
68
|
+
# Device mode bytes and their names.
|
|
69
|
+
MODES = { 0 => :rgb, 1 => :rgb_inverse, 2 => :ws2812, 3 => :ws2812_mirror }.freeze
|
|
70
|
+
|
|
71
|
+
# @param bytes [Array<Integer>, String] the {MODE_SIZE} byte report
|
|
72
|
+
# @return [Symbol, Integer] the mode name, or the raw byte when unknown
|
|
73
|
+
def self.decode_mode(bytes)
|
|
74
|
+
byte = bytes.is_a?(String) ? bytes.getbyte(1) : bytes[1]
|
|
75
|
+
MODES.fetch(byte, byte)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Info block report size in bytes: ID, then 32 data bytes.
|
|
79
|
+
INFO_BLOCK_SIZE = 33
|
|
80
|
+
|
|
81
|
+
# @param number [Integer] info block 1 or 2
|
|
82
|
+
# @return [Integer] the report ID to read (2 or 3)
|
|
83
|
+
# @raise [ArgumentError] unless number is 1 or 2
|
|
84
|
+
def self.info_block_id(number)
|
|
85
|
+
case number
|
|
86
|
+
when 1 then 2
|
|
87
|
+
when 2 then 3
|
|
88
|
+
else raise ArgumentError, "info block must be 1 or 2, got #{number.inspect}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @param bytes [Array<Integer>, String] the {INFO_BLOCK_SIZE} byte report
|
|
93
|
+
# @return [String] frozen ASCII-8BIT text with trailing NULs removed
|
|
94
|
+
def self.decode_info_block(bytes)
|
|
95
|
+
bytes = bytes.bytes if bytes.is_a?(String)
|
|
96
|
+
bytes[1, INFO_BLOCK_SIZE - 1].pack("C*").sub(/\0+\z/n, "").freeze
|
|
97
|
+
end
|
|
29
98
|
end
|
|
30
99
|
end
|
data/lib/blinkstick/stick.rb
CHANGED
|
@@ -4,7 +4,8 @@ module Blinkstick
|
|
|
4
4
|
# An open BlinkStick. Get one from {Blinkstick.open}.
|
|
5
5
|
#
|
|
6
6
|
# A Stick is safe to share between threads: a Mutex serialises every
|
|
7
|
-
# report
|
|
7
|
+
# report written to or read from the device, and reads always ask the
|
|
8
|
+
# stick (nothing is cached). A stick that is garbage collected without
|
|
8
9
|
# {#close} still releases its OS handle, but call {#close} (or use the
|
|
9
10
|
# block form of {Blinkstick.open}) rather than relying on that.
|
|
10
11
|
class Stick
|
|
@@ -12,7 +13,7 @@ module Blinkstick
|
|
|
12
13
|
attr_reader :info
|
|
13
14
|
|
|
14
15
|
# @api private
|
|
15
|
-
# @param handle [#write_feature, #close] an open backend handle
|
|
16
|
+
# @param handle [#write_feature, #read_feature, #close] an open backend handle
|
|
16
17
|
# @param info [Info]
|
|
17
18
|
def initialize(handle, info)
|
|
18
19
|
@handle = handle
|
|
@@ -41,15 +42,110 @@ module Blinkstick
|
|
|
41
42
|
# @param colour [Colour, String, Symbol, Array<Integer>] anything {Colour.parse} takes
|
|
42
43
|
# @raise [InvalidColourError] before anything is sent, when colour is not a colour
|
|
43
44
|
# @raise [ClosedError] after {#close}
|
|
45
|
+
# @raise [BusyError] when another program holds the stick mid-transfer
|
|
46
|
+
# @raise [PermissionError] when the system refuses access mid-transfer
|
|
44
47
|
# @raise [DisconnectedError] when the stick stops responding
|
|
45
48
|
def colour=(colour)
|
|
46
49
|
write(Report.frame([Colour.parse(colour)] * info.model.leds))
|
|
47
50
|
end
|
|
48
51
|
|
|
52
|
+
# Sets one LED, leaving the others as they are.
|
|
53
|
+
#
|
|
54
|
+
# @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)
|
|
55
|
+
# @param colour [Colour, String, Symbol, Array<Integer>] anything {Colour.parse} takes
|
|
56
|
+
# @return [Colour, String, Symbol, Array<Integer>] colour, as Ruby assignment does
|
|
57
|
+
# @raise [IndexError] when index is not an Integer within the model's LEDs
|
|
58
|
+
# @raise [InvalidColourError] when colour is not a colour (both checked before anything is sent)
|
|
59
|
+
# @raise [ClosedError] after {#close}
|
|
60
|
+
# @raise [BusyError] when another program holds the stick mid-transfer
|
|
61
|
+
# @raise [PermissionError] when the system refuses access mid-transfer
|
|
62
|
+
# @raise [DisconnectedError] when the stick stops responding
|
|
63
|
+
def []=(index, colour)
|
|
64
|
+
parsed = Colour.parse(colour)
|
|
65
|
+
check_index(index)
|
|
66
|
+
write(Report.led(index, parsed))
|
|
67
|
+
colour
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Sets every LED at once, one colour each, in a single report.
|
|
71
|
+
#
|
|
72
|
+
# @param colours [Array<Colour, String, Symbol, Array<Integer>>] exactly one per LED of the model
|
|
73
|
+
# @raise [TypeError] when colours is not array like
|
|
74
|
+
# @raise [ArgumentError] when colours has the wrong length
|
|
75
|
+
# @raise [InvalidColourError] when any entry is not a colour
|
|
76
|
+
# @raise [ClosedError] after {#close}
|
|
77
|
+
# @raise [BusyError] when another program holds the stick mid-transfer
|
|
78
|
+
# @raise [PermissionError] when the system refuses access mid-transfer
|
|
79
|
+
# @raise [DisconnectedError] when the stick stops responding
|
|
80
|
+
def frame=(colours)
|
|
81
|
+
list = Array.try_convert(colours)
|
|
82
|
+
raise TypeError, "frame must be an Array of colours, got #{colours.class}" if list.nil?
|
|
83
|
+
|
|
84
|
+
model = info.model
|
|
85
|
+
unless list.size == model.leds
|
|
86
|
+
raise ArgumentError, "a #{model.name} has #{model.leds} LEDs, got #{list.size} colours"
|
|
87
|
+
end
|
|
88
|
+
|
|
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)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Reads the LED mode from the stick.
|
|
119
|
+
#
|
|
120
|
+
# @return [Symbol, Integer] :rgb, :rgb_inverse, :ws2812 or :ws2812_mirror,
|
|
121
|
+
# or the raw byte for a mode the gem does not know
|
|
122
|
+
# @raise [ClosedError] after {#close}
|
|
123
|
+
# @raise [BusyError] when another program holds the stick mid-transfer
|
|
124
|
+
# @raise [PermissionError] when the system refuses access mid-transfer
|
|
125
|
+
# @raise [DisconnectedError] when the stick stops responding
|
|
126
|
+
def mode
|
|
127
|
+
Report.decode_mode(read(Report::MODE_ID, Report::MODE_SIZE))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Reads a user data block from the stick.
|
|
131
|
+
#
|
|
132
|
+
# @param number [Integer] 1 or 2
|
|
133
|
+
# @return [String] frozen binary String, up to 32 bytes, trailing NULs removed
|
|
134
|
+
# @raise [ArgumentError] before anything is sent, when number is not 1 or 2
|
|
135
|
+
# @raise [ClosedError] after {#close}
|
|
136
|
+
# @raise [BusyError] when another program holds the stick mid-transfer
|
|
137
|
+
# @raise [PermissionError] when the system refuses access mid-transfer
|
|
138
|
+
# @raise [DisconnectedError] when the stick stops responding
|
|
139
|
+
def info_block(number)
|
|
140
|
+
Report.decode_info_block(read(Report.info_block_id(number), Report::INFO_BLOCK_SIZE))
|
|
141
|
+
end
|
|
142
|
+
|
|
49
143
|
# Turns every LED off.
|
|
50
144
|
#
|
|
51
145
|
# @return [void]
|
|
52
146
|
# @raise [ClosedError] after {#close}
|
|
147
|
+
# @raise [BusyError] when another program holds the stick mid-transfer
|
|
148
|
+
# @raise [PermissionError] when the system refuses access mid-transfer
|
|
53
149
|
# @raise [DisconnectedError] when the stick stops responding
|
|
54
150
|
def off
|
|
55
151
|
self.colour = Colour::OFF
|
|
@@ -77,27 +173,56 @@ module Blinkstick
|
|
|
77
173
|
|
|
78
174
|
private
|
|
79
175
|
|
|
80
|
-
#
|
|
81
|
-
|
|
176
|
+
# @raise [IndexError] unless index is an Integer from 0 to the model's LEDs
|
|
177
|
+
def check_index(index)
|
|
178
|
+
model = info.model
|
|
179
|
+
return if index.is_a?(Integer) && index >= 0 && index < model.leds
|
|
180
|
+
|
|
181
|
+
raise IndexError, "LED #{index.inspect} is out of range: a #{model.name} has #{model.leds} LEDs"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Sends a report.
|
|
82
185
|
def write(report)
|
|
186
|
+
transfer("writes") { @handle.write_feature(report) }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Reads a report. A reply that is short or carries another report's ID
|
|
190
|
+
# counts as a failed attempt, so it is retried and never decoded.
|
|
191
|
+
#
|
|
192
|
+
# @return [String] at least size bytes, the first being report_id
|
|
193
|
+
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
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Holds the lock across every attempt, so transfers from other threads
|
|
206
|
+
# cannot land in between.
|
|
207
|
+
def transfer(what, &)
|
|
83
208
|
@mutex.synchronize do
|
|
84
209
|
raise ClosedError, "#{info.serial} is closed" if @closed
|
|
85
210
|
|
|
86
|
-
|
|
211
|
+
with_retries(what, &)
|
|
87
212
|
end
|
|
88
213
|
end
|
|
89
214
|
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
def
|
|
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)
|
|
93
218
|
attempt = 0
|
|
94
219
|
begin
|
|
95
220
|
attempt += 1
|
|
96
|
-
|
|
221
|
+
yield
|
|
97
222
|
rescue Backend::TransferFailed
|
|
98
223
|
retry if attempt < Backend::ATTEMPTS
|
|
99
224
|
|
|
100
|
-
raise DisconnectedError, "#{info.serial} stopped responding: #{attempt}
|
|
225
|
+
raise DisconnectedError, "#{info.serial} stopped responding: #{attempt} #{what} failed in a row"
|
|
101
226
|
end
|
|
102
227
|
end
|
|
103
228
|
end
|
data/lib/blinkstick/version.rb
CHANGED