rgpio 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +103 -0
  3. data/LICENSE +21 -0
  4. data/PLAN.md +347 -0
  5. data/README.md +969 -0
  6. data/examples/adc.rb +60 -0
  7. data/examples/adc_led.rb +44 -0
  8. data/examples/button.rb +33 -0
  9. data/examples/lcd.rb +47 -0
  10. data/examples/lcd_thermometer.rb +57 -0
  11. data/examples/led.rb +31 -0
  12. data/examples/lowlevel/blink.rb +46 -0
  13. data/examples/lowlevel/button.rb +69 -0
  14. data/examples/lowlevel/servo.rb +76 -0
  15. data/examples/motion_sensor.rb +70 -0
  16. data/examples/motor.rb +38 -0
  17. data/examples/pwm_info.rb +68 -0
  18. data/examples/pwm_jitter.rb +139 -0
  19. data/examples/pwm_led.rb +56 -0
  20. data/examples/rgb_balance.rb +65 -0
  21. data/examples/rgb_led.rb +72 -0
  22. data/examples/servo.rb +70 -0
  23. data/examples/temperature.rb +53 -0
  24. data/lib/rgpio/bytes.rb +12 -0
  25. data/lib/rgpio/chip.rb +271 -0
  26. data/lib/rgpio/devices/adt7410.rb +116 -0
  27. data/lib/rgpio/devices/device.rb +41 -0
  28. data/lib/rgpio/devices/input_device.rb +150 -0
  29. data/lib/rgpio/devices/mcp3208.rb +104 -0
  30. data/lib/rgpio/devices/motor.rb +51 -0
  31. data/lib/rgpio/devices/output_device.rb +66 -0
  32. data/lib/rgpio/devices/pwm_channel.rb +25 -0
  33. data/lib/rgpio/devices/pwm_output_device.rb +109 -0
  34. data/lib/rgpio/devices/rgb_led.rb +175 -0
  35. data/lib/rgpio/devices/servo.rb +161 -0
  36. data/lib/rgpio/devices/st7032.rb +234 -0
  37. data/lib/rgpio/i2c.rb +175 -0
  38. data/lib/rgpio/line_request.rb +184 -0
  39. data/lib/rgpio/native.rb +238 -0
  40. data/lib/rgpio/pwm.rb +321 -0
  41. data/lib/rgpio/software_pwm.rb +290 -0
  42. data/lib/rgpio/spi.rb +208 -0
  43. data/lib/rgpio/version.rb +3 -0
  44. data/lib/rgpio.rb +99 -0
  45. metadata +152 -0
