Back to Index
Week 09

Input Devices

Fab Academy Barcelona, April 9 – April 15, 2026

PCB business card connected to Quentorres, copper touch pads, OLED header, CR2032 holder visible

Overview

This week's assignment: add a sensor to a microcontroller board you've designed, and read it. I didn't need an extra component, the capacitive touch pads are already baked into my PCB business card from Week 8. Two bare copper circles at the bottom corners. When you hold both at once, the OLED scrolls through a set of personal facts. Let go, and it shows: Hold Me.

The sensing is analog-ish, floating, and environment-dependent. Getting reliable readings out of two unshielded copper pads on a board with no working USB serial meant building a calibration workflow entirely on the OLED display. This page documents the physical principles, the calibration process, and the code.

The group assignment, probing an input device's analog levels and digital signals, is reflected on below and documented on the group work page. The OLED output device is documented in Week 10: Output Devices →

"The raw count is a proxy for capacitance, which is a proxy for contact area, which is a proxy for intention."

The Board

Designed in Week 6 and built in Week 8. The touch pads are a design feature, not an addition, two exposed copper circles connected by trace directly to GPIO pins on the ATSAMD11C14A.

  • ATSAMD11C14A — ARM Cortex-M0+, SOIC-14. Pins PA04 (Arduino 4) and PA05 (Arduino 5) connect to the left and right touch pads.
  • Touch pads — bare copper circles. No component, no soldermask, no pull-up resistor. Just exposed trace.
  • OLED header — JST connector: VCC, GND, SDA (pin 8), SCL (pin 9). Used for calibration output as well as final display.
  • CR2032 holder — Keystone 3034. Powers standalone once programmed.
ATsamD11C14A pinout, A4 and A5 for touch pads, pins 8/9 for SDA/SCL

ATSAMD11C14A pinout. Touch pads sit on A4 and A5. Pins 8 (SDA) and 9 (SCL) carry the OLED, getting those to route correctly required a specific Serial Config change documented in Week 10 →

PCB business card with OLED connected via JST header

Board with OLED connected. The touch pads are the two bare copper circles at the bottom corners, no component, just exposed trace.

Physical Principles: How Capacitive Touch Works

Your finger is a conductor. When it contacts an exposed copper pad, it forms a capacitor, two conductors (finger + pad) separated by a dielectric (skin + air). That capacitance is tiny, in the range of picofarads, but it's measurable using nothing but a GPIO pin and the chip's internal pull-up resistor.

Why Not Just digitalRead()?

Copper pads without debounce or pull-up resistors are floating capacitive sensors, not digital switches. A raw digitalRead() produces flickering touch state and unreliable triggering. Several environmental factors all feed into the reading simultaneously:

  • Humidity — changes the dielectric between finger and pad
  • Grounding — whether you're on rubber, barefoot, touching metal
  • Body capacitance — varies person to person: hand size, contact area, rings
  • Laptop charger noise — switching supply noise couples into unshielded traces

Charge-time measurement reads a continuous value rather than a binary threshold. Environmental drift shifts the number, but the gap between untouched (~3–5) and touched (~20–140) stays large enough to threshold reliably.

The Charge-Time Method

The GPIO pin acts as both the charge source and the sensor:

  1. Discharge: Set pin OUTPUT LOW. Drains any charge on the pad to ground.
  2. Switch to INPUT_PULLUP: The internal pull-up (~30–50kΩ) begins charging the pad capacitance.
  3. Count: Loop until the pin reads HIGH, until voltage crosses ~0.7×VCC.
  4. Read: Count is proportional to τ = R × C. More capacitance (finger) → longer charge time → higher count.
Think of it like filling a bucket with a fixed-rate tap. Small bucket (no finger) fills fast. Big bucket (finger adds capacitance) fills slow. The count tells you the bucket size.

Calibration: Finding the Right Threshold

