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.
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.
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.
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.The
LightNodeclassThe week's parts become a capsule: constructor takes the two pins plus the calibration anchors; attributes
adc,pwm(composition, Week 6 verbatim); methodsread_pct()(averaged, calibrated, clamped),set_brightness(pct), andupdate()for whichever behaviour rung 1 or 2 chose. MicroPython runs your classes unchanged; prove it by driving the instance from the REPL.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.Stretch:
LightLogA hardware-free class tracking min, max and average since boot, composed into the node and reported on demand, the
ReactionLogpattern 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?
A teammate "dims" with freq=100000 at full duty. What did they change, and what happens to the LED?
pwm.duty_u16(512) looks almost off. Convert to percent and diagnose the likely confusion.
duty() scale, where 512 meant half. Scale check before blaming the LED: on the 16-bit API, half is 32768.