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,51 @@
1
+ module Rgpio
2
+ # A DC motor behind a two-input driver such as the DRV8835 or SN754410:
3
+ # one line drives it forward, the other backward.
4
+ #
5
+ # motor = Rgpio::Motor.new(forward: 2, backward: 14)
6
+ # motor.forward
7
+ # sleep 5
8
+ # motor.backward
9
+ # sleep 5
10
+ # motor.stop
11
+ # motor.close
12
+ #
13
+ # Speed control needs PWM on both lines and is not supported yet; the motor
14
+ # runs at full speed in either direction.
15
+ class Motor < Device
16
+ # @param forward [Integer] GPIO line that drives the motor forward
17
+ # @param backward [Integer] GPIO line that drives the motor backward
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(forward:, backward:, chip: nil, consumer: "rgpio")
21
+ super(chip: chip)
22
+ @forward = OutputDevice.new(forward, chip: @chip, consumer: consumer)
23
+ @backward = OutputDevice.new(backward, chip: @chip, consumer: consumer)
24
+ end
25
+
26
+ # Both directions are dropped before one is raised, so the driver is never
27
+ # asked to source and sink the same output at once.
28
+ def forward
29
+ @backward.off
30
+ @forward.on
31
+ end
32
+
33
+ def backward
34
+ @forward.off
35
+ @backward.on
36
+ end
37
+
38
+ def stop
39
+ @forward.off
40
+ @backward.off
41
+ end
42
+
43
+ private
44
+
45
+ def release_resources
46
+ stop
47
+ @forward.close
48
+ @backward.close
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,66 @@
1
+ module Rgpio
2
+ # A single GPIO line driven as an output.
3
+ #
4
+ # out = Rgpio::OutputDevice.new(17)
5
+ # out.on
6
+ # out.off
7
+ # out.close
8
+ class OutputDevice < Device
9
+ # @param gpio [Integer] GPIO line offset (BCM numbering)
10
+ # @param active_low [Boolean] when true, #on drives the line low
11
+ # @param initial_value [Boolean] level to drive as soon as the line is claimed
12
+ # @param chip [Chip, nil] chip to share, or nil to open one
13
+ # @param consumer [String] name shown in the kernel's request list
14
+ def initialize(gpio, active_low: false, initial_value: false, chip: nil, consumer: "rgpio")
15
+ super(chip: chip)
16
+ @gpio = gpio
17
+ @request = @chip.request_lines(
18
+ offsets: [gpio],
19
+ direction: :output,
20
+ active_low: active_low,
21
+ initial_value: initial_value ? :active : :inactive,
22
+ consumer: consumer
23
+ )
24
+ end
25
+
26
+ # @return [Integer] the GPIO line offset this device drives
27
+ attr_reader :gpio
28
+
29
+ def on
30
+ self.value = true
31
+ end
32
+
33
+ def off
34
+ self.value = false
35
+ end
36
+
37
+ def toggle
38
+ self.value = !value
39
+ end
40
+
41
+ # @return [Boolean] true when the line is at its active level
42
+ def value
43
+ @request.get_value(@gpio) == :active
44
+ end
45
+
46
+ def value=(level)
47
+ @request.set_value(@gpio, level ? :active : :inactive)
48
+ end
49
+
50
+ alias on? value
51
+
52
+ private
53
+
54
+ def release_resources
55
+ @request.release
56
+ end
57
+ end
58
+
59
+ # An LED on a GPIO line.
60
+ #
61
+ # led = Rgpio::LED.new(4)
62
+ # 5.times { led.on; sleep 1; led.off; sleep 1 }
63
+ # led.close
64
+ class LED < OutputDevice
65
+ end
66
+ end
@@ -0,0 +1,25 @@
1
+ module Rgpio
2
+ # Shared by the PWM-backed devices ({PWMOutputDevice}, {Servo}): turns the
3
+ # `pwm:` option into a channel to drive the line with.
4
+ #
5
+ # A symbol means the device opens — and later closes — a channel of its own.
6
+ # Anything else is a channel the caller owns and keeps, so that several devices
7
+ # can share one, or a {HardwarePWM} already configured the way they want it.
8
+ module PWMChannel
9
+ private
10
+
11
+ # @return [Array(Object, Boolean)] the channel, and whether it is ours to close
12
+ def resolve_pwm(pwm, gpio:, frequency:, chip:, consumer:)
13
+ return [pwm, false] unless pwm.is_a?(Symbol)
14
+
15
+ case pwm
16
+ when :software
17
+ [SoftwarePWM.new(gpio, frequency: frequency, chip: chip, consumer: consumer), true]
18
+ when :hardware
19
+ [HardwarePWM.new(gpio: gpio).tap { |channel| channel.frequency = frequency }, true]
20
+ else
21
+ raise ArgumentError, "pwm must be :software, :hardware or a PWM channel, got #{pwm.inspect}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,109 @@
1
+ module Rgpio
2
+ # A GPIO line driven by a PWM channel, so it has a level between off and on
3
+ # rather than just the two.
4
+ #
5
+ # The channel is {SoftwarePWM} unless asked otherwise. It needs no dtoverlay
6
+ # and works on every line, where {HardwarePWM} needs a config.txt entry and
7
+ # reaches two header pins at a time — see PLAN.md for the measurements behind
8
+ # that default. Pass `pwm: :hardware` on GPIO12/13/18/19 to use the peripheral,
9
+ # or pass a channel object to share one.
10
+ class PWMOutputDevice
11
+ include PWMChannel
12
+
13
+ # Fast enough that an LED does not visibly flicker, and gpiozero's default.
14
+ DEFAULT_FREQUENCY = 100
15
+
16
+ # @param gpio [Integer] GPIO line offset (BCM numbering)
17
+ # @param frequency [Numeric] Hz; ignored when `pwm:` is a channel object,
18
+ # which the caller has already configured
19
+ # @param initial_value [Float] 0.0..1.0, applied before the channel starts
20
+ # @param active_low [Boolean] when true, 1.0 drives the line low — the
21
+ # wiring of a common-anode LED
22
+ # @param pwm [:software, :hardware, Object] channel to drive the
23
+ # line with, or one to borrow
24
+ # @param chip [Chip, nil] chip to share, or nil to open one
25
+ # @param consumer [String] name shown in the kernel's request list
26
+ def initialize(gpio, frequency: DEFAULT_FREQUENCY, initial_value: 0.0, active_low: false,
27
+ pwm: :software, chip: nil, consumer: "rgpio")
28
+ @gpio = gpio
29
+ @active_low = active_low
30
+ @closed = false
31
+ @pwm, @owns_pwm = resolve_pwm(pwm, gpio: gpio, frequency: frequency, chip: chip, consumer: consumer)
32
+ self.value = initial_value
33
+ @pwm.enable
34
+ end
35
+
36
+ # @return [Integer] the GPIO line this device drives
37
+ attr_reader :gpio
38
+
39
+ # @return [Object] the PWM channel behind this device
40
+ attr_reader :pwm
41
+
42
+ # @return [Float] current level, 0.0..1.0
43
+ attr_reader :value
44
+
45
+ # @param ratio [Float] 0.0 = off, 1.0 = full on
46
+ def value=(ratio)
47
+ raise Error, "#{self.class} on GPIO#{@gpio} is closed" if @closed
48
+ unless ratio.is_a?(Numeric) && (0.0..1.0).cover?(ratio)
49
+ raise ArgumentError, "value must be in 0.0..1.0, got #{ratio.inspect}"
50
+ end
51
+
52
+ @value = ratio.to_f
53
+ @pwm.duty_cycle = @active_low ? 1.0 - @value : @value
54
+ end
55
+
56
+ def on
57
+ self.value = 1.0
58
+ end
59
+
60
+ def off
61
+ self.value = 0.0
62
+ end
63
+
64
+ # Invert the level, so a half-lit LED stays half-lit the other way about.
65
+ def toggle
66
+ self.value = 1.0 - @value
67
+ end
68
+
69
+ # @return [Boolean] true when the line is not fully off
70
+ def on?
71
+ @value.positive?
72
+ end
73
+
74
+ alias active? on?
75
+
76
+ # @return [Numeric] the channel's frequency in Hz
77
+ def frequency
78
+ @pwm.frequency
79
+ end
80
+
81
+ def frequency=(hz)
82
+ @pwm.frequency = hz
83
+ end
84
+
85
+ # Stop the channel, and release it if this device opened it.
86
+ # Safe to call multiple times.
87
+ def close
88
+ return if @closed
89
+
90
+ @closed = true
91
+ @owns_pwm ? @pwm.close : @pwm.disable
92
+ end
93
+
94
+ def closed?
95
+ @closed
96
+ end
97
+ end
98
+
99
+ # An LED whose brightness can be set, not just its state.
100
+ #
101
+ # led = Rgpio::PWMLED.new(4)
102
+ # led.value = 0.25 # a quarter bright
103
+ # 10.times { |i| led.value = i / 9.0; sleep 0.1 }
104
+ # led.close
105
+ class PWMLED < PWMOutputDevice
106
+ alias brightness value
107
+ alias brightness= value=
108
+ end
109
+ end
@@ -0,0 +1,175 @@
1
+ module Rgpio
2
+ # A full-colour LED: three {PWMLED} channels behind one object.
3
+ #
4
+ # led = Rgpio::RGBLED.new(red: 2, green: 3, blue: 4)
5
+ # led.color = :magenta
6
+ # led.color = [1.0, 0.4, 0.0] # amber
7
+ # led.off
8
+ # led.close
9
+ #
10
+ # The default wiring is common cathode: each line sources current through its
11
+ # own resistor and the common leg goes to GND. For a common-anode part, tie the
12
+ # common leg to 3.3 V and pass `active_low: true`.
13
+ #
14
+ # Three software PWM channels means three generating threads, each spinning
15
+ # briefly around its edges (see {SoftwarePWM}). At the default 100 Hz that is
16
+ # some 16% of one core, and the threads occasionally collide, which shifts an
17
+ # edge by microseconds — invisible in an LED's brightness.
18
+ class RGBLED
19
+ # Corners of the colour cube, for the common case of naming a colour.
20
+ COLORS = {
21
+ off: [0.0, 0.0, 0.0],
22
+ red: [1.0, 0.0, 0.0],
23
+ green: [0.0, 1.0, 0.0],
24
+ blue: [0.0, 0.0, 1.0],
25
+ yellow: [1.0, 1.0, 0.0],
26
+ cyan: [0.0, 1.0, 1.0],
27
+ magenta: [1.0, 0.0, 1.0],
28
+ white: [1.0, 1.0, 1.0],
29
+ }.freeze
30
+
31
+ # @param red [Integer] GPIO line of the red channel
32
+ # @param green [Integer] GPIO line of the green channel
33
+ # @param blue [Integer] GPIO line of the blue channel
34
+ # @param frequency [Numeric] Hz, for all three channels
35
+ # @param active_low [Boolean] true for a common-anode LED
36
+ # @param balance [Array<Float>] per-channel scale, 0.0..1.0 each, applied
37
+ # on top of every level asked for
38
+ # @param pwm [:software, :hardware, Hash] channel kind, or a
39
+ # {red:, green:, blue:} hash of channels to borrow
40
+ # @param chip [Chip, nil] chip to share, or nil to open one
41
+ # @param consumer [String] name shown in the kernel's request list
42
+ def initialize(red:, green:, blue:, frequency: PWMOutputDevice::DEFAULT_FREQUENCY,
43
+ active_low: false, balance: [1.0, 1.0, 1.0], pwm: :software, chip: nil, consumer: "rgpio")
44
+ @owns_chip = chip.nil? && pwm == :software
45
+ @chip = @owns_chip ? Chip.new : chip
46
+ @closed = false
47
+ @balance = validate_triple(balance, "balance")
48
+ @color = [0.0, 0.0, 0.0]
49
+ lines = { red: red, green: green, blue: blue }
50
+ # One channel per colour: handing the same channel object to all three
51
+ # would have them overwrite each other's duty cycle.
52
+ channels = pwm.is_a?(Hash) ? pwm : {}
53
+ @channels = lines.to_h do |colour, gpio|
54
+ channel = PWMLED.new(gpio,
55
+ frequency: frequency, active_low: active_low,
56
+ pwm: channels.fetch(colour, pwm), chip: @chip, consumer: consumer)
57
+ [colour, channel]
58
+ end
59
+ end
60
+
61
+ # @return [Hash{Symbol=>PWMLED}] the three channels, by colour
62
+ attr_reader :channels
63
+
64
+ # The colour that was asked for, not the duty cycles it became — {#balance}
65
+ # and `active_low` both sit between the two.
66
+ # @return [Array<Float>] red, green, blue in 0.0..1.0
67
+ attr_reader :color
68
+
69
+ # @param value [Array<Float>, Symbol] an r,g,b triple, or a name from {COLORS}
70
+ def color=(value)
71
+ triple = value.is_a?(Symbol) ? named_color(value) : value
72
+ unless triple.is_a?(Array) && triple.size == 3
73
+ raise ArgumentError, "color must be a three-element array or one of #{COLORS.keys.inspect}, " \
74
+ "got #{value.inspect}"
75
+ end
76
+
77
+ @color = validate_triple(triple, "color")
78
+ apply
79
+ end
80
+
81
+ # @return [Array<Float>] the per-channel scale in force
82
+ attr_reader :balance
83
+
84
+ # Re-scale the channels, keeping the colour that was asked for. Setting this
85
+ # while white is showing is how the calibration example works.
86
+ def balance=(triple)
87
+ @balance = validate_triple(triple, "balance")
88
+ apply
89
+ end
90
+
91
+ def red
92
+ @color[0]
93
+ end
94
+
95
+ def green
96
+ @color[1]
97
+ end
98
+
99
+ def blue
100
+ @color[2]
101
+ end
102
+
103
+ def red=(level)
104
+ self.color = [level, green, blue]
105
+ end
106
+
107
+ def green=(level)
108
+ self.color = [red, level, blue]
109
+ end
110
+
111
+ def blue=(level)
112
+ self.color = [red, green, level]
113
+ end
114
+
115
+ # Full brightness on all three channels, which is white.
116
+ def on
117
+ self.color = :white
118
+ end
119
+
120
+ def off
121
+ self.color = :off
122
+ end
123
+
124
+ # Invert every channel, so a dimmed colour comes back as its complement.
125
+ def toggle
126
+ self.color = @color.map { |level| 1.0 - level }
127
+ end
128
+
129
+ # @return [Boolean] true when any channel is lit
130
+ def on?
131
+ @color.any?(&:positive?)
132
+ end
133
+
134
+ alias active? on?
135
+
136
+ # Stop all three channels, and close the chip if this LED opened it.
137
+ # Safe to call multiple times.
138
+ def close
139
+ return if @closed
140
+
141
+ @closed = true
142
+ @channels.each_value(&:close)
143
+ @chip.close if @owns_chip
144
+ end
145
+
146
+ def closed?
147
+ @closed
148
+ end
149
+
150
+ private
151
+
152
+ # Push the requested colour out through the balance. The channels hold the
153
+ # scaled levels; #color keeps reporting what was asked for.
154
+ def apply
155
+ @channels.values.each_with_index do |channel, i|
156
+ channel.value = @color[i] * @balance[i]
157
+ end
158
+ end
159
+
160
+ def validate_triple(triple, what)
161
+ unless triple.is_a?(Array) && triple.size == 3 &&
162
+ triple.all? { |v| v.is_a?(Numeric) && (0.0..1.0).cover?(v) }
163
+ raise ArgumentError, "#{what} must be three numbers in 0.0..1.0, got #{triple.inspect}"
164
+ end
165
+
166
+ triple.map(&:to_f)
167
+ end
168
+
169
+ def named_color(name)
170
+ COLORS.fetch(name) do
171
+ raise ArgumentError, "unknown colour #{name.inspect}; known: #{COLORS.keys.inspect}"
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,161 @@
1
+ module Rgpio
2
+ # An RC servo, positioned by the width of a pulse repeated every 20 ms.
3
+ #
4
+ # servo = Rgpio::Servo.new(12)
5
+ # servo.max # one end of its travel
6
+ # servo.mid # centre
7
+ # servo.angle = 45 # or by angle
8
+ # servo.detach # stop holding the position
9
+ # servo.close
10
+ #
11
+ # Pulse widths vary by servo: 1000..2000 us is the safe range every hobby
12
+ # servo understands, and many reach further (500..2500 us). A servo driven past
13
+ # its travel buzzes and heats up, so widen the range only as far as the part's
14
+ # datasheet allows, and by measuring rather than by trusting.
15
+ #
16
+ # Accuracy: the default {SoftwarePWM} channel places the pulse within about
17
+ # 6 us on an idle Pi 5, which is half a degree of travel; a busy machine can
18
+ # push an occasional pulse 30-80 us out (see PLAN.md). `pwm: :hardware` on
19
+ # GPIO12/13/18/19 removes that entirely, at the cost of a config.txt entry.
20
+ class Servo
21
+ include PWMChannel
22
+
23
+ # The frame rate every hobby servo expects.
24
+ DEFAULT_FREQUENCY = 50
25
+
26
+ DEFAULT_MIN_PULSE_US = 1000
27
+ DEFAULT_MAX_PULSE_US = 2000
28
+
29
+ # @param gpio [Integer] GPIO line offset (BCM numbering)
30
+ # @param min_pulse_us [Numeric] pulse width at value -1.0
31
+ # @param max_pulse_us [Numeric] pulse width at value +1.0
32
+ # @param frequency [Numeric] frame rate in Hz; ignored when `pwm:` is a
33
+ # channel object, which the caller has already configured
34
+ # @param min_angle [Numeric] angle reported at value -1.0
35
+ # @param max_angle [Numeric] angle reported at value +1.0
36
+ # @param initial_value [Float, nil] -1.0..1.0 to start there, nil to start
37
+ # detached (no pulses, so the horn is free)
38
+ # @param pwm [:software, :hardware, Object] channel to drive with
39
+ # @param chip [Chip, nil] chip to share, or nil to open one
40
+ # @param consumer [String] name shown in the kernel's request list
41
+ def initialize(gpio, min_pulse_us: DEFAULT_MIN_PULSE_US, max_pulse_us: DEFAULT_MAX_PULSE_US,
42
+ frequency: DEFAULT_FREQUENCY, min_angle: -90, max_angle: 90,
43
+ initial_value: 0.0, pwm: :software, chip: nil, consumer: "rgpio")
44
+ raise ArgumentError, "max_pulse_us must exceed min_pulse_us" unless max_pulse_us > min_pulse_us
45
+
46
+ @gpio = gpio
47
+ @min_pulse_us = min_pulse_us.to_f
48
+ @max_pulse_us = max_pulse_us.to_f
49
+ @min_angle = min_angle.to_f
50
+ @max_angle = max_angle.to_f
51
+ @value = nil
52
+ @closed = false
53
+ @pwm, @owns_pwm = resolve_pwm(pwm, gpio: gpio, frequency: frequency, chip: chip, consumer: consumer)
54
+ self.value = initial_value
55
+ @pwm.enable
56
+ end
57
+
58
+ # @return [Integer] the GPIO line this servo is driven from
59
+ attr_reader :gpio
60
+
61
+ # @return [Object] the PWM channel behind this servo
62
+ attr_reader :pwm
63
+
64
+ # @return [Float, nil] -1.0..1.0, or nil when detached
65
+ attr_reader :value
66
+
67
+ # @return [Float] pulse width at value -1.0
68
+ attr_reader :min_pulse_us
69
+
70
+ # @return [Float] pulse width at value +1.0
71
+ attr_reader :max_pulse_us
72
+
73
+ # @param position [Float, nil] -1.0..1.0, or nil to detach
74
+ def value=(position)
75
+ raise Error, "Servo on GPIO#{@gpio} is closed" if @closed
76
+
77
+ if position.nil?
78
+ detach
79
+ else
80
+ unless position.is_a?(Numeric) && (-1.0..1.0).cover?(position)
81
+ raise ArgumentError, "value must be in -1.0..1.0 or nil, got #{position.inspect}"
82
+ end
83
+
84
+ @value = position.to_f
85
+ @pwm.pulse_width_us = @min_pulse_us + ((@max_pulse_us - @min_pulse_us) * ((@value + 1.0) / 2.0))
86
+ end
87
+ end
88
+
89
+ def min
90
+ self.value = -1.0
91
+ end
92
+
93
+ def mid
94
+ self.value = 0.0
95
+ end
96
+
97
+ def max
98
+ self.value = 1.0
99
+ end
100
+
101
+ # @return [Float, nil] the current position in degrees, or nil when detached
102
+ def angle
103
+ return nil if @value.nil?
104
+
105
+ @min_angle + ((@max_angle - @min_angle) * ((@value + 1.0) / 2.0))
106
+ end
107
+
108
+ # @param degrees [Numeric, nil] between min_angle and max_angle
109
+ def angle=(degrees)
110
+ if degrees.nil?
111
+ detach
112
+ else
113
+ low, high = [@min_angle, @max_angle].minmax
114
+ unless degrees.is_a?(Numeric) && (low..high).cover?(degrees)
115
+ raise ArgumentError, "angle must be in #{low}..#{high} degrees or nil, got #{degrees.inspect}"
116
+ end
117
+
118
+ self.value = (((degrees - @min_angle) / (@max_angle - @min_angle)) * 2.0) - 1.0
119
+ end
120
+ end
121
+
122
+ # @return [Float, nil] the pulse width currently being sent, or nil when detached
123
+ def pulse_width_us
124
+ @value.nil? ? nil : @pwm.pulse_width_us
125
+ end
126
+
127
+ # Drive a pulse width directly, for calibrating a servo's real travel.
128
+ def pulse_width_us=(us)
129
+ raise ArgumentError, "pulse width must be positive, got #{us}" unless us.is_a?(Numeric) && us.positive?
130
+
131
+ @pwm.pulse_width_us = us
132
+ # The position no longer follows from #value, so stop claiming it does.
133
+ @value = nil
134
+ end
135
+
136
+ # Stop sending pulses. The servo stops holding its position and can be
137
+ # turned by hand — and stops drawing current fighting a load.
138
+ def detach
139
+ @pwm.duty_cycle = 0.0
140
+ @value = nil
141
+ end
142
+
143
+ # @return [Boolean] true while pulses are being sent
144
+ def attached?
145
+ !@value.nil?
146
+ end
147
+
148
+ # Stop the channel, and release it if this servo opened it.
149
+ # Safe to call multiple times.
150
+ def close
151
+ return if @closed
152
+
153
+ @closed = true
154
+ @owns_pwm ? @pwm.close : @pwm.disable
155
+ end
156
+
157
+ def closed?
158
+ @closed
159
+ end
160
+ end
161
+ end