ruby-blinkstick 0.0.2 → 0.1.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: b6585fad55a10b4656175afc2f1e41675bf19cd58efbfe1776c74f13fb7817b9
4
- data.tar.gz: e1d8dc9095f6d482544f4d06096a0623be521a6e685a2a0714f3e3b38644ad93
3
+ metadata.gz: 538eb17c6e17b2bdaff6ca9b2f39d1c34196d0c72ce7dffee3b57d47a04131b7
4
+ data.tar.gz: e3f10dcdf64efcd8913052ce3f0b0afe7493a1bae9d705ccdb0b5cb7bea788f2
5
5
  SHA512:
6
- metadata.gz: 5bddb3dd5ffe701b5b8a088cd2b99b6fe532950d14ec63346ac34ea202cc6029d3fe7acb9f82c704c561c6e2586a59ef86f9f9f4f4e029229d3caa2ce4d831d4
7
- data.tar.gz: c30d066e7288a8ac7f244602114f7b08f2c30d5a0b601f5d9c84bad22471971cc3a53092c367011be4f757585a176cb8b865a431a2c08d97758338f0fde67e23
6
+ metadata.gz: 17438f01d70babc376163f338220a536daaf11e3704efb69b3748d19b460f58e565f613e4c2d81ad457fa47d2a9c4ed66eaa64cefa0d794397d1443905e19281
7
+ data.tar.gz: 56c068d7cbcfc9094988b69abd46c95b9ba60414e253549a2aed7a720793dbced76e8b87247aee7c2c7ba865890696c469aab0979ad40f88d57b4eea58159d3b
data/CAPABILITIES.md CHANGED
@@ -1,9 +1,7 @@
1
1
  # Capabilities
2
2
 
3
- The gem is a placeholder and supports nothing yet. This table is the checklist for the implementation.
4
-
5
- What the BlinkStick Nano and Square can do, and what this gem supports so far. Back to the
6
- [README](README.md).
3
+ What the BlinkStick Nano and Square can do, and what this gem supports so far. The Ruby column
4
+ names the method or class that covers a row. Back to the [README](README.md).
7
5
 
8
6
  This file is updated whenever a feature lands.
9
7
 
@@ -14,51 +12,51 @@ device may have it but it has not been confirmed on hardware.
14
12
 
15
13
  Features built into the BlinkStick firmware.
16
14
 
