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.
@@ -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