Skip to content

Commit 30f540c

Browse files
aykevldeadprogram
authored andcommitted
touch: add capacitive touch sensing on normal GPIO pins
Tested on the following chips/boards: * RP2040 (Raspberry Pi Pico) * ATSAMD21 (Adafruit PyBadge) * NRF52840 (PCA10056 developer board) * ESP8266 (NodeMCU) * ATmega328p (Arduino Uno) * ESP32C3 (WaveShare ESP-C3-32S-Kit) * FE310 (SiFive HiFive1 rev B) The sensitivity threshold in the example may need to be adjusted per board though, the default value of 100 typically recognizes when a cable is being touched but the RP2040 for example is capable of doing much more precise measurements if the power supply is sufficiently noise-free.
1 parent 6d431e0 commit 30f540c

File tree

3 files changed

+383
-0
lines changed

3 files changed

+383
-0
lines changed

examples/touch/capacitive/main.go

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Capacitive touch sensing example.
2+
//
3+
// This capacitive touch sensor works by charging a normal GPIO pin, then slowly
4+
// discharging it through a 1MΩ resistor and seeing how long it takes to go from
5+
// high to low.
6+
//
7+
// Use as follows:
8+
// - Change touchPin below as needed.
9+
// - Connect this pin to some metal surface, like a piece of aluminimum foil.
10+
// Make sure this surface is covered (using paper, Scotch tape, etc).
11+
// - Also connect this same pin to ground through a 1MΩ resistor.
12+
//
13+
// This sensor is very sensitive to noise on the power source, so you should
14+
// probably try to limit it by running from a battery for example. Especially
15+
// phone chargers can produce a lot of noise.
16+
package main
17+
18+
import (
19+
"machine"
20+
"time"
21+
22+
"tinygo.org/x/drivers/touch/capacitive"
23+
)
24+
25+
const touchPin = machine.GP16 // Raspberry Pi Pico
26+
27+
func main() {
28+
time.Sleep(time.Second * 2)
29+
println("start")
30+
31+
led := machine.LED
32+
led.Configure(machine.PinConfig{Mode: machine.PinOutput})
33+
led.Low()
34+
35+
// Configure the array of GPIO pins used for capacitive touch sensing.
36+
// We're using only one pin.
37+
array := capacitive.NewArray([]machine.Pin{touchPin})
38+
39+
// Use a dynamic threshold, meaning the GPIO pin is automatically calibrated
40+
// and re-calibrated to adjust for varying environments (e.g. changing
41+
// humidity).
42+
array.SetDynamicThreshold(100)
43+
44+
wasTouching := false
45+
for i := uint32(0); ; i++ {
46+
// Update the GPIO pin. This must be called very often.
47+
array.Update()
48+
touching := array.Touching(0)
49+
50+
// Indicate whether the pin is touched via the LED.
51+
led.Set(touching)
52+
53+
// Print something when the touch state changed.
54+
if wasTouching != touching {
55+
wasTouching = touching
56+
if touching {
57+
println(" touch!")
58+
} else {
59+
println(" release!")
60+
}
61+
}
62+
63+
// Print the current value, as a debugging aid. It's not really meant to
64+
// be used directly.
65+
if i%128 == 32 {
66+
println("touch value:", array.RawValue(0))
67+
}
68+
}
69+
}

smoketest.sh

+1
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ tinygo build -size short -o ./build/test.hex -target=microbit ./examples/st7789/
7373
tinygo build -size short -o ./build/test.hex -target=circuitplay-express ./examples/thermistor/main.go
7474
tinygo build -size short -o ./build/test.hex -target=circuitplay-bluefruit ./examples/tone
7575
tinygo build -size short -o ./build/test.hex -target=arduino-nano33 ./examples/tm1637/main.go
76+
tinygo build -size short -o ./build/test.hex -target=pico ./examples/touch/capacitive
7677
tinygo build -size short -o ./build/test.hex -target=pyportal ./examples/touch/resistive/fourwire/main.go
7778
tinygo build -size short -o ./build/test.hex -target=pyportal ./examples/touch/resistive/pyportal_touchpaint/main.go
7879
tinygo build -size short -o ./build/test.hex -target=itsybitsy-m0 ./examples/vl53l1x/main.go

