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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Blinkstick
4
- VERSION = "0.0.2"
4
+ VERSION = "0.1.0"
5
5
  end
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
- # A placeholder that reserves the gem name and does nothing yet.
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