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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -2
- data/README.md +33 -891
- data/examples/rotary_encoder.rb +48 -0
- data/examples/ruby_stack.rb +169 -0
- data/examples/tft.rb +65 -0
- data/examples/touch_calibration.rb +61 -0
- data/examples/touch_paint.rb +67 -0
- data/lib/rgpio/devices/adt7410.rb +1 -1
- data/lib/rgpio/devices/font5x7.rb +209 -0
- data/lib/rgpio/devices/ili9341.rb +338 -0
- data/lib/rgpio/devices/rgb565.rb +61 -0
- data/lib/rgpio/devices/rotary_encoder.rb +202 -0
- data/lib/rgpio/devices/xpt2046.rb +294 -0
- data/lib/rgpio/i2c.rb +1 -1
- data/lib/rgpio/pwm.rb +7 -3
- data/lib/rgpio/spi.rb +39 -1
- data/lib/rgpio/version.rb +1 -1
- data/lib/rgpio.rb +5 -0
- metadata +17 -7
- data/PLAN.md +0 -347
|
@@ -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
|