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,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