Capteur de proximité capacitif optimal en moins de 40 lignes de code

Par Cédric Bérenger | 20 décembre 2020

Capacitive proximity sensor.

Capteur de proximité capacitif: un Arduino, une électrode métallique derrière une feuille de papier, et rien d'autre.

La semaine dernière, on m'a demandé de réaliser une interface covid-safe permettant d'activer, sans contact et à travers une vitrine, une séquence programmée. Après avoir abandonné l'idée d'utiliser un capteur de distance de type Sharp car plus difficile à fixer mécaniquement, je me suis tourné vers le capteur capacitif qui a rapidement donné d’excellent résultats, et permet une captation fiable sans fausse détection à partir de 20cm, même derrière une vitre ou une plaque de bois.

Dans cet article, nous allons d’abord rappeler le fonctionnement des capteurs capacitifs. Ensuite, nous écrirons un code minimal pour réaliser un tel capteur avec n'importe quel microcontrôleur, en utilisant une seule patte d'entrée numérique avec pull-up interne. Nous verrons également que, contrairement à la plupart des librairies existantes, notre code ne nécessite ni ADC, ni interruption, ni timer, ni l'emploi d'une résistance externe. Enfin, nous verrons l'implémentation proposée ici pour l'architecture AVR (utilisée entre autre dans le contrôleur de l'Arduino Uno) est optimale en terme de timings.

Fonctionnement des capteurs de proximité capacitifs

Deux plaques conductrices parallèles forment un condensateur, un réservoir de charges qu'il est possible de charger et de décharger. Lorsque l'on applique une tension à un circuit série composé d'un condensateur et d'une résistance, la tension aux bornes du condensateur s'établit progressivement (voir figure), car le condensateur laisse passer temporairement le courant via l’interaction entre les charges des deux plaques: des électrons s'accumulent sur la plaque négative et repoussent les électrons se trouvant sur la plaque positive. Intuitivement, plus la distance entre les plaques est faible, plus la capacité du condensateur est grande, car l’interaction entre les charges est plus forte.

Pour visualiser le principe, il faut imaginer souffler dans un tube disposant en son centre d'une membrane élastique: le flux d'air passe temporairement jusqu'à ce que la membrane soit suffisamment tendue pour s'opposer au souffle. Il faut alors relâcher la pression pour pouvoir souffler à nouveau. Dans cette image, l'élasticité de la membrane correspond à la capacité du condensateur: plus la capacité est grande, plus le courant peut passer pendant longtemps avant de s'arrêter.

Sur chaque patte d'entrée / sortie de n'importe quel microcontrôleur, il existe un condensateur parasite : en effet, la patte du microcontrôleur et le plan de masse du circuit sont deux plaques conductrices parallèles et, bien que la capacité de ce condensateur parasite soit très faible, elle affecte et ralentit les transitions de niveaux logiques sur la patte. Si l'on connecte une plaque métallique à la patte d'entrée sortie, et que l'on approche la main de cette plaque, alors on augmente la capacité parasite de la patte. En effet, La main est elle aussi une plaque conductrice, et elle est reliée à la masse via nos pieds qui touchent le sol. Ainsi, plus on approche la main de la plaque métallique, plus on augmente la capacité parasite, et plus on ralentit les transitions de niveaux logique sur la patte d'entrée / sortie. Il suffit alors de mesurer la variation du temps de transition du niveau logique bas vers le niveau logique haut pour estimer la variation de capacité parasite et donc estimer la distance à laquelle main se trouve.

Circuit RC.

Circuit RC: Lorsque l'on ferme l'interrupteur, la tension au point bleu augmente progressivement. Plus la valeur de la capacité est grande, plus la tension augmente lentement.

Le code et son analyse

Ci dessous mon implémentation du capteur capacitif. Pour parler au plus grand nombre, j'ai implémenté le procédé sur plateforme AVR Arduino Uno, mais le code peut facilement être porté sur n'importe quelle autre plateforme du moment que le microcontrôleur cible dispose de GPIOs avec résistances internes de pull-up d'environ 20kOhm (ou pull-down).

//MINIMAL CODE FOR CAPACITIVE SENSE

//CEDRIC BERENGER 2020

//NO EXTERNAL HARDWARE REQUIRED!!


void setup()

{

pinMode(13,OUTPUT);

}

unsigned char avg, avgLo, i, frame[16];


unsigned char button()

{

DDRC = 1; //PIN A0 AS OUTPUT (WORK WITH ANY DIGITAL PIN!!)

PORTC = 0; //DISCHARGE PIN A0


__asm__("nop\n\t"); //DELAY TO INSURE FULL DISCHARGE

__asm__("nop\n\t");

char val = 0;

DDRC = 0; //PIN A0 AS DIGITAL INPUT

PORTC = 1; //ENABLE PULLUP RESISTOR


//MONITOR PIN A0 DURING 16 CYCLES:

val += PINC;

val += PINC;

val += PINC;

val += PINC;

val += PINC;

val += PINC;

val += PINC;

val += PINC;

val += PINC;

val += PINC;

val += PINC;

val += PINC;

val += PINC;

val += PINC;

val += PINC;

val += PINC;


//UPDATE AVGLO EVERY 16 SAMPLES (BIG INERTIA):

if(!i)

{

avgLo++;

if(avgLo > avg)

avgLo -= 2;

}


//AVG, AVERAGE ON A 16 SAMPLES FRAME:

avg = avg + val - frame[i];

frame[i] = val;

i = (i + 1) % 16;


//THRESHOLD:

return (avg< avgLo-3);

}


void loop()

{

digitalWrite(13,button()); //TURN ON LED IF BUTTON PRESSED

delay(20);

}

Le programme est très simple et n'utilise ni l'ADC, ni les interruptions. Pour faire une mesure, le microcontrôleur décharge d'abord la patte A0 en appliquant le niveau logique 0. Les instructions nop (no-operations) permettent de ne rien faire pendant quelques cycles, le temps que le condensateur parasite de la patte A0 soit totalement déchargée.

Ensuite, le microcontrôleur passe la patte A0 en mode entrée pull-up: une résistance interne d’environ 20kOhm tire la sortie vers le niveau logique haut. Nous obtenons donc un circuit série composé d'une résistance de 20kOhm et du condensateur parasite Cp dont la capacité est de l'ordre de la dizaine de pF.

Les 16 prochaines instructions surveillent pendant exactement 16 cycles l'état logique de la patte A0. La variable PINC vaut 1 si la patte A0 est au niveau logique haut, 0 dans le cas contrainte. Ainsi, (16 - val) correspond au temps de transition exprimé en nombre de cycles.

Les quelques lignes restantes permettent de lisser la captation et de filtrer le bruit. La variable val contient la valeur brute de la mesure courante. La variable avg contient la moyenne glissante sur les 16 dernières mesures. La variable avgLo sert à filtrer l'offset initial: la valeur de avgLo se rapproche de la valeur d'avg avec une forte inertie, et correspond donc à une moyenne sur le long terme du temps de transition. Enfin, la différence (avg-avgLo) retourne une valeur sans offset. Plus la valeur est négative, plus la main est proche.

Réalisation et résultats

Pour réaliser l'électrode, j'ai simplement collé 4 bandes de scotch de cuivre 3mm derrière une feuille de papier et soudé la dernière à un câble Dupont. Pour un résultat professionnel, il est possible de commander un autocollant pour mur ou fenêtre sur Vistaprint et de coller l'électrode métallique derrière avant de l'installer.

J'ai réalisé de nombreux tests, avec différents réglages de filtres et d'hystérèses ainsi que différentes électrodes. Avec le capteur présenté sur la photo, il est possible d'ajuster le seuil pour créer un bouton qui s'active de manière fiable lorsque la main est à environ 15cm de l'électrode. Ci dessous plusieurs photos et relevés d'expériences:

Capteur de proximité capacitif.

La feuille de papier cache une électrode en cuivre. En approchant la main, qui constitue la seconde plaque d'un condensateur, j'augmente la capacité parasite sur la patte A0 de l'arduino, utilisée ici comme une entrée / sortie numérique et non analogique.

Électrode carée en scotch de cuivre.

Électrode à l'arrière de la feuille réalisée à partir de ruban de cuivre avec adhésif conducteur, de largeur 3mm. Le fil Dupont est soudé directement sur le ruban.

Graphique: valeurs de Avg et AvgLo en fonction du temps.

Relevé de valeurs filtrées sur 10 secondes, recueillies via liaison série: échantillonnage à 50 fps, j'approche trois fois la main, la première fois à environ 10cm, la deuxième fois à environ 5cm de la feuille, et la troisième fois, je touche la feuille.

Graphique. Valeurs brutes de val en fonction du temps.

Même relevé que précédemment, mais cette fois avec les données brutes, sans filtre. On remarque plusieurs pics ainsi que la faible résolution: il y a peu de cycles de différence entre 10cm et 5cm ,d'où l'importance de faire une moyenne, elle permet de lisser le signal et de gagner en résolution.

Graphique, moyenne lissée sur un grand cadre.

En augmentant à 256 la taille de la fenêtre sur laquelle on effectue la moyenne, on obtient une courbe très lisse et une précision accrue. Pour ce relevé, j'ai agité 5 fois la main de haut en bas à environ 10 centimètres de la feuille. L'amplitude du mouvement est de quelques centimètres, la main n'a pas touché la feuille et est toujours restée à plus de 5cm de distance. Attention ! Si vous augmentez la taille de la fenêtre, vérifiez qu'il n'y a pas de problème de dépassement des entiers sur 8 bits, utilisez plutôt des entiers 16 bits, il n'y aura pas beaucoup de différence dans les temps de calcul.

Animation: la led s'allume à l'approche de la main.

Capte sans contact à environ 15cm de distance : la LED s'allume.

Animation: la led s'allume lorsque je pince le câble.

Un simple fil de 10cm sert d'électrode. Rapprocher les doigts de l'isolant suffit à activer la LED. De plus, en fonction du réglage de la sensibilité, il est même possible de déclencher la LED sans même toucher le câble.

Code Optimal pour AVR

Voici les caractéristiques principales du code proposé:

  • 20 octets de mémoire de travail

  • 158 octets de mémoire programme

  • <6µs par échantillon

  • <5µA consommation moyenne @60fps.

Le code proposé ici est optimal pour l'architecture AVR. Le listing assembleur ci-après du code compilé en témoigne: les 16 lectures de l'état de la patte A0 sont consécutives et s’effectuent chacune en 1 seul cycle (l'instruction IN permettant la lecture d'une entrée en un cycle). La résolution temporelle de la mesure est donc maximale, précise à 65.2ns près.

De plus, une prise de mesure s'effectue en 72 instructions. J'ai mesuré 60 secondes pour faire 10M mesures, soit 6µs par mesure. La mesure étant très rapide, il est possible de l'effectuer même à l'intérieur d'une interruption par exemple. Le fait que la mesure soit si rapide permet un fonctionnement basse consommation: il est possible d’échantillonner 60 fois par seconde et de consommer en moyenne moins de 5µA: 10mA * 60 fps * 6µs = 3.6µA, il faut rajouter à cela la consommation du mode économie d'énergie, ~1.3uA sur le ATmega328pB (variante plus récente et basse consommation du ATmega328p, car un ATmega328p normal n'est pas optimisé basse énergie et consomme au minimum 66µA).

Une telle consommation de moins de 5µA à 60 échantillons par seconde est relativement basse si on la compare avec la consommation de puces spécialisées que l'on retrouve fréquemment, notamment sous la forme de modules sur Adafruit ou Aliexpress comme le AD7151 (70µA, 100fps), le AT42QT1010 (16.5µA, 1.8V, 12.5fps), le MPR121 (29µA, 60fps), ou encore le TTP223 (3.5µA, 17fps).

Enfin, concernant la mémoire utilisée, le code que nous avons proposé utilise uniquement 20 octets de RAM (dont 16 octets pour la moyenne glissante) et 158 octets de mémoire ROM.

Code assembleur correspondant obtenu avec avr-objdump, toutes les lectures de l'entrée sont faites à la suite (instruction in):

26c: 17 b9 out 0x07, r17 ; 7

26e: 18 b8 out 0x08, r1 ; 8

270: 00 00 nop

272: 00 00 nop

274: 17 b8 out 0x07, r1 ; 7

276: 18 b9 out 0x08, r17 ; 8

278: 26 b1 in r18, 0x06 ; 6

27a: c6 b0 in r12, 0x06 ; 6

27c: d6 b0 in r13, 0x06 ; 6

27e: e6 b0 in r14, 0x06 ; 6

280: 96 b1 in r25, 0x06 ; 6

282: f6 b0 in r15, 0x06 ; 6

284: b6 b1 in r27, 0x06 ; 6

286: a6 b1 in r26, 0x06 ; 6

288: f6 b1 in r31, 0x06 ; 6

28a: e6 b1 in r30, 0x06 ; 6

28c: 86 b1 in r24, 0x06 ; 6

28e: 76 b1 in r23, 0x06 ; 6

290: 66 b1 in r22, 0x06 ; 6

292: 56 b1 in r21, 0x06 ; 6

294: 46 b1 in r20, 0x06 ; 6

296: 36 b1 in r19, 0x06 ; 6

298: 2c 0d add r18, r12

29a: 2d 0d add r18, r13

29c: 2e 0d add r18, r14

29e: 92 0f add r25, r18

2a0: 9f 0d add r25, r15

2a2: 9b 0f add r25, r27

2a4: 9a 0f add r25, r26

2a6: 9f 0f add r25, r31

2a8: 9e 0f add r25, r30

2aa: 89 0f add r24, r25

2ac: 87 0f add r24, r23

2ae: 86 0f add r24, r22

2b0: 85 0f add r24, r21

2b2: 84 0f add r24, r20

2b4: 83 0f add r24, r19

2b6: 20 91 1b 01 lds r18, 0x011B ; 0x80011b <i>

2ba: 90 91 19 01 lds r25, 0x0119 ; 0x800119 <avg>

2be: 21 11 cpse r18, r1

2c0: 0b c0 rjmp .+22 ; 0x2d8 <main+0x12e>

2c2: 30 91 1a 01 lds r19, 0x011A ; 0x80011a <avgLo>

2c6: 41 e0 ldi r20, 0x01 ; 1

2c8: 43 0f add r20, r19

2ca: 40 93 1a 01 sts 0x011A, r20 ; 0x80011a <avgLo>

2ce: 94 17 cp r25, r20

2d0: 18 f4 brcc .+6 ; 0x2d8 <main+0x12e>

2d2: 31 50 subi r19, 0x01 ; 1

2d4: 30 93 1a 01 sts 0x011A, r19 ; 0x80011a <avgLo>

2d8: 30 e0 ldi r19, 0x00 ; 0

2da: f9 01 movw r30, r18

2dc: e7 5f subi r30, 0xF7 ; 247

2de: fe 4f sbci r31, 0xFE ; 254

2e0: 40 81 ld r20, Z

2e2: 94 1b sub r25, r20

2e4: 98 0f add r25, r24

2e6: 90 93 19 01 sts 0x0119, r25 ; 0x800119 <avg>

2ea: 80 83 st Z, r24

2ec: 2f 5f subi r18, 0xFF ; 255

2ee: 3f 4f sbci r19, 0xFF ; 255

2f0: 2f 70 andi r18, 0x0F ; 15

2f2: 33 27 eor r19, r19

2f4: 20 93 1b 01 sts 0x011B, r18 ; 0x80011b <i>

2f8: 20 91 1a 01 lds r18, 0x011A ; 0x80011a <avgLo>

2fc: 23 50 subi r18, 0x03 ; 3

2fe: 33 0b sbc r19, r19

300: 80 e0 ldi r24, 0x00 ; 0

302: 92 17 cp r25, r18

304: 13 06 cpc r1, r19

306: 0c f4 brge .+2 ; 0x30a <main+0x160>

308: 80 e2 ldi r24, 0x20 ; 32

30a: 85 b9 out 0x05, r24 ; 5

Comparaison avec d'autres librairies

J'ai comparé le code présenté précédemment avec ceux disponibles dans les librairies Arduino, notamment la librairie ADCTouch, la librairie RBD_Capacitance, et la librairie CapacitiveSensor. Ces librairies ne sont pas optimales pour la basse consommation. En effet le code assembleur pour une seule mesure est beaucoup plus long: 140 instructions comportant en plus une attente active dans le cas d'ADCTouch, attente d'au moins 65µs nécessaire à la conversion analogique vers numérique. De plus, ces librairies requièrent soit le périphérique ADC, soit une résistance externe avec une haute valeur pour fonctionner et utilisent deux pattes d'entrée / sortie.

Ce qui m'a vraiment surpris, c'est la comparaison avec la librairie propriétaire QTouch proposé par Atmel Microchip pour AVR. Avec QTouch, la prise d'un échantillon est assez longue, sûrement à cause de la conversion analogique vers numérique. Le microcontrôleur passe donc plus de temps hors du mode basse consommation pour échantillonner, ce qui augmente drastiquement la consommation électrique, comme en témoigne ce tableau extrait de l'application note Atmel AVR3005: Low Power QTouch Design :

Table de la consommation provenant de la documentation doc42056.

Librarie QTouch: table de la consommation électrique fonction du nombre de canaux et de la fréquence d’échantillonnage. Extrait de la documentation Atmel AVR3005: Low Power QTouch Design (doc42056). À approximativement 30 échantillons par seconde, la consommation moyenne est de 44.149µA, car le microcontrôleur reste actif presque 1ms par seconde pour échantillonner.

La librairie QTouch d'Atmel obtient de meilleurs résultats avec des puces plus récentes disposant d'un périphérique dédié appelé PTC, pour Peripheral Touch Controller. Ce périphérique obscur, peu documenté utilise l'ADC et est uniquement accessible via la librairie propriétaire QTouch. (La documentation ne décrit pas les registres permettant de l'utiliser directement, je soupçonne même qu'il ne s'agit que d'un périphérique software utilisant directement l'ADC rapide des puces plus récentes).

Notons également que pour utiliser la librairie QTouch, il faut officiellement passer par les outils Atmel Studio / Atmel Start et Microchip MPLAB et leurs gestionnaires de paquets, ce qui est suffisamment pénible pour être souligné.

Table de la consommation provenant de la doc AT12405

Librairie QTouch (Microcontrôleur AVR Avec PTC): table de la consommation électrique fonction de la fréquence d’échantillonnage. Extrait de la documentation AT12405: Low Power Sensor Design with PTC. À 64 échantillons par seconde, la consommation moyenne est de 9.0µA.

Pour en finir avec la librairie QTouch, j'ajoute que celle-ci nécessite également beaucoup d'espace mémoire : 1234 octets de mémoire programme et 30 octets de mémoire vive pour un ATTiny20 d'après la documention. C'est une quantité non négligeable pour un microcontrôleur comportant uniquement 2K de ROM et 128 octets de RAM !

Enfin, l'herbe ne semble pas plus verte du côté du constructeur ST, la documentation de librairie équivalente STM8L-TOUCH-LIB pour puces basse consommation STM8L annonce Fast acquisition with a typical scan time of 250 μs for 10 Rx channels. Nous sommes très loin de nos 6µs.

Conclusion

Nous avons vu qu'il est possible avec quelques lignes de code d'obtenir un capteur de toucher / proximité capacitif fiable et efficace, à la fois en temps, en énergie et en mémoire: 6µs par échantillon, 5µA @60fps, 158 octets de ROM, 20 octets de RAM. Cela constitue une bonne alternative aux puces dédiées ainsi qu'aux librairies propriétaires comme QTouch qui ont des performances énergétiques moins bonnes. Cependant je ne me prononce pas sur la qualité des filtres de ces solutions.

Je vais terminer en évoquant la problématique du bruit ainsi que celle de la compatibilité électromagnétique: la plaque métallique joue le rôle d'antenne capable, suivant sa taille, de capter le bruit électromagnétique ambiant, mais aussi, en la chargeant puis en la déchargeant rapidement avec une patte du microcontrôleur, d'émettre des parasites électromagnétiques. Une résistance de 1kOhm peut être ajoutée en série avec la plaque métallique pour lisser les fronts lors de la décharge rapide.