Skip to content

Commit

Permalink
touch: add capacitive touch sensing on normal GPIO pins
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
aykevl authored and deadprogram committed Oct 25, 2024
1 parent 6d431e0 commit 30f540c
Show file tree
Hide file tree
Showing 3 changed files with 383 additions and 0 deletions.
69 changes: 69 additions & 0 deletions examples/touch/capacitive/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Capacitive touch sensing example.
//
// This capacitive touch sensor works by charging a normal GPIO pin, then slowly
// discharging it through a 1MΩ resistor and seeing how long it takes to go from
// high to low.
//
// Use as follows:
// - Change touchPin below as needed.
// - Connect this pin to some metal surface, like a piece of aluminimum foil.
// Make sure this surface is covered (using paper, Scotch tape, etc).
// - Also connect this same pin to ground through a 1MΩ resistor.
//
// This sensor is very sensitive to noise on the power source, so you should
// probably try to limit it by running from a battery for example. Especially
// phone chargers can produce a lot of noise.
package main

import (
"machine"
"time"

"tinygo.org/x/drivers/touch/capacitive"
)

const touchPin = machine.GP16 // Raspberry Pi Pico

func main() {
time.Sleep(time.Second * 2)
println("start")

led := machine.LED
led.Configure(machine.PinConfig{Mode: machine.PinOutput})
led.Low()

// Configure the array of GPIO pins used for capacitive touch sensing.
// We're using only one pin.
array := capacitive.NewArray([]machine.Pin{touchPin})

// Use a dynamic threshold, meaning the GPIO pin is automatically calibrated
// and re-calibrated to adjust for varying environments (e.g. changing
// humidity).
array.SetDynamicThreshold(100)

wasTouching := false
for i := uint32(0); ; i++ {
// Update the GPIO pin. This must be called very often.
array.Update()
touching := array.Touching(0)

// Indicate whether the pin is touched via the LED.
led.Set(touching)

// Print something when the touch state changed.
if wasTouching != touching {
wasTouching = touching
if touching {
println(" touch!")
} else {
println(" release!")
}
}

// Print the current value, as a debugging aid. It's not really meant to
// be used directly.
if i%128 == 32 {
println("touch value:", array.RawValue(0))
}
}
}
1 change: 1 addition & 0 deletions smoketest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ tinygo build -size short -o ./build/test.hex -target=microbit ./examples/st7789/
tinygo build -size short -o ./build/test.hex -target=circuitplay-express ./examples/thermistor/main.go
tinygo build -size short -o ./build/test.hex -target=circuitplay-bluefruit ./examples/tone
tinygo build -size short -o ./build/test.hex -target=arduino-nano33 ./examples/tm1637/main.go
tinygo build -size short -o ./build/test.hex -target=pico ./examples/touch/capacitive
tinygo build -size short -o ./build/test.hex -target=pyportal ./examples/touch/resistive/fourwire/main.go
tinygo build -size short -o ./build/test.hex -target=pyportal ./examples/touch/resistive/pyportal_touchpaint/main.go
tinygo build -size short -o ./build/test.hex -target=itsybitsy-m0 ./examples/vl53l1x/main.go
Expand Down
313 changes: 313 additions & 0 deletions touch/capacitive/gpio.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
package capacitive

import (
"machine"
"runtime/interrupt"
"time"
)

const (
// How often to measure.
// The Update function will wait until this amount of time has passed.
measurementFrequency = 200
minTimeBetweenMeasurements = time.Second / measurementFrequency

// How much to multiply values before averaging. A value higher than 1 will
// help to avoid integer rounding errors and may improve accuracy slightly.
oversampling = 8

// How many samples to use for the moving average.
movingAverageWindow = 16

// After how many samples should the touch sensor be recalibrated?
// This should be a power of two (for efficient division) and be a multiple
// of movingAverageWindow. Ideally it should cause a recalibration every 5s
// or so.
recalibrationSamples = 1024
)

type Array struct {
// Time when the last update finished. This is used to make sure we call
// Update() the expected number of times per second.
lastUpdate time.Time

// List of pins to measure each time.
pins []machine.Pin

// Raw values (non-smoothed) from the last read.
values []uint16

hasFirstMeasurement bool

// Static threshold. Zero if using a dynamic threshold.
staticThreshold uint16

// How long to measure.
measureCycles uint16

// Sensitivity (in promille) for the dynamic threshold.
sensitivity uint16

// Capacitance trackers for dynamic capacitance measurement.
trackers []capacitanceTracker
}