17
- | Feature | Nano | Square | Supported since | Notes |
18
- |---|---|---|---|---|
19
- | List attached sticks | no | no | not yet | Enumerate every attached stick |
20
- | Open the first stick or one by serial | no | no | not yet | Open the first stick found, or a stick by its serial |
21
- | Name a stick and open it by name | no | no | not yet | Store a name, read it back, open and list sticks by name. Stored in info block 1 |
22
- | Several sticks open at once | no | no | not yet | |
23
- | Serial and firmware version | no | no | not yet | Read from the device info |
24
- | Manufacturer and product strings | no | no | not yet | Read from the device info |
25
- | Set all LEDs | no | no | not yet | Set every LED to one colour, or turn them off |
26
- | Set one LED | no | no | not yet | Set a single LED by index, using report 5 |
27
- | Set every LED at once | no | no | not yet | Write a whole frame in one report |
28
- | Read LED values back | no | no | not yet | Read the whole frame or a single LED |
29
- | Info blocks 1 and 2 | no | no | not yet | Read and write the info blocks. 32 bytes each, stored in EEPROM |
30
- | 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 |
31
- | Read device mode (report 4) | no | no | not yet | `[4, mode]`. Both read mode 2 |
32
- | 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 |
33
- | Mode 2, WS2812 | no | no | not yet | The mode both are in, and the one every LED feature here needs. Probably the factory default |
34
- | 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 |
35
- | Modes 0 and 1, normal and inverse | n/a | n/a | n/a | For the BlinkStick Pro's RGB outputs. Upstream reports a Nano in mode 1 going dark until replugged. Setting the mode refuses them |
36
- | Set LED 0, or every LED in mode 3 (report 1) | no | no | not yet | `[1, R, G, B]`, RGB order. Used by other BlinkStick software. Setting LED 0 or all LEDs does the same job in either mode |
37
- | Report 10 | n/a | unknown | no | Square only, 2 bytes, reads 00 00. Undocumented; waiting on the manufacturer |
38
- | LED count setting (report 0x81) | n/a | n/a | n/a | BlinkStick Flex only |
39
- | 16, 32 and 64 LED frames (reports 7 to 9) | n/a | n/a | n/a | For longer strips |
15
+ | Feature | Nano | Square | Ruby | Supported since | Notes |
16
+ |---|---|---|---|---|---|
17
+ | List attached sticks | yes | yes | `Blinkstick.list` | 0.1.0 | Enumerate every attached stick |
18
+ | Open the first stick or one by serial | yes | yes | `Blinkstick.open`, `Blinkstick.open(serial:)` | 0.1.0 | Open the first stick found, or a stick by its serial |
19
+ | Name a stick and open it by name | no | no | | not yet | Store a name, read it back, open and list sticks by name. Stored in info block 1 |
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 |
22
+ | Manufacturer and product strings | yes | yes | `info.manufacturer`, `info.product` | 0.1.0 | Read from the device info |
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 |
30
+ | Set device mode (report 4) | no | no | | not yet | Stored in EEPROM, blocks about 50 ms, skipped when the mode is unchanged, takes effect without a replug. 2 to 3 to 2 verified on both |
31
+ | Mode 2, WS2812 | no | no | | not yet | The mode both are in, and the one every LED feature here needs. Probably the factory default |
32
+ | Mode 3, WS2812 mirror | no | no | | not yet | Changes only report 1, whose colour goes to every LED. Reports 5 and 6 behave as in mode 2 |
33
+ | Modes 0 and 1, normal and inverse | n/a | n/a | | n/a | For the BlinkStick Pro's RGB outputs. Upstream reports a Nano in mode 1 going dark until replugged. Setting the mode refuses them |
34
+ | Set LED 0, or every LED in mode 3 (report 1) | no | no | | not yet | `[1, R, G, B]`, RGB order. Used by other BlinkStick software. Setting LED 0 or all LEDs does the same job in either mode |
35
+ | Report 10 | n/a | unknown | | no | Square only, 2 bytes, reads 00 00. Undocumented; waiting on the manufacturer |
36
+ | LED count setting (report 0x81) | n/a | n/a | | n/a | BlinkStick Flex only |
37
+ | 16, 32 and 64 LED frames (reports 7 to 9) | n/a | n/a | | n/a | For longer strips |
40
38
 
41
39
  ## Host features
42
40
 
43
41
  Features this gem adds in software. They work the same on every supported device.
44
42
 
45
- | Feature | Supported since | Notes |
46
- |---|---|---|
47
- | Model detection | not yet | From the serial and USB release number |
48
- | Colour from hex or `r,g,b` | not yet | Parse a colour from hex, with or without the `#`, or `r,g,b` |
49
- | Brightness limit | not yet | Cap the brightness of every colour written |
50
- | Blink, pulse and morph | not yet | Cancellable |
51
- | CSS colour names | not yet | All 148 from CSS Color Level 4 |
52
- | List CSS colour names | not yet | Sorted, for shell completion |
53
- | Colour as hex | not yet | Lower case `#rrggbb` |
54
- | Random colour | not yet | Any colour, or a vivid one |
55
- | Inverse colours | not yet | Invert a colour or the whole frame. Not the firmware inverse mode |
56
- | Reconnect after unplug | not yet | Automatic, repaints the last frame |
43
+ | Feature | Ruby | Supported since | Notes |
44
+ |---|---|---|---|
45
+ | Model detection | `info.model` | 0.1.0 | From the serial and USB release number |
46
+ | Colour from hex or `r,g,b` | `Colour.parse` | 0.1.0 | Parse a colour from hex, with or without the `#`, or `r,g,b` |
47
+ | Brightness limit | | not yet | Cap the brightness of every colour written |
48
+ | Blink, pulse and morph | | not yet | Cancellable |
49
+ | CSS colour names | `Colour.parse` | 0.1.0 | All 148 from CSS Color Level 4 |
50
+ | List CSS colour names | `Colour.names` | 0.1.0 | Sorted, for shell completion |
51
+ | Colour as hex | `Colour#hex`, `Colour#to_s` | 0.1.0 | Lower case `#rrggbb` |
52
+ | Random colour | | not yet | Any colour, or a vivid one |
53
+ | Inverse colours | | not yet | Invert a colour or the whole frame. Not the firmware inverse mode |
54
+ | Reconnect after unplug | | not yet | Automatic, repaints the last frame |
57
55
 
