PROJECT 1 · DIGITAL I/O

Inputs & Outputs: Button → LED

Press a pushbutton → an LED turns on; release it → the LED turns off. The "hello world" of physical computing.

New here? Start with Foundation 2: Introduction & Setup.

Builds on: Foundation 1's current-limiting math and Foundation 2's GPIO safety list, put to work for the first time.

⏱ ~20 min 📊 Beginner 🔖 Prerequisite: Foundation 1 & 2
⬇ Download the sketch Save Project_1_ESP32_Inputs_Outputs.ino, then open it in the Arduino IDE (setup: Foundation 3).
Pushbutton pressed turns LED on; not pressed turns LED off
The whole idea: button pressed → LED on · button released → LED off.
🎲 Try it without hardware 📄 diagram.json Open the Wokwi simulator, then open or download diagram.json above and paste its wiring in to load the circuit.

🎯 Objective

By the end of this project, you will:

  • Understand what a GPIO pin is and how the same pin can be an input or an output.
  • Configure pins with pinMode() and know why that belongs in setup().
  • Read a pushbutton with digitalRead() and drive an LED with digitalWrite().
  • Explain why a floating input is a problem, and fix it with a pull-down resistor.
  • Recognise contact bounce and handle it with a short debounce delay.
  • Wire an LED safely, respecting polarity and using a current-limiting resistor.

1What the sketch does

pinMode(buttonPin, INPUT);   // GPIO 4: read the button
pinMode(ledPin,   OUTPUT);  // GPIO 5: drive the LED

buttonState = digitalRead(buttonPin);
if (buttonState == HIGH) digitalWrite(ledPin, HIGH); // pressed  -> LED on
else                     digitalWrite(ledPin, LOW);  // released -> LED off

The LED lights when the pin reads HIGH, so the button sends 3.3 V to GPIO 4 when pressed, and a 10 kΩ pull‑down holds the pin LOW when released.

Plain INPUT has no internal pull resistor, so the external 10 kΩ pull‑down is required: otherwise the pin "floats" and reads random noise. (§7 shows a software‑only alternative.)
⚠️ Common misconception: an unconnected digital input does not read LOW by default. It floats, picking up whatever electrical noise is nearby, and can flicker between HIGH and LOW with nothing touching it. A pull resistor is what makes the LOW state real, not the absence of a signal.

2The pins: analog or digital?

SignalGPIOUsed asHere it isAnalog capable?
ButtonGPIO 4digitalRead()DIGITAL inputYes: ADC2_CH0 + touch T0 (unused)
LEDGPIO 5digitalWrite()DIGITAL outputNo: no ADC (PWM only)

Both pins are used digitally (HIGH/LOW). Use 3V3 (never 5V) for the button's HIGH side.

3Parts Required

QtyPartNotes
1ESP32 DEVKIT V1 · breadboard · jumper wires-
1LEDAny color: has polarity (§5)
1220 Ω resistorLED limit · Red‑Red‑Black‑Black‑Brown
110 kΩ resistorPull‑down · Brown‑Black‑Black‑Red‑Brown
1Pushbutton4‑leg tactile

4Wiring the Circuit

Breadboard schematic: LED on GPIO 5, button on GPIO 4 with 10 kilo-ohm pull-down
Official kit schematic: LED on GPIO 5 (220 Ω), button on GPIO 4 (10 kΩ pull‑down to GND, 3.3 V on the other side).

LED circuit (output, GPIO 5)

  1. GPIO 5 → 220 Ω → LED anode (long leg, +)
  2. LED cathode (short leg, −)GND rail

Button circuit (input, GPIO 4)

  1. One side of the button → 3.3 V rail
  2. Other side → GPIO 4
  3. From that same GPIO 4 node → 10 kΩGND rail
Behavior: released → pulled to GND through 10 kΩ → LOW → LED off. Pressed → connected to 3.3 V → HIGH → LED on.

