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
data/README.md ADDED
@@ -0,0 +1,969 @@
1
+ # rgpio
2
+
3
+ Ruby bindings for [libgpiod v2](https://git.kernel.org/pub/scm/libs/libgpiod/libgpiod.git) — the modern Linux GPIO character device API.
4
+
5
+ Provides GPIO input/output and jitter-free hardware PWM control on Raspberry Pi, targeting the `uAPI v2` ioctl interface instead of the deprecated sysfs GPIO interface. No C extension — calls `libgpiod.so` directly through the stdlib [`fiddle`](https://github.com/ruby/fiddle), which (unlike the precompiled `ffi` gem) is built with the interpreter and works on every Pi, including ARMv6 boards (Pi Zero / Pi 1).
6
+
7
+ > **Status:** GPIO + hardware PWM verified on Raspberry Pi 5 and Raspberry Pi 4
8
+ > (Trixie, libgpiod 2.x). The device API (`LED` / `Button` / `Motor`) is verified
9
+ > on Pi 5. Hardware PWM (sysfs) also works on a Bookworm Pi 4, where the libgpiod
10
+ > GPIO path is unavailable (v1). Multi-board support, the roadmap, and planned
11
+ > APIs are tracked in [PLAN.md](PLAN.md); released changes are recorded in
12
+ > [CHANGELOG.md](CHANGELOG.md).
13
+
14
+ ## Why libgpiod?
15
+
16
+ | Approach | Pi 5 works? | Notes |
17
+ |---|---|---|
18
+ | `sysfs` GPIO (`/sys/class/gpio`) | No | Deprecated since kernel 4.8 |
19
+ | Direct register access (`pigpio`, old `RPi.GPIO`) | No | RP1 chip not supported |
20
+ | `libgpiod` (GPIO character device) | **Yes** | Modern, Pi-model agnostic |
21
+
22
+ ## Requirements
23
+
24
+ - **OS:** Debian Trixie (13) or later — verified target. Bookworm ships libgpiod 1.x, which is not supported.
25
+ - **Hardware:** Raspberry Pi 5 and Pi 4 (verified). Other Pi models: see [PLAN.md](PLAN.md).
26
+ - **Library:** `libgpiod2` (>= 2.1)
27
+ - **Ruby:** >= 3.3 (CRuby) — matches Trixie's default `ruby`
28
+
29
+ Install the runtime library on the Pi:
30
+
31
+ ```sh
32
+ sudo apt update
33
+ sudo apt install libgpiod3
34
+ ```
35
+
36
+ To verify libgpiod is available and working:
37
+
38
+ ```sh
39
+ gpiodetect # lists GPIO chips
40
+ gpioinfo --chip gpiochip0 # lists lines on chip 0
41
+ ```
42
+
43
+ ## Installation
44
+
45
+ Add to your `Gemfile`:
46
+
47
+ ```ruby
48
+ gem "rgpio"
49
+ ```
50
+
51
+ Or install directly:
52
+
53
+ ```sh
54
+ gem install rgpio
55
+ ```
56
+
57
+ ## Device API
58
+
59
+ `LED`, `Button`, `Motor`, `PWMLED`, `RGBLED` and `Servo` wrap `Chip` /
60
+ `LineRequest` in one object per piece of hardware, in the style of Python's
61
+ gpiozero. A device opens its own chip
62
+ unless you hand it one with `chip:`, and `#close` releases only what it owns.
63
+ Devices still under hardware validation are listed in [PLAN.md](PLAN.md).
64
+
65
+ ### LED
66
+
67
+ ```ruby
68
+ require "rgpio"
69
+
70
+ led = Rgpio::LED.new(4) # GPIO4 = physical pin 7
71
+
72
+ 5.times do
73
+ led.on
74
+ sleep 1
75
+ led.off
76
+ sleep 1
77
+ end
78
+
79
+ led.close # releases the line and the chip
80
+ ```
81
+
82
+ `LED` is an `OutputDevice`, which also offers `#toggle`, `#value` / `#value=`
83
+ and `#on?`. Pass `active_low: true` when the LED is wired to sink current
84
+ (`#on` then drives the line low), and `initial_value: true` to have it lit the
85
+ moment the line is claimed.
86
+
87
+ ### Button
88
+
89
+ ```ruby
90
+ require "rgpio"
91
+
92
+ button = Rgpio::Button.new(4)
93
+
94
+ button.when_pressed { puts "Pressed" }
95
+ button.when_released { puts "Released" }
96
+
97
+ Rgpio.pause # block until Ctrl-C while callbacks run
98
+ button.close
99
+ ```
100
+
101
+ Callbacks run on a watcher thread that waits on kernel edge events, one thread
102
+ per device, started by the first callback and stopped by `#close`. A callback
103
+ that raises is reported on `$stderr` without taking the watcher down.
104
+ `Rgpio.pause` is the counterpart of Python's `signal.pause()`.
105
+
106
+ The default bias is pull-**down**, for a switch wired between the GPIO line and
107
+ 3.3 V; pass `pull_up: true` for one wired to GND. Presses are debounced in the
108
+ kernel for 5 ms (`debounce_us:` to change it), so one press fires one callback.
109
+
110
+ `active_low: true` inverts the logic, and the callbacks follow it: the kernel
111
+ reports edges in logical terms, so `when_pressed` fires when the line goes
112
+ *low*.
113
+
114
+ ### Motor
115
+
116
+ ```ruby
117
+ require "rgpio"
118
+
119
+ motor = Rgpio::Motor.new(forward: 2, backward: 14)
120
+
121
+ motor.forward
122
+ sleep 5
123
+ motor.backward
124
+ sleep 5
125
+ motor.stop
126
+
127
+ motor.close
128
+ ```
129
+
130
+ Written for a two-input driver such as the DRV8835 or SN754410 in IN/IN mode:
131
+ one line drives the motor forward, the other backward, and `Motor` drops one
132
+ before raising the other so the driver is never asked to source and sink the
133
+ same output at once. Wire the motor across the driver's two outputs
134
+ (`AOUT1`/`AOUT2`) — with one terminal on GND only one direction works. Speed
135
+ control would need PWM on both lines and is not implemented.
136
+
137
+ ### PWMLED
138
+
139
+ `LED` is on or off; `PWMLED` has a level in between, from a PWM channel.
140
+
141
+ ```ruby
142
+ led = Rgpio::PWMLED.new(4)
143
+ led.value = 0.25 # a quarter bright (#brightness is an alias)
144
+ led.on # 1.0
145
+ led.toggle # inverts the level: 0.25 becomes 0.75
146
+ led.close
147
+ ```
148
+
149
+ The level holds with no further calls — one assignment, and the channel keeps
150
+ generating. Any line works: the default channel is {Rgpio::SoftwarePWM}, which
151
+ needs no dtoverlay. See [Software PWM](#software-pwm) for `pwm: :hardware`.
152
+
153
+ ### RGBLED
154
+
155
+ Three PWM channels as one object, one per colour.
156
+
157
+ ```ruby
158
+ led = Rgpio::RGBLED.new(red: 17, green: 27, blue: 22)
159
+ led.color = :magenta # or any name in Rgpio::RGBLED::COLORS
160
+ led.color = [1.0, 0.4, 0.0] # or a triple, 0.0..1.0 each
161
+ led.blue = 0.5 # or one channel at a time
162
+ led.off
163
+ led.close
164
+ ```
165
+
166
+ The default wiring is common cathode: each line drives its colour through its own
167
+ resistor and the common leg goes to GND. For a common-anode part, tie the common
168
+ leg to 3.3 V and pass `active_low: true`.
169
+
170
+ White comes out tinted unless the channels are scaled to match, because the three
171
+ dies are not equally bright for equal duty:
172
+
173
+ ```ruby
174
+ led = Rgpio::RGBLED.new(red: 17, green: 27, blue: 22, balance: [1.0, 0.8, 0.8])
175
+ ```
176
+
177
+ `balance:` scales every level asked for, so named colours come out right too, and
178
+ `#color` keeps reporting what was asked for. The right value belongs to the part,
179
+ not to the gem — `ruby examples/rgb_balance.rb` steps through candidates with the
180
+ LED showing white so you can pick one by eye.
181
+
182
+ ### Servo
183
+
184
+ ```ruby
185
+ servo = Rgpio::Servo.new(4)
186
+ servo.max # one end of the travel
187
+ servo.mid # centre
188
+ servo.angle = 45 # or by angle, -90..90 by default
189
+ servo.detach # stop the pulses: the horn goes limp
190
+ servo.close
191
+ ```
192
+
193
+ A servo holds its position only while pulses keep arriving, so `#detach` stops
194
+ sending them: the horn can then be turned by hand, and the servo stops drawing
195
+ the current — and making the heat — of holding against a load. It has no grip on
196
+ anything while detached. Setting `#value` or `#angle` again resumes;
197
+ `#attached?` reports which it is.
198
+
199
+ Pulse widths vary by servo. The default 1000..2000 us is the range every hobby
200
+ servo understands, and many reach further:
201
+
202
+ ```ruby
203
+ servo = Rgpio::Servo.new(4, min_pulse_us: 500, max_pulse_us: 2500,
204
+ min_angle: 0, max_angle: 180)
205
+ servo.pulse_width_us = 2300 # drive a width directly, to find the real travel
206
+ ```
207
+
208
+ A servo driven past its travel buzzes and heats up, so widen the range only as
209
+ far as the part allows, and by measuring rather than by trusting.
210
+
211
+ ### Sharing one chip
212
+
213
+ ```ruby
214
+ chip = Rgpio::Chip.new
215
+ red = Rgpio::LED.new(17, chip: chip)
216
+ green = Rgpio::LED.new(27, chip: chip)
217
+
218
+ red.on
219
+ green.off
220
+
221
+ [red, green].each(&:close) # the chip stays open — the devices did not open it
222
+ chip.close
223
+ ```
224
+
225
+ ---
226
+
227
+ ## GPIO Usage
228
+
229
+ The classes the device API is built on. Use them when you need control the
230
+ device classes do not expose — batch I/O across lines, raw edge-event
231
+ timestamps, or a specific `/dev/gpiochipN`.
232
+
233
+ ### LED blink (output)
234
+
235
+ ```ruby
236
+ require "rgpio"
237
+
238
+ # Block form ensures the chip is closed on exit.
239
+ # With no path, the 40-pin header GPIO controller is auto-detected by label
240
+ # (pass "/dev/gpiochipN" to select one explicitly).
241
+ Rgpio::Chip.open do |chip|
242
+ puts chip.path # "/dev/gpiochip0"
243
+ puts chip.label # "pinctrl-rp1" on Pi 5
244
+ puts chip.num_lines # 54
245
+
246
+ request = chip.request_lines(
247
+ offsets: [17], # GPIO17 = physical pin 11
248
+ direction: :output,
249
+ consumer: "my-app" # visible in gpioinfo output
250
+ )
251
+
252
+ 5.times do
253
+ request.set_value(17, :active)
254
+ sleep 0.5
255
+ request.set_value(17, :inactive)
256
+ sleep 0.5
257
+ end
258
+
259
+ request.release
260
+ end
261
+ ```
262
+
263
+ ### Button input with edge detection
264
+
265
+ ```ruby
266
+ require "rgpio"
267
+
268
+ Rgpio::Chip.open do |chip|
269
+ request = chip.request_lines(
270
+ offsets: [27], # GPIO27 = physical pin 13
271
+ direction: :input,
272
+ edge: :both, # detect press and release
273
+ bias: :pull_up, # internal pull-up resistor
274
+ active_low: true, # button connects pin to GND
275
+ consumer: "button-reader"
276
+ )
277
+
278
+ puts "Waiting for button events (Ctrl-C to stop)..."
279
+ loop do
280
+ events = request.read_edge_events(timeout: nil) # block indefinitely
281
+ events.each do |event|
282
+ state = event[:type] == :rising ? "RELEASED" : "PRESSED"
283
+ puts "GPIO#{event[:offset]} #{state} at #{event[:timestamp_ns]} ns"
284
+ end
285
+ end
286
+ ensure
287
+ request&.release
288
+ end
289
+ ```
290
+
291
+ `read_edge_events` returns an array of hashes:
292
+
293
+ | Key | Type | Description |
294
+ |---|---|---|
295
+ | `:type` | `:rising` / `:falling` | Edge direction |
296
+ | `:offset` | Integer | GPIO line offset |
297
+ | `:timestamp_ns` | Integer | Kernel monotonic timestamp (nanoseconds) |
298
+
299
+ ### `request_lines` options
300
+
301
+ | Option | Values | Default | Notes |
302
+ |---|---|---|---|
303
+ | `offsets:` | `Array<Integer>` | — | Required |
304
+ | `direction:` | `:input`, `:output` | — | Required |
305
+ | `edge:` | `:none`, `:rising`, `:falling`, `:both` | `:none` | Input only |
306
+ | `bias:` | `:as_is`, `:disabled`, `:pull_up`, `:pull_down` | `:as_is` | |
307
+ | `active_low:` | `true` / `false` | `false` | |
308
+ | `initial_value:` | `:active`, `:inactive` | `:inactive` | Output only |
309
+ | `consumer:` | String | `nil` | Shown in `gpioinfo` |
310
+
311
+ ---
312
+
313
+ ## Software PWM
314
+
315
+ `Rgpio::SoftwarePWM` generates PWM in Ruby on any GPIO line. It needs no
316
+ dtoverlay and no config.txt entry, which is why the device classes above use it
317
+ by default.
318
+
319
+ ```ruby
320
+ Rgpio::SoftwarePWM.open(18) do |pwm|
321
+ pwm.frequency = 100
322
+ pwm.duty_cycle = 0.25
323
+ pwm.enable
324
+ sleep 2
325
+ pwm.pulse_width_us = 1500 # or set the high time directly
326
+ end
327
+ ```
328
+
329
+ It presents the same interface as `HardwarePWM` — `frequency=`, `duty_cycle=`,
330
+ `pulse_width_us=`, `enable`/`disable`, `close` — so the device classes take
331
+ either. Pass `pwm: :hardware` on GPIO12/13/18/19 for the peripheral instead:
332
+
333
+ ```ruby
334
+ servo = Rgpio::Servo.new(12, pwm: :hardware) # needs the dtoverlay, see below
335
+ led = Rgpio::PWMLED.new(13, pwm: channel) # or share a channel you own
336
+ ```
337
+
338
+ ### Which to use
339
+
340
+ | | Software PWM | Hardware PWM |
341
+ |---|---|---|
342
+ | Setup | none | `dtoverlay` in config.txt |
343
+ | Lines | any | GPIO12/13/18/19, two at a time on the header |
344
+ | Accuracy | 6 us of spread at 50 Hz on an idle Pi 5 | exact |
345
+ | Cost | 2.7% of one core per channel at 50 Hz | none |
346
+
347
+ The generating thread sleeps until shortly before each edge and then spins for
348
+ the last `spin_us` (300 by default, capped at 5% of the period), because sleeping
349
+ the whole way overshoots a microsecond-scale deadline badly. Spinning holds the
350
+ GVL, which is where the CPU figure comes from.
351
+
352
+ Measured on a Pi 5 with a jumper between two header pins and the kernel's own
353
+ edge timestamps (`examples/pwm_jitter.rb`): a 50 Hz 1500 us pulse — a servo's
354
+ centre — came out at 1504.9 us with 6.2 us of standard deviation, or about half a
355
+ degree of travel. A busy machine widens that: while the main thread holds the
356
+ GVL, the generating thread cannot wake. Python has the same limitation with the
357
+ GIL, and gpiozero's PWM is software-timed too.
358
+
359
+ ---
360
+
361
+ ## Hardware PWM Usage
362
+
363
+ Hardware PWM is controlled through the Linux PWM sysfs interface
364
+ (`/sys/class/pwm/pwmchipN/`). No FFI required — pure file I/O.
365
+
366
+ ### Step 1 — Enable the PWM overlay
367
+
368
+ Add the appropriate line to `/boot/firmware/config.txt` and **reboot**:
369
+
370
+ | GPIO pin | PWM channel | Alt function | config.txt entry |
371
+ |---|---|---|---|
372
+ | GPIO12 (pin 32) | PWM0 | Alt0 | `dtoverlay=pwm,pin=12,func=4` |
373
+ | GPIO13 (pin 33) | PWM1 | Alt0 | `dtoverlay=pwm,pin=13,func=4` |
374
+ | GPIO18 (pin 12) | PWM0 | Alt5 | `dtoverlay=pwm,pin=18,func=2` |
375
+ | GPIO19 (pin 35) | PWM1 | Alt5 | `dtoverlay=pwm,pin=19,func=2` |
376
+
377
+ > **`func` is the pin's Alt function, not the channel number:** GPIO12/13 use
378
+ > `func=4` (Alt0), but GPIO18/19 use `func=2` (Alt5). Using the wrong `func`
379
+ > loads the overlay without routing the pin to PWM — the pin stays `input` and
380
+ > nothing reaches it. Verify with `pinctrl get <n>` (expect e.g. `a0` = Alt0).
381
+
382
+ To enable two channels simultaneously (e.g. GPIO18 + GPIO19):
383
+
384
+ ```
385
+ dtoverlay=pwm-2chan,pin=18,func=2,pin2=19,func2=2
386
+ ```
387
+
388
+ **Without rebooting** (volatile, for quick testing) you can load the same
389
+ overlay at runtime — pass the parameters space-separated instead of as a CSV:
390
+
391
+ ```sh
392
+ sudo dtoverlay pwm pin=12 func=4 # = dtoverlay=pwm,pin=12,func=4
393
+ sudo dtoverlay -l # list loaded overlays
394
+ sudo dtoverlay -r pwm # unload
395
+ ```
396
+
397
+ > **Always pass `pin` and `func` explicitly.** With no arguments
398
+ > `sudo dtoverlay pwm` defaults to `pin=18,func=2` (GPIO18), so a servo wired to
399
+ > GPIO12 gets no signal even though the program runs to completion.
400
+
401
+ ### Step 2 — Verify sysfs entry
402
+
403
+ After reboot, PWM chips should appear:
404
+
405
+ ```sh
406
+ ls /sys/class/pwm/
407
+ # pwmchip0 pwmchip2
408
+ ```
409
+
410
+ On Pi 5 the RP1 GPIO-header PWM chip typically appears as `pwmchip2` with 4 channels (`npwm=4`), but the number can vary with kernel version. `HardwarePWM` auto-detects the correct chip.
411
+
412
+ ### Step 3 — Drive a servo
413
+
414
+ ```ruby
415
+ require "rgpio"
416
+
417
+ # gpio: auto-selects chip and channel for GPIO18 on Pi 5
418
+ Rgpio::HardwarePWM.open(gpio: 18) do |pwm|
419
+ puts "Using pwmchip#{pwm.chip_num}, channel #{pwm.channel}"
420
+
421
+ pwm.frequency = 50 # Hz — standard servo period (20 ms)
422
+ pwm.duty_cycle = 0.075 # 7.5 % = 1.5 ms pulse = center position
423
+ pwm.enable
424
+
425
+ sleep 1
426
+
427
+ pwm.pulse_width_us = 1000 # 1.0 ms — minimum position
428
+ sleep 1
429
+ pwm.pulse_width_us = 2000 # 2.0 ms — maximum position
430
+ sleep 1
431
+ pwm.pulse_width_us = 1500 # back to center
432
+ sleep 1
433
+ end
434
+ # PWM is automatically disabled and unexported here
435
+ ```
436
+
437
+ ### GPIO-to-PWM mapping
438
+
439
+ The mapping is board-specific and auto-detected from the device-tree model.
440
+
441
+ **Pi 5 (RP1)** — one 4-channel chip:
442
+
443
+ | GPIO | Physical pin | RP1 PWM channel |
444
+ |---|---|---|
445
+ | GPIO12 | 32 | 0 |
446
+ | GPIO13 | 33 | 1 |
447
+ | GPIO18 | 12 | 2 |
448
+ | GPIO19 | 35 | 3 |
449
+
450
+ **Pi 4 (BCM2711)** — one 2-channel chip; the two pins on a channel are
451
+ alternatives (use one at a time):
452
+
453
+ | GPIO | Physical pin | BCM2711 PWM channel |
454
+ |---|---|---|
455
+ | GPIO12 | 32 | 0 (PWM0) |
456
+ | GPIO18 | 12 | 0 (PWM0) |
457
+ | GPIO13 | 33 | 1 (PWM1) |
458
+ | GPIO19 | 35 | 1 (PWM1) |
459
+
460
+ ### Manual chip/channel specification
461
+
462
+ The `gpio:` and `chip: :auto` paths detect the board from the device-tree model
463
+ (the resolved family is available as `pwm.board`, e.g. `:pi5` on a Pi 5) and pick
464
+ the header PWM chip and channel accordingly. If auto-detection fails, specify the
465
+ chip number explicitly:
466
+
467
+ ```ruby
468
+ pwm = Rgpio::HardwarePWM.new(chip: 2, channel: 0)
469
+ ```
470
+
471
+ You can also force the board family (e.g. when the model string is unusual):
472
+
473
+ ```ruby
474
+ pwm = Rgpio::HardwarePWM.new(gpio: 18, board: :pi5)
475
+ ```
476
+
477
+ List available chips:
478
+
479
+ ```ruby
480
+ Rgpio::HardwarePWM.available_chips
481
+ # => [{chip: 0, npwm: 2, path: "/sys/class/pwm/pwmchip0"},
482
+ # {chip: 2, npwm: 4, path: "/sys/class/pwm/pwmchip2"}]
483
+ ```
484
+
485
+ ---
486
+
487
+ ## I2C Usage
488
+
489
+ `Rgpio::I2C` talks to a device on a Linux i2c-dev bus (`/dev/i2c-N`). It is
490
+ plain `ioctl` work on a character device, so it needs no libgpiod — it works
491
+ even where `Rgpio.available?` is `false`.
492
+
493
+ ### Step 1 — Enable the header bus
494
+
495
+ The 40-pin header bus (GPIO2 = SDA, GPIO3 = SCL) is bus 1, and is off by
496
+ default:
497
+
498
+ ```sh
499
+ sudo raspi-config nonint do_i2c 0 # or add dtparam=i2c_arm=on to /boot/firmware/config.txt
500
+ sudo reboot
501
+ ```
502
+
503
+ ### Step 2 — Verify
504
+
505
+ ```sh
506
+ ls /dev/i2c-1
507
+ i2cdetect -y 1 # lists the addresses that answer (i2c-tools package)
508
+ ```
509
+
510
+ ```ruby
511
+ Rgpio::I2C.buses # => [1, 13, 14]
512
+ ```
513
+
514
+ ### Step 3 — Talk to a device
515
+
516
+ ```ruby
517
+ require "rgpio"
518
+
519
+ # Block form closes the bus device on exit.
520
+ Rgpio::I2C.open(address: 0x48) do |i2c|
521
+ # Write, then read back without releasing the bus (repeated START) — this is
522
+ # what a device with a register pointer expects.
523
+ msb, lsb = i2c.read_register(0x00, 2)
524
+
525
+ # Or drive the two halves separately.
526
+ i2c.write(0x03, 0x80) # write 0x80 to register 0x03
527
+ bytes = i2c.read(2) # => [Integer, Integer]
528
+ end
529
+ ```
530
+
531
+ Reads return byte arrays. Writes take integers, strings, or a mix, so a control
532
+ byte and a payload can go out in one transaction:
533
+
534
+ ```ruby
535
+ i2c.write(0x40, "Hello") # => 6
536
+ ```
537
+
538
+ Failures surface as the kernel's own `Errno` exceptions: `Errno::EREMOTEIO`
539
+ when nothing acknowledges the address, `Errno::EBUSY` when a kernel driver
540
+ already holds it (pass `force: true` to claim it anyway), `Errno::EACCES` when
541
+ the user is not in the `i2c` group.
542
+
543
+ ### Temperature sensor — `Rgpio::ADT7410`
544
+
545
+ ```ruby
546
+ sensor = Rgpio::ADT7410.new # address 0x48 on bus 1
547
+ puts sensor.temperature # => 27.25 (degrees Celsius)
548
+ sensor.close
549
+ ```
550
+
551
+ The address is set by the A1/A0 pins: 0x48 with both low (the default on the
552
+ breakout boards) through 0x4b. The sensor powers up in 13-bit mode, resolving
553
+ 0.0625 degC; `resolution: 16` resolves 0.0078 degC.
554
+
555
+ ```ruby
556
+ sensor = Rgpio::ADT7410.new(address: 0x49, resolution: 16)
557
+ sensor.detected? # => true when the ID register reports Analog Devices
558
+ sensor.resolution = 13 # switch back at runtime
559
+ ```
560
+
561
+ The first conversion after power-up takes 240 ms
562
+ (`Rgpio::ADT7410::CONVERSION_TIME`); read before it finishes and the register
563
+ still holds its 0 degC reset value.
564
+
565
+ ### Character LCD — `Rgpio::ST7032`
566
+
567
+ For the ST7032-based modules — Akizuki AQM0802 (8x2) and AQM1602 (16x2) — which
568
+ answer at 0x3e.
569
+
570
+ ```ruby
571
+ lcd = Rgpio::ST7032.new(columns: 8) # 3.3 V defaults: contrast 0x20, booster on
572
+ lcd.message = "Hello\nrgpio" # clear, then print; a newline is the next row
573
+
574
+ lcd.move_to(0, 1) # column, row
575
+ lcd.print("27.2 C".rjust(8)) # overwrite in place — clearing every update flickers
576
+ lcd.contrast = 0x28 # 0..63
577
+ lcd.close # the panel keeps whatever was written last
578
+ ```
579
+
580
+ Text that would run past the last column of a row is dropped rather than
581
+ wrapped: the controller's DDRAM addresses are not contiguous between rows, so an
582
+ overrun scatters characters into invisible addresses instead of continuing on the
583
+ next line.
584
+
585
+ A panel showing nothing is almost always contrast, which is the one setting the
586
+ controller cannot read back — sweep `contrast:` across 0x10..0x38. Modules run at
587
+ 5 V want the boost converter off (`booster: false`).
588
+
589
+ ---
590
+
591
+ ## SPI Usage
592
+
593
+ `Rgpio::SPI` talks to a device on a Linux spidev bus (`/dev/spidevB.D`). Like the
594
+ I2C support it is `ioctl` work on a character device, so it needs no libgpiod.
595
+
596
+ ### Step 1 — Enable the header bus
597
+
598
+ SPI0 is off by default. On the header it is GPIO10 (MOSI), GPIO9 (MISO), GPIO11
599
+ (SCLK), GPIO8 (CE0) and GPIO7 (CE1):
600
+
601
+ ```sh
602
+ sudo raspi-config nonint do_spi 0 # or add dtparam=spi=on to /boot/firmware/config.txt
603
+ ```
604
+
605
+ ```sh
606
+ ls /dev/spidev0.0
607
+ ```
608
+
609
+ ```ruby
610
+ Rgpio::SPI.devices # => [[0, 0], [0, 1]] as [bus, chip-select]
611
+ ```
612
+
613
+ ### Step 2 — Talk to a device
614
+
615
+ ```ruby
616
+ Rgpio::SPI.open(bus: 0, device: 0, speed_hz: 1_000_000) do |spi|
617
+ # SPI is full duplex: one byte goes out for every byte that comes in, so
618
+ # #transfer answers with as many bytes as it was given.
619
+ received = spi.transfer([0x06, 0x00, 0x00])
620
+
621
+ spi.write(0x40, "Hello") # transfer, ignoring what came back
622
+ spi.read(4) # transfer of zeros, keeping what came back
623
+ end
624
+ ```
625
+
626
+ `mode:` selects clock polarity and phase (0..3), and `speed_hz`, `mode` and
627
+ `bits_per_word` can all be changed on an open device. A single transfer can take
628
+ a `speed_hz:` of its own.
629
+
630
+ Failures surface as the kernel's own `Errno` exceptions — `Errno::EACCES` when
631
+ the user is not in the `spi` group, `Errno::ENODEV` when the bus is not enabled.
632
+
633
+ ### Analogue input — `Rgpio::MCP3208`
634
+
635
+ Eight 12-bit channels over SPI, the MCP3204's four channels being the same part
636
+ and protocol.
637
+
638
+ ```ruby
639
+ adc = Rgpio::MCP3208.new # bus 0, CE0, 1 MHz, VREF 3.3 V
640
+ adc.read(0) # => 0..4095, the raw code
641
+ adc.value(0) # => 0.0..1.0, a fraction of VREF
642
+ adc.voltage(0) # => volts
643
+ adc.read_all # => every channel, consecutively
644
+ adc.read(0, differential: true) # => the CH0/CH1 pair rather than CH0
645
+ adc.close
646
+ ```
647
+
648
+ ```ruby
649
+ adc = Rgpio::MCP3208.new(channels: 4, reference_voltage: 5.0, speed_hz: 500_000)
650
+ ```
651
+
652
+ Wiring: VDD **and VREF** to 3.3 V, AGND and DGND to ground, CLK/DOUT/DIN to
653
+ SCLK/MISO/MOSI, CS to CE0. A forgotten VREF is the usual reason every channel
654
+ reads 0 or sticks at full scale.
655
+
656
+ The clock rate is a matter of correctness, not just speed: the datasheet allows
657
+ 1 MHz at 2.7 V and 2 MHz at 5 V, and a converter clocked past its sampling rate
658
+ answers with values that look plausible and are wrong. The 1 MHz default is
659
+ inside the envelope for the 3.3 V supply a Pi provides.
660
+
661
+ Unconnected channels float and read whatever is nearby; that is not a fault.
662
+
663
+ ---
664
+
665
+ ## Running the examples
666
+
667
+ All examples require root (or `gpio` group membership):
668
+
669
+ ```sh
670
+ # Blink an LED on GPIO4
671
+ ruby examples/led.rb
672
+
673
+ # Print Pressed / Released for a switch on GPIO4
674
+ ruby examples/button.rb
675
+
676
+ # Drive a DC motor forward and backward through a DRV8835
677
+ ruby examples/motor.rb
678
+
679
+ # Fade an LED with PWM on GPIO4
680
+ ruby examples/pwm_led.rb
681
+
682
+ # Cycle a full-colour LED through the colour cube on GPIO17/27/22
683
+ ruby examples/rgb_led.rb
684
+
685
+ # Find the per-channel balance that makes that LED's white look white
686
+ ruby examples/rgb_balance.rb
687
+
688
+ # Sweep a servo on GPIO4, by value and by angle
689
+ ruby examples/servo.rb
690
+
691
+ # Report which PWM chip and channel each header GPIO resolves to
692
+ ruby examples/pwm_info.rb
693
+
694
+ # Print the ADT7410 temperature once a second
695
+ ruby examples/temperature.rb
696
+
697
+ # Write text and a counter to an ST7032 LCD
698
+ ruby examples/lcd.rb
699
+
700
+ # Show the temperature on the LCD — both I2C devices on one bus
701
+ ruby examples/lcd_thermometer.rb
702
+
703
+ # Print all eight channels of an MCP3208
704
+ ruby examples/adc.rb
705
+
706
+ # Dim an LED from a potentiometer through the MCP3208
707
+ ruby examples/adc_led.rb
708
+ ```
709
+
710
+ `examples/lowlevel/` holds the same LED and button demos written directly
711
+ against `Chip` / `LineRequest`, for when you need control the device classes do
712
+ not expose:
713
+
714
+ ```sh
715
+ sudo ruby examples/lowlevel/blink.rb
716
+ sudo ruby examples/lowlevel/button.rb
717
+
718
+ # The servo driven straight from the PWM peripheral (dtoverlay required)
719
+ sudo ruby examples/lowlevel/servo.rb
720
+ ```
721
+
722
+ `examples/pwm_jitter.rb` measures what a PWM channel really puts on the line,
723
+ using the kernel's edge timestamps and a jumper between two header pins:
724
+
725
+ ```sh
726
+ ruby examples/pwm_jitter.rb --hz 50 --duty 0.075 --seconds 5
727
+ ```
728
+
729
+ ---
730
+
731
+ ## API reference
732
+
733
+ ### `Rgpio`
734
+
735
+ | Method | Description |
736
+ |---|---|
737
+ | `.available?` | `true` if `libgpiod.so` was found |
738
+ | `.version` | libgpiod version string (e.g. `"2.1.3"`) |
739
+
740
+ ### `Rgpio::Chip`
741
+
742
+ | Method | Description |
743
+ |---|---|
744
+ | `.new(path = nil)` | Open chip; auto-detects header controller when `path` is nil |
745
+ | `.open(path = nil) { \|chip\| }` | Block form; closes on exit |
746
+ | `.list` | Array of `{path:, name:, label:, num_lines:}` for every gpiochip |
747
+ | `.detect_path` | Device path of the header GPIO controller (Pi 5 / 4 / Zero) |
748
+ | `#path` | Device path this chip was opened with |
749
+ | `#name` | Kernel name (`"gpiochip0"`) |
750
+ | `#label` | Controller label (`"pinctrl-rp1"`) |
751
+ | `#num_lines` | Number of GPIO lines |
752
+ | `#request_lines(...)` | Returns a `LineRequest` |
753
+ | `#close` | Close the chip |
754
+
755
+ ### `Rgpio::LineRequest`
756
+
757
+ | Method | Description |
758
+ |---|---|
759
+ | `#get_value(offset)` | `:active` or `:inactive` |
760
+ | `#set_value(offset, value)` | Set output level |
761
+ | `#get_values(offsets = all)` | Read several lines atomically → `{offset => :active/:inactive}` |
762
+ | `#set_values(hash)` | Write several lines atomically from `{offset => value}` |
763
+ | `#wait_edge_events(timeout:)` | `true` if event ready |
764
+ | `#read_edge_events(timeout:, capacity:)` | Array of event hashes |
765
+ | `#release` | Release kernel request |
766
+
767
+ ### `Rgpio::OutputDevice` (and `Rgpio::LED`)
768
+
769
+ | Method | Description |
770
+ |---|---|
771
+ | `.new(gpio, active_low:, initial_value:, chip:, consumer:)` | Claim a line as an output |
772
+ | `#on` / `#off` / `#toggle` | Drive the line to its active / inactive level |
773
+ | `#value` / `#value=` | Current level as `true` / `false` (`#on?` is an alias of `#value`) |
774
+ | `#gpio` | Line offset this device drives |
775
+ | `#close` / `#closed?` | Release the line (and the chip, if it opened one) |
776
+
777
+ ### `Rgpio::InputDevice` (and `Rgpio::Button`)
778
+
779
+ | Method | Description |
780
+ |---|---|
781
+ | `.new(gpio, pull_up:, active_low:, debounce_us:, chip:, consumer:)` | Claim a line as an input; `pull_up:` takes `true` / `false` / `nil` (no bias) |
782
+ | `#value` | `true` when the line is at its active level (`#active?`, and `#pressed?` on `Button`) |
783
+ | `#when_pressed { }` / `#when_released { }` | `Button` edge callbacks, run on a watcher thread |
784
+ | `#gpio` | Line offset this device reads |
785
+ | `#close` / `#closed?` | Stop the watcher and release the line |
786
+
787
+ ### `Rgpio::Motor`
788
+
789
+ | Method | Description |
790
+ |---|---|
791
+ | `.new(forward:, backward:, chip:, consumer:)` | Claim both lines of a two-input driver |
792
+ | `#forward` / `#backward` | Run at full speed in one direction |
793
+ | `#stop` | Drop both lines |
794
+ | `#close` | Stop, then release both lines |
795
+
796
+ ### `Rgpio::I2C`
797
+
798
+ | Method | Description |
799
+ |---|---|
800
+ | `.new(address:, bus: 1, force: false)` | Open `/dev/i2c-N` and claim a 7-bit address |
801
+ | `.open(address:, bus:) { \|i2c\| }` | Block form; closes on exit |
802
+ | `.buses` | Bus numbers with a `/dev/i2c-N` node |
803
+ | `#write(*bytes)` | Write integers / strings in one transaction |
804
+ | `#read(count)` | Read `count` bytes → `Array<Integer>` |
805
+ | `#write_read(bytes, count)` | Write then read with a repeated START |
806
+ | `#read_register(register, count = 1)` | `write_read([register], count)` |
807
+ | `#write_register(register, *bytes)` | Write a register in one transaction |
808
+ | `#address` / `#bus` / `#path` | What this device was opened on |
809
+ | `#close` / `#closed?` | Close the bus device |
810
+
811
+ ### `Rgpio::SPI`
812
+
813
+ | Method | Description |
814
+ |---|---|
815
+ | `.new(bus: 0, device: 0, speed_hz:, mode:, bits_per_word:)` | Open `/dev/spidevB.D` |
816
+ | `.open(...) { \|spi\| }` | Block form; closes on exit |
817
+ | `.devices` | `[bus, chip-select]` of every spidev node |
818
+ | `#transfer(bytes, speed_hz:, delay_us:)` | Full-duplex transfer → `Array<Integer>` |
819
+ | `#write(*bytes)` | Transfer, ignoring what came back |
820
+ | `#read(count)` | Transfer of zeros, keeping what came back |
821
+ | `#speed_hz` / `#mode` / `#bits_per_word` (and `=`) | Bus settings |
822
+ | `#bus` / `#device` / `#path` | What this device was opened on |
823
+ | `#close` / `#closed?` | Close the bus device |
824
+
825
+ ### `Rgpio::MCP3208`
826
+
827
+ | Method | Description |
828
+ |---|---|
829
+ | `.new(channels:, reference_voltage:, bus:, device:, speed_hz:, spi:)` | Open the converter; `spi:` shares a bus device |
830
+ | `#read(channel, differential: false)` | Raw code, 0..4095 |
831
+ | `#value(channel, ...)` | Fraction of VREF, 0.0..1.0 |
832
+ | `#voltage(channel, ...)` | Volts |
833
+ | `#read_all` | Every channel, sampled consecutively |
834
+ | `#channels` / `#reference_voltage` / `#spi` | What it was configured with |
835
+ | `#close` / `#closed?` | Close the bus device, if this converter opened it |
836
+
837
+ ### `Rgpio::ADT7410`
838
+
839
+ | Method | Description |
840
+ |---|---|
841
+ | `.new(address: 0x48, bus: 1, resolution: 13, i2c: nil)` | Open the sensor; `i2c:` shares an existing bus device |
842
+ | `.convert(msb, lsb, resolution = 13)` | Raw register pair → degrees Celsius |
843
+ | `#temperature` | Temperature in degrees Celsius (`#value` is an alias) |
844
+ | `#raw_temperature` | The two temperature bytes, MSB first |
845
+ | `#resolution` / `#resolution=` | 13 or 16 bits |
846
+ | `#id` / `#detected?` | ID register, and whether it reports Analog Devices |
847
+ | `#i2c` | The bus device readings go through |
848
+ | `#close` / `#closed?` | Close the bus device, if this sensor opened it |
849
+
850
+ ### `Rgpio::ST7032`
851
+
852
+ | Method | Description |
853
+ |---|---|
854
+ | `.new(address: 0x3e, bus: 1, columns: 8, rows: 2, contrast: 0x20, booster: true, i2c: nil)` | Open and initialise the display |
855
+ | `.open(...) { \|lcd\| }` | Block form; closes on exit |
856
+ | `#message=(text)` | Clear, then print |
857
+ | `#print(text)` | Write at the cursor; a newline moves to the next row |
858
+ | `#move_to(col, row = 0)` | Move the cursor (`#set_cursor` is an alias) |
859
+ | `#clear` / `#home` | Blank the display / return the cursor |
860
+ | `#contrast` / `#contrast=` | 0..63 |
861
+ | `#display_on` / `#display_off` | Blank the panel without losing its contents |
862
+ | `#command(byte)` / `#write_data(text)` | Raw instruction / display-data transfer |
863
+ | `#reset` | Re-run the power-on initialisation sequence |
864
+ | `#columns` / `#rows` / `#i2c` | Geometry, and the bus device writes go through |
865
+ | `#close` / `#closed?` | Close the bus device, if this display opened it |
866
+
867
+ ### `Rgpio::SoftwarePWM`
868
+
869
+ | Method | Description |
870
+ |---|---|
871
+ | `.new(gpio, frequency:, duty_cycle:, spin_us:, chip:, consumer:)` | Claim a line and prepare a channel |
872
+ | `.open(gpio, ...) { \|pwm\| }` | Block form; closes on exit |
873
+ | `#frequency` / `#frequency=` | Hz, 0.1..10000 |
874
+ | `#duty_cycle` / `#duty_cycle=` | 0.0..1.0 (`#duty_ratio` is an alias) |
875
+ | `#pulse_width_us` / `#pulse_width_us=` | High time in microseconds |
876
+ | `#enable` / `#disable` / `#enabled?` | Start and stop generating |
877
+ | `#spin_us` | Microseconds spent spinning before each edge |
878
+ | `#close` / `#closed?` | Stop, release the line, close an owned chip |
879
+
880
+ ### `Rgpio::PWMOutputDevice` (and `Rgpio::PWMLED`)
881
+
882
+ | Method | Description |
883
+ |---|---|
884
+ | `.new(gpio, frequency:, initial_value:, active_low:, pwm:, chip:, consumer:)` | Drive a line from a PWM channel |
885
+ | `#value` / `#value=` | Level, 0.0..1.0 (`#brightness` on `PWMLED`) |
886
+ | `#on` / `#off` / `#toggle` | 1.0 / 0.0 / the complement of the current level |
887
+ | `#on?` | True when not fully off (`#active?` is an alias) |
888
+ | `#frequency` / `#frequency=` | Delegated to the channel |
889
+ | `#pwm` | The channel behind this device |
890
+ | `#close` / `#closed?` | Stop the channel, releasing it if the device opened it |
891
+
892
+ ### `Rgpio::RGBLED`
893
+
894
+ | Method | Description |
895
+ |---|---|
896
+ | `.new(red:, green:, blue:, frequency:, active_low:, balance:, pwm:, chip:, consumer:)` | Three channels as one device |
897
+ | `#color` / `#color=` | An r,g,b triple, or a name from `COLORS` |
898
+ | `#red` / `#green` / `#blue` (and `=`) | One channel at a time |
899
+ | `#balance` / `#balance=` | Per-channel scale under every level asked for |
900
+ | `#on` / `#off` / `#toggle` | White / dark / the complement of each channel |
901
+ | `#on?` | True when any channel is lit |
902
+ | `#channels` | The three `PWMLED`s, by colour |
903
+ | `#close` / `#closed?` | Stop all three, close an owned chip |
904
+
905
+ ### `Rgpio::Servo`
906
+
907
+ | Method | Description |
908
+ |---|---|
909
+ | `.new(gpio, min_pulse_us:, max_pulse_us:, frequency:, min_angle:, max_angle:, initial_value:, pwm:, chip:, consumer:)` | Claim a line and centre the servo |
910
+ | `#value` / `#value=` | -1.0..1.0, or nil to detach |
911
+ | `#angle` / `#angle=` | Degrees between `min_angle` and `max_angle` |
912
+ | `#min` / `#mid` / `#max` | The ends of the travel and the centre |
913
+ | `#pulse_width_us` / `#pulse_width_us=` | The width being sent; assigning one calibrates by hand |
914
+ | `#detach` / `#attached?` | Stop the pulses so the horn goes limp / report whether they are running |
915
+ | `#close` / `#closed?` | Stop the channel, releasing it if the servo opened it |
916
+
917
+ ### `Rgpio::HardwarePWM`
918
+
919
+ | Method | Description |
920
+ |---|---|
921
+ | `.new(gpio:, board:)` | Auto-detect chip/channel for a GPIO pin (board auto-detected) |
922
+ | `.new(chip:, channel:)` | Explicit chip/channel |
923
+ | `.open(...) { \|pwm\| }` | Block form; closes on exit |
924
+ | `.available_chips` | List sysfs PWM chips |
925
+ | `.detect_board` | Board family from the device-tree model (`:pi5` / `:pi4` / `:unknown`) |
926
+ | `#board` | Resolved board family |
927
+ | `#frequency=` / `#frequency` | Hz |
928
+ | `#duty_cycle=` / `#duty_ratio` | 0.0–1.0 ratio |
929
+ | `#pulse_width_us=` / `#pulse_width_us` | Microseconds |
930
+ | `#enable` / `#disable` | Start/stop PWM output |
931
+ | `#close` | Disable and unexport |
932
+
933
+ ---
934
+
935
+ ## Architecture
936
+
937
+ ```
938
+ ┌─────────────────────────────────────────────────────────┐
939
+ │ LED / Button / Motor / PWMLED / RGBLED / Servo │ device classes (gpiozero-style)
940
+ │ ADT7410 / ST7032 / MCP3208 │ (one object per part)
941
+ ├─────────────────────────────────────────────────────────┤
942
+ │ Rgpio::Chip / LineRequest │ OOP wrappers (this gem)
943
+ ├──────────────────┬──────────────────┬───────────────────┤
944
+ │ Native (fiddle) │ HardwarePWM │ I2C / SPI │ libgpiod.so / sysfs PWM / i2c-dev
945
+ │ SoftwarePWM │ │ │ (PWM timed in Ruby on any line)
946
+ └──────────────────┴──────────────────┴───────────────────┘
947
+ libgpiod v2 ABI Linux PWM sysfs /dev/i2c-N, /dev/spidev ioctl
948
+ ```
949
+
950
+ - **Layer 1 (`Native`)** — raw `fiddle` declarations of the libgpiod C functions
951
+ - **Layer 2 (`Chip`, `LineRequest`, `HardwarePWM`, `SoftwarePWM`, `I2C`, `SPI`)** — Ruby-idiomatic wrappers
952
+ - **Layer 3 (`LED`, `Button`, `Motor`, `PWMLED`, `RGBLED`, `Servo`, `ADT7410`,
953
+ `ST7032`, `MCP3208`)** — one object per piece of hardware
954
+
955
+ ---
956
+
957
+ ## Project status
958
+
959
+ `0.1.0` is the first release. It is `0.x` in earnest: everything documented here
960
+ has been exercised on real hardware, but the API may still change before `1.0`.
961
+
962
+ - **Released changes:** [CHANGELOG.md](CHANGELOG.md)
963
+ - **Roadmap, planned APIs, and multi-board validation status:** [PLAN.md](PLAN.md)
964
+
965
+ ---
966
+
967
+ ## License
968
+
969
+ [MIT](LICENSE)