ruby-blinkstick 0.2.0 → 0.3.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 +13 -13
- data/CHANGELOG.md +19 -0
- data/README.md +162 -11
- data/lib/blinkstick/backend/hidraw.rb +5 -0
- data/lib/blinkstick/backend/iokit.rb +8 -0
- data/lib/blinkstick/cancellation.rb +79 -0
- data/lib/blinkstick/colour.rb +38 -0
- data/lib/blinkstick/errors.rb +6 -2
- data/lib/blinkstick/model.rb +10 -2
- data/lib/blinkstick/names.rb +116 -0
- data/lib/blinkstick/report.rb +40 -0
- data/lib/blinkstick/stick/eeprom.rb +158 -0
- data/lib/blinkstick/stick/effects.rb +151 -0
- data/lib/blinkstick/stick/handover.rb +27 -0
- data/lib/blinkstick/stick/lighting.rb +120 -0
- data/lib/blinkstick/stick/reconnect.rb +126 -0
- data/lib/blinkstick/stick.rb +38 -68
- data/lib/blinkstick/version.rb +1 -1
- data/lib/blinkstick.rb +77 -14
- metadata +16 -7
data/lib/blinkstick/report.rb
CHANGED
|
@@ -51,8 +51,10 @@ module Blinkstick
|
|
|
51
51
|
# @param bytes [Array<Integer>, String] the {FRAME_SIZE} byte report
|
|
52
52
|
# @param count [Integer] LEDs to decode, at most {FRAME_LEDS}
|
|
53
53
|
# @return [Array<Colour>] frozen
|
|
54
|
+
# @raise [ArgumentError] when given fewer than {FRAME_SIZE} bytes
|
|
54
55
|
def self.decode_frame(bytes, count)
|
|
55
56
|
bytes = bytes.bytes if bytes.is_a?(String)
|
|
57
|
+
require_size(bytes, FRAME_SIZE)
|
|
56
58
|
Array.new(count) do |i|
|
|
57
59
|
green, red, blue = bytes[2 + (i * 3), 3]
|
|
58
60
|
Colour.new(red:, green:, blue:)
|
|
@@ -70,7 +72,9 @@ module Blinkstick
|
|
|
70
72
|
|
|
71
73
|
# @param bytes [Array<Integer>, String] the {MODE_SIZE} byte report
|
|
72
74
|
# @return [Symbol, Integer] the mode name, or the raw byte when unknown
|
|
75
|
+
# @raise [ArgumentError] when given fewer than {MODE_SIZE} bytes
|
|
73
76
|
def self.decode_mode(bytes)
|
|
77
|
+
require_size(bytes, MODE_SIZE)
|
|
74
78
|
byte = bytes.is_a?(String) ? bytes.getbyte(1) : bytes[1]
|
|
75
79
|
MODES.fetch(byte, byte)
|
|
76
80
|
end
|
|
@@ -91,9 +95,45 @@ module Blinkstick
|
|
|
91
95
|
|
|
92
96
|
# @param bytes [Array<Integer>, String] the {INFO_BLOCK_SIZE} byte report
|
|
93
97
|
# @return [String] frozen ASCII-8BIT text with trailing NULs removed
|
|
98
|
+
# @raise [ArgumentError] when given fewer than {INFO_BLOCK_SIZE} bytes
|
|
94
99
|
def self.decode_info_block(bytes)
|
|
95
100
|
bytes = bytes.bytes if bytes.is_a?(String)
|
|
101
|
+
require_size(bytes, INFO_BLOCK_SIZE)
|
|
96
102
|
bytes[1, INFO_BLOCK_SIZE - 1].pack("C*").sub(/\0+\z/n, "").freeze
|
|
97
103
|
end
|
|
104
|
+
|
|
105
|
+
# Builds report 2 or 3 to write an info block.
|
|
106
|
+
#
|
|
107
|
+
# @param number [Integer] info block 1 or 2
|
|
108
|
+
# @param bytes [String] at most 32 bytes (its raw bytes are sent)
|
|
109
|
+
# @return [String] frozen binary String of {INFO_BLOCK_SIZE} bytes
|
|
110
|
+
# @raise [ArgumentError] unless number is 1 or 2, or when bytes is over 32 bytes
|
|
111
|
+
def self.info_block(number, bytes)
|
|
112
|
+
id = info_block_id(number)
|
|
113
|
+
data = bytes.b
|
|
114
|
+
raise ArgumentError, "info block holds at most #{INFO_BLOCK_SIZE - 1} bytes, got #{data.bytesize}" if
|
|
115
|
+
data.bytesize > INFO_BLOCK_SIZE - 1
|
|
116
|
+
|
|
117
|
+
([id].pack("C") + data).ljust(INFO_BLOCK_SIZE, "\0".b).freeze
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Builds report 4 to set the LED mode.
|
|
121
|
+
#
|
|
122
|
+
# @param byte [Integer] the mode byte, 0 to 255
|
|
123
|
+
# @return [String] frozen binary String of {MODE_SIZE} bytes
|
|
124
|
+
# @raise [ArgumentError] unless byte is an Integer from 0 to 255
|
|
125
|
+
def self.mode(byte)
|
|
126
|
+
raise ArgumentError, "mode must be an Integer from 0 to 255, got #{byte.inspect}" unless
|
|
127
|
+
byte.is_a?(Integer) && byte.between?(0, 255)
|
|
128
|
+
|
|
129
|
+
[MODE_ID, byte].pack("C*").freeze
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @raise [ArgumentError] when bytes is shorter than size
|
|
133
|
+
def self.require_size(bytes, size)
|
|
134
|
+
length = bytes.respond_to?(:bytesize) ? bytes.bytesize : bytes.size
|
|
135
|
+
raise ArgumentError, "report needs #{size} bytes, got #{length}" if length < size
|
|
136
|
+
end
|
|
137
|
+
private_class_method :require_size
|
|
98
138
|
end
|
|
99
139
|
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blinkstick
|
|
4
|
+
class Stick
|
|
5
|
+
# The parts of a {Stick} that write the stick's EEPROM: its name, its
|
|
6
|
+
# user data blocks and its LED mode. EEPROM wears with every write, so
|
|
7
|
+
# none of these belongs in a loop.
|
|
8
|
+
module Eeprom
|
|
9
|
+
# Modes this gem will set, by the byte the stick stores. The BlinkStick
|
|
10
|
+
# Pro modes (0 and 1) are refused.
|
|
11
|
+
SETTABLE_MODES = { ws2812: 2, ws2812_mirror: 3 }.freeze
|
|
12
|
+
private_constant :SETTABLE_MODES
|
|
13
|
+
|
|
14
|
+
# Seconds between reads while waiting for the stick to answer after a
|
|
15
|
+
# mode write.
|
|
16
|
+
MODE_POLL_INTERVAL = 0.005
|
|
17
|
+
private_constant :MODE_POLL_INTERVAL
|
|
18
|
+
|
|
19
|
+
# Seconds to wait for the stick to answer after a mode write. A real
|
|
20
|
+
# stick refuses transfers for 12 to 15 ms while it stores the mode.
|
|
21
|
+
MODE_SETTLE_TIMEOUT = 0.25
|
|
22
|
+
private_constant :MODE_SETTLE_TIMEOUT
|
|
23
|
+
|
|
24
|
+
# Writes a user data block, padded with NULs to 32 bytes. This writes
|
|
25
|
+
# EEPROM, which wears with use, so do not call it in a loop.
|
|
26
|
+
#
|
|
27
|
+
# @param number [Integer] 1 or 2 (block 1 holds the {#name})
|
|
28
|
+
# @param bytes [String] at most 32 bytes (its raw bytes are stored, whatever its encoding)
|
|
29
|
+
# @return [nil]
|
|
30
|
+
# @raise [ArgumentError] before anything is sent, when number is not 1 or 2 or bytes is over 32 bytes
|
|
31
|
+
# @raise [TypeError] before anything is sent, when bytes is not a String
|
|
32
|
+
# @raise [ClosedError] after {#close}
|
|
33
|
+
# @raise [BusyError] when another program holds the stick mid-transfer
|
|
34
|
+
# @raise [PermissionError] when the system refuses access mid-transfer
|
|
35
|
+
# @raise [DisconnectedError] when the stick stops responding and reconnecting to it fails
|
|
36
|
+
def write_info_block(number, bytes)
|
|
37
|
+
data = String.try_convert(bytes)
|
|
38
|
+
raise TypeError, "info block data must be a String, got #{bytes.class}" if data.nil?
|
|
39
|
+
|
|
40
|
+
write(Report.info_block(number, data))
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Reads the name stored on the stick (info block 1, as the official
|
|
45
|
+
# BlinkStick tools do). Another tool may have stored bytes that are not
|
|
46
|
+
# valid UTF-8 or that are control characters, and they are returned as
|
|
47
|
+
# they are: sanitise the name before printing it to a terminal.
|
|
48
|
+
#
|
|
49
|
+
# @return [String] frozen, tagged UTF-8, "" when no name is set
|
|
50
|
+
# @raise [ClosedError] after {#close}
|
|
51
|
+
# @raise [BusyError] when another program holds the stick mid-transfer
|
|
52
|
+
# @raise [PermissionError] when the system refuses access mid-transfer
|
|
53
|
+
# @raise [DisconnectedError] when the stick stops responding and reconnecting to it fails
|
|
54
|
+
def name
|
|
55
|
+
info_block(1).dup.force_encoding(Encoding::UTF_8).freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Stores a name on the stick, replacing any name already there. This
|
|
59
|
+
# writes EEPROM, which wears with use, so do not call it in a loop.
|
|
60
|
+
#
|
|
61
|
+
# @param name [String] UTF-8 of at most 32 bytes with no NUL; "" clears the name
|
|
62
|
+
# @return [String] name, as Ruby assignment does
|
|
63
|
+
# @raise [TypeError] before anything is sent, when name is not a String
|
|
64
|
+
# @raise [ArgumentError] before anything is sent, when name is not valid UTF-8, holds a NUL or is over 32 bytes
|
|
65
|
+
# @raise [ClosedError] after {#close}
|
|
66
|
+
# @raise [BusyError] when another program holds the stick mid-transfer
|
|
67
|
+
# @raise [PermissionError] when the system refuses access mid-transfer
|
|
68
|
+
# @raise [DisconnectedError] when the stick stops responding and reconnecting to it fails
|
|
69
|
+
def name=(name)
|
|
70
|
+
raise TypeError, "name must be a String, got #{name.class}" unless name.is_a?(String)
|
|
71
|
+
|
|
72
|
+
check_name(name)
|
|
73
|
+
write_info_block(1, name)
|
|
74
|
+
name
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Sets the LED mode, unless the stick is already in it. This writes
|
|
78
|
+
# EEPROM, which wears with use, so call it once and not in a loop. The
|
|
79
|
+
# read and the write happen under one lock, so no other thread's
|
|
80
|
+
# transfer lands between them. A stick stops answering for a moment
|
|
81
|
+
# after storing its mode (typically 12 to 15 ms), so after a write this
|
|
82
|
+
# waits, still holding the lock, until the stick answers again and
|
|
83
|
+
# reports the new mode, giving up after 250 ms. No write means no wait.
|
|
84
|
+
#
|
|
85
|
+
# @param mode [Symbol, Integer] :ws2812 (or 2) or :ws2812_mirror (or 3).
|
|
86
|
+
# The BlinkStick Pro modes (0, 1, :rgb, :rgb_inverse) are refused.
|
|
87
|
+
# @return [Symbol, Integer] mode, as Ruby assignment does
|
|
88
|
+
# @raise [ArgumentError] before anything is sent, when mode is not :ws2812, :ws2812_mirror, 2 or 3
|
|
89
|
+
# @raise [ClosedError] after {#close}
|
|
90
|
+
# @raise [BusyError] when another program holds the stick mid-transfer
|
|
91
|
+
# @raise [PermissionError] when the system refuses access mid-transfer
|
|
92
|
+
# @raise [DisconnectedError] when the stick stops responding and reconnecting to it fails,
|
|
93
|
+
# or when it does not answer with the new mode within 250 ms of the write
|
|
94
|
+
def mode=(mode)
|
|
95
|
+
byte = settable_mode(mode)
|
|
96
|
+
locked do
|
|
97
|
+
current = with_reconnect("reads") { fetch(Report::MODE_ID, Report::MODE_SIZE) }
|
|
98
|
+
next if current.getbyte(1) == byte
|
|
99
|
+
|
|
100
|
+
with_reconnect("writes") { @handle.write_feature(Report.mode(byte)) }
|
|
101
|
+
await_mode(byte)
|
|
102
|
+
end
|
|
103
|
+
mode
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# @raise [ArgumentError] unless name is UTF-8 without NULs, 32 bytes at most
|
|
109
|
+
def check_name(name)
|
|
110
|
+
bytes = name.b
|
|
111
|
+
unless bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding? && !bytes.include?("\0")
|
|
112
|
+
raise ArgumentError, "name must be UTF-8 without NUL characters"
|
|
113
|
+
end
|
|
114
|
+
return if bytes.bytesize <= Report::INFO_BLOCK_SIZE - 1
|
|
115
|
+
|
|
116
|
+
raise ArgumentError, "name is #{bytes.bytesize} bytes, the most a stick holds is #{Report::INFO_BLOCK_SIZE - 1}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Reads the mode every MODE_POLL_INTERVAL until the stick answers with
|
|
120
|
+
# byte, without retries or reconnecting. The caller must hold the lock.
|
|
121
|
+
# BusyError, PermissionError and DisconnectedError from the handle pass
|
|
122
|
+
# through.
|
|
123
|
+
#
|
|
124
|
+
# @raise [DisconnectedError] when no read shows byte within MODE_SETTLE_TIMEOUT
|
|
125
|
+
def await_mode(byte)
|
|
126
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + MODE_SETTLE_TIMEOUT
|
|
127
|
+
until mode_reads?(byte)
|
|
128
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
129
|
+
raise DisconnectedError, "#{info.serial.inspect} did not answer after the mode write " \
|
|
130
|
+
"(waited #{(MODE_SETTLE_TIMEOUT * 1000).round} ms)"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
sleep MODE_POLL_INTERVAL
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# One raw read of the mode. A failed attempt or a bad reply counts as
|
|
138
|
+
# not yet.
|
|
139
|
+
#
|
|
140
|
+
# @return [Boolean] true when the stick answered with byte
|
|
141
|
+
def mode_reads?(byte)
|
|
142
|
+
fetch(Report::MODE_ID, Report::MODE_SIZE).getbyte(1) == byte
|
|
143
|
+
rescue Backend::TransferFailed
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [Integer] the mode byte
|
|
148
|
+
# @raise [ArgumentError] unless mode is :ws2812, :ws2812_mirror, 2 or 3
|
|
149
|
+
def settable_mode(mode)
|
|
150
|
+
byte = mode.is_a?(Integer) ? mode : SETTABLE_MODES[mode]
|
|
151
|
+
return byte if SETTABLE_MODES.value?(byte)
|
|
152
|
+
|
|
153
|
+
raise ArgumentError, "mode must be :ws2812, :ws2812_mirror, 2 or 3, got #{mode.inspect} " \
|
|
154
|
+
"(the other modes are for the BlinkStick Pro, which this gem does not support)"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blinkstick
|
|
4
|
+
class Stick
|
|
5
|
+
# Blink, pulse and morph, ported from go-blinkstick's effects.go. Each
|
|
6
|
+
# blocks the calling thread (no thread is spawned), pauses with
|
|
7
|
+
# <tt>cancel.wait(seconds)</tt> so a {Cancellation} cuts it short, and
|
|
8
|
+
# takes the Stick's lock per transfer, not for the whole effect.
|
|
9
|
+
#
|
|
10
|
+
# Two effects running on one stick at once interleave their frames; that
|
|
11
|
+
# is documented, not prevented. A cancelled effect returns false and
|
|
12
|
+
# leaves the LEDs as they are.
|
|
13
|
+
module Effects
|
|
14
|
+
# Seconds between morph steps.
|
|
15
|
+
STEP = 0.02
|
|
16
|
+
|
|
17
|
+
# STEP in integer nanoseconds, for counting steps as Go does.
|
|
18
|
+
STEP_NS = (STEP * 1_000_000_000).round
|
|
19
|
+
private_constant :STEP, :STEP_NS
|
|
20
|
+
|
|
21
|
+
# Flashes every LED on and off, ending off.
|
|
22
|
+
#
|
|
23
|
+
# @param colour [Colour, String, Symbol, Array<Integer>] anything {Colour.parse} takes
|
|
24
|
+
# @param period [Numeric] seconds per flash, finite and above 0 (half on, half off)
|
|
25
|
+
# @param repeats [Integer] how many flashes, 1 or more
|
|
26
|
+
# @param cancel [#cancelled?, #wait, nil] a {Cancellation} (or anything with
|
|
27
|
+
# its two methods); nil makes pauses plain timed waits
|
|
28
|
+
# @return [Boolean] true when it ran to the end, false when cancelled
|
|
29
|
+
# @raise [InvalidColourError] before anything is sent, when colour is not a colour
|
|
30
|
+
# @raise [ArgumentError] before anything is sent, when period or repeats is out of range
|
|
31
|
+
# @raise [ClosedError] after {#close}
|
|
32
|
+
# @raise [BusyError] when another program holds the stick mid-transfer
|
|
33
|
+
# @raise [PermissionError] when the system refuses access mid-transfer
|
|
34
|
+
# @raise [DisconnectedError] when the stick stops responding and reconnecting to it fails
|
|
35
|
+
def blink(colour, period:, repeats: 1, cancel: nil)
|
|
36
|
+
target = Colour.parse(colour)
|
|
37
|
+
check_time(:period, period, positive: true)
|
|
38
|
+
check_repeats(repeats)
|
|
39
|
+
cancel ||= Cancellation.new
|
|
40
|
+
half = period / 2.0
|
|
41
|
+
repeats.times.all? do
|
|
42
|
+
beat(cancel, half) { self.colour = target } && beat(cancel, half) { off }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Fades every LED up to a colour and back down, starting and ending off.
|
|
47
|
+
#
|
|
48
|
+
# @param colour [Colour, String, Symbol, Array<Integer>] anything {Colour.parse} takes
|
|
49
|
+
# @param duration [Numeric] seconds per pulse, finite and above 0 (half up, half down)
|
|
50
|
+
# @param repeats [Integer] how many pulses, 1 or more
|
|
51
|
+
# @param cancel [#cancelled?, #wait, nil] a {Cancellation} (or anything with
|
|
52
|
+
# its two methods); nil makes pauses plain timed waits
|
|
53
|
+
# @return [Boolean] true when it ran to the end, false when cancelled
|
|
54
|
+
# @raise [InvalidColourError] before anything is sent, when colour is not a colour
|
|
55
|
+
# @raise [ArgumentError] before anything is sent, when duration or repeats is out of range
|
|
56
|
+
# @raise [ClosedError] after {#close}
|
|
57
|
+
# @raise [BusyError] when another program holds the stick mid-transfer
|
|
58
|
+
# @raise [PermissionError] when the system refuses access mid-transfer
|
|
59
|
+
# @raise [DisconnectedError] when the stick stops responding and reconnecting to it fails
|
|
60
|
+
def pulse(colour, duration:, repeats: 1, cancel: nil)
|
|
61
|
+
target = Colour.parse(colour)
|
|
62
|
+
check_time(:duration, duration, positive: true)
|
|
63
|
+
check_repeats(repeats)
|
|
64
|
+
cancel ||= Cancellation.new
|
|
65
|
+
half = duration / 2.0
|
|
66
|
+
repeats.times.all? do
|
|
67
|
+
beat(cancel, 0) { off } && fade(target, half, cancel) && fade(Colour::OFF, half, cancel)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Fades every LED from what the stick shows now to a colour, in 20 ms
|
|
72
|
+
# steps. The colour is brightness limited once, so a fade to full red at
|
|
73
|
+
# a limit of 128 ends at 128, not 64. A duration under one step jumps
|
|
74
|
+
# straight to the colour.
|
|
75
|
+
#
|
|
76
|
+
# @param colour [Colour, String, Symbol, Array<Integer>] anything {Colour.parse} takes
|
|
77
|
+
# @param duration [Numeric] seconds, finite and 0 or more
|
|
78
|
+
# @param cancel [#cancelled?, #wait, nil] a {Cancellation} (or anything with
|
|
79
|
+
# its two methods); nil makes pauses plain timed waits
|
|
80
|
+
# @return [Boolean] true when it ran to the end, false when cancelled
|
|
81
|
+
# @raise [InvalidColourError] before anything is sent, when colour is not a colour
|
|
82
|
+
# @raise [ArgumentError] before anything is sent, when duration is out of range
|
|
83
|
+
# @raise [ClosedError] after {#close}
|
|
84
|
+
# @raise [BusyError] when another program holds the stick mid-transfer
|
|
85
|
+
# @raise [PermissionError] when the system refuses access mid-transfer
|
|
86
|
+
# @raise [DisconnectedError] when the stick stops responding and reconnecting to it fails
|
|
87
|
+
def morph(colour, duration:, cancel: nil)
|
|
88
|
+
target = Colour.parse(colour)
|
|
89
|
+
check_time(:duration, duration, positive: false)
|
|
90
|
+
fade(target, duration, cancel || Cancellation.new)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Fades from the stick's frame to target; see {#morph}.
|
|
96
|
+
def fade(target, duration, cancel)
|
|
97
|
+
return false if cancel.cancelled?
|
|
98
|
+
|
|
99
|
+
from = frame
|
|
100
|
+
to = brightness_scaled(target)
|
|
101
|
+
# Integer nanoseconds as Go does: 0.58 s is 29 steps, where float division gives 28.
|
|
102
|
+
steps = [(duration * 1_000_000_000).round / STEP_NS, 1].max
|
|
103
|
+
(1..steps).all? do |step|
|
|
104
|
+
next false if cancel.cancelled?
|
|
105
|
+
|
|
106
|
+
write_frame(from.map { |start| lerp(start, to, step, steps) }, scale: false)
|
|
107
|
+
step == steps || !cancel.wait(STEP)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Runs one write then pauses (a pause of 0 is skipped). Returns truthy
|
|
112
|
+
# to keep going; nil when cancelled before the write, false when the
|
|
113
|
+
# pause was cancelled.
|
|
114
|
+
def beat(cancel, seconds)
|
|
115
|
+
return if cancel.cancelled?
|
|
116
|
+
|
|
117
|
+
yield
|
|
118
|
+
!(seconds.positive? && cancel.wait(seconds))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The colour step/steps of the way from one colour to another.
|
|
122
|
+
def lerp(from, target, step, steps)
|
|
123
|
+
Colour.new(red: towards(from.red, target.red, step, steps),
|
|
124
|
+
green: towards(from.green, target.green, step, steps),
|
|
125
|
+
blue: towards(from.blue, target.blue, step, steps))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# One channel: a + (b - a) * step / steps, truncating towards zero as Go
|
|
129
|
+
# does (Integer#/ would floor).
|
|
130
|
+
def towards(from, target, step, steps)
|
|
131
|
+
delta = (target - from) * step
|
|
132
|
+
from + (delta.negative? ? -(-delta / steps) : delta / steps)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def check_time(name, seconds, positive:)
|
|
136
|
+
ok = seconds.is_a?(Numeric) && seconds.real? && seconds.to_f.finite? &&
|
|
137
|
+
(positive ? seconds.positive? : seconds >= 0)
|
|
138
|
+
return if ok
|
|
139
|
+
|
|
140
|
+
bound = positive ? "above 0" : "of 0 or more"
|
|
141
|
+
raise ArgumentError, "#{name} must be a finite number #{bound}, got #{seconds.inspect}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def check_repeats(repeats)
|
|
145
|
+
return if repeats.is_a?(Integer) && repeats >= 1
|
|
146
|
+
|
|
147
|
+
raise ArgumentError, "repeats must be an Integer of 1 or more, got #{repeats.inspect}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blinkstick
|
|
4
|
+
class Stick
|
|
5
|
+
# Passing an open handle from one {Stick} to another.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module Handover
|
|
9
|
+
# Gives up the open handle without closing it, leaving this stick closed
|
|
10
|
+
# and its finaliser removed, so the handle can go to a new Stick. A
|
|
11
|
+
# closed stick no longer owns its handle, so it refuses.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
# @return [#write_feature, #read_feature, #close] the handle, still open
|
|
15
|
+
# @raise [ClosedError] after {Stick#close} or an earlier detach
|
|
16
|
+
def detach
|
|
17
|
+
@mutex.synchronize do
|
|
18
|
+
raise ClosedError, "#{info.serial} is closed" if @closed
|
|
19
|
+
|
|
20
|
+
@closed = true
|
|
21
|
+
ObjectSpace.undefine_finalizer(self)
|
|
22
|
+
@handle
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blinkstick
|
|
4
|
+
class Stick
|
|
5
|
+
# The LED write path of a {Stick}: the brightness limit, inverse, and the
|
|
6
|
+
# last frame written (which a reconnect repaints). All state is guarded by
|
|
7
|
+
# the Stick's lock.
|
|
8
|
+
module Lighting
|
|
9
|
+
# @return [Integer] the brightness limit, 0 to 255 (255 by default)
|
|
10
|
+
def brightness_limit
|
|
11
|
+
@mutex.synchronize { @limit }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Caps every channel of every write from now on: a channel v is sent as
|
|
15
|
+
# <tt>v * limit / 255</tt> (Integer division). Reads return what the stick
|
|
16
|
+
# holds, so they are never scaled back up. Per Stick, not stored on the
|
|
17
|
+
# device, and it does not repaint what is already lit.
|
|
18
|
+
#
|
|
19
|
+
# @param limit [Integer] 0 to 255
|
|
20
|
+
# @raise [ArgumentError] unless limit is an Integer from 0 to 255
|
|
21
|
+
def brightness_limit=(limit)
|
|
22
|
+
raise ArgumentError, "brightness limit must be an Integer from 0 to 255, got #{limit.inspect}" unless
|
|
23
|
+
limit.is_a?(Integer) && limit.between?(0, 255)
|
|
24
|
+
|
|
25
|
+
@mutex.synchronize { @limit = limit }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Boolean] whether writes are flipped to 255 minus the value
|
|
29
|
+
def inverse?
|
|
30
|
+
@mutex.synchronize { @inverse }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Flips every channel written from now on to <tt>255 - v</tt>, after the
|
|
34
|
+
# brightness limit, for LEDs wired so 255 is off. Reads flip back, so
|
|
35
|
+
# {Stick#frame} and {Stick#[]} return what was written. It does not
|
|
36
|
+
# repaint, so set it before setting LEDs. Not the firmware inverse mode.
|
|
37
|
+
#
|
|
38
|
+
# @param on [Object] truthiness is stored as true or false
|
|
39
|
+
def inverse=(on)
|
|
40
|
+
@mutex.synchronize { @inverse = on ? true : false }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Reads every LED back from the stick. Nothing is cached: each call asks
|
|
44
|
+
# the stick. Values are what the stick holds (after the brightness limit),
|
|
45
|
+
# flipped back when {#inverse?} is on.
|
|
46
|
+
#
|
|
47
|
+
# @return [Array<Colour>] frozen, one per LED of the model
|
|
48
|
+
# @raise [ClosedError] after {#close}
|
|
49
|
+
# @raise [BusyError] when another program holds the stick mid-transfer
|
|
50
|
+
# @raise [PermissionError] when the system refuses access mid-transfer
|
|
51
|
+
# @raise [DisconnectedError] when the stick stops responding and reconnecting to it fails
|
|
52
|
+
def frame
|
|
53
|
+
transfer("reads") { flipped_frame }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Reads one LED back from the stick, by reading the whole frame.
|
|
57
|
+
#
|
|
58
|
+
# @param index [Integer] LED index, 0 to one less than the model's LED count (Nano 0 or 1, Square 0 to 7)
|
|
59
|
+
# @return [Colour]
|
|
60
|
+
# @raise [IndexError] before anything is sent, when index is not an Integer within the model's LEDs
|
|
61
|
+
# @raise [ClosedError] after {#close}
|
|
62
|
+
# @raise [BusyError] when another program holds the stick mid-transfer
|
|
63
|
+
# @raise [PermissionError] when the system refuses access mid-transfer
|
|
64
|
+
# @raise [DisconnectedError] when the stick stops responding and reconnecting to it fails
|
|
65
|
+
def [](index)
|
|
66
|
+
check_index(index)
|
|
67
|
+
frame.fetch(index)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Scales, flips and sends a whole frame, then records it (scaled, not
|
|
73
|
+
# flipped) as the last frame. Takes the lock itself.
|
|
74
|
+
#
|
|
75
|
+
# @param colours [Array<Colour>] one per LED of the model
|
|
76
|
+
# @param scale [Boolean] false to skip the brightness limit
|
|
77
|
+
def write_frame(colours, scale: true)
|
|
78
|
+
transfer("writes", frame_write: true) do
|
|
79
|
+
limited = scale ? colours.map { |colour| scaled(colour) } : colours.dup
|
|
80
|
+
@handle.write_feature(Report.frame(limited.map { |colour| flipped(colour) }))
|
|
81
|
+
@last = limited.freeze
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Sends one LED, seeding the last frame from the stick first when
|
|
86
|
+
# nothing has been written yet. One lock hold covers both.
|
|
87
|
+
def write_led(index, colour)
|
|
88
|
+
locked do
|
|
89
|
+
@last ||= with_reconnect("reads") { flipped_frame }
|
|
90
|
+
limited = scaled(colour)
|
|
91
|
+
with_reconnect("writes") { @handle.write_feature(Report.led(index, flipped(limited))) }
|
|
92
|
+
@last = @last.dup.tap { |frame| frame[index] = limited }.freeze
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# One read of the frame, flipped back if inverse. The caller holds the lock.
|
|
97
|
+
def flipped_frame
|
|
98
|
+
frame = Report.decode_frame(fetch(Report::FRAME_ID, Report::FRAME_SIZE), info.model.leds)
|
|
99
|
+
@inverse ? frame.map(&:inverse).freeze : frame
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Applies the brightness limit, for a caller that does not hold the lock.
|
|
103
|
+
def brightness_scaled(colour)
|
|
104
|
+
@mutex.synchronize { scaled(colour) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Applies the brightness limit. The caller holds the lock.
|
|
108
|
+
def scaled(colour)
|
|
109
|
+
return colour if @limit == 255
|
|
110
|
+
|
|
111
|
+
Colour.new(red: colour.red * @limit / 255, green: colour.green * @limit / 255,
|
|
112
|
+
blue: colour.blue * @limit / 255)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def flipped(colour)
|
|
116
|
+
@inverse ? colour.inverse : colour
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blinkstick
|
|
4
|
+
class Stick
|
|
5
|
+
# How a {Stick} talks to its handle: under its lock, with retries, and
|
|
6
|
+
# finding the stick again after it was unplugged (ported from
|
|
7
|
+
# go-blinkstick's transferLocked and reconnectLocked). When a transfer
|
|
8
|
+
# runs out of attempts or the handle says the stick is gone, the handle is
|
|
9
|
+
# dropped, the stick reopened by serial, the last frame repainted and the
|
|
10
|
+
# transfer run again. Everything here runs under the Stick's lock.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
module Reconnect
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# Holds the lock across every attempt and any reconnect, so transfers
|
|
17
|
+
# from other threads cannot land in between.
|
|
18
|
+
def transfer(what, frame_write: false, &)
|
|
19
|
+
locked { with_reconnect(what, frame_write:, &) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Holds the lock for one call, refusing a closed stick. Each call may
|
|
23
|
+
# reconnect once, however many transfers it makes.
|
|
24
|
+
def locked
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
raise ClosedError, "#{info.serial} is closed" if @closed
|
|
27
|
+
|
|
28
|
+
@reconnected = false
|
|
29
|
+
yield
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Runs the block up to Backend::ATTEMPTS times while it raises
|
|
34
|
+
# TransferFailed. The caller must hold the lock.
|
|
35
|
+
def with_retries(what)
|
|
36
|
+
attempt = 0
|
|
37
|
+
begin
|
|
38
|
+
attempt += 1
|
|
39
|
+
yield
|
|
40
|
+
rescue Backend::TransferFailed
|
|
41
|
+
retry if attempt < Backend::ATTEMPTS
|
|
42
|
+
|
|
43
|
+
raise DisconnectedError, "#{info.serial.inspect} stopped responding: #{attempt} #{what} failed in a row"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Runs one transfer, reconnecting once if it fails. A call that makes
|
|
48
|
+
# several transfers reconnects at most once in all: a later failure in
|
|
49
|
+
# the same call raises and keeps the new handle. BusyError,
|
|
50
|
+
# PermissionError and ClosedError pass through untouched.
|
|
51
|
+
#
|
|
52
|
+
# @param what [String] "reads" or "writes", for the error message
|
|
53
|
+
# @param frame_write [Boolean] true when the transfer is a whole frame
|
|
54
|
+
# write, which repainting the old frame first would only flash
|
|
55
|
+
# @raise [DisconnectedError] when the transfer fails and reconnecting
|
|
56
|
+
# cannot be tried, fails, or does not help
|
|
57
|
+
def with_reconnect(what, frame_write: false, &)
|
|
58
|
+
if @handle
|
|
59
|
+
begin
|
|
60
|
+
return with_retries(what, &)
|
|
61
|
+
rescue DisconnectedError
|
|
62
|
+
raise if @reopen.nil? || @reconnected
|
|
63
|
+
|
|
64
|
+
drop_handle
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
reconnect(repaint: !frame_write)
|
|
68
|
+
with_retries(what, &)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Reopens the stick and repaints the last frame, if asked and there is
|
|
72
|
+
# one. Leaves no handle when either fails.
|
|
73
|
+
#
|
|
74
|
+
# @raise [DisconnectedError] naming the cause when either fails
|
|
75
|
+
def reconnect(repaint:)
|
|
76
|
+
@reconnected = true
|
|
77
|
+
adopt(reopen_handle)
|
|
78
|
+
repaint_last if repaint && @last
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The reopen's own message already names the serial (or says there is
|
|
82
|
+
# none), so it is not repeated here.
|
|
83
|
+
#
|
|
84
|
+
# @return [#write_feature, #read_feature, #close] a fresh handle
|
|
85
|
+
# @raise [DisconnectedError] wrapping whatever stopped the reopen
|
|
86
|
+
def reopen_handle
|
|
87
|
+
@reopen.call
|
|
88
|
+
rescue Error => e
|
|
89
|
+
raise DisconnectedError, "could not reconnect: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Sends the last frame again exactly as it was sent: already limited,
|
|
93
|
+
# so not limited again, and flipped if inverse is on. The failure's own
|
|
94
|
+
# message names the stick, so the serial is not repeated here.
|
|
95
|
+
#
|
|
96
|
+
# @raise [DisconnectedError] wrapping the failure, with the handle dropped
|
|
97
|
+
def repaint_last
|
|
98
|
+
report = Report.frame(@last.map { |colour| flipped(colour) })
|
|
99
|
+
with_retries("writes") { @handle.write_feature(report) }
|
|
100
|
+
rescue Error => e
|
|
101
|
+
drop_handle
|
|
102
|
+
raise DisconnectedError, "reconnected, but repainting the last frame failed: #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Takes a new handle, moving the finaliser onto it.
|
|
106
|
+
def adopt(handle)
|
|
107
|
+
@handle = handle
|
|
108
|
+
ObjectSpace.undefine_finalizer(self)
|
|
109
|
+
ObjectSpace.define_finalizer(self, self.class.releaser(handle))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Closes the handle, ignoring any error doing so, and forgets it along
|
|
113
|
+
# with its finaliser.
|
|
114
|
+
def drop_handle
|
|
115
|
+
handle = @handle
|
|
116
|
+
@handle = nil
|
|
117
|
+
ObjectSpace.undefine_finalizer(self)
|
|
118
|
+
begin
|
|
119
|
+
handle.close
|
|
120
|
+
rescue Error
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|