ESP32 & analog: the world is more than on and off
420-302-VA · WEEK 8 · FALL 2026

Stage 3 of 6 · Theory then bench · about 40 minutes

Analog in

Light, temperature, pressure, position: the physical world does not come in highs and lows, it comes in amounts. This page is the theory and practice of turning an amount into a number, the single most consequential operation in instrumentation, and the one your Pi could not perform.

The world is continuous

A digital input, Week 4's Button, answers one question: is the voltage above or below a threshold? That is the right question for a pressed switch and the wrong one for almost every sensor: "is it bright?" throws away everything a greenhouse controller needs to know. An analog-to-digital converter (ADC) answers the real question, "how much?", by measuring the voltage at a pin and reporting it as a number. Everything else this week, dividers, calibration, averaging, exists to put a meaningful voltage on that pin and to interpret the number honestly. Reference article: analog-to-digital converter.

Two discretizations: sampling and quantization

A continuous signal has infinitely fine detail in two directions, time and amplitude, and a digital system can store neither. An ADC therefore discretizes twice:

  • Sampling chops time: the signal is measured at discrete instants, and anything that happens between samples is invisible. The classical result (the Nyquist–Shannon theorem) says the sampling rate must exceed twice the signal's highest frequency to capture it faithfully; room light changes over seconds, so our few samples per second are luxurious, but the idea governs every data-acquisition system you will ever meet.
  • Quantization chops amplitude: each measurement is rounded to the nearest of 2N levels, where N is the converter's resolution in bits. The rounding error, at most half a step, is the quantization error: not a malfunction but the price of finiteness.
A smooth rising and falling curve, sampled at regular instants shown as dots, each rounded to the nearest of several horizontal quantization levels, producing a staircase that approximates the curve. 0 5 levels (counts) time → (dots: sampling instants) the signal what the ADC stores
Continuous in, staircase out. Dots are sampling (time chopped); the step heights are quantization (amplitude chopped). Finer steps and faster dots make the staircase hug the curve, at the cost of bits and samples, which is the eternal instrumentation trade.

The number that sizes the staircase is the step size:

step = V_ref / 2^N = 3.3 V / 4096 ≈ 0.8 mV per count     (12-bit converter)

The ESP32's converter is 12-bit: 4096 distinguishable levels across its range. MicroPython's read_u16() reports on a 16-bit scale, 0 to 65535, for portability across chips; the hardware still resolves 4096 steps, rescaled, so neighbouring raw values move in jumps of about 16. More counts is finer, not automatically more accurate: noise, the reference and the circuit decide accuracy, which is why calibration closes this page.

The ESP32 ADC, in practice

>>> from machine import ADC, Pin
>>> adc = ADC(Pin(34))
>>> adc.atten(ADC.ATTN_11DB)      # stretch the range to the full 0..3.3 V
>>> adc.read_u16()
31248

Three facts carry the practice:

  • Use ADC1, pins 32 to 39. The chip has two converter blocks, and the second one is commandeered by the radio whenever Wi-Fi runs; a circuit on an ADC2 pin works today and dies mysteriously next week. GPIO34 is this course's light pin: ADC1, input-only, perfect for a sensor. Plan for Week 9 now.
  • Always set the attenuation. The bare converter saturates around 1 V; ATTN_11DB inserts a known attenuator so the useful range spans roughly the full 0 to 3.3 V. Forgotten attenuation is the classic "my readings max out early" bug.
  • Convert counts to volts for intuition, not for truth. v ≈ read_u16() / 65535 × 3.3 is the honest estimate, and the ESP32's converter is known to be nonlinear near the extremes of its range. For lab-grade volts there is read_uv(), which applies the chip's factory calibration; for this course and A2, relative, bench-calibrated readings are the right tool, and the conversion formula is for sanity checks. API details: machine.ADC.

The voltage divider, derived

The ADC measures voltage; an LDR changes resistance. The bridge between them is the oldest circuit in instrumentation, and it falls out of Week 4's series-loop reasoning in three lines. Put two resistances in series across the supply; the same current flows through both (one loop), so:

I = V_cc / (R1 + R2)                      # Ohm's law on the whole loop
V_out = I × R2                            # Ohm's law on the bottom resistor
V_out = V_cc × R2 / (R1 + R2)             # the voltage-divider equation

The midpoint voltage is the supply, split in proportion to the resistances. Make one of the two a sensor and the midpoint becomes a voltage that tracks the physical world, which is precisely what an ADC pin wants to see. The general form: voltage divider.

The light circuit, with numbers

The light-sensing circuit: 3.3 volts at the top, through the LDR, to a midpoint node wired to GPIO34, then through a 10 kilo-ohm resistor to ground. The equation V node equals 3.3 times 10k over R LDR plus 10k, with a table of worked values beside it. 3V3 LDR (light-variable) 10 kΩ fixed GND → GPIO34 (ADC1) V_node = 3.3 × 10k / (R_LDR + 10k) bright: R_LDR ≈ 2 kΩ → 2.75 V (high counts) dim: R_LDR ≈ 20 kΩ → 1.10 V dark: R_LDR ≈ 200 kΩ → 0.16 V (low counts) LDR on top → more light, less resistance, node pulled toward 3.3 V: bright reads high
Light to resistance to voltage to counts. The LDR up top and the 10 kΩ below make a divider whose midpoint rises with light. Swap the two and the sense inverts, equally valid; the course standard is LDR on top so that bright means big numbers.

