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

Stage 4 of 6 · Theory then bench ladder · about 45 minutes

Analog out

The ADC let the board listen in amounts; this page lets it answer in amounts. A GPIO pin can only output 0 or 3.3 V, and yet by the end of the hour your LED will glow at any brightness you name, your light reading will drive it directly, and the week's parts will have assembled into a class.

The problem: half a volt from a whole pin

Suppose the design wants the LED at one-third brightness. A digital output has no one-third: the pin is a switch, fully high or fully low, and nothing in machine.Pin changes that. The escape is to stop thinking in voltage and start thinking in time: switch the pin on and off faster than the load (or the eye) can follow, and what the world experiences is the average, which you control precisely by choosing what fraction of each cycle is spent on.

PWM: the average is in the timing

Pulse-width modulation repeats a fixed-length cycle (the period, set by the frequency f = 1/T) and holds the pin high for a chosen fraction of it, the duty cycle D. The average output is then:

V_avg = D × V_cc        # D = t_on / T, from 0 to 1

Two independent knobs, often confused: duty sets the level, frequency sets how fast the switching repeats. Frequency must only be fast enough that the load smooths the pulses: an eye watching an LED stops seeing flicker above roughly 100 Hz, so the course default of 1 kHz gives a comfortable margin; a motor's inertia or a heater's thermal mass smooth far slower switching. The receiving system is the low-pass filter, which is why PWM, one humble trick, drives LEDs, motor speed, heater power and servo positions across all of industry. Reference: pulse-width modulation.

Three PWM waveforms at the same frequency with 25, 50 and 75 percent duty cycles. Each is a square wave high for that fraction of every period; a dashed line on each shows the average level rising with duty. 25% 50% 75% same frequency throughout · one period = 120 px here dashed: the average the load experiences, V_avg = D × 3.3 V
Three duties, one frequency. The pin only ever visits 0 and 3.3 V; the dashed average climbs with the on-fraction. Everything a dimmer, a fan controller or A2's actuator does is choose where that dashed line sits.

The API, and a dimmed LED

Wire an external LED exactly as in Week 4, new address: GPIO25 → 330 Ω → LED → GND, long leg toward the resistor, wired with the USB unplugged. The rules and the mathematics travel unchanged (3.3 V supply, same ≈3.9 mA). Then:

>>> from machine import Pin, PWM
>>> pwm = PWM(Pin(25), freq=1000)
>>> pwm.duty_u16(32768)       # 50 %
>>> pwm.duty_u16(6554)        # 10 %
>>> pwm.duty_u16(0)           # off
>>> pwm.deinit()              # release the pin when done

duty_u16 takes the on-fraction on a 16-bit scale: 0 is off, 65535 is fully on, 32768 the midpoint, so duty in percent is value / 65535 × 100. Note the deliberate symmetry with the ADC: 16 bits in, 16 bits out, a design courtesy you cash in two sections from now. API details: machine.PWM.

Sweeps and perceived brightness

from machine import Pin, PWM
from time import sleep

pwm = PWM(Pin(25), freq=1000)
while True:
    for d in range(0, 65536, 1024):      # up...
        pwm.duty_u16(d)
        sleep(0.02)
    for d in range(65535, -1, -1024):    # ...and down
        pwm.duty_u16(d)
        sleep(0.02)

A breathing LED, and a small perceptual lesson while it breathes: the climb looks fast at the bottom and sluggish near the top, because the eye's response to light is roughly logarithmic, so equal duty steps are not equal brightness steps. Nothing in this course needs the correction (display engineers call it gamma), but noticing it is good instrumentation hygiene: the sensor at your bench this hour, your own eye included, is never perfectly linear.

The light follower: analog in meets analog out

The 16-bit symmetry makes the week's two halves click together in one line:

from machine import ADC, Pin, PWM
from time import sleep

adc = ADC(Pin(34))
adc.atten(ADC.ATTN_11DB)
pwm = PWM(Pin(25), freq=1000)

while True:
    raw = adc.read_u16()
    pwm.duty_u16(raw)            # bright room, bright LED
    sleep(0.05)

Shade the LDR and the LED dims with the room; one swapped line, pwm.duty_u16(65535 - raw), inverts it into a night light that brightens as the room darkens. Twenty lines of Python and the bench is already behaving like an instrument. Savour it for a minute, then meet the limitation that funds the next three weeks.

Open loop, closed loop