@@ -0,0 +1,234 @@
1
+ module Rgpio
2
+ # A character LCD driven by a Sitronix ST7032 controller over I2C — the
3
+ # module in the Akizuki AQM0802 (8x2) and AQM1602 (16x2) boards.
4
+ #
5
+ # lcd = Rgpio::ST7032.new
6
+ # lcd.message = "Hello\nrgpio"
7
+ # lcd.close
8
+ #
9
+ # Every transfer is a control byte followed by payload: 0x00 for an
10
+ # instruction, 0x40 for display data.
11
+ #
12
+ # The boost converter and contrast settings below are the 3.3 V values. The
13
+ # controller has no way to read the panel back, so a blank display with the
14
+ # backlight on is almost always contrast: raise or lower it with #contrast=.
15
+ class ST7032
16
+ DEFAULT_ADDRESS = 0x3e
17
+
18
+ # Control byte: instruction vs. display data.
19
+ CONTROL_COMMAND = 0x00
20
+ CONTROL_DATA = 0x40
21
+
22
+ # Instruction set (IS = 0).
23
+ CMD_CLEAR = 0x01
24
+ CMD_HOME = 0x02
25
+ CMD_ENTRY_MODE = 0x06 # increment cursor, no display shift
26
+ CMD_DISPLAY_OFF = 0x08
27
+ CMD_DISPLAY_ON = 0x0c # display on, cursor off, blink off
28
+ CMD_FUNCTION_SET = 0x38 # 8-bit bus, 2 lines, normal instructions
29
+ CMD_SET_DDRAM = 0x80
30
+
31
+ # Extended instruction set (IS = 1).
32
+ CMD_FUNCTION_SET_EXT = 0x39
33
+ CMD_OSC_FREQUENCY = 0x14 # 1/5 bias, 183 kHz internal oscillator
34
+ CMD_FOLLOWER_ON = 0x6c # follower on, amplifier ratio 4
35
+ CMD_CONTRAST_LOW = 0x70 # | C3..C0
36
+ CMD_POWER_CONTRAST_HIGH = 0x50 # | Ion << 3 | Bon << 2 | C5..C4
37
+ BOOSTER_ON = 0x04
38
+
39
+ # Contrast is 6 bits split across two instructions. 0x20 suits 3.3 V panels.
40
+ CONTRAST_RANGE = (0..0x3f)
41
+ DEFAULT_CONTRAST = 0x20
42
+
43
+ # DDRAM address of each row's first column.
44
+ ROW_OFFSETS = [0x00, 0x40].freeze
45
+
46
+ # Instructions need 26.3 us to execute; clear and home need 1.08 ms. The
47
+ # follower circuit needs time to stabilise before the panel is driven.
48
+ EXECUTION_DELAY = 0.000_05
49
+ CLEAR_DELAY = 0.002
50
+ POWER_DELAY = 0.2
51
+
52
+ # Open a display, yielding it and closing it afterwards when a block is given.
53
+ # @return [ST7032, Object] the display, or the block's value
54
+ def self.open(**)
55
+ lcd = new(**)
56
+ return lcd unless block_given?
57
+
58
+ begin
59
+ yield lcd
60
+ ensure
61
+ lcd.close
62
+ end
63
+ end
64
+
65
+ # @param address [Integer] 7-bit address; the ST7032 is fixed at 0x3e
66
+ # @param bus [Integer] i2c-dev bus number
67
+ # @param columns [Integer] characters per row (8 for AQM0802, 16 for AQM1602)
68
+ # @param rows [Integer] number of rows
69
+ # @param contrast [Integer] 0..63
70
+ # @param booster [Boolean] enable the internal boost converter (needed at 3.3 V)
71
+ # @param i2c [I2C, nil] an open device to share, or nil to open one
72
+ def initialize(address: DEFAULT_ADDRESS, bus: I2C::DEFAULT_BUS, columns: 8, rows: 2,
73
+ contrast: DEFAULT_CONTRAST, booster: true, i2c: nil)
74
+ raise ArgumentError, "rows must be 1 or 2, got #{rows}" unless (1..ROW_OFFSETS.size).cover?(rows)
75
+
76
+ @owns_i2c = i2c.nil?
77
+ @i2c = i2c || I2C.new(address: address, bus: bus)
78
+ @columns = columns
79
+ @rows = rows
80
+ @contrast = validate_contrast(contrast)
81
+ @booster = booster
82
+ @closed = false
83
+ reset
84
+ end
85
+
86
+ # @return [I2C] the bus device this display is written through
87
+ attr_reader :i2c
88
+
89
+ # @return [Integer] characters per row
90
+ attr_reader :columns
91
+
92
+ # @return [Integer] number of rows
93
+ attr_reader :rows
94
+
95
+ # @return [Integer] current contrast setting, 0..63
96
+ attr_reader :contrast
97
+
98
+ # Run the power-on initialisation sequence. Called by .new; call it again
99
+ # after the panel has been power-cycled behind the driver's back.
100
+ def reset
101
+ command(CMD_FUNCTION_SET)
102
+ command(CMD_FUNCTION_SET_EXT)
103
+ command(CMD_OSC_FREQUENCY)
104
+ apply_contrast
105
+ command(CMD_FOLLOWER_ON)
106
+ sleep POWER_DELAY
107
+ command(CMD_FUNCTION_SET)
108
+ command(CMD_DISPLAY_ON)
109
+ command(CMD_ENTRY_MODE)
110
+ clear
111
+ self
112
+ end
113
+
114
+ # Blank the display and return the cursor to the top left.
115
+ def clear
116
+ command(CMD_CLEAR, delay: CLEAR_DELAY)
117
+ @col = 0
118
+ @row = 0
119
+ self
120
+ end
121
+
122
+ # Return the cursor to the top left, leaving the contents alone.
123
+ def home
124
+ command(CMD_HOME, delay: CLEAR_DELAY)
125
+ @col = 0
126
+ @row = 0
127
+ self
128
+ end
129
+
130
+ # Move the cursor. Out-of-range positions raise rather than wrapping, since
131
+ # the DDRAM address they would land on is rarely what the caller meant.
132
+ # @param col [Integer] 0-based column
133
+ # @param row [Integer] 0-based row
134
+ def move_to(col, row = 0)
135
+ raise ArgumentError, "column must be in 0...#{@columns}, got #{col}" unless (0...@columns).cover?(col)
136
+ raise ArgumentError, "row must be in 0...#{@rows}, got #{row}" unless (0...@rows).cover?(row)
137
+
138
+ command(CMD_SET_DDRAM | (ROW_OFFSETS[row] + col))
139
+ @col = col
140
+ @row = row
141
+ self
142
+ end
143
+
144
+ alias set_cursor move_to
145
+
146
+ # Write text at the cursor. A newline moves to the start of the next row;
147
+ # text that would run past the last column of a row is dropped, as the
148
+ # controller would otherwise scatter it into the other row's DDRAM.
149
+ def print(text)
150
+ text.to_s.split("\n", -1).each_with_index do |line, index|
151
+ if index.positive?
152
+ break if @row + 1 >= @rows
153
+
154
+ move_to(0, @row + 1)
155
+ end
156
+ write_data(line)
157
+ end
158
+ self
159
+ end
160
+
161
+ # Replace the whole display with +text+ (clear, then print).
162
+ def message=(text)
163
+ clear
164
+ print(text)
165
+ end
166
+
167
+ def display_on
168
+ command(CMD_DISPLAY_ON)
169
+ self
170
+ end
171
+
172
+ def display_off
173
+ command(CMD_DISPLAY_OFF)
174
+ self
175
+ end
176
+
177
+ # @param value [Integer] 0..63
178
+ def contrast=(value)
179
+ @contrast = validate_contrast(value)
180
+ command(CMD_FUNCTION_SET_EXT)
181
+ apply_contrast
182
+ command(CMD_FUNCTION_SET)
183
+ value
184
+ end
185
+
186
+ # Send a raw instruction byte.
187
+ def command(byte, delay: EXECUTION_DELAY)
188
+ @i2c.write(CONTROL_COMMAND, byte)
189
+ sleep delay
190
+ self
191
+ end
192
+
193
+ # Send display data (a String is taken as its bytes — the ST7032 character
194
+ # ROM is ASCII in 0x20..0x7d, so non-ASCII text needs a custom mapping).
195
+ def write_data(text)
196
+ bytes = text.is_a?(String) ? text.b.bytes : Array(text)
197
+ bytes = bytes.first(@columns - @col)
198
+ return self if bytes.empty?
199
+
200
+ @i2c.write(CONTROL_DATA, bytes)
201
+ @col += bytes.size
202
+ sleep EXECUTION_DELAY
203
+ self
204
+ end
205
+
206
+ # Close the bus device, but only if this display opened it. The panel keeps
207
+ # showing whatever was written last.
208
+ def close
209
+ return if @closed
210
+
211
+ @closed = true
212
+ @i2c.close if @owns_i2c
213
+ end
214
+
215
+ def closed?
216
+ @closed
217
+ end
218
+
219
+ private
220
+
221
+ def validate_contrast(value)
222
+ raise ArgumentError, "contrast must be in 0..63, got #{value}" unless CONTRAST_RANGE.cover?(value)
223
+
224
+ value
225
+ end
226
+
227
+ # Both halves of the contrast value live in the extended instruction set,
228
+ # so the caller must have selected it first.
229
+ def apply_contrast
230
+ command(CMD_CONTRAST_LOW | (@contrast & 0x0f))
231
+ command(CMD_POWER_CONTRAST_HIGH | (@booster ? BOOSTER_ON : 0) | ((@contrast >> 4) & 0x03))
232
+ end
233
+ end
234
+ end
data/lib/rgpio/i2c.rb ADDED
@@ -0,0 +1,175 @@
1
+ require "fiddle"
2
+
3
+ module Rgpio
4
+ # An I2C device on a Linux i2c-dev bus (/dev/i2c-N).
5
+ #
6
+ # No libgpiod involved: the kernel exposes the whole bus through a character
7
+ # device, so this works even where Rgpio.available? is false.
8
+ #
9
+ # Usage (block form — recommended):
10
+ # Rgpio::I2C.open(address: 0x48) do |i2c|
11
+ # msb, lsb = i2c.read_register(0x00, 2)
12
+ # end
13
+ #
14
+ # Usage (manual):
15
+ # i2c = Rgpio::I2C.new(address: 0x3e)
16
+ # i2c.write(0x00, 0x38)
17
+ # i2c.close
18
+ #
19
+ # The I2C bus on the 40-pin header (GPIO2 = SDA, GPIO3 = SCL) is bus 1, and
20
+ # only appears once it is enabled — see README for the dtparam line.
21
+ class I2C
22
+ # ioctl numbers from <linux/i2c-dev.h>.
23
+ I2C_SLAVE = 0x0703
24
+ I2C_SLAVE_FORCE = 0x0706
25
+ I2C_RDWR = 0x0707
26
+
27
+ # i2c_msg flag: this message is a read (from <linux/i2c.h>).
28
+ I2C_M_RD = 0x0001
29
+
30
+ # The 40-pin header bus. Bus 0 exists on some boards but is reserved for
31
+ # HAT EEPROMs and camera/display peripherals.
32
+ DEFAULT_BUS = 1
33
+
34
+ # Valid 7-bit addressing range: below 0x08 and above 0x77 is reserved.
35
+ ADDRESS_RANGE = (0x08..0x77)
36
+
37
+ # struct i2c_msg is { __u16 addr; __u16 flags; __u16 len; __u8 *buf; }:
38
+ # three shorts, then the pointer on its natural alignment (offset 8 on both
39
+ # 32- and 64-bit ARM, since the shorts are padded out to it).
40
+ MSG_BUF_OFFSET = 8
41
+ MSG_SIZE = MSG_BUF_OFFSET + Fiddle::SIZEOF_VOIDP
42
+
43
+ # struct i2c_rdwr_ioctl_data is { struct i2c_msg *msgs; __u32 nmsgs; },
44
+ # rounded up to pointer alignment.
45
+ RDWR_SIZE = Fiddle::SIZEOF_VOIDP * 2
46
+
47
+ # Pack a struct i2c_msg. Kept at class level so the layout can be checked
48
+ # without a bus present.
49
+ # @param buf_addr [Integer] address of the message's data buffer
50
+ # @return [String]
51
+ def self.pack_msg(address, flags, len, buf_addr)
52
+ [address, flags, len].pack("SSS").ljust(MSG_BUF_OFFSET, "\0") + [buf_addr].pack("J")
53
+ end
54
+
55
+ # Pack a struct i2c_rdwr_ioctl_data pointing at `count` messages.
56
+ # @param msgs_addr [Integer] address of the message array
57
+ # @return [String]
58
+ def self.pack_rdwr(msgs_addr, count)
59
+ ([msgs_addr].pack("J") + [count].pack("L")).ljust(RDWR_SIZE, "\0")
60
+ end
61
+
62
+ # @return [Array<Integer>] bus numbers with a /dev/i2c-N node, ascending
63
+ def self.buses
64
+ Dir.glob("/dev/i2c-*").filter_map { |path| path[%r{/dev/i2c-(\d+)\z}, 1]&.to_i }.sort
65
+ end
66
+
67
+ # Open a device, yielding it and closing it afterwards when a block is given.
68
+ # @return [I2C, Object] the device, or the block's value
69
+ def self.open(address:, bus: DEFAULT_BUS)
70
+ i2c = new(address: address, bus: bus)
71
+ return i2c unless block_given?
72
+
73
+ begin
74
+ yield i2c
75
+ ensure
76
+ i2c.close
77
+ end
78
+ end
79
+
80
+ # @param address [Integer] 7-bit device address (e.g. 0x48)
81
+ # @param bus [Integer] i2c-dev bus number; 1 is the 40-pin header
82
+ # @param force [Boolean] claim the address even if a kernel driver holds it
83
+ def initialize(address:, bus: DEFAULT_BUS, force: false)
84
+ unless ADDRESS_RANGE.cover?(address)
85
+ raise ArgumentError,
86
+ format("address must be in 0x%02x..0x%02x, got 0x%02x", ADDRESS_RANGE.first, ADDRESS_RANGE.last, address)
87
+ end
88
+
89
+ @address = address
90
+ @bus = bus
91
+ @path = "/dev/i2c-#{bus}"
92
+ unless File.exist?(@path)
93
+ raise I2CError,
94
+ "#{@path} not found. Enable the header bus with `dtparam=i2c_arm=on` in /boot/firmware/config.txt"
95
+ end
96
+
97
+ @io = File.open(@path, "r+b")
98
+ @io.ioctl(force ? I2C_SLAVE_FORCE : I2C_SLAVE, @address)
99
+ @closed = false
100
+ end
101
+
102
+ # @return [Integer] 7-bit device address
103
+ attr_reader :address
104
+
105
+ # @return [Integer] i2c-dev bus number
106
+ attr_reader :bus
107
+
108
+ # @return [String] path of the bus character device
109
+ attr_reader :path
110
+
111
+ # Write bytes in a single transaction.
112
+ # @param bytes [Array<Integer>, String] byte values, or a packed String
113
+ # @return [Integer] number of bytes written
114
+ def write(*bytes)
115
+ @io.syswrite(Bytes.pack(bytes))
116
+ end
117
+
118
+ # Read bytes in a single transaction.
119
+ # @param count [Integer] how many bytes to read
120
+ # @return [Array<Integer>]
121
+ def read(count)
122
+ @io.sysread(count).unpack("C*")
123
+ end
124
+
125
+ # Write, then read without releasing the bus (repeated START). Devices with
126
+ # an address pointer need this: a STOP between the two halves lets another
127
+ # master move the pointer in between.
128
+ # @param bytes [Array<Integer>, String] bytes to write first
129
+ # @param count [Integer] how many bytes to read back
130
+ # @return [Array<Integer>]
131
+ def write_read(bytes, count)
132
+ out = Bytes.pack(bytes)
133
+ raise ArgumentError, "write_read needs at least one byte to write" if out.empty?
134
+ raise ArgumentError, "count must be positive, got #{count}" unless count.positive?
135
+
136
+ wbuf = buffer(out)
137
+ rbuf = Fiddle::Pointer.malloc(count, Fiddle::RUBY_FREE)
138
+ msgs = buffer(self.class.pack_msg(@address, 0, out.bytesize, wbuf.to_i) +
139
+ self.class.pack_msg(@address, I2C_M_RD, count, rbuf.to_i))
140
+ @io.ioctl(I2C_RDWR, self.class.pack_rdwr(msgs.to_i, 2))
141
+ rbuf[0, count].unpack("C*")
142
+ end
143
+
144
+ # Read `count` bytes from a register, addressing it with a repeated START.
145
+ # @return [Array<Integer>]
146
+ def read_register(register, count = 1)
147
+ write_read([register], count)
148
+ end
149
+
150
+ # Write bytes to a register in one transaction.
151
+ # @return [Integer] number of bytes written
152
+ def write_register(register, *bytes)
153
+ write(register, *bytes)
154
+ end
155
+
156
+ def close
157
+ return if @closed
158
+
159
+ @closed = true
160
+ @io.close
161
+ end
162
+
163
+ def closed?
164
+ @closed
165
+ end
166
+
167
+ private
168
+
169
+ def buffer(str)
170
+ ptr = Fiddle::Pointer.malloc(str.bytesize, Fiddle::RUBY_FREE)
171
+ ptr[0, str.bytesize] = str
172
+ ptr
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,184 @@
1
+ module Rgpio
2
+ # Holds an active kernel line request returned by Chip#request_lines.
3
+ # Must be released when done via #release (or the block form of Chip.open).
4
+ #
5
+ # Example — output:
6
+ # request = chip.request_lines(offsets: [17], direction: :output)
7
+ # request.set_value(17, :active)
8
+ # request.release
9
+ #
10
+ # Example — input with edge detection (blocking):
11
+ # request = chip.request_lines(offsets: [27], direction: :input,
12
+ # edge: :both, bias: :pull_up)
13
+ # loop do
14
+ # events = request.read_edge_events(timeout: 5.0)
15
+ # events.each { |e| puts "#{e[:type]} on offset #{e[:offset]}" }
16
+ # end
17
+ # request.release
18
+ class LineRequest
19
+ # @param request_ptr [Fiddle::Pointer] raw gpiod_line_request*
20
+ # @param offsets [Array<Integer>] offsets included in this request
21
+ def initialize(request_ptr, offsets)
22
+ @request_ptr = request_ptr
23
+ @offsets = offsets.freeze
24
+ end
25
+
26
+ attr_reader :offsets
27
+
28
+ # Read the current value of a single line.
29
+ #
30
+ # @param offset [Integer] GPIO line offset (must be in #offsets)
31
+ # @return [:active, :inactive]
32
+ # @raise [SystemCallError] on error
33
+ def get_value(offset)
34
+ assert_active!
35
+ result = Native.gpiod_line_request_get_value(@request_ptr, offset)
36
+ case result
37
+ when Native::LINE_VALUE_ACTIVE then :active
38
+ when Native::LINE_VALUE_INACTIVE then :inactive
39
+ else
40
+ raise SystemCallError.new("gpiod_line_request_get_value(offset=#{offset})", Native.errno)
41
+ end
42
+ end
43
+
44
+ # Set the output value of a single line.
45
+ #
46
+ # @param offset [Integer] GPIO line offset
47
+ # @param value [:active, :inactive, 1, 0] desired output level
48
+ # @raise [SystemCallError] on error
49
+ def set_value(offset, value)
50
+ assert_active!
51
+ v = normalize_value(value)
52
+ ret = Native.gpiod_line_request_set_value(@request_ptr, offset, v)
53
+ raise SystemCallError.new("gpiod_line_request_set_value(offset=#{offset})", Native.errno) if ret == -1
54
+ end
55
+
56
+ # Read several lines atomically in a single ioctl.
57
+ #
58
+ # @param offsets [Array<Integer>] offsets to read; defaults to every offset
59
+ # in this request. Must be a subset of #offsets.
60
+ # @return [Hash{Integer=>Symbol}] offset => :active / :inactive, in the
61
+ # order given
62
+ # @raise [SystemCallError] on error
63
+ def get_values(offsets = @offsets)
64
+ assert_active!
65
+ offs = Array(offsets)
66
+ return {} if offs.empty?
67
+
68
+ offsets_ptr = Native.uint32_buffer(offs)
69
+ values_ptr = Native.int_output_buffer(offs.size)
70
+ ret = Native.gpiod_line_request_get_values_subset(@request_ptr, offs.size, offsets_ptr, values_ptr)
71
+ raise SystemCallError.new("gpiod_line_request_get_values_subset", Native.errno) if ret == -1
72
+
73
+ raw = Native.read_int_buffer(values_ptr, offs.size)
74
+ offs.zip(raw).to_h { |offset, value| [offset, decode_value(value, offset)] }
75
+ end
76
+
77
+ # Set several output lines atomically in a single ioctl.
78
+ #
79
+ # @param values [Hash{Integer=>Object}] offset => desired level
80
+ # (:active/:inactive/1/0/true/false). Offsets must be a subset
81
+ # of #offsets.
82
+ # @raise [ArgumentError] when values is not a Hash
83
+ # @raise [SystemCallError] on error
84
+ def set_values(values)
85
+ assert_active!
86
+ raise ArgumentError, "set_values expects a Hash of offset => value, got #{values.class}" unless values.is_a?(Hash)
87
+ return if values.empty?
88
+
89
+ offs = values.keys
90
+ vals = values.values.map { |v| normalize_value(v) }
91
+ offsets_ptr = Native.uint32_buffer(offs)
92
+ values_ptr = Native.int_buffer(vals)
93
+ ret = Native.gpiod_line_request_set_values_subset(@request_ptr, offs.size, offsets_ptr, values_ptr)
94
+ raise SystemCallError.new("gpiod_line_request_set_values_subset", Native.errno) if ret == -1
95
+ end
96
+
97
+ # Wait for edge events on any line in this request.
98
+ #
99
+ # @param timeout [Float, nil] seconds to wait; nil = block indefinitely;
100
+ # 0 = non-blocking poll
101
+ # @return [Boolean] true if at least one event is ready
102
+ # @raise [SystemCallError] on error
103
+ def wait_edge_events(timeout: nil)
104
+ assert_active!
105
+ timeout_ns = if timeout.nil?
106
+ -1
107
+ else
108
+ (timeout * 1_000_000_000).to_i
109
+ end
110
+ ret = Native.gpiod_line_request_wait_edge_events(@request_ptr, timeout_ns)
111
+ raise SystemCallError.new("gpiod_line_request_wait_edge_events", Native.errno) if ret == -1
112
+
113
+ ret == 1
114
+ end
115
+
116
+ # Read pending edge events into an array.
117
+ # Typically called after #wait_edge_events returns true.
118
+ #
119
+ # @param timeout [Float, nil] wait up to this many seconds before reading
120
+ # @param capacity [Integer] maximum events to read per call
121
+ # @return [Array<Hash>] array of event hashes:
122
+ # { type: :rising | :falling, offset: Integer, timestamp_ns: Integer }
123
+ def read_edge_events(timeout: nil, capacity: 16)
124
+ assert_active!
125
+ return [] unless wait_edge_events(timeout: timeout)
126
+
127
+ buf = nil
128
+ begin
129
+ buf = Native.gpiod_edge_event_buffer_new(capacity)
130
+ raise Error, "gpiod_edge_event_buffer_new failed" if buf.null?
131
+
132
+ n = Native.gpiod_line_request_read_edge_events(@request_ptr, buf)
133
+ raise SystemCallError.new("gpiod_line_request_read_edge_events", Native.errno) if n == -1
134
+
135
+ Array.new(n) do |i|
136
+ ev = Native.gpiod_edge_event_buffer_get_event(buf, i)
137
+ type_int = Native.gpiod_edge_event_get_event_type(ev)
138
+ {
139
+ type: type_int == Native::EDGE_EVENT_RISING_EDGE ? :rising : :falling,
140
+ offset: Native.gpiod_edge_event_get_line_offset(ev),
141
+ timestamp_ns: Native.gpiod_edge_event_get_timestamp_ns(ev),
142
+ }
143
+ end
144
+ ensure
145
+ Native.gpiod_edge_event_buffer_free(buf) if buf && !buf.null?
146
+ end
147
+ end
148
+
149
+ # Release the kernel line request. Safe to call multiple times.
150
+ def release
151
+ return unless @request_ptr && !@request_ptr.null?
152
+
153
+ Native.gpiod_line_request_release(@request_ptr)
154
+ @request_ptr = nil
155
+ end
156
+
157
+ def released?
158
+ @request_ptr.nil?
159
+ end
160
+
161
+ private
162
+
163
+ def assert_active!
164
+ raise Error, "LineRequest has already been released" if released?
165
+ end
166
+
167
+ def normalize_value(value)
168
+ case value
169
+ when :active, 1, true then Native::LINE_VALUE_ACTIVE
170
+ when :inactive, 0, false then Native::LINE_VALUE_INACTIVE
171
+ else raise ArgumentError, "Unknown line value: #{value.inspect}"
172
+ end
173
+ end
174
+
175
+ def decode_value(int, offset)
176
+ case int
177
+ when Native::LINE_VALUE_ACTIVE then :active
178
+ when Native::LINE_VALUE_INACTIVE then :inactive
179
+ else
180
+ raise SystemCallError.new("gpiod_line_request_get_values_subset(offset=#{offset})", Native.errno)
181
+ end
182
+ end
183
+ end
184
+ end