Before I could set a touch threshold, I needed to know what the raw counts actually looked like on this specific board in real conditions. USB serial never enumerated correctly on the ATSAMD11 with NO_BOOTLOADER, Serial Monitor stayed blank throughout the project. So I printed live counts directly to the OLED.

The OLED as Debugger

I ran a calibration sketch that wrote the raw count from each pad live to the display:

// Calibration sketch, live counts on OLED
u8g2.clearBuffer();
u8g2.setCursor(31, 30);
u8g2.print("L:"); u8g2.print(left);
u8g2.setCursor(31, 45);
u8g2.print("R:"); u8g2.print(right);
u8g2.sendBuffer();

With this running, I could watch the numbers change in real time as I touched and released each pad, with bare fingertip, full palm, through a sleeve, with the charger plugged in. The display updated every 20ms, fast enough to see the count respond to contact.

Measured Values

Across multiple conditions and people:

  • No touch (noise floor): Left ~3–5, Right ~3–5
  • Light fingertip graze: Left ~18–35, Right ~20–40
  • Full pad contact: Left ~80–140, Right ~70–120

The gap between noise (~5) and a real touch (~20+) is large and consistent across conditions. Threshold set at 10 — comfortably above noise, triggered by even a light graze. Both pads must exceed threshold simultaneously to prevent accidental triggers from setting the board down.

How the Code Works

readTouch() — The Core Sensing Function

Takes a pin number, runs the charge-discharge cycle, returns a count. The cap of 300 prevents an infinite loop if a wire is disconnected or a pad is shorted to VCC.

long readTouch(int pin) {
  pinMode(pin, OUTPUT);
  digitalWrite(pin, LOW);  // discharge pad to ground
  delay(1);

  pinMode(pin, INPUT_PULLUP); // start charging via internal ~40kΩ pull-up

  long count = 0;
  while (digitalRead(pin) == LOW && count < 300) {
    count++; // count loops until voltage crosses logic threshold
  }
  return count;
}

Detection Logic — Both Pads Required

A single-pad trigger is too easy to hit accidentally. Requiring both pads simultaneously changes the interaction from a tap to a deliberate two-hand hold — the card held between your palms.

long left  = readTouch(TOUCH_LEFT);   // pin 4
long right = readTouch(TOUCH_RIGHT);  // pin 5

bool touched = (left > 10) && (right > 10);

The Scrolling Marquee

When touched, the OLED draws a string at xPos and decrements xPos each frame. The bounds 31 and 95 are the left and right edges of the Grove OLED's visible crop window — the SSD1306 operates a 128×64 framebuffer but only X:31→95 is physically visible on the 64×48 panel. Full explanation in Week 10: Output Devices →

if (touched) {
  const char* message = facts[currentFact];
  u8g2.drawStr(xPos, 40, message);

  xPos -= 14; // 14px per frame at 20ms delay

  int textWidth = strlen(message) * 6; // 6x12 font: 6px per char

  if (xPos < -textWidth + 31) { // text has exited left edge of visible window
    currentFact++;
    if (currentFact >= factCount) currentFact = 0;
    xPos = 95; // reset to right edge of visible window
  }
} else {
  u8g2.drawStr(40, 40, "Hold Me");
}
u8g2.sendBuffer();
delay(20);

Full Source Code

#include <Arduino.h>
#include <U8g2lib.h>

U8G2_SSD1306_128X64_NONAME_F_SW_I2C u8g2(
  U8G2_R0,
  /* clock=*/ 9,
  /* data=*/ 8,
  /* reset=*/ U8X8_PIN_NONE
);

#define TOUCH_LEFT  4
#define TOUCH_RIGHT 5

const char* facts[] = {
  "Loves Gilda",
  "New To BCN",
  "From India",
  "FAB LAB Noob",
  "Loves Emotional Design",
  "Built This Board with sweat blood and tears",
  "Created Whiff",
  "Has Congenital Anosmia",
  "She/ Her/ A11y"
};

const int factCount = 8; // note: 9 strings, 8 cycle — "She/Her/A11y" unreachable (known bug)