touch/capacitive/gpio.go

+313
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
package capacitive
2+
3+
import (
4+
"machine"
5+
"runtime/interrupt"
6+
"time"
7+
)
8+
9+
const (
10+
// How often to measure.
11+
// The Update function will wait until this amount of time has passed.
12+
measurementFrequency = 200
13+
minTimeBetweenMeasurements = time.Second / measurementFrequency
14+
15+
// How much to multiply values before averaging. A value higher than 1 will
16+
// help to avoid integer rounding errors and may improve accuracy slightly.
17+
oversampling = 8
18+
19+
// How many samples to use for the moving average.
20+
movingAverageWindow = 16
21+
22+
// After how many samples should the touch sensor be recalibrated?
23+
// This should be a power of two (for efficient division) and be a multiple
24+
// of movingAverageWindow. Ideally it should cause a recalibration every 5s
25+
// or so.
26+
recalibrationSamples = 1024
27+
)
28+
29+
type Array struct {
30+
// Time when the last update finished. This is used to make sure we call
31+
// Update() the expected number of times per second.
32+
lastUpdate time.Time
33+
34+
// List of pins to measure each time.
35+
pins []machine.Pin
36+
37+
// Raw values (non-smoothed) from the last read.
38+
values []uint16
39+
40+
hasFirstMeasurement bool
41+
42+
// Static threshold. Zero if using a dynamic threshold.
43+
staticThreshold uint16
44+
45+
// How long to measure.
46+
measureCycles uint16
47+
48+
// Sensitivity (in promille) for the dynamic threshold.
49+
sensitivity uint16
50+
51+
// Capacitance trackers for dynamic capacitance measurement.
52+
trackers []capacitanceTracker
53+
}
54+
55+
// Create a new array of pins to be used as touch sensors.
56+
// The pins do not need to be initialized. The array is immediately ready to
57+
// use.
58+
//
59+
// By default, NewArray configures a static threshold that is not very
60+
// sensitive. If you want the touch inputs to be more sensitive, use
61+
// SetDynamicThreshold.
62+
func NewArray(pins []machine.Pin) *Array {
63+
for _, pin := range pins {
64+
pin.Configure(machine.PinConfig{Mode: machine.PinOutput})
65+
pin.High()
66+
}
67+
array := &Array{
68+
pins: pins,
69+
values: make([]uint16, len(pins)),
70+
measureCycles: uint16(machine.CPUFrequency() / 125000), // 1000 on the RP2040 (which is 125MHz)
71+
lastUpdate: time.Now(),
72+
}
73+
74+
// A threshold of 500 works well on the RP2040. Scale this number to
75+
// something similar on other chips.
76+
array.SetStaticThreshold(int(machine.CPUFrequency() / 250000))
77+
78+
return array
79+
}
80+
81+
// Use a static threshold. This works well on simple touch surfaces where you'll
82+
// directly touch the metal.
83+
func (a *Array) SetStaticThreshold(threshold int) {
84+
if threshold > 0xffff {
85+
threshold = 0xffff
86+
}
87+
a.staticThreshold = uint16(threshold)
88+
a.trackers = nil
89+
}
90+
91+
// Use a dynamic threshold (as promille), that will calibrate automatically.
92+
// This is needed when you want to be able to detect touches through a
93+
// non-conducting surface for example. Something like 100‰ (10%) will probably
94+
// work in many cases, though you may need to try different value to reliably
95+
// detect touches.
96+
func (a *Array) SetDynamicThreshold(sensitivity int) {
97+
a.sensitivity = uint16(sensitivity)
98+
a.staticThreshold = 0
99+
a.trackers = make([]capacitanceTracker, len(a.pins))
100+
}
101+
102+
// Measure all GPIO pins. This function must be called very often, ideally about
103+
// 100-200 times per second (it will delay a bit when called more than 200 times
104+
// per second).
105+
func (a *Array) Update() {
106+
// Wait until enough time has passed to charge all pins.
107+
now := time.Now()
108+
timeSinceLastUpdate := now.Sub(a.lastUpdate)
109+
sleepTime := minTimeBetweenMeasurements - timeSinceLastUpdate
110+
time.Sleep(sleepTime)
111+
a.lastUpdate = now.Add(sleepTime) // should be ~equivalent to time.Now()
112+
113+
// Measure each pin in turn.
114+
for i, pin := range a.pins {
115+
// Interrupts must be disabled during measuring for accurate results.
116+
mask := interrupt.Disable()
117+
118+
// Switch to input. This will stop the charging, and let it discharge
119+
// through the resistor.
120+
pin.Configure(machine.PinConfig{Mode: machine.PinInput})
121+
122+
// Wait for the pin to go low again.
123+
// A longer duration means more capacitance, which means something is
124+
// touching it (finger, banana, etc).
125+
count := uint32(i)
126+
for i := 0; i < int(a.measureCycles); i++ {
127+
if !pin.Get() {
128+
break
129+
}
130+
count++
131+
}
132+
133+
interrupt.Restore(mask)
134+
135+
a.values[i] = uint16(count)
136+
137+
// Set the pin to high, to charge it for the next measurement.
138+
pin.Configure(machine.PinConfig{Mode: machine.PinOutput})
139+
pin.High()
140+
}
141+
142+
// The first measurement tends to be slightly off (too low value) so ignore
143+
// that one.
144+
if !a.hasFirstMeasurement {
145+
a.hasFirstMeasurement = true
146+
return
147+
}
148+
149+
for i := 0; i < len(a.trackers); i++ {
150+
a.trackers[i].addValue(int(a.values[i]), int(a.sensitivity))
151+
}
152+
}
153+
154+
// Return the raw value of the given pin index of the most recent call to
155+
// Update. This value is not smoothed in any way.
156+
func (a *Array) RawValue(index int) int {
157+
return int(a.values[index])
158+
}
159+
160+
// Return the value from the moving average. This value is only available when a
161+
// dynamic threshold has been set, it will panic otherwise.
162+
func (a *Array) SmoothedValue(index int) int {
163+
return int(a.trackers[index].avg) / oversampling
164+
}
165+
166+
// Return whether the given pin index is currently being touched.
167+
func (a *Array) Touching(index int) bool {
168+
if a.staticThreshold != 0 {
169+
// Using a static threshold.
170+
return a.values[index] > a.staticThreshold
171+
}
172+
173+
return a.trackers[index].touching
174+
}
175+
176+
// Separate object to store calibration data and track capacitance over time.
177+
type capacitanceTracker struct {
178+
recentValues [movingAverageWindow]uint16
179+
sum uint32
180+
avg uint16
181+
182+
baseline uint16
183+
noise uint16
184+
valueCount uint8
185+
touching bool
186+
187+
recalibrationCount uint8
188+
recalibrationPrevAvg uint16
189+
recalibrationNoiseSum int32
190+
recalibrationSum uint32
191+
}
192+
193+
func (ct *capacitanceTracker) addValue(value int, sensitivity int) {
194+
// Maybe increase the resolution slightly by oversampling. This should
195+
// increase the resolution a little bit after averaging and should reduce
196+
// rounding errors.
197+
// Typical input values on the RP2040 are 100-200 (or up to 1000 or so when
198+
// touching the metal) so multiplying by 4-8 should be fine. Other chips
199+
// generally have much lower values.
200+
value *= oversampling
201+
if value > 0xffff {
202+
value = 0xffff // unlikely, but make sure we don't overflow
203+
}
204+
205+
// This does a number of things at the same time:
206+
// * Add the new value to the recentValues array.
207+
// * Calculate the moving sum (and average) of recentValues using a
208+
// recursive moving average algorithm:
209+
// https://www.dspguide.com/ch15/5.htm
210+
ptr := &ct.recentValues[ct.valueCount%movingAverageWindow]
211+
ct.sum -= uint32(*ptr)
212+
ct.sum += uint32(value)
213+
ct.avg = uint16(ct.sum / movingAverageWindow)
214+
*ptr = uint16(value)
215+
ct.valueCount++
216+
217+
// Do an initial calibration once the first values have been read.
218+
if ct.baseline == 0 && ct.valueCount == movingAverageWindow {
219+
ct.baseline = ct.avg
220+
221+
// Calculate initial noise as an average absolute deviation:
222+
// https://en.wikipedia.org/wiki/Average_absolute_deviation
223+
// This is a quick and imprecise way to find the noise, better noise
224+
// detection happens during recalibration.
225+
var diffSum uint32
226+
for _, sample := range ct.recentValues {
227+
diff := int(ct.avg) - int(sample)
228+
if diff < 0 {
229+
diff = -diff
230+
}
231+
diffSum += uint32(diff)
232+
}
233+
ct.noise = uint16(diffSum / (movingAverageWindow / 2))
234+
}
235+
236+
// Now determine whether the touch pad is being touched.
237+
238+
if ct.baseline == 0 {
239+
// Not yet calibrated.
240+
ct.touching = false
241+
return
242+
}
243+
244+
// Calculate the threshold.
245+
// Divide by 65536 (instead of 65500) to avoid a potentially expensive
246+
// division while still being close enough.
247+
threshold := (uint32(ct.baseline) * uint32(sensitivity+1000) * 65) / 65536
248+
249+
// Add noise to the threshold, to avoid toggling quickly. This mainly
250+
// filters out mains noise.
251+
threshold += uint32(ct.noise)
252+
253+
// Implement some hysteresis: if the touch pad was previously touched, lower
254+
// the threshold a little to avoid bouncing effects.
255+
// TODO: let this hysteresis depend on the amount of noise.
256+
if ct.touching {
257+
threshold = (threshold*3 + uint32(ct.baseline)) / 4 // lower the threshold by 25%
258+
}
259+
260+
// Is the pad being touched?
261+
ct.touching = uint32(ct.avg) > threshold
262+
263+
// Do a recalibration after the sensor hasn't been touched for ~5s, to
264+
// account for drift over time (humidity etc).
265+
if ct.touching {
266+
// Reset calibration (start from zero).
267+
ct.recalibrationCount = 0
268+
ct.recalibrationSum = 0
269+
ct.recalibrationNoiseSum = 0
270+
} else {
271+
// Add the last batch of samples to the sum.
272+
if ct.valueCount%movingAverageWindow == 0 {
273+
ct.recalibrationCount++
274+
275+
// Wait a few cycles before starting data collection for
276+
// calibration.
277+
cycle := int(ct.recalibrationCount) - 3
278+
279+
if cycle < 0 {
280+
// Store the previous average, to calculate the noise value.
281+
ct.recalibrationPrevAvg = ct.avg
282+
283+
} else if cycle >= 0 {
284+
// Collect data for recalibration.
285+
ct.recalibrationSum += ct.sum
286+
287+
// Add difference between two (averaged) samples as a measure of
288+
// the noise.
289+
diff := int32(ct.recalibrationPrevAvg) - int32(ct.avg)
290+
if diff < 0 {
291+
diff = -diff
292+
}
293+
ct.recalibrationNoiseSum += diff
294+
ct.recalibrationPrevAvg = ct.avg
295+
296+
}
297+
298+
// Do the recalibration after enough samples have been collected.
299+
// Note: the noise is basically the average of absolute differences
300+
// between two averaging windows. I don't know whether this
301+
// algorithm has a name, but it seems to work here to detect the
302+
// amount of noise.
303+
const totalRecalibrationCount = recalibrationSamples / movingAverageWindow
304+
if cycle == totalRecalibrationCount {
305+
ct.baseline = uint16(ct.recalibrationSum / recalibrationSamples)
306+
ct.noise = uint16(ct.recalibrationNoiseSum / (totalRecalibrationCount / 2))
307+
ct.recalibrationCount = 0
308+
ct.recalibrationSum = 0
309+
ct.recalibrationNoiseSum = 0
310+
}
311+
}
312+
}
313+
}

0 commit comments

Comments
 (0)