// Create a new array of pins to be used as touch sensors.
// The pins do not need to be initialized. The array is immediately ready to
// use.
//
// By default, NewArray configures a static threshold that is not very
// sensitive. If you want the touch inputs to be more sensitive, use
// SetDynamicThreshold.
func NewArray(pins []machine.Pin) *Array {
for _, pin := range pins {
pin.Configure(machine.PinConfig{Mode: machine.PinOutput})
pin.High()
}
array := &Array{
pins: pins,
values: make([]uint16, len(pins)),
measureCycles: uint16(machine.CPUFrequency() / 125000), // 1000 on the RP2040 (which is 125MHz)
lastUpdate: time.Now(),
}

// A threshold of 500 works well on the RP2040. Scale this number to
// something similar on other chips.
array.SetStaticThreshold(int(machine.CPUFrequency() / 250000))

return array
}

// Use a static threshold. This works well on simple touch surfaces where you'll
// directly touch the metal.
func (a *Array) SetStaticThreshold(threshold int) {
if threshold > 0xffff {
threshold = 0xffff
}
a.staticThreshold = uint16(threshold)
a.trackers = nil
}

// Use a dynamic threshold (as promille), that will calibrate automatically.
// This is needed when you want to be able to detect touches through a
// non-conducting surface for example. Something like 100‰ (10%) will probably
// work in many cases, though you may need to try different value to reliably
// detect touches.
func (a *Array) SetDynamicThreshold(sensitivity int) {
a.sensitivity = uint16(sensitivity)
a.staticThreshold = 0
a.trackers = make([]capacitanceTracker, len(a.pins))
}

// Measure all GPIO pins. This function must be called very often, ideally about
// 100-200 times per second (it will delay a bit when called more than 200 times
// per second).
func (a *Array) Update() {
// Wait until enough time has passed to charge all pins.
now := time.Now()
timeSinceLastUpdate := now.Sub(a.lastUpdate)
sleepTime := minTimeBetweenMeasurements - timeSinceLastUpdate
time.Sleep(sleepTime)
a.lastUpdate = now.Add(sleepTime) // should be ~equivalent to time.Now()

// Measure each pin in turn.
for i, pin := range a.pins {
// Interrupts must be disabled during measuring for accurate results.
mask := interrupt.Disable()

// Switch to input. This will stop the charging, and let it discharge
// through the resistor.
pin.Configure(machine.PinConfig{Mode: machine.PinInput})

// Wait for the pin to go low again.
// A longer duration means more capacitance, which means something is
// touching it (finger, banana, etc).
count := uint32(i)
for i := 0; i < int(a.measureCycles); i++ {
if !pin.Get() {
break
}
count++
}

interrupt.Restore(mask)

a.values[i] = uint16(count)

// Set the pin to high, to charge it for the next measurement.
pin.Configure(machine.PinConfig{Mode: machine.PinOutput})
pin.High()
}

// The first measurement tends to be slightly off (too low value) so ignore
// that one.
if !a.hasFirstMeasurement {
a.hasFirstMeasurement = true
return
}

for i := 0; i < len(a.trackers); i++ {
a.trackers[i].addValue(int(a.values[i]), int(a.sensitivity))
}
}

// Return the raw value of the given pin index of the most recent call to
// Update. This value is not smoothed in any way.
func (a *Array) RawValue(index int) int {
return int(a.values[index])
}

// Return the value from the moving average. This value is only available when a
// dynamic threshold has been set, it will panic otherwise.
func (a *Array) SmoothedValue(index int) int {
return int(a.trackers[index].avg) / oversampling
}

// Return whether the given pin index is currently being touched.
func (a *Array) Touching(index int) bool {
if a.staticThreshold != 0 {
// Using a static threshold.
return a.values[index] > a.staticThreshold
}

return a.trackers[index].touching
}

// Separate object to store calibration data and track capacitance over time.
type capacitanceTracker struct {
recentValues [movingAverageWindow]uint16
sum uint32
avg uint16

baseline uint16
noise uint16
valueCount uint8
touching bool

recalibrationCount uint8
recalibrationPrevAvg uint16
recalibrationNoiseSum int32
recalibrationSum uint32
}