58
56
  ## Platforms
59
57
 
60
58
  | Platform | Supported since |
61
59
  |---|---|
62
- | macOS | not yet |
63
- | Linux | not yet |
60
+ | macOS | 0.1.0 |
61
+ | Linux | 0.1.0 |
64
62
  | Windows | no |
data/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ All notable changes to this project are recorded here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.0] - 2026-10-28
10
+
11
+ ### Added
12
+
13
+ - `Blinkstick.list` lists every attached BlinkStick as an `Info` value with its serial, firmware version, manufacturer, product and model.
14
+ - `Blinkstick.open` opens the first stick or one by serial, with or without a block. Several sticks can be open at once.
15
+ - `Stick#colour=` sets every LED to one colour and `Stick#off` turns them off. A stick is safe to share between threads, and `close` and `closed?` manage its lifetime.
16
+ - `Colour.parse` accepts hex, `r,g,b`, an array of three integers and the 148 CSS colour names (strings or symbols, any case). `Colour#hex` and `Colour#to_s` give lower case `#rrggbb`, and `Colour.names` lists the names.
17
+ - Model detection for the BlinkStick Nano (2 LEDs) and Square (8 LEDs). Other models are listed as `Model::UNKNOWN` and refused by `open`.
18
+ - Errors under `Blinkstick::Error` for a missing stick, a stick seized by another program, a refused permission, an unsupported model, a closed stick and a disconnected stick. `InvalidColourError` is an `ArgumentError`.
19
+ - macOS support through IOKit (via `ffi`, loaded only on macOS) and Linux support through `hidraw`, with nothing to install but the gem. Sticks are opened shared, so other programs can use them too.
20
+
21
+ ### Changed
22
+
23
+ - The documented `gh attestation verify` command checks the release workflow, not just the repository.
24
+ - The release workflow also checks that the attestation names the tagged commit, and a re-run refuses to go on if rubygems.org has a different gem for the version.
25
+
9
26
  ## [0.0.2] - 2026-10-28
10
27
 
11
28
  ### Added
data/README.md CHANGED
@@ -12,9 +12,10 @@ of BlinkStick.
12
12
 
13
13
  ## Status
14
14
 
