Learning Goals 5 min
- Replace yesterday's buzzer with a tri-colour LED display — green = safe, amber = caution, red = danger — and explain why visual signals beat audio in many real settings.
- Re-use the
readDistanceCm()helper from L02-23 without changing a line of it. Today is the proof that good helpers travel between projects unmodified. - Add a fourth state — "no echo" — and decide deliberately what visual feedback it should produce (we'll go with a slow heartbeat blink on the blue LED).
Warm-Up 10 min
Yesterday's alarm beeped. Useful in a car (the driver's looking forward, ears available); annoying in a library; useless if the user is deaf. Today we change one thing — the output — and end up with a completely different feeling product. Same sensor pipeline, same code shape, different effect on the human.
The four-state design
Take a minute and sketch your own design before reading on. What LED colours would you light at what distances? What about when nothing is in front of the sensor at all? Defending a design before reading our suggestion is the best way to make yours sharper.
One reasonable design
- No echo (d < 0) → blue LED "heartbeat" (200 ms on every 1500 ms) — "system alive, just no one around".
- Safe (> 100 cm) → green steady — "all clear".
- Caution (30–100 cm) → amber steady — "getting close".
- Danger (< 30 cm) → red flashing (250 ms on, 250 ms off) — "stop NOW".
The flashing red is your highest-attention signal — humans notice change much faster than steady state, so reserve flashing for the most urgent zone.
New Concept · Visual states + zone transitions 20 min
Wiring four LEDs
| LED | Pin | Resistor |
|---|---|---|
| Blue | D3 | 220 Ω |
| Green | D5 | 220 Ω |
| Amber / Yellow | D6 | 220 Ω |
| Red | D11 | 220 Ω |
Plus the HC-SR04 on D9 (TRIG) and D10 (ECHO) as before. Four LEDs eat four pins, but it's the simplest wiring to debug — each LED is one wire away from its colour. Tomorrow's smart-bin uses a single RGB LED to consolidate, but for learning four discrete LEDs is clearer.
The pattern: name your states with an enum
We met enum in passing in yesterday's challenge. Today let's make it the centrepiece. An enum lets us give names to a small fixed set of states:
enum Zone { NO_ECHO, SAFE, CAUTION, DANGER };
Zone current = NO_ECHO;
Zone previous = NO_ECHO;Now current == SAFE reads exactly like English. The compiler also catches typos — write SAFFE and it fails at compile time, not silently at runtime.
One function per zone's visual behaviour
void showNoEcho() {
bool on = ((millis() / 200) % 8 == 0); // 200 ms on every 1600 ms
setColour(on ? HIGH : LOW, LOW, LOW, LOW);
}
void showSafe() { setColour(LOW, HIGH, LOW, LOW); }
void showCaution() { setColour(LOW, LOW, HIGH, LOW); }
void showDanger() {
bool on = ((millis() / 250) % 2 == 0); // flash 4 Hz
setColour(LOW, LOW, LOW, on ? HIGH : LOW);
}The "blink without delay" trick — (millis() / period) % count == something — generates a periodic on/off pattern with no delay() calls. We'll formalise this fully in L02-35; for now treat it as a one-line incantation.
Detect zone transitions for clean logging
Same prev/now pattern as the rain sensor (L02-17) and yesterday's challenge. We only print when the zone actually changes — keeps the Serial Monitor calm:
if (current != previous) {
Serial.print(">> entering ");
Serial.println(zoneName(current));
previous = current;
}Worked Example · Four-state parking display 20 min
Step 1 — wiring
HC-SR04 on D9/D10 (TRIG/ECHO) plus +5 V / GND. Four LEDs as in the table above, each with its own 220 Ω resistor. Tape the sensor pointing along a clear runway and stand the four LEDs in a row.
Step 2 — the sketch
Save as parking-display.ino:
// L02-25: Four-state parking display
const int TRIG = 9;
const int ECHO = 10;
const int BLUE = 3;
const int GREEN = 5;
const int AMBER = 6;
const int RED = 11;
const int SAFE_CM = 100;
const int CAUTION_CM = 30;
enum Zone { NO_ECHO, SAFE, CAUTION, DANGER };
Zone current = NO_ECHO, previous = NO_ECHO;
unsigned long lastRead = 0;
float readDistanceCm() {
digitalWrite(TRIG, LOW); delayMicroseconds(2);
digitalWrite(TRIG, HIGH); delayMicroseconds(10);
digitalWrite(TRIG, LOW);
unsigned long w = pulseIn(ECHO, HIGH, 25000);
if (w == 0) return -1;
float cm = w / 58.0;
if (cm < 2 || cm > 400) return -1;
return cm;
}
Zone zoneFor(float d) {
if (d < 0) return NO_ECHO;
if (d < CAUTION_CM) return DANGER;
if (d < SAFE_CM) return CAUTION;
return SAFE;
}
const char* zoneName(Zone z) {
switch (z) {
case NO_ECHO: return "no echo";
case SAFE: return "SAFE";
case CAUTION: return "caution";
case DANGER: return "DANGER";
}
return "?";
}
void setColour(int b, int g, int a, int r) {
digitalWrite(BLUE, b);
digitalWrite(GREEN, g);
digitalWrite(AMBER, a);
digitalWrite(RED, r);
}
void render(Zone z) {
switch (z) {
case NO_ECHO: {
bool on = ((millis() / 200) % 8 == 0);
setColour(on ? HIGH : LOW, LOW, LOW, LOW);
break;
}
case SAFE: setColour(LOW, HIGH, LOW, LOW); break;
case CAUTION: setColour(LOW, LOW, HIGH, LOW); break;
case DANGER: {
bool on = ((millis() / 250) % 2 == 0);
setColour(LOW, LOW, LOW, on ? HIGH : LOW);
break;
}
}
}
void setup() {
pinMode(TRIG, OUTPUT);
pinMode(ECHO, INPUT);
pinMode(BLUE, OUTPUT);
pinMode(GREEN, OUTPUT);
pinMode(AMBER, OUTPUT);
pinMode(RED, OUTPUT);
Serial.begin(9600);
}
void loop() {
if (millis() - lastRead >= 100) {
lastRead = millis();
float d = readDistanceCm();
current = zoneFor(d);
if (current != previous) {
Serial.print(">> entering ");
Serial.println(zoneName(current));
previous = current;
}
}
render(current);
}Step 3 — upload and watch
Power up. With nothing in front of the sensor you should see the blue LED blink once every 1.6 seconds. Wave your hand at 1.5 m → green steady. Approach to 70 cm → amber. Approach to 20 cm → red flashing.
Step 4 — read the Serial output
Each zone change prints exactly one line:
>> entering no echo >> entering SAFE >> entering caution >> entering DANGER >> entering caution >> entering SAFE >> entering no echo
No spam, no per-loop printout. Quiet logging is the secret to a sketch you can actually read while debugging.
Step 5 — challenge yourself: hold the hand at exactly 30 cm
Slowly close in on the caution/danger boundary. You'll see the LEDs flip between amber and flashing red whenever the reading wobbles ±2 cm across 30. That's threshold chatter — fix it with hysteresis (try-it-yourself task below).
Try It Yourself 20 min
Goal: Add an "extreme close" fifth zone: distance < 10 cm should light all four LEDs at once, with the red one flashing faster (every 100 ms).
Hint
Add the new enum value: enum Zone { NO_ECHO, SAFE, CAUTION, DANGER, IMPACT };. Update zoneFor, zoneName, render. Five small parallel edits — that's the cost of adding a state. Worth it for clarity.
Goal: Add hysteresis between caution and danger. Enter DANGER below 30 cm; only return to CAUTION above 35 cm. Stops the boundary chatter when the target hovers around 30 cm.
Hint
The cleanest approach: don't put the hysteresis in zoneFor (which only sees one distance). Put it in loop() after computing the candidate:
Zone candidate = zoneFor(d);
if (current == DANGER && candidate == CAUTION && d < 35) {
candidate = DANGER; // hold off
}
current = candidate;The transition gets a 5 cm buffer. Same pattern from the rain sensor — the band kills the chatter.
Goal: Use PWM on the amber LED in the CAUTION zone to fade from dim-amber (at 100 cm) to bright-amber (at 30 cm). Same idea inside the zone — the LED's brightness tells you where you are within caution.
Hint
Replace digitalWrite(AMBER, HIGH) in the CAUTION render with an analogWrite driven by the current distance. You'll need access to d inside render() — pass it in as an argument:
void render(Zone z, float d) {
// ... NO_ECHO, SAFE, DANGER as before ...
if (z == CAUTION) {
int clipped = constrain((int)d, CAUTION_CM, SAFE_CM);
int bright = map(clipped, SAFE_CM, CAUTION_CM, 30, 255);
setColour(LOW, LOW, 0, LOW);
analogWrite(AMBER, bright);
}
}Note that setColour still uses digitalWrite internally — calling analogWrite on AMBER afterwards overrides it. Or refactor setColour to accept PWM values throughout.
Mini-Challenge · Build a real-world tabletop demo 15 min
Use the parking display to build a useful tabletop tool. Pick one of the following — or invent your own — and ship it as a polished demo. The criterion is "something a parent or teacher would think is genuinely cool, not just a school project".
- Cup-fill indicator: point the sensor down into a tall glass. Empty cup → red (lots of distance to bottom). Filling up → amber → green when full. Useful for sneakily watching how full your cup is from across the room.
- Garage-door height check: mount above a doorway. Walk under it — green means you fit, red means duck. (For taller siblings this is hilarious.)
- Tidy-desk monitor: sensor points down at your desk surface. As clutter rises, the colour shifts amber → red. Reminds you to put things away before the pile gets too tall.
It's a real demo when:
- Thresholds are tuned for your physical setup — not the demo values.
- The top-of-file comment explains in 3–4 lines what it does and what context it's for.
- You can hand the project to a sibling/parent and they can use it without an explanation.
- It runs for 10 minutes unattended.
Recap 5 min
Today was a swap-out lesson: same sensor, same helper, same enum-driven state machine — only the output changed, from buzzer to LEDs. That's the payoff for the "sensor function returns a clean value" pattern from L02-23: yesterday's sketch and today's sketch share their entire sensor pipeline. The four states (no echo, safe, caution, danger) introduced one new trick: periodic patterns without delay(), written as (millis() / period) % n == something. We'll formalise that in Cluster F. Tomorrow we add a servo and the lesson becomes the Smart Bin Lid — the cluster's capstone project.
enum(enumeration)- A C++ type that gives names to a small fixed set of values.
enum Zone { NO_ECHO, SAFE, CAUTION, DANGER };beatsint 0, 1, 2, 3for clarity and compiler-checked typos. switch / case- A cleaner alternative to long
if/else ifchains when branching on a single value (especially an enum). Compiles to a fast jump table. - State machine
- A program organised around a small number of named states and the rules for moving between them.
enum+switch+ a current-state variable is the textbook implementation. - Periodic pattern without delay
- The
(millis() / period) % n == ktrick that produces a recurring on/off pattern without ever pausing the loop. The bridge to L02-35's Blink Without Delay. - Heartbeat blink
- A short, slow blink that indicates "system alive but idle". Conventional design language for embedded devices — your laptop's power LED, your wifi router's status, your smoke alarm's green light.
- Visual vs audio output
- Visual signals are silent (good in classrooms, libraries, sleeping environments), unaffected by hearing impairment, and need direct sight-line. Audio signals are omnidirectional and don't need attention. Most real products use both.
Homework 5 min
Combine yesterday's alarm with today's display. Write one sketch that produces BOTH the buzzer cadence from L02-24 AND the four-LED display from L02-25. The two output systems should run in parallel, never interfering with each other, off the same shared distance helper.
On paper, before coding, sketch a table that maps each zone to its full multi-channel output (buzzer pitch + cadence, LED colour + steady/flashing). Pick choices that don't step on each other — e.g. the red flashing LED and the loud continuous tone fire together, not in alternation. Aim for a UI a stressed driver could read in 0.1 seconds.
Bring back next class:
- Your output-mapping table.
- Your
hw-l02-25.inosketch. - A short observation: when you tested it, which output (audio or visual) did you notice first? Why?