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/lib/rgpio/chip.rb ADDED
@@ -0,0 +1,271 @@
1
+ module Rgpio
2
+ # Represents an open GPIO chip (e.g. /dev/gpiochip0).
3
+ #
4
+ # Usage (block form — recommended, ensures close on exit):
5
+ # # Auto-detect the GPIO controller wired to the 40-pin header
6
+ # # (works on Pi 5 / Pi 4 / Pi Zero without code changes):
7
+ # Rgpio::Chip.open do |chip|
8
+ # request = chip.request_lines(offsets: [17], direction: :output)
9
+ # # ...
10
+ # end
11
+ #
12
+ # # Or open a specific device explicitly:
13
+ # Rgpio::Chip.open("/dev/gpiochip0") { |chip| ... }
14
+ #
15
+ # Usage (manual):
16
+ # chip = Rgpio::Chip.new # auto-detect
17
+ # # ...
18
+ # chip.close
19
+ class Chip
20
+ # Glob matching every GPIO character device exposed by the kernel.
21
+ DEVICE_GLOB = "/dev/gpiochip*".freeze
22
+
23
+ # Labels of the GPIO controller wired to the 40-pin header, in detection
24
+ # priority order (newest SoC first). The label comes from the chip's
25
+ # info record (gpiod_chip_info_get_label) and is stable per SoC family:
26
+ #
27
+ # pinctrl-rp1 → Raspberry Pi 5 (RP1 I/O controller)
28
+ # pinctrl-bcm2711 → Raspberry Pi 4 / 400
29
+ # pinctrl-bcm2835 → Pi Zero / Zero W / Zero 2 W / Pi 1 / 2 / 3
30
+ #
31
+ # On Pi 5 the SoC also exposes several "gpio-brcmstb@..." chips that are
32
+ # NOT on the header — matching by label avoids selecting those.
33
+ HEADER_CHIP_LABELS = %w[
34
+ pinctrl-rp1
35
+ pinctrl-bcm2711
36
+ pinctrl-bcm2835
37
+ ].freeze
38
+
39
+ # @param path [String, nil] path to the GPIO chip device. When nil
40
+ # (the default), the header GPIO controller is auto-detected.
41
+ def initialize(path = nil)
42
+ Rgpio.assert_available!
43
+ @path = path || self.class.detect_path
44
+ @chip_ptr = Native.gpiod_chip_open(@path)
45
+ return unless @chip_ptr.null?
46
+
47
+ raise SystemCallError.new("gpiod_chip_open(#{@path})", Native.errno)
48
+ end
49
+
50
+ # Open a chip, yield it to the block, then close it.
51
+ # With no path, auto-detects the header GPIO controller.
52
+ def self.open(path = nil, &block)
53
+ chip = new(path)
54
+ block.call(chip)
55
+ ensure
56
+ chip&.close
57
+ end
58
+
59
+ # Enumerate every GPIO chip present on the system.
60
+ #
61
+ # @return [Array<Hash>] one entry per chip, sorted by device index:
62
+ # { path:, name:, label:, num_lines: }
63
+ def self.list
64
+ Rgpio.assert_available!
65
+ Dir.glob(DEVICE_GLOB).sort_by { |p| device_index(p) }.filter_map do |path|
66
+ chip = new(path)
67
+ begin
68
+ { path: path, name: chip.name, label: chip.label, num_lines: chip.num_lines }
69
+ ensure
70
+ chip.close
71
+ end
72
+ rescue SystemCallError
73
+ # Skip chips we cannot open (e.g. permissions, races) rather than abort.
74
+ nil
75
+ end
76
+ end
77
+
78
+ # Resolve the device path of the GPIO controller wired to the 40-pin
79
+ # header. Pass `chips` to test the selection logic without hardware.
80
+ #
81
+ # @param chips [Array<Hash>] chip records as returned by {.list}
82
+ # @return [String] device path (e.g. "/dev/gpiochip0")
83
+ # @raise [NotAvailableError] when no usable GPIO chip is found
84
+ def self.detect_path(chips = list)
85
+ if chips.empty?
86
+ raise NotAvailableError,
87
+ "No GPIO chips found under #{DEVICE_GLOB}. Is this a Raspberry Pi?"
88
+ end
89
+ select_header_chip(chips).fetch(:path)
90
+ end
91
+
92
+ # Pick the header GPIO controller from a list of chip records.
93
+ # Prefers a known SoC label; falls back to the chip with the most lines
94
+ # (the header controller is, in practice, the largest one).
95
+ #
96
+ # @param chips [Array<Hash>] chip records as returned by {.list}
97
+ # @return [Hash] the selected chip record
98
+ def self.select_header_chip(chips)
99
+ HEADER_CHIP_LABELS.each do |label|
100
+ match = chips.find { |c| c[:label] == label }
101
+ return match if match
102
+ end
103
+ chips.max_by { |c| c[:num_lines] }
104
+ end
105
+
106
+ # Numeric index of a gpiochip device path ("/dev/gpiochip10" → 10),
107
+ # so chips sort numerically rather than lexically.
108
+ def self.device_index(path)
109
+ File.basename(path).delete_prefix("gpiochip").to_i
110
+ end
111
+
112
+ # @return [String] device path this chip was opened with
113
+ attr_reader :path
114
+
115
+ # @return [String] kernel name of the chip (e.g. "gpiochip0")
116
+ def name
117
+ with_info { |info| Native.gpiod_chip_info_get_name(info) }
118
+ end
119
+
120
+ # @return [String] label identifying the GPIO controller
121
+ # (e.g. "pinctrl-rp1" on Raspberry Pi 5)
122
+ def label
123
+ with_info { |info| Native.gpiod_chip_info_get_label(info) }
124
+ end
125
+
126
+ # @return [Integer] total number of GPIO lines on this chip
127
+ def num_lines
128
+ with_info { |info| Native.gpiod_chip_info_get_num_lines(info) }
129
+ end
130
+
131
+ # Request one or more lines for exclusive use.
132
+ #
133
+ # @param offsets [Array<Integer>] GPIO line offsets to request
134
+ # @param direction [:input, :output] line direction
135
+ # @param edge [:none, :rising, :falling, :both] edge detection
136
+ # (only meaningful when direction is :input)
137
+ # @param bias [:as_is, :disabled, :pull_up, :pull_down]
138
+ # @param active_low [Boolean] invert active/inactive logic
139
+ # @param debounce_us [Integer] kernel-level debounce period in microseconds
140
+ # (0 = disabled). Suppresses bounces shorter than this window.
141
+ # Typical value for mechanical buttons: 5_000 (5 ms).
142
+ # @param initial_value [:active, :inactive] initial output value
143
+ # (only meaningful when direction is :output)
144
+ # @param consumer [String] name shown in kernel request list (optional)
145
+ # @return [LineRequest]
146
+ def request_lines(offsets:, direction:, edge: :none, bias: :as_is,
147
+ active_low: false, debounce_us: 0, initial_value: :inactive, consumer: nil)
148
+ assert_open!
149
+
150
+ settings_ptr = Native.gpiod_line_settings_new
151
+ raise Error, "gpiod_line_settings_new failed" if settings_ptr.null?
152
+
153
+ begin
154
+ check! Native.gpiod_line_settings_set_direction(settings_ptr, direction_value(direction)),
155
+ "gpiod_line_settings_set_direction"
156
+ check! Native.gpiod_line_settings_set_edge_detection(settings_ptr, edge_value(edge)),
157
+ "gpiod_line_settings_set_edge_detection"
158
+ check! Native.gpiod_line_settings_set_bias(settings_ptr, bias_value(bias)),
159
+ "gpiod_line_settings_set_bias"
160
+ Native.gpiod_line_settings_set_active_low(settings_ptr, active_low)
161
+ if debounce_us&.positive?
162
+ check! Native.gpiod_line_settings_set_debounce_period_us(settings_ptr, debounce_us),
163
+ "gpiod_line_settings_set_debounce_period_us"
164
+ end
165
+ if direction == :output
166
+ check! Native.gpiod_line_settings_set_output_value(
167
+ settings_ptr, initial_value == :active ? Native::LINE_VALUE_ACTIVE : Native::LINE_VALUE_INACTIVE
168
+ ), "gpiod_line_settings_set_output_value"
169
+ end
170
+
171
+ line_config_ptr = Native.gpiod_line_config_new
172
+ raise Error, "gpiod_line_config_new failed" if line_config_ptr.null?
173
+
174
+ begin
175
+ offsets_arr = Array(offsets)
176
+ offsets_ptr = Native.uint32_buffer(offsets_arr)
177
+
178
+ check! Native.gpiod_line_config_add_line_settings(
179
+ line_config_ptr, offsets_ptr, offsets_arr.size, settings_ptr
180
+ ), "gpiod_line_config_add_line_settings"
181
+
182
+ req_config_ptr = build_request_config(consumer)
183
+ begin
184
+ request_ptr = Native.gpiod_chip_request_lines(@chip_ptr, req_config_ptr, line_config_ptr)
185
+ raise SystemCallError.new("gpiod_chip_request_lines", Native.errno) if request_ptr.null?
186
+
187
+ LineRequest.new(request_ptr, offsets_arr)
188
+ ensure
189
+ Native.gpiod_request_config_free(req_config_ptr) unless req_config_ptr.null?
190
+ end
191
+ ensure
192
+ Native.gpiod_line_config_free(line_config_ptr)
193
+ end
194
+ ensure
195
+ Native.gpiod_line_settings_free(settings_ptr)
196
+ end
197
+ end
198
+
199
+ # Close the chip and release all kernel resources.
200
+ # Safe to call multiple times.
201
+ def close
202
+ return unless @chip_ptr && !@chip_ptr.null?
203
+
204
+ Native.gpiod_chip_close(@chip_ptr)
205
+ @chip_ptr = nil
206
+ end
207
+
208
+ def closed?
209
+ @chip_ptr.nil?
210
+ end
211
+
212
+ private
213
+
214
+ def assert_open!
215
+ raise Error, "Chip is already closed" if closed?
216
+ end
217
+
218
+ def with_info
219
+ assert_open!
220
+ info = Native.gpiod_chip_get_info(@chip_ptr)
221
+ raise SystemCallError.new("gpiod_chip_get_info", Native.errno) if info.null?
222
+
223
+ begin
224
+ yield info
225
+ ensure
226
+ Native.gpiod_chip_info_free(info)
227
+ end
228
+ end
229
+
230
+ def build_request_config(consumer)
231
+ ptr = Native.gpiod_request_config_new
232
+ return Native::NULL if ptr.null?
233
+
234
+ Native.gpiod_request_config_set_consumer(ptr, consumer) if consumer
235
+ ptr
236
+ end
237
+
238
+ def check!(ret, fn_name)
239
+ raise SystemCallError.new(fn_name, Native.errno) if ret == -1
240
+ end
241
+
242
+ def direction_value(sym)
243
+ case sym
244
+ when :as_is then Native::LINE_DIRECTION_AS_IS
245
+ when :input then Native::LINE_DIRECTION_INPUT
246
+ when :output then Native::LINE_DIRECTION_OUTPUT
247
+ else raise ArgumentError, "Unknown direction: #{sym.inspect}"
248
+ end
249
+ end
250
+
251
+ def edge_value(sym)
252
+ case sym
253
+ when :none then Native::LINE_EDGE_NONE
254
+ when :rising then Native::LINE_EDGE_RISING
255
+ when :falling then Native::LINE_EDGE_FALLING
256
+ when :both then Native::LINE_EDGE_BOTH
257
+ else raise ArgumentError, "Unknown edge: #{sym.inspect}"
258
+ end
259
+ end
260
+
261
+ def bias_value(sym)
262
+ case sym
263
+ when :as_is then Native::LINE_BIAS_AS_IS
264
+ when :disabled then Native::LINE_BIAS_DISABLED
265
+ when :pull_up then Native::LINE_BIAS_PULL_UP
266
+ when :pull_down then Native::LINE_BIAS_PULL_DOWN
267
+ else raise ArgumentError, "Unknown bias: #{sym.inspect}"
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,116 @@
1
+ module Rgpio
2
+ # An ADT7410 I2C temperature sensor (Analog Devices).
3
+ #
4
+ # sensor = Rgpio::ADT7410.new
5
+ # puts sensor.temperature # => 24.5 (degrees Celsius)
6
+ # sensor.close
7
+ #
8
+ # The address is set by the A1/A0 pins: 0x48 with both tied low (the default
9
+ # on the common breakout boards), through 0x4b with both high.
10
+ #
11
+ # The sensor powers up in 13-bit mode, which resolves 0.0625 degC and is what
12
+ # the datasheet calls the default; 16-bit mode resolves 0.0078 degC and is
13
+ # selected with `resolution: 16`.
14
+ class ADT7410
15
+ DEFAULT_ADDRESS = 0x48
16
+
17
+ # Register map (the subset this driver uses).
18
+ REG_TEMPERATURE = 0x00
19
+ REG_STATUS = 0x02
20
+ REG_CONFIG = 0x03
21
+ REG_ID = 0x0b
22
+
23
+ # Configuration register bit 7 selects 16-bit resolution.
24
+ CONFIG_RESOLUTION_16 = 0x80
25
+
26
+ # Upper five bits of the ID register are the manufacturer ID; the lower
27
+ # three are the silicon revision.
28
+ MANUFACTURER_ID = 0b11001
29
+
30
+ # Counts per degree Celsius, per resolution. In 13-bit mode the three low
31
+ # bits of the register hold the Tcrit/Thigh/Tlow flags instead of data.
32
+ COUNTS_PER_DEGREE = { 13 => 16.0, 16 => 128.0 }.freeze
33
+
34
+ # The first conversion after power-up takes 240 ms; reading before it has
35
+ # finished returns the 0 degC reset value rather than an error.
36
+ CONVERSION_TIME = 0.24
37
+
38
+ # @param address [Integer] 7-bit address, 0x48..0x4b
39
+ # @param bus [Integer] i2c-dev bus number
40
+ # @param resolution [Integer] 13 or 16 bits
41
+ # @param i2c [I2C, nil] an open device to share, or nil to open one
42
+ def initialize(address: DEFAULT_ADDRESS, bus: I2C::DEFAULT_BUS, resolution: 13, i2c: nil)
43
+ @owns_i2c = i2c.nil?
44
+ @i2c = i2c || I2C.new(address: address, bus: bus)
45
+ @closed = false
46
+ self.resolution = resolution
47
+ end
48
+
49
+ # @return [I2C] the bus device this sensor is read through
50
+ attr_reader :i2c
51
+
52
+ # @return [Integer] 13 or 16
53
+ attr_reader :resolution
54
+
55
+ # @param bits [Integer] 13 or 16
56
+ def resolution=(bits)
57
+ raise ArgumentError, "resolution must be 13 or 16, got #{bits.inspect}" unless COUNTS_PER_DEGREE.key?(bits)
58
+
59
+ config = @i2c.read_register(REG_CONFIG).first
60
+ config = bits == 16 ? config | CONFIG_RESOLUTION_16 : config & ~CONFIG_RESOLUTION_16
61
+ @i2c.write_register(REG_CONFIG, config & 0xff)
62
+ @resolution = bits
63
+ end
64
+
65
+ # @return [Float] temperature in degrees Celsius
66
+ def temperature
67
+ msb, lsb = @i2c.read_register(REG_TEMPERATURE, 2)
68
+ self.class.convert(msb, lsb, @resolution)
69
+ end
70
+
71
+ alias value temperature
72
+
73
+ # @return [Array<Integer>] the raw two temperature bytes, MSB first
74
+ def raw_temperature
75
+ @i2c.read_register(REG_TEMPERATURE, 2)
76
+ end
77
+
78
+ # @return [Integer] the ID register: manufacturer ID in bits 7..3,
79
+ # silicon revision in bits 2..0
80
+ def id
81
+ @i2c.read_register(REG_ID).first
82
+ end
83
+
84
+ # A cheap "is the sensor really there" check for examples and diagnostics.
85
+ # @return [Boolean] true when the ID register reports Analog Devices
86
+ def detected?
87
+ (id >> 3) == MANUFACTURER_ID
88
+ rescue SystemCallError
89
+ false
90
+ end
91
+
92
+ # Convert a raw register pair to degrees Celsius. Both resolutions are
93
+ # two's complement, so a set sign bit means the reading is below 0 degC.
94
+ # @return [Float]
95
+ def self.convert(msb, lsb, resolution = 13)
96
+ counts = COUNTS_PER_DEGREE.fetch(resolution)
97
+ raw = ((msb << 8) | lsb)
98
+ raw >>= 3 if resolution == 13
99
+ sign_bit = resolution == 13 ? 0x1000 : 0x8000
100
+ raw -= sign_bit * 2 if raw & sign_bit != 0
101
+ raw / counts
102
+ end
103
+
104
+ # Close the bus device, but only if this sensor opened it.
105
+ def close
106
+ return if @closed
107
+
108
+ @closed = true
109
+ @i2c.close if @owns_i2c
110
+ end
111
+
112
+ def closed?
113
+ @closed
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,41 @@
1
+ module Rgpio
2
+ # Base class for the high-level device API (LED, Button, MotionSensor, Motor).
3
+ #
4
+ # A device either borrows a Chip given by the caller or opens its own. Only a
5
+ # chip the device opened itself is closed by #close, so several devices can
6
+ # share one chip handle:
7
+ #
8
+ # chip = Rgpio::Chip.new
9
+ # red = Rgpio::LED.new(17, chip: chip)
10
+ # green = Rgpio::LED.new(27, chip: chip)
11
+ class Device
12
+ # @param chip [Chip, nil] chip to drive this device on; nil opens (and
13
+ # later closes) a chip of its own via auto-detection
14
+ def initialize(chip: nil)
15
+ @owns_chip = chip.nil?
16
+ @chip = chip || Chip.new
17
+ @closed = false
18
+ end
19
+
20
+ # @return [Chip] the chip this device is driven on
21
+ attr_reader :chip
22
+
23
+ # Release the device's lines, and the chip too if this device opened it.
24
+ # Safe to call multiple times.
25
+ def close
26
+ return if @closed
27
+
28
+ @closed = true
29
+ release_resources
30
+ @chip.close if @owns_chip
31
+ end
32
+
33
+ def closed?
34
+ @closed
35
+ end
36
+
37
+ private
38
+
39
+ def release_resources; end
40
+ end
41
+ end
@@ -0,0 +1,150 @@
1
+ module Rgpio
2
+ # A single GPIO line read as an input, with optional edge callbacks.
3
+ #
4
+ # Callbacks run on a background thread that waits on kernel edge events, so
5
+ # the main thread is free (see {Rgpio.pause}). The thread is started by the
6
+ # first callback registration and stopped by #close.
7
+ class InputDevice < Device
8
+ # How long a single edge wait blocks before the watcher rechecks whether it
9
+ # has been asked to stop.
10
+ WATCH_TIMEOUT = 0.2
11
+
12
+ # @param gpio [Integer] GPIO line offset (BCM numbering)
13
+ # @param pull_up [Boolean, nil] internal bias: true = pull-up (wire the
14
+ # switch to GND), false = pull-down (wire it to 3.3 V),
15
+ # nil = no bias (external resistor)
16
+ # @param active_low [Boolean] when true, a low line reads as active
17
+ # @param debounce_us [Integer] kernel debounce window in microseconds
18
+ # @param chip [Chip, nil] chip to share, or nil to open one
19
+ # @param consumer [String] name shown in the kernel's request list
20
+ def initialize(gpio, pull_up: false, active_low: false, debounce_us: 0, chip: nil, consumer: "rgpio")
21
+ super(chip: chip)
22
+ @gpio = gpio
23
+ @callbacks = {}
24
+ @request = @chip.request_lines(
25
+ offsets: [gpio],
26
+ direction: :input,
27
+ edge: :both,
28
+ bias: bias_for(pull_up),
29
+ active_low: active_low,
30
+ debounce_us: debounce_us,
31
+ consumer: consumer
32
+ )
33
+ end
34
+
35
+ # @return [Integer] the GPIO line offset this device reads
36
+ attr_reader :gpio
37
+
38
+ # @return [Boolean] true when the line is at its active level
39
+ def value
40
+ @request.get_value(@gpio) == :active
41
+ end
42
+
43
+ alias active? value
44
+
45
+ private
46
+
47
+ def bias_for(pull_up)
48
+ case pull_up
49
+ when true then :pull_up
50
+ when false then :pull_down
51
+ when nil then :disabled
52
+ else raise ArgumentError, "pull_up must be true, false or nil, got #{pull_up.inspect}"
53
+ end
54
+ end
55
+
56
+ # Register +block+ for the transition to :active or :inactive, and make sure
57
+ # the watcher thread is running.
58
+ def on_edge(state, &block)
59
+ raise ArgumentError, "a callback block is required" unless block
60
+
61
+ @callbacks[state] = block
62
+ start_watching
63
+ self
64
+ end
65
+
66
+ def start_watching
67
+ return if @watcher
68
+
69
+ @watching = true
70
+ @watcher = Thread.new { watch_loop }
71
+ end
72
+
73
+ def watch_loop
74
+ while @watching
75
+ @request.read_edge_events(timeout: WATCH_TIMEOUT).each do |event|
76
+ dispatch(event[:type] == :rising ? :active : :inactive)
77
+ end
78
+ end
79
+ rescue StandardError => e
80
+ warn "rgpio: edge watcher for GPIO#{@gpio} stopped: #{e.class}: #{e.message}"
81
+ end
82
+
83
+ # A raising callback must not take the watcher down with it, or the device
84
+ # would go quiet with no indication why.
85
+ def dispatch(state)
86
+ callback = @callbacks[state]
87
+ return unless callback
88
+
89
+ callback.call
90
+ rescue StandardError => e
91
+ warn "rgpio: #{self.class} callback for GPIO#{@gpio} raised #{e.class}: #{e.message}"
92
+ end
93
+
94
+ # Stop the watcher before releasing the request: a wait in progress holds a
95
+ # pointer into the request that libgpiod would free underneath it.
96
+ def release_resources
97
+ @watching = false
98
+ @watcher&.join
99
+ @watcher = nil
100
+ @request.release
101
+ end
102
+ end
103
+
104
+ # A push button or switch.
105
+ #
106
+ # button = Rgpio::Button.new(4)
107
+ # button.when_pressed { puts "Pressed" }
108
+ # button.when_released { puts "Released" }
109
+ # Rgpio.pause
110
+ #
111
+ # The default pull-down bias suits a switch wired between the GPIO line and
112
+ # 3.3 V. Pass pull_up: true for a switch wired to GND instead.
113
+ class Button < InputDevice
114
+ # Mechanical contacts bounce for a few milliseconds; without this one press
115
+ # fires the callback several times.
116
+ DEBOUNCE_US = 5_000
117
+
118
+ def initialize(gpio, pull_up: false, active_low: false, debounce_us: DEBOUNCE_US, chip: nil, consumer: "rgpio")
119
+ super
120
+ end
121
+
122
+ def when_pressed(&)
123
+ on_edge(:active, &)
124
+ end
125
+
126
+ def when_released(&)
127
+ on_edge(:inactive, &)
128
+ end
129
+
130
+ alias pressed? value
131
+ end
132
+
133
+ # A PIR motion sensor. Its output is a clean digital signal, so no debounce
134
+ # is applied.
135
+ #
136
+ # sensor = Rgpio::MotionSensor.new(4)
137
+ # sensor.when_motion { puts "motion detected!" }
138
+ # Rgpio.pause
139
+ class MotionSensor < InputDevice
140
+ def when_motion(&)
141
+ on_edge(:active, &)
142
+ end
143
+
144
+ def when_no_motion(&)
145
+ on_edge(:inactive, &)
146
+ end
147
+
148
+ alias motion_detected? value
149
+ end
150
+ end
@@ -0,0 +1,104 @@
1
+ module Rgpio
2
+ # An MCP3208 analogue-to-digital converter: eight 12-bit channels over SPI.
3
+ #
4
+ # adc = Rgpio::MCP3208.new
5
+ # adc.read(0) # => 0..4095, the raw code
6
+ # adc.voltage(0) # => volts, against the reference
7
+ # adc.close
8
+ #
9
+ # Wiring is SPI plus a reference: VDD and VREF to 3.3 V, AGND and DGND to
10
+ # ground, CLK/DOUT/DIN to SCLK/MISO/MOSI, CS to CE0.
11
+ #
12
+ # The MCP3204 is the same part with four channels and the same protocol, so it
13
+ # works through this class with `channels: 4`.
14
+ #
15
+ # Clock rate matters for correctness, not just speed: the datasheet allows
16
+ # 1 MHz at 2.7 V and 2 MHz at 5 V, and a converter clocked past its sampling
17
+ # rate returns values that look plausible and are wrong. The 1 MHz default is
18
+ # inside the envelope for a 3.3 V supply.
19
+ class MCP3208
20
+ DEFAULT_CHANNELS = 8
21
+
22
+ # 12 bits, so 4096 codes: code 4095 means the input is at the reference.
23
+ RESOLUTION = 4096
24
+
25
+ DEFAULT_REFERENCE_VOLTAGE = 3.3
26
+ DEFAULT_SPEED_HZ = 1_000_000
27
+
28
+ # First command byte: a start bit, then SGL/DIFF, then the top channel bit.
29
+ START_SINGLE = 0x06
30
+ START_DIFFERENTIAL = 0x04
31
+
32
+ # @param channels [Integer] 8 for an MCP3208, 4 for an MCP3204
33
+ # @param reference_voltage [Float] volts on the VREF pin
34
+ # @param bus [Integer] spidev bus number
35
+ # @param device [Integer] chip-select index
36
+ # @param speed_hz [Integer] clock rate
37
+ # @param spi [SPI, nil] an open bus device to share, or nil to open one
38
+ def initialize(channels: DEFAULT_CHANNELS, reference_voltage: DEFAULT_REFERENCE_VOLTAGE,
39
+ bus: 0, device: 0, speed_hz: DEFAULT_SPEED_HZ, spi: nil)
40
+ @channels = channels
41
+ @reference_voltage = reference_voltage.to_f
42
+ @owns_spi = spi.nil?
43
+ @spi = spi || SPI.new(bus: bus, device: device, speed_hz: speed_hz, mode: 0)
44
+ @closed = false
45
+ end
46
+
47
+ # @return [SPI] the bus device readings go through
48
+ attr_reader :spi
49
+
50
+ # @return [Integer] how many input channels this part has
51
+ attr_reader :channels
52
+
53
+ # @return [Float] volts on the VREF pin
54
+ attr_reader :reference_voltage
55
+
56
+ # Read one channel.
57
+ # @param channel [Integer] 0...channels
58
+ # @param differential [Boolean] measure the channel pair rather than the
59
+ # single input: 0/1, 2/3, 4/5, 6/7, with the even channel
60
+ # as IN+ when `channel` is even
61
+ # @return [Integer] the raw code, 0..4095
62
+ def read(channel, differential: false)
63
+ raise Error, "MCP3208 is closed" if @closed
64
+ unless channel.is_a?(Integer) && (0...@channels).cover?(channel)
65
+ raise ArgumentError, "channel must be in 0...#{@channels}, got #{channel.inspect}"
66
+ end
67
+
68
+ start = differential ? START_DIFFERENTIAL : START_SINGLE
69
+ received = @spi.transfer([start | ((channel & 0x04) >> 2), (channel & 0x03) << 6, 0x00])
70
+ # The conversion arrives across the last two bytes: four null bits, then
71
+ # the twelve data bits, most significant first.
72
+ ((received[1] & 0x0f) << 8) | received[2]
73
+ end
74
+
75
+ # @return [Float] the channel as a fraction of the reference, 0.0..1.0
76
+ def value(channel, differential: false)
77
+ read(channel, differential: differential) / (RESOLUTION - 1).to_f
78
+ end
79
+
80
+ # @return [Float] the channel in volts
81
+ def voltage(channel, differential: false)
82
+ value(channel, differential: differential) * @reference_voltage
83
+ end
84
+
85
+ # Read every channel in turn. Not simultaneous — the part has one converter
86
+ # behind a multiplexer, so these are consecutive samples.
87
+ # @return [Array<Integer>] raw codes, channel 0 first
88
+ def read_all
89
+ Array.new(@channels) { |channel| read(channel) }
90
+ end
91
+
92
+ # Close the bus device, but only if this converter opened it.
93
+ def close
94
+ return if @closed
95
+
96
+ @closed = true
97
+ @spi.close if @owns_spi
98
+ end
99
+
100
+ def closed?
101
+ @closed
102
+ end
103
+ end
104
+ end