From 30f540c29ff233e335d92ee49ada71cf3dbcda08 Mon Sep 17 00:00:00 2001 From: Ayke van Laethem Date: Sat, 19 Oct 2024 11:32:22 +0200 Subject: [PATCH] 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. --- examples/touch/capacitive/main.go | 69 +++++++ smoketest.sh | 1 + touch/capacitive/gpio.go | 313 ++++++++++++++++++++++++++++++ 3 files changed, 383 insertions(+) create mode 100644 examples/touch/capacitive/main.go create mode 100644 touch/capacitive/gpio.go diff --git a/examples/touch/capacitive/main.go b/examples/touch/capacitive/main.go new file mode 100644 index 000000000..fc3f92682 --- /dev/null +++ b/examples/touch/capacitive/main.go @@ -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)) + } + } +} diff --git a/smoketest.sh b/smoketest.sh index b09c063ff..c89ba0410 100755 --- a/smoketest.sh +++ b/smoketest.sh @@ -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 diff --git a/touch/capacitive/gpio.go b/touch/capacitive/gpio.go new file mode 100644 index 000000000..c98d353c7 --- /dev/null +++ b/touch/capacitive/gpio.go @@ -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 + } + } + } +}