rgpio 0.1.0 → 0.1.1

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,338 @@
1
+ require "monitor"
2
+
3
+ module Rgpio
4
+ # A 240x320 colour TFT driven by an Ilitek ILI9341 over SPI — the 2.4"/2.8"
5
+ # modules with an XPT2046 touch controller on the same board.
6
+ #
7
+ # lcd = Rgpio::ILI9341.new(dc: 24, reset: 25, backlight: 18)
8
+ # lcd.fill(:black)
9
+ # lcd.fill_rect(10, 10, 100, 50, :red)
10
+ # lcd.text(10, 80, "Hello", color: :white, scale: 2)
11
+ # lcd.close
12
+ #
13
+ # The controller tells a command byte from its parameters by the D/C line —
14
+ # low for a command, high for data — so D/C (and RESET, if wired) are GPIO
15
+ # outputs alongside the SPI bus. Pixels are 16-bit RGB565, high byte first.
16
+ #
17
+ # Colours are anything {RGB565.from} takes: a name such as :red, an
18
+ # [r, g, b] triple of 0..255, or an RGB565 Integer. Drawing is clipped to the
19
+ # screen.
20
+ #
21
+ # Text is drawn by {Font5x7::Drawing#text}.
22
+ #
23
+ # Every drawing call is one command sequence (window, then pixel data) held
24
+ # under a lock, so a touch callback on another thread can draw too.
25
+ class ILI9341
26
+ include Font5x7::Drawing
27
+
28
+ NATIVE_WIDTH = 240
29
+ NATIVE_HEIGHT = 320
30
+
31
+ # The Pi 5's SPI divides a 200 MHz clock by an even number, so this runs at
32
+ # 20 MHz (32 MHz would be 25 MHz, which garbled full-screen fills on jumper
33
+ # wires). Pi 4 dividers differ; pass speed_hz: to go faster on short wires.
34
+ DEFAULT_SPEED_HZ = 24_000_000
35
+
36
+ # Command set (datasheet section 8).
37
+ CMD_SWRESET = 0x01
38
+ CMD_SLPOUT = 0x11
39
+ CMD_INVOFF = 0x20
40
+ CMD_INVON = 0x21
41
+ CMD_GAMMASET = 0x26
42
+ CMD_DISPON = 0x29
43
+ CMD_CASET = 0x2a
44
+ CMD_PASET = 0x2b
45
+ CMD_RAMWR = 0x2c
46
+ CMD_MADCTL = 0x36
47
+ CMD_PIXFMT = 0x3a
48
+ CMD_FRMCTR1 = 0xb1
49
+ CMD_DFUNCTR = 0xb6
50
+ CMD_PWCTR1 = 0xc0
51
+ CMD_PWCTR2 = 0xc1
52
+ CMD_VMCTR1 = 0xc5
53
+ CMD_VMCTR2 = 0xc7
54
+ CMD_GMCTRP1 = 0xe0
55
+ CMD_GMCTRN1 = 0xe1
56
+
57
+ # Power, VCOM, frame rate and gamma for the common 2.4"/2.8" panels. These
58
+ # are the values the widely used Adafruit driver ships.
59
+ INIT_SEQUENCE = [
60
+ [CMD_PWCTR1, 0x23],
61
+ [CMD_PWCTR2, 0x10],
62
+ [CMD_VMCTR1, 0x3e, 0x28],
63
+ [CMD_VMCTR2, 0x86],
64
+ [CMD_PIXFMT, 0x55], # 16 bits per pixel
65
+ [CMD_FRMCTR1, 0x00, 0x18], # 79 Hz
66
+ [CMD_DFUNCTR, 0x08, 0x82, 0x27],
67
+ [CMD_GAMMASET, 0x01],
68
+ [CMD_GMCTRP1, 0x0f, 0x31, 0x2b, 0x0c, 0x0e, 0x08, 0x4e, 0xf1,
69
+ 0x37, 0x07, 0x10, 0x03, 0x0e, 0x09, 0x00,],
70
+ [CMD_GMCTRN1, 0x00, 0x0e, 0x14, 0x03, 0x11, 0x07, 0x31, 0xc1,
71
+ 0x48, 0x08, 0x0f, 0x0c, 0x31, 0x36, 0x0f,],
72
+ ].freeze
73
+
74
+ # MADCTL bits: row/column order and exchange, and the panel's colour order.
75
+ MADCTL_MY = 0x80
76
+ MADCTL_MX = 0x40
77
+ MADCTL_MV = 0x20
78
+ MADCTL_BGR = 0x08
79
+
80
+ # Rotation in degrees => MADCTL. 0 is portrait with the FPC at the bottom.
81
+ ROTATIONS = {
82
+ 0 => MADCTL_MX,
83
+ 90 => MADCTL_MV,
84
+ 180 => MADCTL_MY,
85
+ 270 => MADCTL_MX | MADCTL_MY | MADCTL_MV,
86
+ }.freeze
87
+
88
+ # Reset pulse, and the waits the datasheet asks for after reset and
89
+ # sleep-out before the next command.
90
+ RESET_PULSE = 0.01
91
+ RESET_DELAY = 0.12
92
+ SLEEP_OUT_DELAY = 0.12
93
+
94
+ # Open a display, yielding it and closing it afterwards when a block is given.
95
+ # @return [ILI9341, Object] the display, or the block's value
96
+ def self.open(**)
97
+ lcd = new(**)
98
+ return lcd unless block_given?
99
+
100
+ begin
101
+ yield lcd
102
+ ensure
103
+ lcd.close
104
+ end
105
+ end
106
+
107
+ # @param dc [Integer] GPIO line wired to D/C (sometimes DC/RS)
108
+ # @param reset [Integer, nil] GPIO line wired to RESET, or nil when
109
+ # it is tied high; a software reset is sent either way
110
+ # @param backlight [Integer, nil] GPIO line wired to LED, or nil when it
111
+ # is tied to 3.3 V
112
+ # @param backlight_pwm [Boolean] drive the backlight with SoftwarePWM so
113
+ # #backlight= takes a level, not just on/off
114
+ # @param rotation [Integer] 0, 90, 180 or 270
115
+ # @param bgr [Boolean] the panel's colour order; true for nearly
116
+ # every module, false if red and blue come out swapped
117
+ # @param bus [Integer] spidev bus number
118
+ # @param device [Integer] chip-select index
119
+ # @param speed_hz [Integer] clock rate
120
+ # @param spi [SPI, nil] an open bus device to share, or nil to open one
121
+ # @param chip [Chip, nil] chip for the GPIO lines, or nil to open one
122
+ # @param consumer [String] name shown in the kernel's request list
123
+ def initialize(dc:, reset: nil, backlight: nil, backlight_pwm: false, rotation: 0, bgr: true,
124
+ bus: 0, device: 0, speed_hz: DEFAULT_SPEED_HZ, spi: nil, chip: nil, consumer: "rgpio")
125
+ validate_rotation(rotation)
126
+ @bgr = bgr
127
+ @lock = Monitor.new
128
+ @closed = false
129
+ @owns_spi = spi.nil?
130
+ @spi = spi || SPI.new(bus: bus, device: device, speed_hz: speed_hz, mode: 0)
131
+ @owns_chip = chip.nil?
132
+ @chip = chip || Chip.new
133
+ @dc = dc
134
+ @dc_request = @chip.request_lines(offsets: [dc], direction: :output, initial_value: :inactive,
135
+ consumer: consumer)
136
+ @dc_level = :inactive
137
+ @reset = reset
138
+ @reset_request = reset && @chip.request_lines(offsets: [reset], direction: :output,
139
+ initial_value: :active, consumer: consumer)
140
+ @backlight = backlight && open_backlight(backlight, backlight_pwm, consumer)
141
+ @rotation = rotation
142
+ reset!
143
+ self.backlight = true if @backlight
144
+ end
145
+
146
+ # @return [SPI] the bus device pixels go through
147
+ attr_reader :spi
148
+
149
+ # @return [Integer] 0, 90, 180 or 270
150
+ attr_reader :rotation
151
+
152
+ # @return [Integer] screen width at the current rotation
153
+ def width
154
+ (@rotation % 180).zero? ? NATIVE_WIDTH : NATIVE_HEIGHT
155
+ end
156
+
157
+ # @return [Integer] screen height at the current rotation
158
+ def height
159
+ (@rotation % 180).zero? ? NATIVE_HEIGHT : NATIVE_WIDTH
160
+ end
161
+
162
+ # Reset the controller (by the RESET line when wired, and by command) and
163
+ # bring it up again. The screen content is lost.
164
+ def reset!
165
+ synchronize do
166
+ if @reset_request
167
+ @reset_request.set_value(@reset, :inactive)
168
+ wait(RESET_PULSE)
169
+ @reset_request.set_value(@reset, :active)
170
+ end
171
+ command(CMD_SWRESET)
172
+ wait(RESET_DELAY)
173
+ INIT_SEQUENCE.each { |cmd, *params| command(cmd, *params) }
174
+ command(CMD_MADCTL, madctl)
175
+ command(CMD_SLPOUT)
176
+ wait(SLEEP_OUT_DELAY)
177
+ command(CMD_DISPON)
178
+ end
179
+ self
180
+ end
181
+
182
+ # Turn the drawing coordinates. What is already on screen stays where it
183
+ # is; only what is drawn next follows the new orientation.
184
+ def rotation=(degrees)
185
+ validate_rotation(degrees)
186
+ synchronize do
187
+ @rotation = degrees
188
+ command(CMD_MADCTL, madctl)
189
+ end
190
+ end
191
+
192
+ # Swap every colour for its complement — some panels sold as ILI9341 need
193
+ # this to show colours the right way round.
194
+ def invert=(on)
195
+ synchronize { command(on ? CMD_INVON : CMD_INVOFF) }
196
+ end
197
+
198
+ # @param level [Boolean, Float] on/off, or 0.0..1.0 with backlight_pwm: true
199
+ def backlight=(level)
200
+ raise Error, "no backlight line was given to #{self.class}" unless @backlight
201
+
202
+ if @backlight.is_a?(PWMOutputDevice)
203
+ @backlight.value = { true => 1.0, false => 0.0 }.fetch(level, level)
204
+ elsif [true, false].include?(level)
205
+ @backlight.value = level
206
+ else
207
+ raise ArgumentError, "a backlight level needs backlight_pwm: true; use true or false"
208
+ end
209
+ end
210
+
211
+ # Fill the whole screen.
212
+ def fill(color)
213
+ fill_rect(0, 0, width, height, color)
214
+ end
215
+
216
+ # Fill a rectangle; the part off screen is skipped.
217
+ def fill_rect(x, y, w, h, color)
218
+ x0, y0, x1, y1 = clip(x, y, w, h)
219
+ return self unless x0
220
+
221
+ pixel = [RGB565.from(color)].pack("n")
222
+ synchronize do
223
+ set_window(x0, y0, x1, y1)
224
+ write_pixels(pixel * ((x1 - x0 + 1) * (y1 - y0 + 1)))
225
+ end
226
+ self
227
+ end
228
+
229
+ def pixel(x, y, color)
230
+ fill_rect(x, y, 1, 1, color)
231
+ end
232
+
233
+ # Copy a block of raw RGB565 pixels (two bytes each, high byte first, row by
234
+ # row) to the screen. The part off screen is skipped.
235
+ # @param data [String] w * h * 2 bytes
236
+ def blit(x, y, w, h, data)
237
+ data = data.b
238
+ unless data.bytesize == w * h * 2
239
+ raise ArgumentError, "blit of #{w}x#{h} needs #{w * h * 2} bytes, got #{data.bytesize}"
240
+ end
241
+
242
+ x0, y0, x1, y1 = clip(x, y, w, h)
243
+ return self unless x0
244
+
245
+ row_bytes = (x1 - x0 + 1) * 2
246
+ visible = (y0..y1).map { |row| data.byteslice((((row - y) * w) + (x0 - x)) * 2, row_bytes) }.join
247
+ synchronize do
248
+ set_window(x0, y0, x1, y1)
249
+ write_pixels(visible)
250
+ end
251
+ self
252
+ end
253
+
254
+ # Release the GPIO lines, and the bus and chip if this display opened them.
255
+ # The panel keeps showing its last picture while it has power.
256
+ def close
257
+ return if @closed
258
+
259
+ @closed = true
260
+ @backlight&.close
261
+ @reset_request&.release
262
+ @dc_request.release
263
+ @spi.close if @owns_spi
264
+ @chip.close if @owns_chip
265
+ end
266
+
267
+ def closed?
268
+ @closed
269
+ end
270
+
271
+ private
272
+
273
+ def synchronize(&)
274
+ raise Error, "#{self.class} is closed" if @closed
275
+
276
+ @lock.synchronize(&)
277
+ end
278
+
279
+ def validate_rotation(degrees)
280
+ return if ROTATIONS.key?(degrees)
281
+
282
+ raise ArgumentError, "rotation must be one of #{ROTATIONS.keys.join(", ")}, got #{degrees.inspect}"
283
+ end
284
+
285
+ def open_backlight(gpio, pwm, consumer)
286
+ pwm ? PWMLED.new(gpio, chip: @chip, consumer: consumer) : LED.new(gpio, chip: @chip, consumer: consumer)
287
+ end
288
+
289
+ def madctl
290
+ ROTATIONS.fetch(@rotation) | (@bgr ? MADCTL_BGR : 0)
291
+ end
292
+
293
+ # @return [Array(Integer, Integer, Integer, Integer), nil] the visible
294
+ # corners x0, y0, x1, y1 (inclusive), or nil when nothing is visible
295
+ def clip(x, y, w, h)
296
+ x0 = [x, 0].max
297
+ y0 = [y, 0].max
298
+ x1 = [x + w, width].min - 1
299
+ y1 = [y + h, height].min - 1
300
+ return nil if x1 < x0 || y1 < y0
301
+
302
+ [x0, y0, x1, y1]
303
+ end
304
+
305
+ def set_window(x0, y0, x1, y1)
306
+ command(CMD_CASET, x0 >> 8, x0 & 0xff, x1 >> 8, x1 & 0xff)
307
+ command(CMD_PASET, y0 >> 8, y0 & 0xff, y1 >> 8, y1 & 0xff)
308
+ command(CMD_RAMWR)
309
+ end
310
+
311
+ def command(cmd, *params)
312
+ dc(:inactive)
313
+ @spi.write(cmd)
314
+ return if params.empty?
315
+
316
+ dc(:active)
317
+ @spi.write(*params)
318
+ end
319
+
320
+ def write_pixels(data)
321
+ dc(:active)
322
+ @spi.send_bytes(data)
323
+ end
324
+
325
+ # D/C only changes between a command and its data, so skip the ioctl when
326
+ # it is already where it needs to be.
327
+ def dc(level)
328
+ return if @dc_level == level
329
+
330
+ @dc_request.set_value(@dc, level)
331
+ @dc_level = level
332
+ end
333
+
334
+ def wait(seconds)
335
+ sleep(seconds)
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,61 @@
1
+ module Rgpio
2
+ # 16-bit colour as colour TFT controllers take it: 5 bits red, 6 green,
3
+ # 5 blue, sent high byte first.
4
+ #
5
+ # Rgpio::RGB565.from(:orange) # => 0xfb20
6
+ # Rgpio::RGB565.from([255, 128, 0]) # => 0xfc00
7
+ # Rgpio::RGB565.from(0x07e0) # => 0x07e0
8
+ module RGB565
9
+ COLORS = {
10
+ black: 0x0000,
11
+ white: 0xffff,
12
+ red: 0xf800,
13
+ green: 0x07e0,
14
+ blue: 0x001f,
15
+ yellow: 0xffe0,
16
+ cyan: 0x07ff,
17
+ magenta: 0xf81f,
18
+ orange: 0xfb20, # [255, 100, 0]: 255,165,0 looks yellow on these panels
19
+ gray: 0x8410,
20
+ navy: 0x0010,
21
+ maroon: 0x8000,
22
+ olive: 0x8400,
23
+ purple: 0x8010,
24
+ teal: 0x0410,
25
+ dark_green: 0x0400,
26
+ }.freeze
27
+
28
+ # @return [Integer] 8-bit red, green and blue packed as RGB565; the low
29
+ # bits that do not fit are dropped
30
+ def self.pack(red, green, blue)
31
+ [red, green, blue].each do |c|
32
+ next if c.is_a?(Integer) && c.between?(0, 255)
33
+
34
+ raise ArgumentError, "colour components must be in 0..255, got #{c.inspect}"
35
+ end
36
+ ((red & 0xf8) << 8) | ((green & 0xfc) << 3) | (blue >> 3)
37
+ end
38
+
39
+ # @param value [Symbol, String, Array<Integer>, Integer] a name from
40
+ # {COLORS}, an [r, g, b] triple of 0..255, or an RGB565 Integer
41
+ # @return [Integer] the colour as RGB565
42
+ def self.from(value)
43
+ case value
44
+ when Symbol, String
45
+ COLORS.fetch(value.to_sym) do
46
+ raise ArgumentError, "unknown colour #{value.inspect}; one of #{COLORS.keys.join(", ")}"
47
+ end
48
+ when Array
49
+ raise ArgumentError, "a colour array is [r, g, b], got #{value.inspect}" unless value.size == 3
50
+
51
+ pack(*value)
52
+ when Integer
53
+ raise ArgumentError, "an RGB565 colour is in 0..0xffff, got #{value}" unless value.between?(0, 0xffff)
54
+
55
+ value
56
+ else
57
+ raise ArgumentError, "colour must be a name, [r, g, b] or an RGB565 Integer, got #{value.inspect}"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,202 @@
1
+ module Rgpio
2
+ # A quadrature rotary encoder such as the KY-040 module.
3
+ #
4
+ # encoder = Rgpio::RotaryEncoder.new(a: 17, b: 18, max_steps: 16)
5
+ # encoder.when_rotated_clockwise { puts "CW #{encoder.steps}" }
6
+ # encoder.when_rotated_counter_clockwise { puts "CCW #{encoder.steps}" }
7
+ # Rgpio.pause
8
+ #
9
+ # Both phases are claimed in one request so a single watcher thread reads
10
+ # their edges from one kernel queue, in timestamp order. Unlike Button the
11
+ # watcher starts at once: steps are counted whether or not a callback is set.
12
+ #
13
+ # Each edge is decoded through the Gray-code transition table into +1 or -1,
14
+ # and a step is counted only when the phases come back to rest four
15
+ # transitions on. Contact bounce moves the phases back and forth, so its
16
+ # transitions cancel out and never reach a full detent. Rest is both high
17
+ # with pull_up: true or nil (the KY-040 has its own pull-ups), both low with
18
+ # pull_up: false. Clockwise is A changing before B; if a knob counts
19
+ # backwards, swap a: and b:.
20
+ #
21
+ # The push switch on the shaft is a separate contact: use Rgpio::Button.
22
+ class RotaryEncoder < Device
23
+ # How long a single edge wait blocks before the watcher rechecks whether it
24
+ # has been asked to stop.
25
+ WATCH_TIMEOUT = 0.2
26
+
27
+ # Direction of each move between two phase states, indexed by
28
+ # (previous << 2) | current with a state of (A << 1) | B. Clockwise runs
29
+ # 00 -> 10 -> 11 -> 01 -> 00; no change and two-bit jumps are 0.
30
+ TRANSITIONS = [
31
+ 0, -1, 1, 0,
32
+ 1, 0, 0, -1,
33
+ -1, 0, 0, 1,
34
+ 0, 1, -1, 0,
35
+ ].freeze
36
+
37
+ # Transitions per detent.
38
+ TRANSITIONS_PER_STEP = 4
39
+
40
+ # @param a [Integer] GPIO line of phase A (CLK on a KY-040)
41
+ # @param b [Integer] GPIO line of phase B (DT on a KY-040)
42
+ # @param max_steps [Integer] bound of #steps in either direction; 0 for none
43
+ # @param wrap [Boolean] past a bound, continue from the other end
44
+ # instead of stopping there
45
+ # @param pull_up [Boolean, nil] internal bias, as for InputDevice
46
+ # @param debounce_us [Integer] kernel debounce window in microseconds
47
+ # @param chip [Chip, nil] chip to share, or nil to open one
48
+ # @param consumer [String] name shown in the kernel's request list
49
+ def initialize(a:, b:, max_steps: 16, wrap: false, pull_up: true, debounce_us: 0, chip: nil, consumer: "rgpio")
50
+ unless max_steps.is_a?(Integer) && max_steps >= 0
51
+ raise ArgumentError, "max_steps must be a non-negative Integer, got #{max_steps.inspect}"
52
+ end
53
+
54
+ bias = bias_for(pull_up)
55
+ super(chip: chip)
56
+ @a = a
57
+ @b = b
58
+ @max_steps = max_steps
59
+ @wrap = wrap
60
+ @rest = pull_up == false ? 0b00 : 0b11
61
+ @steps = 0
62
+ @partial = 0
63
+ @callbacks = {}
64
+ @lock = Mutex.new
65
+ @request = @chip.request_lines(
66
+ offsets: [a, b],
67
+ direction: :input,
68
+ edge: :both,
69
+ bias: bias,
70
+ debounce_us: debounce_us,
71
+ consumer: consumer
72
+ )
73
+ @state = (level(@a) << 1) | level(@b)
74
+ start_watching
75
+ end
76
+
77
+ # @return [Integer] GPIO lines of phase A and phase B
78
+ attr_reader :a, :b
79
+
80
+ # @return [Integer] the bound of #steps, or 0 when unbounded
81
+ attr_reader :max_steps
82
+
83
+ # @return [Integer] detents turned since start, clockwise positive
84
+ def steps
85
+ @lock.synchronize { @steps }
86
+ end
87
+
88
+ # Set the count, bounded (or wrapped) like rotation is.
89
+ def steps=(value)
90
+ raise ArgumentError, "steps must be an Integer, got #{value.inspect}" unless value.is_a?(Integer)
91
+
92
+ @lock.synchronize { @steps = bound(value) }
93
+ end
94
+
95
+ # @return [Float] #steps / #max_steps in -1.0..1.0, or 0.0 when unbounded
96
+ def value
97
+ return 0.0 if @max_steps.zero?
98
+
99
+ steps.fdiv(@max_steps)
100
+ end
101
+
102
+ def wrap?
103
+ @wrap
104
+ end
105
+
106
+ # Callbacks fire once per detent, also at a bound where #steps stays put.
107
+
108
+ def when_rotated(&)
109
+ on(:rotated, &)
110
+ end
111
+
112
+ def when_rotated_clockwise(&)
113
+ on(:clockwise, &)
114
+ end
115
+
116
+ def when_rotated_counter_clockwise(&)
117
+ on(:counter_clockwise, &)
118
+ end
119
+
120
+ private
121
+
122
+ def bias_for(pull_up)
123
+ case pull_up
124
+ when true then :pull_up
125
+ when false then :pull_down
126
+ when nil then :disabled
127
+ else raise ArgumentError, "pull_up must be true, false or nil, got #{pull_up.inspect}"
128
+ end
129
+ end
130
+
131
+ def level(offset)
132
+ @request.get_value(offset) == :active ? 1 : 0
133
+ end
134
+
135
+ def on(kind, &block)
136
+ raise ArgumentError, "a callback block is required" unless block
137
+
138
+ @callbacks[kind] = block
139
+ self
140
+ end
141
+
142
+ def bound(value)
143
+ return value if @max_steps.zero?
144
+ return value.clamp(-@max_steps, @max_steps) unless @wrap
145
+
146
+ span = (2 * @max_steps) + 1
147
+ ((value + @max_steps) % span) - @max_steps
148
+ end
149
+
150
+ def start_watching
151
+ @watching = true
152
+ @watcher = Thread.new { watch_loop }
153
+ end
154
+
155
+ def watch_loop
156
+ @request.read_edge_events(timeout: WATCH_TIMEOUT).each { |event| handle(event) } while @watching
157
+ rescue StandardError => e
158
+ warn "rgpio: edge watcher for GPIO#{@a}/GPIO#{@b} stopped: #{e.class}: #{e.message}"
159
+ end
160
+
161
+ # Fold one edge into the phase state, and count a step once the phases are
162
+ # back at rest. Each edge changes one bit, so the net at rest is 0 (a half
163
+ # turn and back, or bounce) or a full detent either way. A repeated edge
164
+ # for a line already at that level changes nothing and reads as 0.
165
+ def handle(event)
166
+ bit = event[:offset] == @a ? 0b10 : 0b01
167
+ current = event[:type] == :rising ? @state | bit : @state & ~bit
168
+ @partial += TRANSITIONS[(@state << 2) | current]
169
+ @state = current
170
+ return unless current == @rest
171
+
172
+ direction = @partial.abs >= TRANSITIONS_PER_STEP ? @partial <=> 0 : 0
173
+ @partial = 0
174
+ rotate(direction) unless direction.zero?
175
+ end
176
+
177
+ def rotate(direction)
178
+ @lock.synchronize { @steps = bound(@steps + direction) }
179
+ dispatch(direction.positive? ? :clockwise : :counter_clockwise)
180
+ dispatch(:rotated)
181
+ end
182
+
183
+ # A raising callback must not take the watcher down with it.
184
+ def dispatch(kind)
185
+ callback = @callbacks[kind]
186
+ return unless callback
187
+
188
+ callback.call
189
+ rescue StandardError => e
190
+ warn "rgpio: #{self.class} callback for GPIO#{@a}/GPIO#{@b} raised #{e.class}: #{e.message}"
191
+ end
192
+
193
+ # Stop the watcher before releasing the request: a wait in progress holds a
194
+ # pointer into the request that libgpiod would free underneath it.
195
+ def release_resources
196
+ @watching = false
197
+ @watcher&.join
198
+ @watcher = nil
199
+ @request.release
200
+ end
201
+ end
202
+ end