The follower is an open-loop system: the output is computed from the input and sent, and nothing ever checks whether the result achieved anything. Ask the night light to hold the room at a steady light level and open loop fails on contact: it cannot know that a cloud passed, that the LED aged, that someone opened a blind, because it never measures the consequence of its own action. A closed-loop system does: it measures the actual result, compares it to the setpoint (the target), and corrects in proportion to the error. That correction law is the controller, PID is its classic form, and designing one is precisely Assignment 2 and the business of Week 10. Today's job was to build the loop's two ends, sense and act; the brain between them is bought and paid for by how well those ends work.

Hysteresis: two thresholds beat one

Before the ladder, one control idea cheap enough to use today. An automatic night light with a single rule, "LED on below 30% light", misbehaves at exactly 30%: noise jitters the reading across the line and the LED chatters on and off at dusk, the most annoying failure in home automation. The fix is hysteresis: two thresholds with a deadband between, on below 30%, off above 40%, so that once a decision is made, the reading must travel meaningfully before it reverses. Your home thermostat works this way (heat on at 19.5°, off at 20.5°), every comparator chip offers it (the Schmitt trigger), and GRAFCET designers will recognize the shape: state plus guarded transitions, which means Week 6 told you where it lives in code, a mode attribute and thresholds in the transition methods. Reference: hysteresis.

The build ladder

Spec first, Week 5 protocol, navigator and driver swapping each rung: R-lines with acceptance criteria, a sketch (state diagram where modes exist, class box where a class is asked), one trace, then code and the verification table. Scripts' canonical copies live in the repo; working versions get uploaded to the board.

  1. Auto night light, with hysteresis

    LED fully on below a low threshold, off above a high one, nothing in between changes it. Spec the two thresholds in calibrated percent and the acceptance check ("no chatter when held at the band for 30 s"). A mode variable appears; let it.

  2. Smooth follower

    The light follower rebuilt on read_avg() from the analog page. Verify the claim: same scene, n = 1 versus n = 16, describe the LED's steadiness in the table. You are measuring your own filter.

  3. The LightNode class

    The week's parts become a capsule: constructor takes the two pins plus the calibration anchors; attributes adc, pwm (composition, Week 6 verbatim); methods read_pct() (averaged, calibrated, clamped), set_brightness(pct), and update() for whichever behaviour rung 1 or 2 chose. MicroPython runs your classes unchanged; prove it by driving the instance from the REPL.

  4. The telemetry line

    Make the node report: once per second, print one line in a format you design and write down, e.g. light=42.3. Choose deliberately, keys, units, decimals, because Week 9 ships exactly these lines over the network, and a message format designed on purpose is a protocol, while one that just happened is a bug factory.

  5. Stretch: LightLog

    A hardware-free class tracking min, max and average since boot, composed into the node and reported on demand, the ReactionLog pattern earning rent on new hardware. Or: auto-calibration, the node tracking its own running extremes as provisional anchors, with a one-paragraph note on when that is a good idea and when it is dangerous.

Checklist for this stage

Check yourself

The pin only outputs 0 or 3.3 V. In what sense does PWM produce 1.1 V?
As a time average: at D = 1/3, the pin is high a third of each cycle, and any load slower than the switching, an eye, a motor, a heater, experiences D × 3.3 = 1.1 V. The level lives in the timing, not the voltage.
A teammate "dims" with freq=100000 at full duty. What did they change, and what happens to the LED?
Nothing visible: frequency sets how often the cycle repeats, not the on-fraction, and at 100% duty the pin is simply high. Brightness is the duty knob; frequency only needs to exceed what the load can follow.
pwm.duty_u16(512) looks almost off. Convert to percent and diagnose the likely confusion.
512 / 65535 ≈ 0.8%. The number came from the older 10-bit duty() scale, where 512 meant half. Scale check before blaming the LED: on the 16-bit API, half is 32768.
Why does pwm.duty_u16(adc.read_u16()) work with no scaling arithmetic at all?
Both ends speak the same 16-bit scale, 0 to 65535, by API design: the reading's range is the duty's range. The deliberate symmetry turns "sensor drives actuator" into one line.
Explain, with the dusk scenario, why one threshold chatters and two do not.
At dusk the reading sits near the single threshold, and noise crosses it repeatedly, toggling the LED. With on-below-30 / off-above-40, the first crossing decides, and reversing requires a real 10-point journey through the deadband; noise-sized wiggles change nothing. Hysteresis trades a little precision at the boundary for stability.
Why is the follower called open loop even though it clearly reacts to light?
It reacts to its input, never to the result of its own output: nothing measures whether the produced brightness achieved any goal. Closed loop means the output's effect is measured and compared to a setpoint, with the error driving the correction, which is exactly what A2 adds.