func (ct *capacitanceTracker) addValue(value int, sensitivity int) {
// Maybe increase the resolution slightly by oversampling. This should
// increase the resolution a little bit after averaging and should reduce
// rounding errors.
// Typical input values on the RP2040 are 100-200 (or up to 1000 or so when
// touching the metal) so multiplying by 4-8 should be fine. Other chips
// generally have much lower values.
value *= oversampling
if value > 0xffff {
value = 0xffff // unlikely, but make sure we don't overflow
}

// This does a number of things at the same time:
// * Add the new value to the recentValues array.
// * Calculate the moving sum (and average) of recentValues using a
// recursive moving average algorithm:
// https://www.dspguide.com/ch15/5.htm
ptr := &ct.recentValues[ct.valueCount%movingAverageWindow]
ct.sum -= uint32(*ptr)
ct.sum += uint32(value)
ct.avg = uint16(ct.sum / movingAverageWindow)
*ptr = uint16(value)
ct.valueCount++

// Do an initial calibration once the first values have been read.
if ct.baseline == 0 && ct.valueCount == movingAverageWindow {
ct.baseline = ct.avg

// Calculate initial noise as an average absolute deviation:
// https://en.wikipedia.org/wiki/Average_absolute_deviation
// This is a quick and imprecise way to find the noise, better noise
// detection happens during recalibration.
var diffSum uint32
for _, sample := range ct.recentValues {
diff := int(ct.avg) - int(sample)
if diff < 0 {
diff = -diff
}
diffSum += uint32(diff)
}
ct.noise = uint16(diffSum / (movingAverageWindow / 2))
}

// Now determine whether the touch pad is being touched.

if ct.baseline == 0 {
// Not yet calibrated.
ct.touching = false
return
}

// Calculate the threshold.
// Divide by 65536 (instead of 65500) to avoid a potentially expensive
// division while still being close enough.
threshold := (uint32(ct.baseline) * uint32(sensitivity+1000) * 65) / 65536

// Add noise to the threshold, to avoid toggling quickly. This mainly
// filters out mains noise.
threshold += uint32(ct.noise)

// Implement some hysteresis: if the touch pad was previously touched, lower
// the threshold a little to avoid bouncing effects.
// TODO: let this hysteresis depend on the amount of noise.
if ct.touching {
threshold = (threshold*3 + uint32(ct.baseline)) / 4 // lower the threshold by 25%
}

// Is the pad being touched?
ct.touching = uint32(ct.avg) > threshold

// Do a recalibration after the sensor hasn't been touched for ~5s, to
// account for drift over time (humidity etc).
if ct.touching {
// Reset calibration (start from zero).
ct.recalibrationCount = 0
ct.recalibrationSum = 0
ct.recalibrationNoiseSum = 0
} else {
// Add the last batch of samples to the sum.
if ct.valueCount%movingAverageWindow == 0 {
ct.recalibrationCount++

// Wait a few cycles before starting data collection for
// calibration.
cycle := int(ct.recalibrationCount) - 3

if cycle < 0 {
// Store the previous average, to calculate the noise value.
ct.recalibrationPrevAvg = ct.avg

} else if cycle >= 0 {
// Collect data for recalibration.
ct.recalibrationSum += ct.sum

// Add difference between two (averaged) samples as a measure of
// the noise.
diff := int32(ct.recalibrationPrevAvg) - int32(ct.avg)
if diff < 0 {
diff = -diff
}
ct.recalibrationNoiseSum += diff
ct.recalibrationPrevAvg = ct.avg

}

// Do the recalibration after enough samples have been collected.
// Note: the noise is basically the average of absolute differences
// between two averaging windows. I don't know whether this
// algorithm has a name, but it seems to work here to detect the
// amount of noise.
const totalRecalibrationCount = recalibrationSamples / movingAverageWindow
if cycle == totalRecalibrationCount {
ct.baseline = uint16(ct.recalibrationSum / recalibrationSamples)
ct.noise = uint16(ct.recalibrationNoiseSum / (totalRecalibrationCount / 2))
ct.recalibrationCount = 0
ct.recalibrationSum = 0
ct.recalibrationNoiseSum = 0
}
}
}
}

0 comments on commit 30f540c

Please sign in to comment.