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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Blinkstick
|
|
6
|
+
# Finds and opens sticks through the operating system. One backend exists
|
|
7
|
+
# per OS; it is chosen and loaded on the first {Blinkstick.list} or
|
|
8
|
+
# {Blinkstick.open}, never by `require "blinkstick"`.
|
|
9
|
+
#
|
|
10
|
+
# A backend responds to `#list`, returning an Array of {Device}, and
|
|
11
|
+
# `#open(device)`, returning a handle. A handle responds to
|
|
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.
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
module Backend
|
|
19
|
+
# How many times a transfer is tried before the stick counts as gone.
|
|
20
|
+
ATTEMPTS = 3
|
|
21
|
+
|
|
22
|
+
# Raised by a handle when a transfer failed but may work if tried again.
|
|
23
|
+
class TransferFailed < Error; end
|
|
24
|
+
|
|
25
|
+
# A stick as the OS reports it. The id is opaque outside the backend
|
|
26
|
+
# that listed it (a path on Linux, an IOKit reference on macOS) and the
|
|
27
|
+
# serial is always a String.
|
|
28
|
+
Device = Data.define(:id, :serial, :manufacturer, :product, :release)
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# Replaces the backend, for tests.
|
|
32
|
+
attr_writer :current
|
|
33
|
+
|
|
34
|
+
# @return [#list, #open] the backend for the running OS, loaded once
|
|
35
|
+
# @raise [Blinkstick::Error] when the OS has no backend
|
|
36
|
+
def current
|
|
37
|
+
@current ||= for_host(RbConfig::CONFIG["host_os"])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def for_host(host_os)
|
|
43
|
+
case host_os
|
|
44
|
+
when /darwin/
|
|
45
|
+
require_relative "backend/iokit"
|
|
46
|
+
IOKit.new
|
|
47
|
+
when /linux/
|
|
48
|
+
require_relative "backend/hidraw"
|
|
49
|
+
Hidraw.new
|
|
50
|
+
else
|
|
51
|
+
raise Error, "unsupported platform: #{host_os}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Blinkstick
|
|
6
|
+
# An immutable colour with 8 bits per channel.
|
|
7
|
+
#
|
|
8
|
+
# Blinkstick::Colour.parse("#ff8800")
|
|
9
|
+
# Blinkstick::Colour.parse("255,136,0")
|
|
10
|
+
# Blinkstick::Colour.parse(:cornflowerblue)
|
|
11
|
+
Colour = Data.define(:red, :green, :blue) do
|
|
12
|
+
# @raise [InvalidColourError] unless each channel is an Integer from 0 to 255
|
|
13
|
+
def initialize(red:, green:, blue:)
|
|
14
|
+
[red, green, blue].each do |channel|
|
|
15
|
+
next if channel.is_a?(Integer) && channel.between?(0, 255)
|
|
16
|
+
|
|
17
|
+
raise InvalidColourError, "invalid colour channel #{channel.inspect}"
|
|
18
|
+
end
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Converts a value to a Colour.
|
|
23
|
+
#
|
|
24
|
+
# Accepts a Colour (returned as is), a Symbol or String CSS colour name in
|
|
25
|
+
# any case, hex ("#rgb" or "#rrggbb", with or without the "#"), a decimal
|
|
26
|
+
# "r,g,b" String, or an Array of three Integers.
|
|
27
|
+
#
|
|
28
|
+
# @param input [Colour, String, Symbol, Array<Integer>]
|
|
29
|
+
# @return [Colour]
|
|
30
|
+
# @raise [InvalidColourError] when the input is not a colour
|
|
31
|
+
def self.parse(input)
|
|
32
|
+
case input
|
|
33
|
+
when Colour then input
|
|
34
|
+
when Symbol then lookup(input.name.downcase) || invalid(input)
|
|
35
|
+
when Array then from_array(input)
|
|
36
|
+
when String then from_string(input)
|
|
37
|
+
else invalid(input)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Array<String>] every CSS colour name {parse} accepts, lower case and sorted
|
|
42
|
+
def self.names
|
|
43
|
+
NAMES
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [String] the colour as lower case "#rrggbb"
|
|
47
|
+
def hex
|
|
48
|
+
format("#%<r>02x%<g>02x%<b>02x", r: red, g: green, b: blue)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
alias_method :to_s, :hex
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def lookup(name)
|
|
57
|
+
COLOUR_NAMES[name]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def from_array(array)
|
|
61
|
+
invalid(array) unless array.size == 3 && array.all?(Integer)
|
|
62
|
+
new(red: array[0], green: array[1], blue: array[2])
|
|
63
|
+
rescue InvalidColourError
|
|
64
|
+
invalid(array)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Bytes that are invalid in the String's encoding are not a colour, and
|
|
68
|
+
# would otherwise raise from strip or the regexps below.
|
|
69
|
+
def from_string(input)
|
|
70
|
+
return invalid(input) unless input.valid_encoding?
|
|
71
|
+
|
|
72
|
+
text = input.strip
|
|
73
|
+
return from_hex(input, text.delete_prefix("#")) if text.start_with?("#")
|
|
74
|
+
|
|
75
|
+
lookup(text.downcase) || (text.include?(",") ? from_decimals(input, text) : from_hex(input, text))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def from_hex(input, digits)
|
|
79
|
+
digits = digits.chars.map { |digit| digit * 2 }.join if digits.size == 3
|
|
80
|
+
invalid(input) unless digits.match?(/\A\h{6}\z/)
|
|
81
|
+
value = digits.to_i(16)
|
|
82
|
+
new(red: value >> 16, green: (value >> 8) & 0xff, blue: value & 0xff)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def from_decimals(input, text)
|
|
86
|
+
parts = text.split(",", -1).map(&:strip)
|
|
87
|
+
invalid(input) unless parts.size == 3 && parts.all?(/\A\d{1,3}\z/)
|
|
88
|
+
red, green, blue = parts.map(&:to_i)
|
|
89
|
+
new(red:, green:, blue:)
|
|
90
|
+
rescue InvalidColourError
|
|
91
|
+
invalid(input)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def invalid(input)
|
|
95
|
+
raise InvalidColourError, "invalid colour #{input.is_a?(Symbol) ? input.name.inspect : input.inspect}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Colour with every channel off.
|
|
101
|
+
Colour::OFF = Colour.new(red: 0, green: 0, blue: 0)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
require_relative "colour_names"
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blinkstick
|
|
4
|
+
# The 148 named colours from CSS Color Module Level 4, keyed in lower case.
|
|
5
|
+
# transparent and currentcolor are left out because they have no fixed RGB
|
|
6
|
+
# value.
|
|
7
|
+
COLOUR_NAMES = {
|
|
8
|
+
"aliceblue" => Colour.new(red: 240, green: 248, blue: 255),
|
|
9
|
+
"antiquewhite" => Colour.new(red: 250, green: 235, blue: 215),
|
|
10
|
+
"aqua" => Colour.new(red: 0, green: 255, blue: 255),
|
|
11
|
+
"aquamarine" => Colour.new(red: 127, green: 255, blue: 212),
|
|
12
|
+
"azure" => Colour.new(red: 240, green: 255, blue: 255),
|
|
13
|
+
"beige" => Colour.new(red: 245, green: 245, blue: 220),
|
|
14
|
+
"bisque" => Colour.new(red: 255, green: 228, blue: 196),
|
|
15
|
+
"black" => Colour.new(red: 0, green: 0, blue: 0),
|
|
16
|
+
"blanchedalmond" => Colour.new(red: 255, green: 235, blue: 205),
|
|
17
|
+
"blue" => Colour.new(red: 0, green: 0, blue: 255),
|
|
18
|
+
"blueviolet" => Colour.new(red: 138, green: 43, blue: 226),
|
|
19
|
+
"brown" => Colour.new(red: 165, green: 42, blue: 42),
|
|
20
|
+
"burlywood" => Colour.new(red: 222, green: 184, blue: 135),
|
|
21
|
+
"cadetblue" => Colour.new(red: 95, green: 158, blue: 160),
|
|
22
|
+
"chartreuse" => Colour.new(red: 127, green: 255, blue: 0),
|
|
23
|
+
"chocolate" => Colour.new(red: 210, green: 105, blue: 30),
|
|
24
|
+
"coral" => Colour.new(red: 255, green: 127, blue: 80),
|
|
25
|
+
"cornflowerblue" => Colour.new(red: 100, green: 149, blue: 237),
|
|
26
|
+
"cornsilk" => Colour.new(red: 255, green: 248, blue: 220),
|
|
27
|
+
"crimson" => Colour.new(red: 220, green: 20, blue: 60),
|
|
28
|
+
"cyan" => Colour.new(red: 0, green: 255, blue: 255),
|
|
29
|
+
"darkblue" => Colour.new(red: 0, green: 0, blue: 139),
|
|
30
|
+
"darkcyan" => Colour.new(red: 0, green: 139, blue: 139),
|
|
31
|
+
"darkgoldenrod" => Colour.new(red: 184, green: 134, blue: 11),
|
|
32
|
+
"darkgray" => Colour.new(red: 169, green: 169, blue: 169),
|
|
33
|
+
"darkgreen" => Colour.new(red: 0, green: 100, blue: 0),
|
|
34
|
+
"darkgrey" => Colour.new(red: 169, green: 169, blue: 169),
|
|
35
|
+
"darkkhaki" => Colour.new(red: 189, green: 183, blue: 107),
|
|
36
|
+
"darkmagenta" => Colour.new(red: 139, green: 0, blue: 139),
|
|
37
|
+
"darkolivegreen" => Colour.new(red: 85, green: 107, blue: 47),
|
|
38
|
+
"darkorange" => Colour.new(red: 255, green: 140, blue: 0),
|
|
39
|
+
"darkorchid" => Colour.new(red: 153, green: 50, blue: 204),
|
|
40
|
+
"darkred" => Colour.new(red: 139, green: 0, blue: 0),
|
|
41
|
+
"darksalmon" => Colour.new(red: 233, green: 150, blue: 122),
|
|
42
|
+
"darkseagreen" => Colour.new(red: 143, green: 188, blue: 143),
|
|
43
|
+
"darkslateblue" => Colour.new(red: 72, green: 61, blue: 139),
|
|
44
|
+
"darkslategray" => Colour.new(red: 47, green: 79, blue: 79),
|
|
45
|
+
"darkslategrey" => Colour.new(red: 47, green: 79, blue: 79),
|
|
46
|
+
"darkturquoise" => Colour.new(red: 0, green: 206, blue: 209),
|
|
47
|
+
"darkviolet" => Colour.new(red: 148, green: 0, blue: 211),
|
|
48
|
+
"deeppink" => Colour.new(red: 255, green: 20, blue: 147),
|
|
49
|
+
"deepskyblue" => Colour.new(red: 0, green: 191, blue: 255),
|
|
50
|
+
"dimgray" => Colour.new(red: 105, green: 105, blue: 105),
|
|
51
|
+
"dimgrey" => Colour.new(red: 105, green: 105, blue: 105),
|
|
52
|
+
"dodgerblue" => Colour.new(red: 30, green: 144, blue: 255),
|
|
53
|
+
"firebrick" => Colour.new(red: 178, green: 34, blue: 34),
|
|
54
|
+
"floralwhite" => Colour.new(red: 255, green: 250, blue: 240),
|
|
55
|
+
"forestgreen" => Colour.new(red: 34, green: 139, blue: 34),
|
|
56
|
+
"fuchsia" => Colour.new(red: 255, green: 0, blue: 255),
|
|
57
|
+
"gainsboro" => Colour.new(red: 220, green: 220, blue: 220),
|
|
58
|
+
"ghostwhite" => Colour.new(red: 248, green: 248, blue: 255),
|
|
59
|
+
"gold" => Colour.new(red: 255, green: 215, blue: 0),
|
|
60
|
+
"goldenrod" => Colour.new(red: 218, green: 165, blue: 32),
|
|
61
|
+
"gray" => Colour.new(red: 128, green: 128, blue: 128),
|
|
62
|
+
"green" => Colour.new(red: 0, green: 128, blue: 0),
|
|
63
|
+
"greenyellow" => Colour.new(red: 173, green: 255, blue: 47),
|
|
64
|
+
"grey" => Colour.new(red: 128, green: 128, blue: 128),
|
|
65
|
+
"honeydew" => Colour.new(red: 240, green: 255, blue: 240),
|
|
66
|
+
"hotpink" => Colour.new(red: 255, green: 105, blue: 180),
|
|
67
|
+
"indianred" => Colour.new(red: 205, green: 92, blue: 92),
|
|
68
|
+
"indigo" => Colour.new(red: 75, green: 0, blue: 130),
|
|
69
|
+
"ivory" => Colour.new(red: 255, green: 255, blue: 240),
|
|
70
|
+
"khaki" => Colour.new(red: 240, green: 230, blue: 140),
|
|
71
|
+
"lavender" => Colour.new(red: 230, green: 230, blue: 250),
|
|
72
|
+
"lavenderblush" => Colour.new(red: 255, green: 240, blue: 245),
|
|
73
|
+
"lawngreen" => Colour.new(red: 124, green: 252, blue: 0),
|
|
74
|
+
"lemonchiffon" => Colour.new(red: 255, green: 250, blue: 205),
|
|
75
|
+
"lightblue" => Colour.new(red: 173, green: 216, blue: 230),
|
|
76
|
+
"lightcoral" => Colour.new(red: 240, green: 128, blue: 128),
|
|
77
|
+
"lightcyan" => Colour.new(red: 224, green: 255, blue: 255),
|
|
78
|
+
"lightgoldenrodyellow" => Colour.new(red: 250, green: 250, blue: 210),
|
|
79
|
+
"lightgray" => Colour.new(red: 211, green: 211, blue: 211),
|
|
80
|
+
"lightgreen" => Colour.new(red: 144, green: 238, blue: 144),
|
|
81
|
+
"lightgrey" => Colour.new(red: 211, green: 211, blue: 211),
|
|
82
|
+
"lightpink" => Colour.new(red: 255, green: 182, blue: 193),
|
|
83
|
+
"lightsalmon" => Colour.new(red: 255, green: 160, blue: 122),
|
|
84
|
+
"lightseagreen" => Colour.new(red: 32, green: 178, blue: 170),
|
|
85
|
+
"lightskyblue" => Colour.new(red: 135, green: 206, blue: 250),
|
|
86
|
+
"lightslategray" => Colour.new(red: 119, green: 136, blue: 153),
|
|
87
|
+
"lightslategrey" => Colour.new(red: 119, green: 136, blue: 153),
|
|
88
|
+
"lightsteelblue" => Colour.new(red: 176, green: 196, blue: 222),
|
|
89
|
+
"lightyellow" => Colour.new(red: 255, green: 255, blue: 224),
|
|
90
|
+
"lime" => Colour.new(red: 0, green: 255, blue: 0),
|
|
91
|
+
"limegreen" => Colour.new(red: 50, green: 205, blue: 50),
|
|
92
|
+
"linen" => Colour.new(red: 250, green: 240, blue: 230),
|
|
93
|
+
"magenta" => Colour.new(red: 255, green: 0, blue: 255),
|
|
94
|
+
"maroon" => Colour.new(red: 128, green: 0, blue: 0),
|
|
95
|
+
"mediumaquamarine" => Colour.new(red: 102, green: 205, blue: 170),
|
|
96
|
+
"mediumblue" => Colour.new(red: 0, green: 0, blue: 205),
|
|
97
|
+
"mediumorchid" => Colour.new(red: 186, green: 85, blue: 211),
|
|
98
|
+
"mediumpurple" => Colour.new(red: 147, green: 112, blue: 219),
|
|
99
|
+
"mediumseagreen" => Colour.new(red: 60, green: 179, blue: 113),
|
|
100
|
+
"mediumslateblue" => Colour.new(red: 123, green: 104, blue: 238),
|
|
101
|
+
"mediumspringgreen" => Colour.new(red: 0, green: 250, blue: 154),
|
|
102
|
+
"mediumturquoise" => Colour.new(red: 72, green: 209, blue: 204),
|
|
103
|
+
"mediumvioletred" => Colour.new(red: 199, green: 21, blue: 133),
|
|
104
|
+
"midnightblue" => Colour.new(red: 25, green: 25, blue: 112),
|
|
105
|
+
"mintcream" => Colour.new(red: 245, green: 255, blue: 250),
|
|
106
|
+
"mistyrose" => Colour.new(red: 255, green: 228, blue: 225),
|
|
107
|
+
"moccasin" => Colour.new(red: 255, green: 228, blue: 181),
|
|
108
|
+
"navajowhite" => Colour.new(red: 255, green: 222, blue: 173),
|
|
109
|
+
"navy" => Colour.new(red: 0, green: 0, blue: 128),
|
|
110
|
+
"oldlace" => Colour.new(red: 253, green: 245, blue: 230),
|
|
111
|
+
"olive" => Colour.new(red: 128, green: 128, blue: 0),
|
|
112
|
+
"olivedrab" => Colour.new(red: 107, green: 142, blue: 35),
|
|
113
|
+
"orange" => Colour.new(red: 255, green: 165, blue: 0),
|
|
114
|
+
"orangered" => Colour.new(red: 255, green: 69, blue: 0),
|
|
115
|
+
"orchid" => Colour.new(red: 218, green: 112, blue: 214),
|
|
116
|
+
"palegoldenrod" => Colour.new(red: 238, green: 232, blue: 170),
|
|
117
|
+
"palegreen" => Colour.new(red: 152, green: 251, blue: 152),
|
|
118
|
+
"paleturquoise" => Colour.new(red: 175, green: 238, blue: 238),
|
|
119
|
+
"palevioletred" => Colour.new(red: 219, green: 112, blue: 147),
|
|
120
|
+
"papayawhip" => Colour.new(red: 255, green: 239, blue: 213),
|
|
121
|
+
"peachpuff" => Colour.new(red: 255, green: 218, blue: 185),
|
|
122
|
+
"peru" => Colour.new(red: 205, green: 133, blue: 63),
|
|
123
|
+
"pink" => Colour.new(red: 255, green: 192, blue: 203),
|
|
124
|
+
"plum" => Colour.new(red: 221, green: 160, blue: 221),
|
|
125
|
+
"powderblue" => Colour.new(red: 176, green: 224, blue: 230),
|
|
126
|
+
"purple" => Colour.new(red: 128, green: 0, blue: 128),
|
|
127
|
+
"rebeccapurple" => Colour.new(red: 102, green: 51, blue: 153),
|
|
128
|
+
"red" => Colour.new(red: 255, green: 0, blue: 0),
|
|
129
|
+
"rosybrown" => Colour.new(red: 188, green: 143, blue: 143),
|
|
130
|
+
"royalblue" => Colour.new(red: 65, green: 105, blue: 225),
|
|
131
|
+
"saddlebrown" => Colour.new(red: 139, green: 69, blue: 19),
|
|
132
|
+
"salmon" => Colour.new(red: 250, green: 128, blue: 114),
|
|
133
|
+
"sandybrown" => Colour.new(red: 244, green: 164, blue: 96),
|
|
134
|
+
"seagreen" => Colour.new(red: 46, green: 139, blue: 87),
|
|
135
|
+
"seashell" => Colour.new(red: 255, green: 245, blue: 238),
|
|
136
|
+
"sienna" => Colour.new(red: 160, green: 82, blue: 45),
|
|
137
|
+
"silver" => Colour.new(red: 192, green: 192, blue: 192),
|
|
138
|
+
"skyblue" => Colour.new(red: 135, green: 206, blue: 235),
|
|
139
|
+
"slateblue" => Colour.new(red: 106, green: 90, blue: 205),
|
|
140
|
+
"slategray" => Colour.new(red: 112, green: 128, blue: 144),
|
|
141
|
+
"slategrey" => Colour.new(red: 112, green: 128, blue: 144),
|
|
142
|
+
"snow" => Colour.new(red: 255, green: 250, blue: 250),
|
|
143
|
+
"springgreen" => Colour.new(red: 0, green: 255, blue: 127),
|
|
144
|
+
"steelblue" => Colour.new(red: 70, green: 130, blue: 180),
|
|
145
|
+
"tan" => Colour.new(red: 210, green: 180, blue: 140),
|
|
146
|
+
"teal" => Colour.new(red: 0, green: 128, blue: 128),
|
|
147
|
+
"thistle" => Colour.new(red: 216, green: 191, blue: 216),
|
|
148
|
+
"tomato" => Colour.new(red: 255, green: 99, blue: 71),
|
|
149
|
+
"turquoise" => Colour.new(red: 64, green: 224, blue: 208),
|
|
150
|
+
"violet" => Colour.new(red: 238, green: 130, blue: 238),
|
|
151
|
+
"wheat" => Colour.new(red: 245, green: 222, blue: 179),
|
|
152
|
+
"white" => Colour.new(red: 255, green: 255, blue: 255),
|
|
153
|
+
"whitesmoke" => Colour.new(red: 245, green: 245, blue: 245),
|
|
154
|
+
"yellow" => Colour.new(red: 255, green: 255, blue: 0),
|
|
155
|
+
"yellowgreen" => Colour.new(red: 154, green: 205, blue: 50)
|
|
156
|
+
}.freeze
|
|
157
|
+
private_constant :COLOUR_NAMES
|
|
158
|
+
|
|
159
|
+
NAMES = COLOUR_NAMES.keys.sort.freeze
|
|
160
|
+
private_constant :NAMES
|
|
161
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blinkstick
|
|
4
|
+
# Base class for every error this gem raises on purpose.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when no BlinkStick matches the requested serial or index.
|
|
8
|
+
class NotFoundError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when another program has seized the stick (macOS only).
|
|
11
|
+
class BusyError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when the operating system refuses access to the device.
|
|
14
|
+
class PermissionError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when opening a stick whose model is not recognised.
|
|
17
|
+
class UnsupportedModelError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when using a device after it has been closed.
|
|
20
|
+
class ClosedError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when the stick is unplugged or stops responding mid-use.
|
|
23
|
+
class DisconnectedError < Error; end
|
|
24
|
+
|
|
25
|
+
# Raised when a value cannot be turned into a {Colour}.
|
|
26
|
+
class InvalidColourError < ArgumentError; end
|
|
27
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blinkstick
|
|
4
|
+
# USB vendor ID shared by every BlinkStick model.
|
|
5
|
+
VENDOR_ID = 0x20A0
|
|
6
|
+
|
|
7
|
+
# USB product ID shared by every BlinkStick model.
|
|
8
|
+
PRODUCT_ID = 0x41E5
|
|
9
|
+
|
|
10
|
+
# A BlinkStick product, described by its name and addressable LED count.
|
|
11
|
+
Model = Data.define(:name, :leds) do
|
|
12
|
+
# @return [Boolean] false only for {UNKNOWN}, which can be listed but not opened
|
|
13
|
+
def supported?
|
|
14
|
+
self != Model::UNKNOWN
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The BlinkStick Nano: two LEDs.
|
|
19
|
+
Model::NANO = Model.new(name: "Nano", leds: 2)
|
|
20
|
+
|
|
21
|
+
# The BlinkStick Square: eight LEDs.
|
|
22
|
+
Model::SQUARE = Model.new(name: "Square", leds: 8)
|
|
23
|
+
|
|
24
|
+
# Any other stick: listed, but refused by {Blinkstick.open}. It has no LEDs here.
|
|
25
|
+
Model::UNKNOWN = Model.new(name: "unknown", leds: 0)
|
|
26
|
+
|
|
27
|
+
# Maps [serial major version, USB bcdDevice] to a {Model}. A Square
|
|
28
|
+
# (BS073788-3.1) reports 0x0201, so that value is not a Strip here.
|
|
29
|
+
MODELS = {
|
|
30
|
+
["3", 0x0202] => Model::NANO,
|
|
31
|
+
["3", 0x0201] => Model::SQUARE
|
|
32
|
+
}.freeze
|
|
33
|
+
private_constant :MODELS
|
|
34
|
+
|
|
35
|
+
# Identifies an attached BlinkStick. It holds no OS path or handle.
|
|
36
|
+
Info = Data.define(:serial, :version, :manufacturer, :product, :model) do
|
|
37
|
+
# @param serial [String] for example "BS072777-3.0"
|
|
38
|
+
# @param manufacturer [String]
|
|
39
|
+
# @param product [String]
|
|
40
|
+
# @param release [Integer] the USB bcdDevice value
|
|
41
|
+
# @return [Info]
|
|
42
|
+
def self.from(serial:, manufacturer:, product:, release:)
|
|
43
|
+
_, _, version = serial.partition("-")
|
|
44
|
+
major, = version.split(".", 2)
|
|
45
|
+
new(serial: serial, version: version, manufacturer: manufacturer, product: product,
|
|
46
|
+
model: MODELS.fetch([major, release], Model::UNKNOWN))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blinkstick
|
|
4
|
+
# Builds the HID feature report bytes the device understands.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
module Report
|
|
8
|
+
# Report ID of the LED frame.
|
|
9
|
+
FRAME_ID = 6
|
|
10
|
+
|
|
11
|
+
# Frame size in bytes: ID, channel, then 8 x (G, R, B).
|
|
12
|
+
FRAME_SIZE = 26
|
|
13
|
+
|
|
14
|
+
# LEDs addressable in one frame.
|
|
15
|
+
FRAME_LEDS = 8
|
|
16
|
+
|
|
17
|
+
# Builds report 6 for channel 0. The device wants each LED as G, R, B and
|
|
18
|
+
# slots beyond the given colours are zero.
|
|
19
|
+
#
|
|
20
|
+
# @param colours [Array<Colour>] at most {FRAME_LEDS}
|
|
21
|
+
# @return [String] frozen binary String of {FRAME_SIZE} bytes
|
|
22
|
+
# @raise [ArgumentError] when given more than {FRAME_LEDS} colours
|
|
23
|
+
def self.frame(colours)
|
|
24
|
+
raise ArgumentError, "at most #{FRAME_LEDS} colours, got #{colours.size}" if colours.size > FRAME_LEDS
|
|
25
|
+
|
|
26
|
+
bytes = [FRAME_ID, 0] + colours.flat_map { |c| [c.green, c.red, c.blue] }
|
|
27
|
+
bytes.fill(0, bytes.size...FRAME_SIZE).pack("C*").freeze
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blinkstick
|
|
4
|
+
# An open BlinkStick. Get one from {Blinkstick.open}.
|
|
5
|
+
#
|
|
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
|
|
8
|
+
# {#close} still releases its OS handle, but call {#close} (or use the
|
|
9
|
+
# block form of {Blinkstick.open}) rather than relying on that.
|
|
10
|
+
class Stick
|
|
11
|
+
# @return [Info] the identity of this stick
|
|
12
|
+
attr_reader :info
|
|
13
|
+
|
|
14
|
+
# @api private
|
|
15
|
+
# @param handle [#write_feature, #close] an open backend handle
|
|
16
|
+
# @param info [Info]
|
|
17
|
+
def initialize(handle, info)
|
|
18
|
+
@handle = handle
|
|
19
|
+
@info = info
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@closed = false
|
|
22
|
+
ObjectSpace.define_finalizer(self, self.class.releaser(handle))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Builds the finaliser here, not in an instance method, so it holds the
|
|
26
|
+
# handle only and the Stick itself can still be collected.
|
|
27
|
+
#
|
|
28
|
+
# @api private
|
|
29
|
+
# @param handle [#close]
|
|
30
|
+
# @return [Proc]
|
|
31
|
+
def self.releaser(handle)
|
|
32
|
+
proc do
|
|
33
|
+
handle.close
|
|
34
|
+
rescue Error
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Sets every LED to one colour.
|
|
40
|
+
#
|
|
41
|
+
# @param colour [Colour, String, Symbol, Array<Integer>] anything {Colour.parse} takes
|
|
42
|
+
# @raise [InvalidColourError] before anything is sent, when colour is not a colour
|
|
43
|
+
# @raise [ClosedError] after {#close}
|
|
44
|
+
# @raise [DisconnectedError] when the stick stops responding
|
|
45
|
+
def colour=(colour)
|
|
46
|
+
write(Report.frame([Colour.parse(colour)] * info.model.leds))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Turns every LED off.
|
|
50
|
+
#
|
|
51
|
+
# @return [void]
|
|
52
|
+
# @raise [ClosedError] after {#close}
|
|
53
|
+
# @raise [DisconnectedError] when the stick stops responding
|
|
54
|
+
def off
|
|
55
|
+
self.colour = Colour::OFF
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Releases the stick. Calling it again does nothing.
|
|
60
|
+
#
|
|
61
|
+
# @return [void]
|
|
62
|
+
def close
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
next if @closed
|
|
65
|
+
|
|
66
|
+
@closed = true
|
|
67
|
+
ObjectSpace.undefine_finalizer(self)
|
|
68
|
+
@handle.close
|
|
69
|
+
end
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Boolean] true once {#close} has been called
|
|
74
|
+
def closed?
|
|
75
|
+
@closed
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Holds the lock across every attempt, so reports from other threads
|
|
81
|
+
# cannot land in between.
|
|
82
|
+
def write(report)
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
raise ClosedError, "#{info.serial} is closed" if @closed
|
|
85
|
+
|
|
86
|
+
transfer(report)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
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)
|
|
93
|
+
attempt = 0
|
|
94
|
+
begin
|
|
95
|
+
attempt += 1
|
|
96
|
+
@handle.write_feature(report)
|
|
97
|
+
rescue Backend::TransferFailed
|
|
98
|
+
retry if attempt < Backend::ATTEMPTS
|
|
99
|
+
|
|
100
|
+
raise DisconnectedError, "#{info.serial} stopped responding: #{attempt} writes failed in a row"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/blinkstick/version.rb
CHANGED
data/lib/blinkstick.rb
CHANGED
|
@@ -1,7 +1,78 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "blinkstick/version"
|
|
4
|
+
require_relative "blinkstick/errors"
|
|
5
|
+
require_relative "blinkstick/colour"
|
|
6
|
+
require_relative "blinkstick/model"
|
|
7
|
+
require_relative "blinkstick/report"
|
|
8
|
+
require_relative "blinkstick/stick"
|
|
4
9
|
|
|
5
|
-
#
|
|
10
|
+
# Finds and drives BlinkStick Nano and Square USB LED sticks on macOS and Linux.
|
|
11
|
+
#
|
|
12
|
+
# Requiring the gem touches no device; the OS backend loads on first use.
|
|
13
|
+
#
|
|
14
|
+
# Blinkstick.open do |stick|
|
|
15
|
+
# stick.colour = :cornflowerblue
|
|
16
|
+
# end
|
|
6
17
|
module Blinkstick
|
|
18
|
+
# Loaded on first use, so requiring the gem touches no device or backend.
|
|
19
|
+
autoload :Backend, "blinkstick/backend"
|
|
20
|
+
|
|
21
|
+
# Lists every attached BlinkStick, including models this gem cannot open
|
|
22
|
+
# (their {Info#model} is {Model::UNKNOWN}).
|
|
23
|
+
#
|
|
24
|
+
# @return [Array<Info>]
|
|
25
|
+
# @raise [Error] when the OS cannot list devices
|
|
26
|
+
def self.list
|
|
27
|
+
Backend.current.list.map { |device| info_for(device) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Opens a BlinkStick: the one with the given serial, or else the first one
|
|
31
|
+
# found. If the first one found is an unknown model this raises, even when
|
|
32
|
+
# a supported stick is also attached; pass a serial from {list} to choose.
|
|
33
|
+
#
|
|
34
|
+
# Without a block the caller must {Stick#close} the stick. With a block,
|
|
35
|
+
# like File.open, the stick is yielded, closed afterwards even if the
|
|
36
|
+
# block raises, and the block's value is returned.
|
|
37
|
+
#
|
|
38
|
+
# @param serial [String, nil] for example "BS072777-3.0"
|
|
39
|
+
# @yieldparam stick [Stick]
|
|
40
|
+
# @return [Stick, Object] the stick, or the block's value
|
|
41
|
+
# @raise [NotFoundError] when no stick (or none with that serial) is attached
|
|
42
|
+
# @raise [UnsupportedModelError] when the stick's model is unknown
|
|
43
|
+
# @raise [BusyError] when another program has seized the stick
|
|
44
|
+
# @raise [PermissionError] when the OS refuses access
|
|
45
|
+
def self.open(serial: nil)
|
|
46
|
+
stick = open_stick(serial)
|
|
47
|
+
return stick unless block_given?
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
yield stick
|
|
51
|
+
ensure
|
|
52
|
+
stick.close
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Checks the model before opening, so an unknown stick is never opened.
|
|
57
|
+
def self.open_stick(serial)
|
|
58
|
+
backend = Backend.current
|
|
59
|
+
device = find(backend.list, serial)
|
|
60
|
+
info = info_for(device)
|
|
61
|
+
raise UnsupportedModelError, "#{info.serial} is a model this gem does not support" unless info.model.supported?
|
|
62
|
+
|
|
63
|
+
Stick.new(backend.open(device), info)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.find(devices, serial)
|
|
67
|
+
return devices.first || raise(NotFoundError, "no BlinkStick attached") if serial.nil?
|
|
68
|
+
|
|
69
|
+
devices.find { |device| device.serial == serial } || raise(NotFoundError, "no BlinkStick with serial #{serial}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.info_for(device)
|
|
73
|
+
Info.from(serial: device.serial, manufacturer: device.manufacturer, product: device.product,
|
|
74
|
+
release: device.release)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private_class_method :open_stick, :find, :info_for
|
|
7
78
|
end
|