The sensor itself: a light-dependent resistor (photoresistor), a photoconductive cell whose resistance falls as illumination rises, from hundreds of kilohms in the dark to a few kilohms or less in bright light. Its response is large, cheap and robust, and also nonlinear and part-to-part variable, which is the engineering reason the next section calibrates instead of computing lux from a datasheet. The 10 kΩ fixed resistor is chosen to sit mid-range of the LDR's swing, spreading the interesting light levels across the ADC's span; the worked numbers above show the spread.

Bench: live readings

Wire the divider with everything unpowered (rule 5 travels too: unplug the USB while wiring). LDR from 3V3 to a breadboard node, 10 kΩ from that node to GND, jumper from the node to GPIO34. Then the oldest experiment in sensing:

from machine import ADC, Pin
from time import sleep

adc = ADC(Pin(34))
adc.atten(ADC.ATTN_11DB)

while True:
    raw = adc.read_u16()
    volts = raw / 65535 * 3.3
    print(f"raw={raw:5d}   v={volts:.2f}")
    sleep(0.2)

Cover the LDR with a finger, aim a phone flashlight at it, and watch the numbers chase your hand. Note your bench's extremes while you are there, finger-dark and flashlight-bright raw values; the next section needs them, and so does your lab evidence.

Calibration: counts to meaning

Raw counts are honest but parochial: your bench's "dark" and your partner's differ with the room, the part and the resistor. Calibration maps counts to meaning using measurements you took, the two-point version being all this course needs:

DARK = 1800        # your bench's covered-LDR reading
BRIGHT = 52000     # your bench's flashlight reading

def light_pct(raw):
    pct = (raw - DARK) / (BRIGHT - DARK) * 100
    return max(0, min(100, pct))          # clamp: readings can stray past the anchors

Now the node speaks a portable language, 0 to 100 percent of your bench's range, which is exactly the quantity A2's setpoint will be written in. The clamp matters: sunlight brighter than your flashlight, or a darker finger, must saturate gracefully rather than report 112% or −3%. Record the two anchors in your repo; calibration constants are configuration, and configuration is documented, not remembered.

Noise and averaging

Hold the light steady and the raw value still flickers by tens of counts: supply ripple, converter noise, the radio next week. The simplest cure is the oldest filter, the mean of several samples:

def read_avg(adc, n=16):
    total = 0
    for _ in range(n):
        total += adc.read_u16()
    return total // n

Averaging n samples shrinks uncorrelated noise by roughly √n (sixteen samples, a quarter of the jitter) at the price of time: sixteen reads take longer than one, so a heavily averaged sensor responds more smoothly and more slowly. That trade, smoothness against latency, is your first filter-design decision, and it returns with force in Week 10, where a control loop fed by jittery readings twitches and one fed by over-filtered readings lags. For now: average, note the n you chose, and know why it is a choice.

Checklist for this stage

Check yourself

A 12-bit ADC referenced to 3.3 V: how many levels, and what step size? One more bit would do what?
212 = 4096 levels; step = 3.3 / 4096 ≈ 0.8 mV. A 13th bit doubles the levels to 8192 and halves the step: each bit of resolution is a factor of two in fineness.
Why does the course insist on ADC1 pins (32 to 39) when ADC2 pins read perfectly well today?
ADC2 shares hardware with the Wi-Fi radio and becomes unusable while Wi-Fi is active. The circuit goes on ADC1 now so that Week 9's networking does not mysteriously kill a working sensor.
Derive V_out for R1 = 2 kΩ (top) and R2 = 10 kΩ (bottom) on 3.3 V, stating the law used at each step.
One loop, so Ohm's law on the whole: I = 3.3 / 12k = 0.275 mA. Ohm's law on R2: V_out = 0.275 mA × 10 kΩ = 2.75 V, i.e. 3.3 × 10/12. The divider equation is just Ohm's law applied twice.
With the LDR on top, which way does the node voltage move at dusk, and what would swapping the two components change?
Dusk raises the LDR's resistance, so the fixed resistor claims a smaller share: the node voltage falls. Swapping puts the LDR on the bottom and inverts the sense, dark reads high; both work, the code's interpretation must match the wiring.
Why calibrate with your own dark/bright anchors instead of converting counts to lux with a formula?
The LDR's response is nonlinear and varies part to part, and the divider's mapping depends on the fixed resistor and the scene. Two measured anchors give a meaningful, reproducible 0 to 100% for this bench, which is the quantity the controller will target; a datasheet lux formula would be precise-looking and wrong.
Averaging 16 samples: what improves, by roughly how much, and what is the cost?
Uncorrelated noise shrinks by about √16 = 4×, so the reading steadies. The cost is latency: the value now reflects the recent past rather than this instant, a smoothness-versus-responsiveness trade that becomes a genuine design decision once a control loop consumes the readings.