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 +4 -4
- data/CAPABILITIES.md +39 -41
- data/CHANGELOG.md +17 -0
- data/README.md +51 -4
- data/lib/blinkstick/backend/hidraw.rb +149 -0
- data/lib/blinkstick/backend/iokit.rb +308 -0
- data/lib/blinkstick/backend.rb +56 -0
- data/lib/blinkstick/colour.rb +104 -0
- data/lib/blinkstick/colour_names.rb +161 -0
- data/lib/blinkstick/errors.rb +27 -0
- data/lib/blinkstick/model.rb +49 -0
- data/lib/blinkstick/report.rb +30 -0
- data/lib/blinkstick/stick.rb +104 -0
- data/lib/blinkstick/version.rb +1 -1
- data/lib/blinkstick.rb +72 -1
- metadata +37 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 538eb17c6e17b2bdaff6ca9b2f39d1c34196d0c72ce7dffee3b57d47a04131b7
|
|
4
|
+
data.tar.gz: e3f10dcdf64efcd8913052ce3f0b0afe7493a1bae9d705ccdb0b5cb7bea788f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 17438f01d70babc376163f338220a536daaf11e3704efb69b3748d19b460f58e565f613e4c2d81ad457fa47d2a9c4ed66eaa64cefa0d794397d1443905e19281
|
|
7
|
+
data.tar.gz: 56c068d7cbcfc9094988b69abd46c95b9ba60414e253549a2aed7a720793dbced76e8b87247aee7c2c7ba865890696c469aab0979ad40f88d57b4eea58159d3b
|
data/CAPABILITIES.md
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
# Capabilities
|
|
2
2
|
|
|
3
|
-
|
|
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 |
|
|
20
|
-
| Open the first stick or one by 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 |
|
|
23
|
-
| Serial and firmware version |
|
|
24
|
-
| Manufacturer and product strings |
|
|
25
|
-
| Set all LEDs |
|
|
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 |
|
|
48
|
-
| Colour from hex 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 |
|
|
52
|
-
| List CSS colour names |
|
|
53
|
-
| Colour as hex |
|
|
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 |
|
|
63
|
-
| Linux |
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
[CAPABILITIES.md](CAPABILITIES.md)
|
|
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
|