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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +103 -0
- data/LICENSE +21 -0
- data/PLAN.md +347 -0
- data/README.md +969 -0
- data/examples/adc.rb +60 -0
- data/examples/adc_led.rb +44 -0
- data/examples/button.rb +33 -0
- data/examples/lcd.rb +47 -0
- data/examples/lcd_thermometer.rb +57 -0
- data/examples/led.rb +31 -0
- data/examples/lowlevel/blink.rb +46 -0
- data/examples/lowlevel/button.rb +69 -0
- data/examples/lowlevel/servo.rb +76 -0
- data/examples/motion_sensor.rb +70 -0
- data/examples/motor.rb +38 -0
- data/examples/pwm_info.rb +68 -0
- data/examples/pwm_jitter.rb +139 -0
- data/examples/pwm_led.rb +56 -0
- data/examples/rgb_balance.rb +65 -0
- data/examples/rgb_led.rb +72 -0
- data/examples/servo.rb +70 -0
- data/examples/temperature.rb +53 -0
- data/lib/rgpio/bytes.rb +12 -0
- data/lib/rgpio/chip.rb +271 -0
- data/lib/rgpio/devices/adt7410.rb +116 -0
- data/lib/rgpio/devices/device.rb +41 -0
- data/lib/rgpio/devices/input_device.rb +150 -0
- data/lib/rgpio/devices/mcp3208.rb +104 -0
- data/lib/rgpio/devices/motor.rb +51 -0
- data/lib/rgpio/devices/output_device.rb +66 -0
- data/lib/rgpio/devices/pwm_channel.rb +25 -0
- data/lib/rgpio/devices/pwm_output_device.rb +109 -0
- data/lib/rgpio/devices/rgb_led.rb +175 -0
- data/lib/rgpio/devices/servo.rb +161 -0
- data/lib/rgpio/devices/st7032.rb +234 -0
- data/lib/rgpio/i2c.rb +175 -0
- data/lib/rgpio/line_request.rb +184 -0
- data/lib/rgpio/native.rb +238 -0
- data/lib/rgpio/pwm.rb +321 -0
- data/lib/rgpio/software_pwm.rb +290 -0
- data/lib/rgpio/spi.rb +208 -0
- data/lib/rgpio/version.rb +3 -0
- data/lib/rgpio.rb +99 -0
- metadata +152 -0
data/lib/rgpio/native.rb
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
require "fiddle"
|
|
2
|
+
require "fiddle/import"
|
|
3
|
+
|
|
4
|
+
module Rgpio
|
|
5
|
+
# Raw bindings to libgpiod v2, built on Ruby's stdlib `fiddle`.
|
|
6
|
+
#
|
|
7
|
+
# Why fiddle instead of the `ffi` gem: fiddle ships compiled together with
|
|
8
|
+
# the Ruby interpreter, so it always matches the host architecture. The
|
|
9
|
+
# precompiled `ffi` gem targets an ARMv7 baseline and crashes with an
|
|
10
|
+
# "Illegal instruction" on ARMv6 boards (Pi Zero / Pi 1). libgpiod v2 is an
|
|
11
|
+
# opaque-pointer API (callers never touch struct internals), so dropping ffi
|
|
12
|
+
# costs us nothing here.
|
|
13
|
+
#
|
|
14
|
+
# Do not use this module directly — use Chip / LineRequest / HardwarePWM.
|
|
15
|
+
module Native
|
|
16
|
+
extend Fiddle::Importer
|
|
17
|
+
|
|
18
|
+
# Try each soname in turn; stop at the first that loads AND exposes the
|
|
19
|
+
# libgpiod v2 API. dlload raises Fiddle::DLError when a library is missing.
|
|
20
|
+
#
|
|
21
|
+
# The soname is not a reliable version signal: Debian Bookworm ships
|
|
22
|
+
# libgpiod 1.x as `libgpiod.so.2`, while Trixie ships libgpiod 2.x as
|
|
23
|
+
# `libgpiod.so.3`. So after loading we probe a v2-only symbol
|
|
24
|
+
# (gpiod_api_version); a v1 library fails the probe and is treated as
|
|
25
|
+
# unavailable rather than crashing later when its missing functions are
|
|
26
|
+
# bound. This keeps the sysfs-only HardwarePWM usable on such systems.
|
|
27
|
+
LIBRARY_AVAILABLE = ["libgpiod.so.3", "libgpiod.so.2", "libgpiod.so"].any? do |soname|
|
|
28
|
+
dlload soname
|
|
29
|
+
Fiddle::Handle.new(soname)["gpiod_api_version"] # raises DLError on v1
|
|
30
|
+
true
|
|
31
|
+
rescue Fiddle::DLError
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A NULL pointer (replaces FFI::Pointer::NULL).
|
|
36
|
+
NULL = Fiddle::NULL
|
|
37
|
+
|
|
38
|
+
# errno saved by the most recent native call (replaces FFI.errno).
|
|
39
|
+
# @return [Integer]
|
|
40
|
+
def self.errno
|
|
41
|
+
Fiddle.last_error
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Allocate a native buffer holding an array of uint32 line offsets
|
|
45
|
+
# (replaces FFI::MemoryPointer + put_array_of_uint32). The buffer owns its
|
|
46
|
+
# memory and is freed when garbage-collected.
|
|
47
|
+
# @param values [Array<Integer>]
|
|
48
|
+
# @return [Fiddle::Pointer]
|
|
49
|
+
def self.uint32_buffer(values)
|
|
50
|
+
packed = values.pack("L*")
|
|
51
|
+
ptr = Fiddle::Pointer.malloc(packed.bytesize, Fiddle::RUBY_FREE)
|
|
52
|
+
ptr[0, packed.bytesize] = packed
|
|
53
|
+
ptr
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Allocate a native buffer holding an array of C ints, e.g. an array of
|
|
57
|
+
# `enum gpiod_line_value` for gpiod_line_request_set_values_subset.
|
|
58
|
+
# @param values [Array<Integer>]
|
|
59
|
+
# @return [Fiddle::Pointer]
|
|
60
|
+
def self.int_buffer(values)
|
|
61
|
+
packed = values.pack("l*")
|
|
62
|
+
ptr = Fiddle::Pointer.malloc(packed.bytesize, Fiddle::RUBY_FREE)
|
|
63
|
+
ptr[0, packed.bytesize] = packed
|
|
64
|
+
ptr
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Allocate an uninitialised buffer sized for `count` C ints, for use as an
|
|
68
|
+
# output parameter (e.g. gpiod_line_request_get_values_subset).
|
|
69
|
+
# @param count [Integer]
|
|
70
|
+
# @return [Fiddle::Pointer]
|
|
71
|
+
def self.int_output_buffer(count)
|
|
72
|
+
Fiddle::Pointer.malloc(count * Fiddle::SIZEOF_INT, Fiddle::RUBY_FREE)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Read `count` C ints back out of a buffer into a Ruby Array.
|
|
76
|
+
# @param ptr [Fiddle::Pointer]
|
|
77
|
+
# @param count [Integer]
|
|
78
|
+
# @return [Array<Integer>]
|
|
79
|
+
def self.read_int_buffer(ptr, count)
|
|
80
|
+
ptr[0, count * Fiddle::SIZEOF_INT].unpack("l*")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if LIBRARY_AVAILABLE
|
|
84
|
+
# -----------------------------------------------------------------------
|
|
85
|
+
# Direction enum values (gpiod_line_direction)
|
|
86
|
+
# -----------------------------------------------------------------------
|
|
87
|
+
LINE_DIRECTION_AS_IS = 1
|
|
88
|
+
LINE_DIRECTION_INPUT = 2
|
|
89
|
+
LINE_DIRECTION_OUTPUT = 3
|
|
90
|
+
|
|
91
|
+
# -----------------------------------------------------------------------
|
|
92
|
+
# Line value enum (gpiod_line_value)
|
|
93
|
+
# -----------------------------------------------------------------------
|
|
94
|
+
LINE_VALUE_ERROR = -1
|
|
95
|
+
LINE_VALUE_INACTIVE = 0
|
|
96
|
+
LINE_VALUE_ACTIVE = 1
|
|
97
|
+
|
|
98
|
+
# -----------------------------------------------------------------------
|
|
99
|
+
# Edge detection enum (gpiod_line_edge)
|
|
100
|
+
# -----------------------------------------------------------------------
|
|
101
|
+
LINE_EDGE_NONE = 1
|
|
102
|
+
LINE_EDGE_RISING = 2
|
|
103
|
+
LINE_EDGE_FALLING = 3
|
|
104
|
+
LINE_EDGE_BOTH = 4
|
|
105
|
+
|
|
106
|
+
# -----------------------------------------------------------------------
|
|
107
|
+
# Edge event type enum (gpiod_edge_event_type)
|
|
108
|
+
# -----------------------------------------------------------------------
|
|
109
|
+
EDGE_EVENT_RISING_EDGE = 1
|
|
110
|
+
EDGE_EVENT_FALLING_EDGE = 2
|
|
111
|
+
|
|
112
|
+
# -----------------------------------------------------------------------
|
|
113
|
+
# Bias enum (gpiod_line_bias)
|
|
114
|
+
# -----------------------------------------------------------------------
|
|
115
|
+
LINE_BIAS_AS_IS = 1
|
|
116
|
+
LINE_BIAS_UNKNOWN = 2
|
|
117
|
+
LINE_BIAS_DISABLED = 3
|
|
118
|
+
LINE_BIAS_PULL_UP = 4
|
|
119
|
+
LINE_BIAS_PULL_DOWN = 5
|
|
120
|
+
|
|
121
|
+
# -----------------------------------------------------------------------
|
|
122
|
+
# Version
|
|
123
|
+
# -----------------------------------------------------------------------
|
|
124
|
+
extern "const char *gpiod_api_version(void)"
|
|
125
|
+
|
|
126
|
+
# -----------------------------------------------------------------------
|
|
127
|
+
# Chip — gpiod_chip_*
|
|
128
|
+
# -----------------------------------------------------------------------
|
|
129
|
+
extern "void *gpiod_chip_open(const char *path)"
|
|
130
|
+
extern "void gpiod_chip_close(void *chip)"
|
|
131
|
+
|
|
132
|
+
# -----------------------------------------------------------------------
|
|
133
|
+
# Chip info — gpiod_chip_info_*
|
|
134
|
+
# -----------------------------------------------------------------------
|
|
135
|
+
extern "void *gpiod_chip_get_info(void *chip)"
|
|
136
|
+
extern "void gpiod_chip_info_free(void *info)"
|
|
137
|
+
extern "const char *gpiod_chip_info_get_name(void *info)"
|
|
138
|
+
extern "const char *gpiod_chip_info_get_label(void *info)"
|
|
139
|
+
extern "size_t gpiod_chip_info_get_num_lines(void *info)"
|
|
140
|
+
|
|
141
|
+
# -----------------------------------------------------------------------
|
|
142
|
+
# Line settings — gpiod_line_settings_*
|
|
143
|
+
# -----------------------------------------------------------------------
|
|
144
|
+
extern "void *gpiod_line_settings_new(void)"
|
|
145
|
+
extern "void gpiod_line_settings_free(void *settings)"
|
|
146
|
+
extern "int gpiod_line_settings_set_direction(void *settings, int direction)"
|
|
147
|
+
extern "int gpiod_line_settings_set_edge_detection(void *settings, int edge)"
|
|
148
|
+
extern "int gpiod_line_settings_set_bias(void *settings, int bias)"
|
|
149
|
+
# The C parameter is _Bool (1 byte). We declare it as int and pass 1/0
|
|
150
|
+
# via the wrapper below; the callee reads the value as boolean.
|
|
151
|
+
extern "void gpiod_line_settings_set_active_low(void *settings, int active_low)"
|
|
152
|
+
extern "int gpiod_line_settings_set_output_value(void *settings, int value)"
|
|
153
|
+
extern "int gpiod_line_settings_set_debounce_period_us(void *settings, unsigned long period_us)"
|
|
154
|
+
|
|
155
|
+
# -----------------------------------------------------------------------
|
|
156
|
+
# Line config — gpiod_line_config_*
|
|
157
|
+
# offsets is const unsigned int* — pass a Native.uint32_buffer pointer.
|
|
158
|
+
# -----------------------------------------------------------------------
|
|
159
|
+
extern "void *gpiod_line_config_new(void)"
|
|
160
|
+
extern "void gpiod_line_config_free(void *config)"
|
|
161
|
+
extern "int gpiod_line_config_add_line_settings(void *config, void *offsets, size_t num_offsets, void *settings)"
|
|
162
|
+
|
|
163
|
+
# -----------------------------------------------------------------------
|
|
164
|
+
# Request config — gpiod_request_config_*
|
|
165
|
+
# -----------------------------------------------------------------------
|
|
166
|
+
extern "void *gpiod_request_config_new(void)"
|
|
167
|
+
extern "void gpiod_request_config_free(void *config)"
|
|
168
|
+
extern "void gpiod_request_config_set_consumer(void *config, const char *consumer)"
|
|
169
|
+
|
|
170
|
+
# -----------------------------------------------------------------------
|
|
171
|
+
# Line request — gpiod_chip_request_lines / gpiod_line_request_*
|
|
172
|
+
# req_cfg may be NULL (pass Native::NULL).
|
|
173
|
+
# -----------------------------------------------------------------------
|
|
174
|
+
extern "void *gpiod_chip_request_lines(void *chip, void *req_cfg, void *line_cfg)"
|
|
175
|
+
extern "void gpiod_line_request_release(void *request)"
|
|
176
|
+
# Returns LINE_VALUE_ACTIVE / LINE_VALUE_INACTIVE / LINE_VALUE_ERROR
|
|
177
|
+
extern "int gpiod_line_request_get_value(void *request, unsigned int offset)"
|
|
178
|
+
# Returns 0 on success, -1 on error
|
|
179
|
+
extern "int gpiod_line_request_set_value(void *request, unsigned int offset, int value)"
|
|
180
|
+
# Atomic multi-line I/O over a subset of the requested offsets.
|
|
181
|
+
# offsets: uint32 buffer; values: int buffer of enum gpiod_line_value.
|
|
182
|
+
# Both return 0 on success, -1 on error.
|
|
183
|
+
extern "int gpiod_line_request_get_values_subset(void *request, size_t num_values, void *offsets, void *values)"
|
|
184
|
+
extern "int gpiod_line_request_set_values_subset(void *request, size_t num_values, void *offsets, void *values)"
|
|
185
|
+
|
|
186
|
+
# -----------------------------------------------------------------------
|
|
187
|
+
# Edge event waiting / reading
|
|
188
|
+
# timeout_ns: -1 = block forever, 0 = non-blocking, >0 = wait N ns
|
|
189
|
+
# Returns: 1 (event ready), 0 (timeout), -1 (error)
|
|
190
|
+
# -----------------------------------------------------------------------
|
|
191
|
+
extern "int gpiod_line_request_wait_edge_events(void *request, int64_t timeout_ns)"
|
|
192
|
+
# Returns number of events read, or -1 on error
|
|
193
|
+
extern "int gpiod_line_request_read_edge_events(void *request, void *buffer)"
|
|
194
|
+
|
|
195
|
+
# -----------------------------------------------------------------------
|
|
196
|
+
# Edge event buffer — gpiod_edge_event_buffer_*
|
|
197
|
+
# -----------------------------------------------------------------------
|
|
198
|
+
extern "void *gpiod_edge_event_buffer_new(size_t capacity)"
|
|
199
|
+
extern "void gpiod_edge_event_buffer_free(void *buffer)"
|
|
200
|
+
extern "size_t gpiod_edge_event_buffer_get_num_events(void *buffer)"
|
|
201
|
+
extern "void *gpiod_edge_event_buffer_get_event(void *buffer, unsigned long index)"
|
|
202
|
+
|
|
203
|
+
# -----------------------------------------------------------------------
|
|
204
|
+
# Edge event — gpiod_edge_event_*
|
|
205
|
+
# -----------------------------------------------------------------------
|
|
206
|
+
# Returns EDGE_EVENT_RISING_EDGE or EDGE_EVENT_FALLING_EDGE
|
|
207
|
+
extern "int gpiod_edge_event_get_event_type(void *event)"
|
|
208
|
+
extern "unsigned int gpiod_edge_event_get_line_offset(void *event)"
|
|
209
|
+
extern "uint64_t gpiod_edge_event_get_timestamp_ns(void *event)"
|
|
210
|
+
|
|
211
|
+
# -- Ruby-side conversion wrappers -------------------------------------
|
|
212
|
+
|
|
213
|
+
# fiddle returns char* as a Fiddle::Pointer; convert to a Ruby String
|
|
214
|
+
# (nil when NULL) to match the old FFI :string behavior.
|
|
215
|
+
STRING_RETURNING = %i[
|
|
216
|
+
gpiod_api_version
|
|
217
|
+
gpiod_chip_info_get_name
|
|
218
|
+
gpiod_chip_info_get_label
|
|
219
|
+
].freeze
|
|
220
|
+
|
|
221
|
+
STRING_RETURNING.each do |meth|
|
|
222
|
+
raw = :"#{meth}__ptr"
|
|
223
|
+
singleton_class.send(:alias_method, raw, meth)
|
|
224
|
+
singleton_class.send(:define_method, meth) do |*args|
|
|
225
|
+
ptr = send(raw, *args)
|
|
226
|
+
ptr.null? ? nil : ptr.to_s
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Accept a Ruby boolean for active_low and pass it as 1/0.
|
|
231
|
+
singleton_class.send(:alias_method, :gpiod_line_settings_set_active_low__int,
|
|
232
|
+
:gpiod_line_settings_set_active_low)
|
|
233
|
+
singleton_class.send(:define_method, :gpiod_line_settings_set_active_low) do |settings, flag|
|
|
234
|
+
gpiod_line_settings_set_active_low__int(settings, flag ? 1 : 0)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
data/lib/rgpio/pwm.rb
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
module Rgpio
|
|
2
|
+
# Controls a hardware PWM channel via the Linux PWM sysfs interface
|
|
3
|
+
# (/sys/class/pwm/pwmchipN/pwmM/).
|
|
4
|
+
#
|
|
5
|
+
# No FFI required — the kernel exposes the entire API through file I/O.
|
|
6
|
+
#
|
|
7
|
+
# Raspberry Pi 5 prerequisites
|
|
8
|
+
# -----------------------------
|
|
9
|
+
# The RP1 PWM peripheral must be enabled via dtoverlay in
|
|
10
|
+
# /boot/firmware/config.txt before the chip appears in sysfs.
|
|
11
|
+
# See README.md for the required overlay configuration.
|
|
12
|
+
#
|
|
13
|
+
# GPIO-to-PWM mapping on Pi 5 (RP1):
|
|
14
|
+
# GPIO12 → RP1 PWM chip, channel 0
|
|
15
|
+
# GPIO13 → RP1 PWM chip, channel 1
|
|
16
|
+
# GPIO18 → RP1 PWM chip, channel 2
|
|
17
|
+
# GPIO19 → RP1 PWM chip, channel 3
|
|
18
|
+
#
|
|
19
|
+
# Usage (block form — recommended):
|
|
20
|
+
# Rgpio::HardwarePWM.open(gpio: 18) do |pwm|
|
|
21
|
+
# pwm.frequency = 50 # Hz (standard servo)
|
|
22
|
+
# pwm.duty_cycle = 0.075 # 7.5% = center position
|
|
23
|
+
# pwm.enable
|
|
24
|
+
# sleep 1
|
|
25
|
+
# pwm.pulse_width_us = 1000 # 1 ms = minimum position
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# Usage (manual):
|
|
29
|
+
# pwm = Rgpio::HardwarePWM.new(chip: 2, channel: 0)
|
|
30
|
+
# pwm.frequency = 50
|
|
31
|
+
# pwm.duty_cycle = 0.075
|
|
32
|
+
# pwm.enable
|
|
33
|
+
# pwm.close # disables + unexports
|
|
34
|
+
class HardwarePWM
|
|
35
|
+
PWM_SYSFS_ROOT = "/sys/class/pwm".freeze
|
|
36
|
+
|
|
37
|
+
# Device-tree model string, used to pick the board's PWM mapping.
|
|
38
|
+
BOARD_MODEL_PATH = "/proc/device-tree/model".freeze
|
|
39
|
+
|
|
40
|
+
# GPIO offset → PWM channel, per board family. The pwmchip *number* is
|
|
41
|
+
# resolved separately at runtime (see {PWM_CHIP_PROFILE}).
|
|
42
|
+
#
|
|
43
|
+
# Pi 5 (RP1): GPIO12/13/18/19 → channels 0/1/2/3 (one 4-channel chip)
|
|
44
|
+
# Pi 4 (BCM2711): GPIO12/18 → channel 0 (PWM0), GPIO13/19 → channel 1 (PWM1)
|
|
45
|
+
GPIO_TO_PWM_CHANNEL = {
|
|
46
|
+
pi5: { 12 => 0, 13 => 1, 18 => 2, 19 => 3 }.freeze,
|
|
47
|
+
pi4: { 12 => 0, 13 => 1, 18 => 0, 19 => 1 }.freeze,
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# Backwards-compatible alias for the Pi 5 mapping.
|
|
51
|
+
GPIO_TO_PWM_CHANNEL_PI5 = GPIO_TO_PWM_CHANNEL[:pi5]
|
|
52
|
+
|
|
53
|
+
# Hints for locating the header PWM chip in sysfs, per board family:
|
|
54
|
+
# address — substring of the chip's device symlink (the peripheral the
|
|
55
|
+
# 40-pin header PWM pins route to)
|
|
56
|
+
# npwm — channel count of that chip
|
|
57
|
+
# Pi 5 (RP1 PWM0 at 1f00098000, 4 channels) is verified. Pi 4 (BCM2711 PWM0
|
|
58
|
+
# at fe20c000, 2 channels) is provisional, pending hardware validation
|
|
59
|
+
# (see PLAN.md).
|
|
60
|
+
PWM_CHIP_PROFILE = {
|
|
61
|
+
pi5: { address: "1f00098000", npwm: 4 }.freeze,
|
|
62
|
+
pi4: { address: "fe20c000", npwm: 2 }.freeze,
|
|
63
|
+
}.freeze
|
|
64
|
+
|
|
65
|
+
# @param gpio [Integer, nil] GPIO line offset to look up chip/channel
|
|
66
|
+
# automatically. Takes priority over chip:/channel:.
|
|
67
|
+
# @param chip [Integer, :auto] pwmchip number, or :auto to detect the
|
|
68
|
+
# header PWM chip for the current board.
|
|
69
|
+
# @param channel [Integer] PWM channel number within the chip
|
|
70
|
+
# @param board [:auto, :pi5, :pi4] board family for the gpio: mapping and
|
|
71
|
+
# chip auto-detection. :auto reads the device-tree model.
|
|
72
|
+
def initialize(gpio: nil, chip: :auto, channel: 0, board: :auto)
|
|
73
|
+
@board = board == :auto ? self.class.detect_board : board
|
|
74
|
+
|
|
75
|
+
if gpio
|
|
76
|
+
channel = gpio_channel(gpio)
|
|
77
|
+
chip = :auto
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@channel = channel
|
|
81
|
+
@chip_num = chip == :auto ? detect_pwm_chip! : chip
|
|
82
|
+
@chip_path = "#{PWM_SYSFS_ROOT}/pwmchip#{@chip_num}"
|
|
83
|
+
@channel_path = File.join(@chip_path, "pwm#{@channel}")
|
|
84
|
+
|
|
85
|
+
raise PWMError, "PWM chip not found: #{@chip_path}" unless File.exist?(@chip_path)
|
|
86
|
+
|
|
87
|
+
@period_ns = nil
|
|
88
|
+
@exported = false
|
|
89
|
+
export_channel
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Open a PWM channel, yield it, then close it (disable + unexport).
|
|
93
|
+
def self.open(**, &block)
|
|
94
|
+
pwm = new(**)
|
|
95
|
+
block.call(pwm)
|
|
96
|
+
ensure
|
|
97
|
+
pwm&.close
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Integer] resolved pwmchip number
|
|
101
|
+
attr_reader :chip_num
|
|
102
|
+
|
|
103
|
+
# @return [Integer] channel number within the chip
|
|
104
|
+
attr_reader :channel
|
|
105
|
+
|
|
106
|
+
# @return [:pi5, :pi4, :unknown] resolved board family
|
|
107
|
+
attr_reader :board
|
|
108
|
+
|
|
109
|
+
# Set PWM frequency in Hz.
|
|
110
|
+
# Updates period_ns; preserves duty cycle ratio if already set.
|
|
111
|
+
# @param hz [Numeric]
|
|
112
|
+
def frequency=(hz)
|
|
113
|
+
new_period_ns = (1_000_000_000.0 / hz).round
|
|
114
|
+
if @period_ns && enabled?
|
|
115
|
+
# Prevent duty_cycle > period violation during update
|
|
116
|
+
write_sysfs("duty_cycle", 0)
|
|
117
|
+
end
|
|
118
|
+
write_sysfs("period", new_period_ns)
|
|
119
|
+
# Restore duty cycle ratio
|
|
120
|
+
write_sysfs("duty_cycle", (@duty_ratio * new_period_ns).round) if @period_ns && @duty_ratio
|
|
121
|
+
@period_ns = new_period_ns
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Numeric, nil] current frequency in Hz, or nil if period not set
|
|
125
|
+
def frequency
|
|
126
|
+
return nil unless @period_ns&.positive?
|
|
127
|
+
|
|
128
|
+
1_000_000_000.0 / @period_ns
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Set duty cycle as a ratio (0.0–1.0).
|
|
132
|
+
# frequency= must be called first.
|
|
133
|
+
# @param ratio [Float] 0.0 = always off, 1.0 = always on
|
|
134
|
+
def duty_cycle=(ratio)
|
|
135
|
+
raise PWMError, "Set frequency= before duty_cycle=" unless @period_ns
|
|
136
|
+
|
|
137
|
+
ratio = ratio.clamp(0.0, 1.0)
|
|
138
|
+
@duty_ratio = ratio
|
|
139
|
+
write_sysfs("duty_cycle", (@duty_ratio * @period_ns).round)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [Float, nil] current duty cycle ratio
|
|
143
|
+
attr_reader :duty_ratio
|
|
144
|
+
|
|
145
|
+
# Set pulse width in microseconds (convenience for servo control).
|
|
146
|
+
# frequency= must be called first.
|
|
147
|
+
# @param us [Numeric] pulse width in microseconds
|
|
148
|
+
def pulse_width_us=(us)
|
|
149
|
+
raise PWMError, "Set frequency= before pulse_width_us=" unless @period_ns
|
|
150
|
+
|
|
151
|
+
ns = (us * 1000).round
|
|
152
|
+
@duty_ratio = ns.to_f / @period_ns
|
|
153
|
+
write_sysfs("duty_cycle", ns)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @return [Float, nil] current pulse width in microseconds
|
|
157
|
+
def pulse_width_us
|
|
158
|
+
return nil unless @period_ns && @duty_ratio
|
|
159
|
+
|
|
160
|
+
(@duty_ratio * @period_ns / 1000.0).round(3)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Enable PWM output.
|
|
164
|
+
def enable
|
|
165
|
+
write_sysfs("enable", 1)
|
|
166
|
+
@enabled = true
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Disable PWM output (pin goes low).
|
|
170
|
+
def disable
|
|
171
|
+
write_sysfs("enable", 0)
|
|
172
|
+
@enabled = false
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def enabled?
|
|
176
|
+
@enabled || false
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Disable and unexport the PWM channel, freeing the sysfs resource.
|
|
180
|
+
# Safe to call multiple times.
|
|
181
|
+
def close
|
|
182
|
+
return unless @exported
|
|
183
|
+
|
|
184
|
+
disable rescue nil
|
|
185
|
+
unexport_channel
|
|
186
|
+
@exported = false
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Return a human-readable description of this PWM instance.
|
|
190
|
+
def inspect
|
|
191
|
+
"#<Rgpio::HardwarePWM board=#{@board} chip=#{@chip_num} channel=#{@channel} " \
|
|
192
|
+
"freq=#{frequency&.round(2)}Hz duty=#{@duty_ratio&.round(4)} " \
|
|
193
|
+
"enabled=#{enabled?}>"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# List all available PWM chips with their number of channels.
|
|
197
|
+
# @return [Array<Hash>] [{ chip: Integer, npwm: Integer, path: String }, ...]
|
|
198
|
+
def self.available_chips
|
|
199
|
+
Dir.glob("#{PWM_SYSFS_ROOT}/pwmchip*").filter_map do |path|
|
|
200
|
+
npwm = Integer(File.read(File.join(path, "npwm")).strip, 10) rescue next
|
|
201
|
+
chip_num = File.basename(path).delete_prefix("pwmchip").to_i
|
|
202
|
+
{ chip: chip_num, npwm: npwm, path: path }
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Detect the Raspberry Pi board family from the device-tree model string.
|
|
207
|
+
# @return [:pi5, :pi4, :unknown]
|
|
208
|
+
def self.detect_board(model = board_model)
|
|
209
|
+
case model
|
|
210
|
+
when /Raspberry Pi 5/ then :pi5
|
|
211
|
+
when /Raspberry Pi (?:4|400)/, /Compute Module 4/ then :pi4
|
|
212
|
+
else :unknown
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# @return [String] the device-tree model string ("" when unavailable)
|
|
217
|
+
def self.board_model
|
|
218
|
+
File.read(BOARD_MODEL_PATH, encoding: "BINARY").delete("\0").strip
|
|
219
|
+
rescue SystemCallError
|
|
220
|
+
""
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
# Look up the PWM channel for a header GPIO on the resolved board.
|
|
226
|
+
def gpio_channel(gpio)
|
|
227
|
+
table = GPIO_TO_PWM_CHANNEL.fetch(@board) do
|
|
228
|
+
raise ArgumentError,
|
|
229
|
+
"Hardware PWM gpio: mapping is unknown for board #{@board.inspect}. " \
|
|
230
|
+
"Pass board: :pi5 / :pi4, or chip:/channel: explicitly."
|
|
231
|
+
end
|
|
232
|
+
table.fetch(gpio) do
|
|
233
|
+
raise ArgumentError,
|
|
234
|
+
"GPIO#{gpio} is not a hardware PWM pin on #{@board}. " \
|
|
235
|
+
"Valid pins: #{table.keys.join(", ")}"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Resolve the pwmchip number of the header PWM controller for @board.
|
|
240
|
+
#
|
|
241
|
+
# Strategy (in order of preference):
|
|
242
|
+
# 1. Chip whose sysfs device symlink contains the board's PWM address.
|
|
243
|
+
# On Pi 5 the RP1 also exposes a PWM1 instance (1f0009c000, fan) that
|
|
244
|
+
# ALSO reports npwm == 4 but is not on the header, so this address
|
|
245
|
+
# match is required to avoid selecting it.
|
|
246
|
+
# 2. Chip whose channel count matches the board profile.
|
|
247
|
+
# 3. The only chip present.
|
|
248
|
+
#
|
|
249
|
+
# Raises PWMError when no chip can be chosen.
|
|
250
|
+
def detect_pwm_chip!
|
|
251
|
+
chips = self.class.available_chips
|
|
252
|
+
if chips.empty?
|
|
253
|
+
raise PWMError, "No PWM chips found under #{PWM_SYSFS_ROOT}. " \
|
|
254
|
+
"Is the dtoverlay configured? See README.md."
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
profile = PWM_CHIP_PROFILE[@board]
|
|
258
|
+
if profile
|
|
259
|
+
by_address = chips.find do |c|
|
|
260
|
+
device_link = File.readlink(c[:path]) rescue ""
|
|
261
|
+
device_link.include?(profile[:address])
|
|
262
|
+
end
|
|
263
|
+
return by_address[:chip] if by_address
|
|
264
|
+
|
|
265
|
+
by_npwm = chips.find { |c| c[:npwm] == profile[:npwm] }
|
|
266
|
+
return by_npwm[:chip] if by_npwm
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Only one chip present — unambiguous.
|
|
270
|
+
return chips.first[:chip] if chips.size == 1
|
|
271
|
+
|
|
272
|
+
# Cannot determine — require explicit chip: argument.
|
|
273
|
+
chip_list = chips.map { |c| "pwmchip#{c[:chip]}(npwm=#{c[:npwm]})" }.join(", ")
|
|
274
|
+
raise PWMError,
|
|
275
|
+
"Cannot auto-detect the header PWM chip for board #{@board.inspect}. " \
|
|
276
|
+
"Available: #{chip_list}. Pass chip: <number> explicitly (see README.md)."
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def export_channel
|
|
280
|
+
return if File.exist?(@channel_path)
|
|
281
|
+
|
|
282
|
+
File.write(File.join(@chip_path, "export"), @channel.to_s)
|
|
283
|
+
wait_for_channel_path!
|
|
284
|
+
@exported = true
|
|
285
|
+
rescue Errno::EBUSY
|
|
286
|
+
# Already exported by a previous run that did not unexport cleanly.
|
|
287
|
+
raise unless File.exist?(@channel_path)
|
|
288
|
+
|
|
289
|
+
@exported = true
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def unexport_channel
|
|
293
|
+
File.write(File.join(@chip_path, "unexport"), @channel.to_s)
|
|
294
|
+
rescue Errno::EINVAL, Errno::ENOENT
|
|
295
|
+
# Already unexported — nothing to do.
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def wait_for_channel_path!
|
|
299
|
+
# Wait for the `period` file to become writable, not just the directory.
|
|
300
|
+
# The udev rule (99-com.rules) runs chgrp/chmod after the directory appears,
|
|
301
|
+
# so polling only for directory existence creates a race.
|
|
302
|
+
period_path = File.join(@channel_path, "period")
|
|
303
|
+
deadline = Time.now + 3.0
|
|
304
|
+
until File.writable?(period_path)
|
|
305
|
+
raise PWMError, "Timeout: #{period_path} did not become writable after export" if Time.now > deadline
|
|
306
|
+
|
|
307
|
+
sleep 0.02
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def write_sysfs(attr, value)
|
|
312
|
+
path = File.join(@channel_path, attr.to_s)
|
|
313
|
+
File.write(path, value.to_s)
|
|
314
|
+
rescue Errno::EACCES => e
|
|
315
|
+
raise PWMError, "Failed to write #{path}: #{e.message} " \
|
|
316
|
+
"(ensure user is in the gpio group, or run with sudo)"
|
|
317
|
+
rescue Errno::ENOENT, Errno::EPERM => e
|
|
318
|
+
raise PWMError, "Failed to write #{path}: #{e.message}"
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|