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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 538eb17c6e17b2bdaff6ca9b2f39d1c34196d0c72ce7dffee3b57d47a04131b7
4
- data.tar.gz: e3f10dcdf64efcd8913052ce3f0b0afe7493a1bae9d705ccdb0b5cb7bea788f2
3
+ metadata.gz: dbeec7298582de4220e66562a9fadfd994fc540c920b6af2da8ea0b91cbc7cee
4
+ data.tar.gz: d016a0be08f195f84e43a1e663a7b70c49d8174d4a1fba2b1332cc70c6159c7b
5
5
  SHA512:
6
- metadata.gz: 17438f01d70babc376163f338220a536daaf11e3704efb69b3748d19b460f58e565f613e4c2d81ad457fa47d2a9c4ed66eaa64cefa0d794397d1443905e19281
7
- data.tar.gz: 56c068d7cbcfc9094988b69abd46c95b9ba60414e253549a2aed7a720793dbced76e8b87247aee7c2c7ba865890696c469aab0979ad40f88d57b4eea58159d3b
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 | no | no | | not yet | Set a single LED by index, using report 5 |
25
- | Set every LED at once | no | no | | not yet | Write a whole frame in one report |
26
- | Read LED values back | no | no | | not yet | Read the whole frame or a single LED |
27
- | Info blocks 1 and 2 | no | no | | not yet | Read and write the info blocks. 32 bytes each, stored in EEPROM |
28
- | Set one LED directly (report 5) | no | no | | not yet | `[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) | no | no | | not yet | `[4, mode]`. Both read mode 2 |
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
- Version 0.1.0 finds a BlinkStick Nano or Square, opens it, sets every LED to a colour and
16
- turns them off, on macOS and Linux. Single LEDs, frames, effects and the rest are still to
17
- come. [CAPABILITIES.md](CAPABILITIES.md) lists what is supported now and the Ruby method for
18
- each feature.
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
- raise ClosedError, "#{name} is closed" if @closed
49
+ transfer { @io.ioctl(Hidraw.set_feature_request(report.bytesize), report.dup) }
50
+ end
41
51
 
42
- @io.ioctl(Hidraw.set_feature_request(report.bytesize), report.dup)
43
- rescue Errno::ENODEV, Errno::ENOENT, Errno::ESHUTDOWN => e
44
- raise DisconnectedError, "#{name} was unplugged (#{e.class})"
45
- rescue Errno::EACCES, Errno::EPERM => e
46
- raise PermissionError, "#{name}: #{e.message}. #{UDEV_HINT}"
47
- rescue SystemCallError => e
48
- raise TransferFailed, "#{name}: #{e.message}"
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 sends
9
- # feature reports with IOHIDDeviceSetReport, calling IOKit and
10
- # CoreFoundation through ffi. Sticks are opened shared, never seized.
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 error a handle raises.
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
- raise ClosedError, "#{name} is closed" if @closed
205
-
206
- code = Native.hid_device_set_report(@device, REPORT_TYPE_FEATURE, report.getbyte(0), report,
207
- report.bytesize)
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
- raise IOKit.error_for(code), "#{name}: writing a report failed (#{IOKit.describe(code)})"
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
@@ -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, and to `#close`, which may be called more than
14
- # once. Handles raise {Blinkstick::Error} subclasses, or {TransferFailed}
15
- # for a failure worth trying again.
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
@@ -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
@@ -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 sent to the device. A stick that is garbage collected without
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
- # Holds the lock across every attempt, so reports from other threads
81
- # cannot land in between.
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
- transfer(report)
211
+ with_retries(what, &)
87
212
  end
88
213
  end
89
214
 
90
- # Tries up to Backend::ATTEMPTS times while the handle says the failure
91
- # is transient. The caller must hold the lock.
92
- def transfer(report)
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
- @handle.write_feature(report)
221
+ yield
97
222
  rescue Backend::TransferFailed
98
223
  retry if attempt < Backend::ATTEMPTS
99
224
 
100
- raise DisconnectedError, "#{info.serial} stopped responding: #{attempt} writes failed in a row"
225
+ raise DisconnectedError, "#{info.serial} stopped responding: #{attempt} #{what} failed in a row"
101
226
  end
102
227
  end
103
228
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Blinkstick
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-blinkstick
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keith Bawden