15
- This gem is a placeholder that reserves the name and does nothing else yet. The implementation
16
- will port [go-blinkstick](https://github.com/bawdo/go-blinkstick). See
17
- [CAPABILITIES.md](CAPABILITIES.md) for the features it will cover.
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.
18
19
 
19
20
  ## Install
20
21
 
@@ -34,6 +35,51 @@ you `require "blinkstick"` and use the `Blinkstick` module.
34
35
  ## Requirements
35
36
 
36
37
  - Ruby 3.4 or later.
38
+ - macOS or Linux (Windows is not supported).
39
+ - Nothing else to install. The gem needs no system library and no compiler: its one
40
+ dependency, `ffi`, installs as a prebuilt gem, and it is only loaded on macOS. Linux talks
41
+ to the stick through `hidraw` using Ruby's standard library alone. Linux also needs the
42
+ udev rule described below.
43
+
44
+ ## Quick start
45
+
46
+ ```ruby
47
+ require "blinkstick"
48
+
49
+ Blinkstick.list
50
+ # => [#<data Blinkstick::Info serial="BS072777-3.0", version="3.0",
51
+ # manufacturer="Agile Innovative Ltd", product="BlinkStick",
52
+ # model=#<data Blinkstick::Model name="Nano", leds=2>>]
53
+
54
+ Blinkstick.open do |stick| # the first stick; the block form closes it, like File.open
55
+ stick.colour = :cornflowerblue # set every LED
56
+ stick.colour = "#ff8800"
57
+ stick.colour = [255, 136, 0]
58
+ stick.off # every LED off
59
+ end
60
+
61
+ stick = Blinkstick.open(serial: "BS072777-3.0") # without a block, you close it
62
+ stick.info # => Blinkstick::Info
63
+ stick.close
64
+ stick.closed? # => true
65
+
66
+ Blinkstick::Colour.parse("255,136,0").hex # => "#ff8800"
67
+ Blinkstick::Colour.names.first(3) # => ["aliceblue", "antiquewhite", "aqua"]
68
+ ```
69
+
70
+ A colour can be a CSS colour name (a string or symbol, any case), hex (`#rgb` or `#rrggbb`,
71
+ with or without the `#`), `"r,g,b"`, or an array of three integers.
72
+
73
+ Failures raise subclasses of `Blinkstick::Error`: `NotFoundError`, `BusyError`,
74
+ `PermissionError`, `UnsupportedModelError`, `ClosedError` and `DisconnectedError`. A bad
75
+ colour raises `Blinkstick::InvalidColourError`, which is an `ArgumentError`.
76
+
77
+ ### Sharing a stick
78
+
79
+ Sticks are shared on both platforms, so several programs (and several Ruby processes) can
80
+ drive the same stick at once, and the last write is what shows. The exception is a program
81
+ that seizes the stick for itself, as ones built on hidapi do on macOS. While that program has
82
+ it, this gem raises `Blinkstick::BusyError`. Close the other program and try again.
37
83
 
38
84
  ## Linux
39
85
 
@@ -65,7 +111,8 @@ came from this repository's workflow, with the [GitHub CLI](https://cli.github.c
65
111
 
66
112
  ```sh
67
113
  gem fetch ruby-blinkstick -v 0.0.2
68
- gh attestation verify ruby-blinkstick-0.0.2.gem --repo bawdo/ruby-blinkstick
114
+ gh attestation verify ruby-blinkstick-0.0.2.gem --repo bawdo/ruby-blinkstick \
115
+ --signer-workflow bawdo/ruby-blinkstick/.github/workflows/release.yml
69
116
  ```
70
117
 
71
118
  Swap in the version you are checking. [SECURITY.md](SECURITY.md) has the details.
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../blinkstick"
4
+
5
+ module Blinkstick
6
+ module Backend
7
+ # The Linux backend. It finds sticks by reading sysfs and talks to them
8
+ # through their /dev/hidraw node with the HIDIOCSFEATURE ioctl, so it
9
+ # needs nothing beyond the standard library.
10
+ #
11
+ # @api private
12
+ class Hidraw
13
+ # The ioctl request number for HIDIOCSFEATURE of the given length:
14
+ # direction read/write (3), size, type 'H' (0x48) and number 6.
15
+ #
16
+ # @param length [Integer] report size in bytes
17
+ # @return [Integer]
18
+ def self.set_feature_request(length) # rubocop:disable Naming/AccessorMethodName
19
+ # The name is the kernel's (HIDIOCSFEATURE), not a Ruby writer.
20
+ (3 << 30) | (length << 16) | (0x48 << 8) | 0x06
21
+ end
22
+
23
+ # An open hidraw node.
24
+ class Handle
25
+ # @param io [#ioctl, #close] the open device node
26
+ # @param serial [String, nil] named in error messages
27
+ def initialize(io, serial: nil)
28
+ @io = io
29
+ @serial = serial
30
+ @closed = false
31
+ end
32
+
33
+ # Sends a feature report.
34
+ #
35
+ # @param report [String] binary String whose first byte is the report ID
36
+ # @raise [ClosedError] after {#close}
37
+ # @raise [DisconnectedError] when the stick is unplugged
38
+ # @raise [TransferFailed] for any other failed transfer
39
+ def write_feature(report)
40
+ raise ClosedError, "#{name} is closed" if @closed
41
+
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}"
49
+ end
50
+
51
+ # Releases the node. Calling it again does nothing, and it never raises.
52
+ #
53
+ # @return [void]
54
+ def close
55
+ return if @closed
56
+
57
+ @closed = true
58
+ begin
59
+ @io.close
60
+ rescue IOError, SystemCallError
61
+ nil
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def name
68
+ @serial || "the stick"
69
+ end
70
+ end
71
+
72
+ UDEV_HINT = 'See the "Linux" section of the README (the udev rule) to grant access.'
73
+ private_constant :UDEV_HINT
74
+
75
+ # @param sysfs [String] where sysfs is mounted
76
+ # @param dev [String] where the device nodes live
77
+ def initialize(sysfs: "/sys", dev: "/dev")
78
+ @sysfs = sysfs
79
+ @dev = dev
80
+ end
81
+
82
+ # @return [Array<Device>] every BlinkStick attached, in hidraw order
83
+ # (hidraw2 before hidraw10)
84
+ def list
85
+ Dir.glob(File.join(@sysfs, "class/hidraw/hidraw*"))
86
+ .sort_by { |entry| File.basename(entry).delete_prefix("hidraw").to_i }
87
+ .filter_map { |entry| device_for(entry) }
88
+ end
89
+
90
+ # @param device [Device] a device from {#list}
91
+ # @return [Handle]
92
+ # @raise [NotFoundError, PermissionError, BusyError] when it cannot be opened
93
+ # @raise [Error] when opening fails for any other reason
94
+ def open(device)
95
+ Handle.new(File.open(device.id, "r+b"), serial: device.serial)
96
+ rescue SystemCallError => e
97
+ raise open_error(e, device)
98
+ end
99
+
100
+ private
101
+
102
+ def open_error(error, device)
103
+ where = "#{device.serial} (#{device.id})"
104
+ case error
105
+ when Errno::ENOENT, Errno::ENODEV then NotFoundError.new("#{where} is no longer attached")
106
+ when Errno::EACCES, Errno::EPERM then PermissionError.new("Permission denied opening #{where}. #{UDEV_HINT}")
107
+ when Errno::EBUSY then BusyError.new("#{where} is in use by another program")
108
+ else Error.new("Could not open #{where}: #{error.message}")
109
+ end
110
+ end
111
+
112
+ def device_for(entry)
113
+ hid = File.realpath(File.join(entry, "device"))
114
+ uevent = parse_uevent(File.join(hid, "uevent"))
115
+ return unless blinkstick?(uevent["HID_ID"])
116
+
117
+ usb = File.expand_path("../..", hid)
118
+ Device.new(id: File.join(@dev, File.basename(entry)), serial: text(uevent.fetch("HID_UNIQ", "")),
119
+ manufacturer: attribute(usb, "manufacturer"), product: attribute(usb, "product"),
120
+ release: attribute(usb, "bcdDevice").to_i(16))
121
+ rescue SystemCallError
122
+ nil
123
+ end
124
+
125
+ # Read in binary, so a device name that is not valid in the locale's
126
+ # encoding (a UTF-8 "®" under LANG=C, say) cannot break parsing.
127
+ def parse_uevent(path)
128
+ File.readlines(path, chomp: true, mode: "rb").to_h { |line| line.split("=", 2).then { |k, v| [k, v.to_s] } }
129
+ end
130
+
131
+ # HID_ID is bus:vendor:product in hex.
132
+ def blinkstick?(hid_id)
133
+ _bus, vendor, product = hid_id.to_s.split(":")
134
+ vendor.to_s.hex == VENDOR_ID && product.to_s.hex == PRODUCT_ID
135
+ end
136
+
137
+ def attribute(dir, file)
138
+ text(File.binread(File.join(dir, file)).strip)
139
+ rescue SystemCallError
140
+ ""
141
+ end
142
+
143
+ # sysfs strings are UTF-8 by convention; invalid bytes become U+FFFD.
144
+ def text(bytes)
145
+ bytes.dup.force_encoding(Encoding::UTF_8).scrub
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+ require_relative "../../blinkstick"
5
+
6
+ module Blinkstick
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.
11
+ #
12
+ # CoreFoundation ownership follows Apple's Create rule: anything returned
13
+ # by a Create or Copy call is released here, and anything returned by a
14
+ # Get call is borrowed. Each listed device reference is retained, and
15
+ # released by its FFI::AutoPointer once no {Device} refers to it. {#open}
16
+ # takes a second retain that the {Handle} owns and releases on close, so
17
+ # an open device outlives the AutoPointer whatever order finalisers run in.
18
+ #
19
+ # @api private
20
+ class IOKit
21
+ # kIOHIDOptionsTypeNone: open the device shared, never seized.
22
+ OPEN_OPTIONS = 0
23
+
24
+ # The IOKit and CoreFoundation functions this backend calls, and
25
+ # helpers that make and read CF values.
26
+ #
27
+ # @api private
28
+ module Native
29
+ extend FFI::Library
30
+
31
+ ffi_lib "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation",
32
+ "/System/Library/Frameworks/IOKit.framework/IOKit"
33
+
34
+ attach_function :cf_retain, :CFRetain, [:pointer], :pointer
35
+ attach_function :cf_release, :CFRelease, [:pointer], :void
36
+ attach_function :cf_get_type_id, :CFGetTypeID, [:pointer], :ulong
37
+ attach_function :cf_string_get_type_id, :CFStringGetTypeID, [], :ulong
38
+ attach_function :cf_number_get_type_id, :CFNumberGetTypeID, [], :ulong
39
+ attach_function :cf_string_create_with_c_string, :CFStringCreateWithCString, %i[pointer string uint32], :pointer
40
+ attach_function :cf_string_get_length, :CFStringGetLength, [:pointer], :long
41
+ attach_function :cf_string_get_maximum_size_for_encoding, :CFStringGetMaximumSizeForEncoding,
42
+ %i[long uint32], :long
43
+ attach_function :cf_string_get_c_string, :CFStringGetCString, %i[pointer pointer long uint32], :uchar
44
+ attach_function :cf_number_create, :CFNumberCreate, %i[pointer long pointer], :pointer
45
+ attach_function :cf_number_get_value, :CFNumberGetValue, %i[pointer long pointer], :uchar
46
+ attach_function :cf_dictionary_create_mutable, :CFDictionaryCreateMutable,
47
+ %i[pointer long pointer pointer], :pointer
48
+ attach_function :cf_dictionary_set_value, :CFDictionarySetValue, %i[pointer pointer pointer], :void
49
+ attach_function :cf_set_get_count, :CFSetGetCount, [:pointer], :long
50
+ attach_function :cf_set_get_values, :CFSetGetValues, %i[pointer pointer], :void
51
+
52
+ attach_function :hid_manager_create, :IOHIDManagerCreate, %i[pointer uint32], :pointer
53
+ attach_function :hid_manager_set_device_matching, :IOHIDManagerSetDeviceMatching, %i[pointer pointer], :void
54
+ attach_function :hid_manager_copy_devices, :IOHIDManagerCopyDevices, [:pointer], :pointer
55
+ attach_function :hid_device_get_property, :IOHIDDeviceGetProperty, %i[pointer pointer], :pointer
56
+ # IOReturn is a signed 32-bit kern_return_t, so failures come back negative.
57
+ attach_function :hid_device_open, :IOHIDDeviceOpen, %i[pointer uint32], :int
58
+ attach_function :hid_device_close, :IOHIDDeviceClose, %i[pointer uint32], :int
59
+ attach_function :hid_device_set_report, :IOHIDDeviceSetReport, %i[pointer int long buffer_in long], :int
60
+
61
+ UTF8 = 0x08000100 # kCFStringEncodingUTF8
62
+ SINT32 = 3 # kCFNumberSInt32Type
63
+ private_constant :UTF8, :SINT32
64
+
65
+ class << self
66
+ # The address of a CoreFoundation global, such as a callbacks struct.
67
+ #
68
+ # @param name [String]
69
+ # @return [FFI::Pointer]
70
+ def global(name)
71
+ ffi_libraries.each do |library|
72
+ symbol = library.find_variable(name)
73
+ return symbol if symbol
74
+ end
75
+ raise Error, "CoreFoundation has no #{name}"
76
+ end
77
+
78
+ # @param text [String]
79
+ # @return [FFI::Pointer] a new CFString the caller must {release}
80
+ def string(text)
81
+ cf_string_create_with_c_string(nil, text, UTF8)
82
+ end
83
+
84
+ # @param value [Integer]
85
+ # @return [FFI::Pointer] a new CFNumber the caller must {release}
86
+ def number(value)
87
+ buffer = FFI::MemoryPointer.new(:int32)
88
+ buffer.write_int32(value)
89
+ cf_number_create(nil, SINT32, buffer)
90
+ end
91
+
92
+ # Reads a borrowed CFString.
93
+ #
94
+ # @param ref [FFI::Pointer]
95
+ # @return [String] "" when ref is not a CFString or cannot be converted
96
+ def read_string(ref)
97
+ return "" unless type?(ref, cf_string_get_type_id)
98
+
99
+ size = cf_string_get_maximum_size_for_encoding(cf_string_get_length(ref), UTF8) + 1
100
+ buffer = FFI::MemoryPointer.new(:char, size)
101
+ return "" if cf_string_get_c_string(ref, buffer, size, UTF8).zero?
102
+
103
+ buffer.read_string.force_encoding(Encoding::UTF_8)
104
+ end
105
+
106
+ # Reads a borrowed CFNumber.
107
+ #
108
+ # @param ref [FFI::Pointer]
109
+ # @return [Integer] 0 when ref is not a CFNumber
110
+ def read_integer(ref)
111
+ return 0 unless type?(ref, cf_number_get_type_id)
112
+
113
+ buffer = FFI::MemoryPointer.new(:int32)
114
+ cf_number_get_value(ref, SINT32, buffer)
115
+ buffer.read_int32
116
+ end
117
+
118
+ # Releases a CF object, ignoring nil and NULL.
119
+ #
120
+ # @param ref [FFI::Pointer, nil]
121
+ # @return [void]
122
+ def release(ref)
123
+ cf_release(ref) unless ref.nil? || ref.null?
124
+ end
125
+
126
+ private
127
+
128
+ def type?(ref, type_id)
129
+ !ref.null? && cf_get_type_id(ref) == type_id
130
+ end
131
+ end
132
+ end
133
+
134
+ # IOReturn values, from IOKit/IOReturn.h.
135
+ NO_DEVICE = 0xE00002C0
136
+ NOT_PRIVILEGED = 0xE00002C1
137
+ EXCLUSIVE_ACCESS = 0xE00002C5
138
+ NOT_ATTACHED = 0xE00002D9
139
+ NOT_PERMITTED = 0xE00002E2
140
+ REPORT_TYPE_FEATURE = 2 # kIOHIDReportTypeFeature
141
+ private_constant :NO_DEVICE, :NOT_PRIVILEGED, :EXCLUSIVE_ACCESS, :NOT_ATTACHED, :NOT_PERMITTED,
142
+ :REPORT_TYPE_FEATURE
143
+
144
+ OPEN_REASONS = {
145
+ BusyError => "it is in use by another program",
146
+ PermissionError => "permission denied",
147
+ NotFoundError => "it is no longer attached"
148
+ }.freeze
149
+ PROPERTY_KEYS = %w[VendorID ProductID VersionNumber Manufacturer Product SerialNumber].freeze
150
+ private_constant :OPEN_REASONS, :PROPERTY_KEYS
151
+
152
+ # Maps a failed IOHIDDeviceSetReport to the error a handle raises.
153
+ #
154
+ # @param code [Integer] the IOReturn, signed or unsigned
155
+ # @return [Class] a {Blinkstick::Error} subclass
156
+ def self.error_for(code)
157
+ case code & 0xFFFF_FFFF
158
+ when EXCLUSIVE_ACCESS then BusyError
159
+ when NOT_PRIVILEGED, NOT_PERMITTED then PermissionError
160
+ when NO_DEVICE, NOT_ATTACHED then DisconnectedError
161
+ else TransferFailed
162
+ end
163
+ end
164
+
165
+ # Maps a failed IOHIDDeviceOpen to the error {#open} raises.
166
+ #
167
+ # @param code [Integer] the IOReturn, signed or unsigned
168
+ # @return [Class] a {Blinkstick::Error} subclass
169
+ def self.open_error_for(code)
170
+ case code & 0xFFFF_FFFF
171
+ when EXCLUSIVE_ACCESS then BusyError
172
+ when NOT_PRIVILEGED, NOT_PERMITTED then PermissionError
173
+ when NO_DEVICE, NOT_ATTACHED then NotFoundError
174
+ else Error
175
+ end
176
+ end
177
+
178
+ # Formats an IOReturn for messages.
179
+ #
180
+ # @param code [Integer]
181
+ # @return [String]
182
+ def self.describe(code)
183
+ format("IOReturn 0x%08X", code & 0xFFFF_FFFF)
184
+ end
185
+
186
+ # An open IOHIDDevice.
187
+ class Handle
188
+ # @param device [FFI::Pointer] an IOHIDDeviceRef retained for this handle, which
189
+ # releases it on {#close}
190
+ # @param serial [String, nil] named in error messages
191
+ def initialize(device, serial: nil)
192
+ @device = device
193
+ @serial = serial
194
+ @closed = false
195
+ end
196
+
197
+ # Sends a feature report.
198
+ #
199
+ # @param report [String] binary String whose first byte is the report ID
200
+ # @raise [ClosedError] after {#close}
201
+ # @raise [DisconnectedError] when the stick is unplugged
202
+ # @raise [TransferFailed] for any other failed transfer
203
+ 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?
209
+
210
+ raise IOKit.error_for(code), "#{name}: writing a report failed (#{IOKit.describe(code)})"
211
+ end
212
+
213
+ # Closes the device, then releases the reference the handle owns.
214
+ # Calling it again does nothing, and it never raises.
215
+ #
216
+ # @return [void]
217
+ def close
218
+ return if @closed
219
+
220
+ @closed = true
221
+ begin
222
+ Native.hid_device_close(@device, OPEN_OPTIONS)
223
+ ensure
224
+ Native.release(@device)
225
+ end
226
+ nil
227
+ end
228
+
229
+ private
230
+
231
+ def name
232
+ @serial || "the stick"
233
+ end
234
+ end
235
+
236
+ # @return [Array<Device>] every BlinkStick attached
237
+ def list
238
+ keys = PROPERTY_KEYS.to_h { |key| [key, Native.string(key)] }
239
+ manager = Native.hid_manager_create(nil, OPEN_OPTIONS)
240
+ raise Error, "IOKit could not create an IOHIDManager" if manager.null?
241
+
242
+ match(manager, keys)
243
+ copy_devices(manager).map { |ref| device_for(ref, keys) }
244
+ ensure
245
+ Native.release(manager)
246
+ keys&.each_value { |key| Native.release(key) }
247
+ end
248
+
249
+ # Retains the device reference for the handle, so the handle never
250
+ # depends on the listed {Device} staying alive. The retain is released
251
+ # here if the open fails, and by {Handle#close} otherwise.
252
+ #
253
+ # @param device [Device] a device from {#list}
254
+ # @return [Handle]
255
+ # @raise [NotFoundError, PermissionError, BusyError] when it cannot be opened
256
+ def open(device)
257
+ ref = Native.cf_retain(device.id)
258
+ code = Native.hid_device_open(ref, OPEN_OPTIONS)
259
+ return handle = Handle.new(ref, serial: device.serial) if code.zero?
260
+
261
+ error = IOKit.open_error_for(code)
262
+ reason = OPEN_REASONS.fetch(error, "the OS refused")
263
+ raise error, "Could not open #{device.serial}: #{reason} (#{IOKit.describe(code)})"
264
+ ensure
265
+ Native.release(ref) unless handle
266
+ end
267
+
268
+ private
269
+
270
+ def match(manager, keys)
271
+ dict = Native.cf_dictionary_create_mutable(nil, 2, Native.global("kCFTypeDictionaryKeyCallBacks"),
272
+ Native.global("kCFTypeDictionaryValueCallBacks"))
273
+ { "VendorID" => VENDOR_ID, "ProductID" => PRODUCT_ID }.each do |key, id|
274
+ Native.cf_dictionary_set_value(dict, keys.fetch(key), number = Native.number(id))
275
+ ensure
276
+ Native.release(number)
277
+ end
278
+ Native.hid_manager_set_device_matching(manager, dict)
279
+ ensure
280
+ Native.release(dict)
281
+ end
282
+
283
+ # Retains each device before the set that holds it is released.
284
+ def copy_devices(manager)
285
+ set = Native.hid_manager_copy_devices(manager)
286
+ return [] if set.null?
287
+
288
+ count = Native.cf_set_get_count(set)
289
+ values = FFI::MemoryPointer.new(:pointer, count)
290
+ Native.cf_set_get_values(set, values)
291
+ values.read_array_of_pointer(count).map do |ref|
292
+ FFI::AutoPointer.new(Native.cf_retain(ref), Native.method(:cf_release))
293
+ end
294
+ ensure
295
+ Native.release(set)
296
+ end
297
+
298
+ # Properties are borrowed (the Get rule), so nothing read here is released.
299
+ def device_for(ref, keys)
300
+ property = ->(key) { Native.hid_device_get_property(ref, keys.fetch(key)) }
301
+ Device.new(id: ref, serial: Native.read_string(property["SerialNumber"]),
302
+ manufacturer: Native.read_string(property["Manufacturer"]),
303
+ product: Native.read_string(property["Product"]),
304
+ release: Native.read_integer(property["VersionNumber"]))
305
+ end
306
+ end
307
+ end
308
+ end