5LED polarity (don't skip)

LONG leg  = Anode  (+)  -> toward the 220 Ω / GPIO 5 side
SHORT leg = Cathode (-)  -> toward GND   (flat notch on the rim = - side)

If the LED never lights, check first that it isn't reversed.

6Code Walkthrough: Understanding the Sketch

Let's walk through the sketch so you understand what each line does and why.

Step 1: Pin definitions & state variables

const int buttonPin = 4;  // the pushbutton pin (digital input)
const int ledPin    = 5;  // the LED pin        (digital output)

int buttonState = 0;       // this round's reading: HIGH or LOW
int lastButtonState = -1;  // the previous reading, so we can spot changes

Naming the pins with const int means the number appears once: rewire the LED to another GPIO and you change one line. lastButtonState starts at -1, a value digitalRead() can never return, so the first real reading always counts as a change.

Step 2: one-time preparation in setup()

void setup() {
  Serial.begin(115200);        // open the USB link to the computer
  delay(500);                  // let the Serial Monitor connect

  pinMode(buttonPin, INPUT);   // GPIO 4 becomes an input
  pinMode(ledPin, OUTPUT);     // GPIO 5 becomes an output
}

setup() runs once at power-up or reset. pinMode() decides the direction of a pin: an input listens for a voltage someone else supplies, an output drives the pin to 3.3 V or 0 V itself. Every GPIO can do either, so you must say which you want.

Step 3: loop() reads → compares → acts

void loop() {
  buttonState = digitalRead(buttonPin);      // 1. READ the button

  if (buttonState != lastButtonState) {      // 2. did it CHANGE?
    if (buttonState == HIGH) {
      digitalWrite(ledPin, HIGH);            // 3. pressed  -> LED on
      Serial.println("[EVENT] Button PRESSED  -> LED is ON");
    } else {
      digitalWrite(ledPin, LOW);             //    released -> LED off
      Serial.println("[EVENT] Button RELEASED -> LED is OFF");
    }
    lastButtonState = buttonState;           // 4. remember for next time
  }

  delay(50);                                 // 5. debounce
}

loop() runs again and again forever, thousands of times per second. The if comparing this reading with the last one is what keeps the Serial Monitor usable: without it, holding the button down would print the same line endlessly.

Key concepts: what each line really does

CodeWhat it does
pinMode(pin, INPUT)Sets the pin to listen. Plain INPUT connects no internal resistor, so an unconnected pin floats and reads random noise. That is why the 10 kΩ pull-down is required here.
pinMode(pin, OUTPUT)Lets the pin drive itself to 3.3 V (HIGH) or 0 V (LOW), supplying current to whatever is attached.
digitalRead(pin)Samples the pin right now and returns HIGH or LOW. Above roughly 2.0 V reads HIGH, below about 0.8 V reads LOW.
digitalWrite(pin, HIGH)Connects the pin to 3.3 V, so current flows through the 220 Ω resistor and the LED lights. LOW connects it to 0 V and the LED goes dark.
buttonState != lastButtonStateEdge detection: acts only when the state differs from last time, turning a continuous stream of readings into discrete press and release events.
delay(50)Waits 50 ms. A mechanical button does not switch cleanly: its contacts bounce for a few milliseconds, which one press can register as several. Waiting past the bounce fixes it.

Expected output on the Serial Monitor

========================================
 Project 1: ESP32 Inputs & Outputs
========================================
Button (input)  -> GPIO 4
LED    (output) -> GPIO 5
Press the button to turn the LED ON.
Waiting for button presses...
----------------------------------------
[EVENT] Button PRESSED  -> LED is ON
[EVENT] Button RELEASED -> LED is OFF

Each press and release prints exactly one line. If a single press prints several, the debounce delay is too short for your button.

7Optional: no external resistor (software pull‑up)

Wire the button between GPIO 4 and GND and enable the internal pull‑up. This inverts the logic:

pinMode(buttonPin, INPUT_PULLUP);   // reads HIGH when NOT pressed
if (buttonState == LOW) digitalWrite(ledPin, HIGH); // pressed  -> LED on
else                    digitalWrite(ledPin, LOW);  // released -> LED off
Pick one approach: 10 kΩ pull‑down + original code, or INPUT_PULLUP + flipped code. Don't mix them.
Active-high vs. active-low. The original wiring is active-high: a HIGH signal means "pressed". Switching to INPUT_PULLUP makes the button active-low: a LOW signal means "pressed", because the resting state flips from LOW to HIGH. You'll meet both conventions again: the PIR sensor in Project 4 is active-high, while this kit's relay module defaults to an active-low trigger.

Going further: debounce without blocking (revisit after Project 4)

delay(50) works here because there's nothing else for the sketch to do while it waits. Once a project also needs to serve web pages or watch other sensors, a delay() freezes everything, not just the button. Project 4 introduces millis() as the fix: record when the button last changed and compare against the clock instead of pausing:

unsigned long lastChangeTime = 0;
const unsigned long debounceDelay = 50;

void loop() {
  int reading = digitalRead(buttonPin);
  if (reading != lastButtonState && millis() - lastChangeTime > debounceDelay) {
    lastChangeTime = millis();
    lastButtonState = reading;
    digitalWrite(ledPin, reading);
  }
}

Come back and try this once you've read Project 4's millis() walkthrough: the button keeps working exactly the same from the outside, but the loop is never blocked.

Predict, then check. Before you press the button: what will the Serial Monitor print the instant you press it, and again the instant you release it? Write it down, then press the button and compare.

8Demonstration

Animated demo: button press turns LED on, release turns it off
Press → LED lights and the monitor prints a PRESSED event. Release → off.

🎯 Knowledge Check

1. What happens when you call digitalRead() on a digital input pin that is connected to nothing?

2. What is the purpose of the 10 kO pull-down resistor in this circuit?

3. What does the delay(50) at the end of loop() accomplish?

4. Which leg of a standard LED is the anode (+)?

5. What does pinMode(pin, INPUT) do with the pin's internal pull resistor?

10Wrapping Up: What You've Learned

This is the smallest complete physical-computing program: read the world, decide, act on the world. Everything later in the kit is a variation on it. The key takeaways:

  • Every GPIO can be an input or an output. pinMode() in setup() decides which.
  • digitalRead() returns one of two values, HIGH or LOW, and digitalWrite() drives a pin to 3.3 V or 0 V. This is digital I/O, the opposite of the analog reading you meet in Project 2.
  • An input pin connected to nothing floats and reads noise. A pull-down resistor gives it a definite LOW when the button is open; INPUT_PULLUP (§7) does the same job in software, with the logic inverted.
  • Mechanical buttons bounce. A short delay() after a change is the simplest cure.
  • Comparing each reading against the previous one turns a fast polling loop into clean events, which is what makes the serial log readable.
  • LEDs are polarized and always need a series resistor. 220 Ω keeps the current near 6 mA, well inside the ESP32's safe range.

Now try

  • Debounce with millis(): Replace delay(50) with a millis()-based debounce so the loop never blocks. Project 4 walks through the technique in detail.
  • Use INPUT_PULLUP: Rewire the button between GPIO 4 and GND, enable INPUT_PULLUP, and flip the logic in the code. You can then remove the 10 kO pull-down resistor entirely.

11Troubleshooting

SymptomLikely causeFix
LED never lightsLED reversedFlip it: long leg toward the 220 Ω side
LED always on / flickersFloating input (missing pull‑down)Add the 10 kΩ from GPIO 4 to GND
Monitor blank / garbageWrong baudSet it to 115200
Button seems invertedINPUT_PULLUP with original codeMatch code and wiring (§7)
Nothing uploadsCharge‑only cable / wrong portData cable; correct port; hold BOOT
LED very dim10 kΩ in the LED pathUse 220 Ω for the LED