int currentFact = 0;
int xPos = 95;

long readTouch(int pin) {
  pinMode(pin, OUTPUT);
  digitalWrite(pin, LOW);
  delay(1);
  pinMode(pin, INPUT_PULLUP);
  long count = 0;
  while (digitalRead(pin) == LOW && count < 300) count++;
  return count;
}

void setup() {
  delay(1000);
  u8g2.begin();
  u8g2.setFont(u8g2_font_6x12_tf);
}

void loop() {
  long left  = readTouch(TOUCH_LEFT);
  long right = readTouch(TOUCH_RIGHT);
  bool touched = (left > 10) && (right > 10);

  u8g2.clearBuffer();
  if (touched) {
    const char* message = facts[currentFact];
    u8g2.drawStr(xPos, 40, message);
    xPos -= 14;
    int textWidth = strlen(message) * 6;
    if (xPos < -textWidth + 31) {
      currentFact++;
      if (currentFact >= factCount) currentFact = 0;
      xPos = 95;
    }
  } else {
    u8g2.drawStr(40, 40, "Hold Me");
  }
  u8g2.sendBuffer();
  delay(20);
}

Demo

Both pads held, OLED scrolling through personal facts. Release returns to "Hold Me."

Problems & Fixes

1. digitalRead() — flickering, unreliable UI

The first attempt used digitalRead() with a threshold. Touch state flickered, triggered inconsistently, and caused the UI to blink. Copper pads are floating capacitive sensors, not buttons, binary reads can't handle environmental drift. Fix: charge-time measurement. A continuous count robust to drift; the gap between noise and touch stays large regardless of charger noise or humidity.

2. USB serial never worked — no way to print debug values

USB CDC didn't enumerate on ATSAMD11 with NO_BOOTLOADER. Serial Monitor stayed blank the entire project. This would have been the expected calibration path, print counts, observe, set threshold. Fix: the OLED became the debugging surface. Ended up being faster: real hardware, real time, no driver indirection.

3. factCount = 8 with 9 facts, last fact unreachable

The array has 9 strings but factCount = 8, so "She/ Her/ A11y" never displays, the cycle wraps to 0 before reaching index 8. This is a known bug in the current code, documented here rather than silently fixed. The board still demonstrates the full concept correctly across the 8 cycling facts.

Group Assignment: Analog Levels vs Digital Signals

The group brief was to probe an input device's analog levels and digital signals , to actually look at the difference between the continuous value a sensor produces and the on/off decision a microcontroller makes from it. The group's captures and notes are on the group work page, linked in the sidebar.

My own board turned out to be a small, living example of exactly that distinction, which is what I keep coming back to. The touch pad doesn't hand the chip a clean 0 or 1 — it hands it a raw charge-time count that drifts with humidity, grounding, and even the laptop charger humming nearby. That's the analog level: a smear of numbers, not a switch. The digital signal — touched — only exists because I drew a threshold line through that smear and said "above this, it's a yes." Probing the count live on the OLED was me watching the analog reality before it got flattened into a bit.

That reframed what a "sensor reading" even is. The digital signal feels like the truth, but it's the interpretation; the analog level is the truth, messy and continuous. Every reliable input device is really a good decision about where to put the threshold — and you can only place it well once you've looked at the analog levels underneath, which is the whole point of the group probing exercise.

Design Files & Source Code

Board designed in Week 6: Electronics Design and produced in Week 8: Electronics Production. OLED output device documentation — setup, framebuffer crop, rendering — in Week 10: Output Devices →

Arduino settings:

  • Board: Generic D11C14A (Fab SAM core)
  • Programmer: CMSIS-DAP via Quentorres
  • Bootloader Size: NO_BOOTLOADER
  • Clock: INTERNAL_USB_CALIBRATED_OSCILLATOR
  • Serial Config: NO_UART_ONE_WIRE_ONE_SPI
  • Build Options: config.h enabled (code size reductions)