Learning Goals 5 min
- Use yesterday's
readDistanceCm()helper to drive a buzzer whose chirp cadence speeds up as something approaches — the canonical "reversing sensor" sound. - Map a distance value (in centimetres) to a beep interval (in milliseconds) using
map()andconstrain()— your fourth use of these functions in Cluster D. - Avoid the classic "chirp inside a delay" pitfall: write the whole sketch in the
millis()-driven non-blocking style so the distance reading and the beep timing are independent.
Warm-Up 10 min
You've all heard the reverse-parking beep in a car: slow at 2 metres, faster at 1, urgent at half a metre, frantic at 20 cm, continuous at 5 cm. That's exactly the project for today — a tiny version built around the HC-SR04 and the piezo buzzer. The clean distance helper from yesterday means today's sketch is almost entirely about timing: when to beep next, given the current distance.
Quick prediction
Without writing code, what rule of thumb would map distance to beep interval? Discuss with a neighbour.
One reasonable mapping
- Distance ≥ 200 cm → no beep at all (out of warning range).
- 200 cm → 1000 ms between beeps.
- 50 cm → 250 ms between beeps.
- ≤ 20 cm → continuous tone (no gaps).
A linear map from 20–200 cm onto 250–1000 ms gives the "slow at far, fast at near" behaviour. Below 20 cm we switch to a different mode entirely (continuous tone). Above 200 cm: silence.
New Concept · Distance → beep cadence 20 min
The mapping function
Given a distance in cm, return the next beep interval in milliseconds. map() + constrain() handle the linear part:
int beepIntervalMs(float d) {
if (d < 0) return 0; // sentinel: no echo → no beep
if (d > 200) return 0; // out of range → no beep
if (d < 20) return -1; // sentinel: continuous tone
int clipped = constrain((int)d, 20, 200);
return map(clipped, 20, 200, 250, 1000);
}Notice the three sentinels:
- 0 = "no beep" (silence — for "far away" and "no echo").
- −1 = "continuous tone" (for "very close").
- Any positive number = "chirp every N ms".
The caller switches on these values to drive the buzzer. Same "sensor function returns a clean value, caller decides" pattern as before.
The non-blocking beep state machine
The buzzer code is the heart of today's lesson. It needs to:
- Track when the next chirp is due.
- Fire a one-off beep when due; reschedule.
- Manage a separate state for continuous-tone mode.
- Stop the tone cleanly when transitioning out of continuous mode.
bool continuous = false;
unsigned long nextBeep = 0;
void updateBuzzer(int intervalMs) {
if (intervalMs == -1) {
// continuous mode
if (!continuous) { tone(BUZZ, 2500); continuous = true; }
return;
}
if (continuous) { noTone(BUZZ); continuous = false; }
if (intervalMs == 0) return; // silence
if (millis() >= nextBeep) {
tone(BUZZ, 1500, 60); // 60 ms chirp at 1500 Hz
nextBeep = millis() + intervalMs;
}
}This is a compact state machine. There are three states (silent, intermittent-beeping, continuous-tone) and the function's job is to transition cleanly between them on every call.
Why does this matter? Try the obvious (and wrong) version:
void loopWrong() {
float d = readDistanceCm();
int interval = beepIntervalMs(d);
if (interval > 0) {
tone(BUZZ, 1500, 60);
delay(interval); // ← the bug
}
}The delay(interval) freezes the loop. During that delay, the sensor isn't read. If you move quickly toward the sensor, by the time the next reading happens you might already be in the "very close" zone — but the loop is still mid-delay on the previous reading. The car would beep slowly while you crashed into the wall. Hence the millis() gate.
Worked Example · The parking-style alarm 25 min
Step 1 — wiring
| Component | Pin |
|---|---|
| HC-SR04 TRIG | D9 |
| HC-SR04 ECHO | D10 |
| Piezo buzzer (+) | D8 |
| Piezo buzzer (−) | GND |
Plus +5 V and GND for the HC-SR04. Tape the sensor pointing along the bench, give it a clear ~2 m runway.
Step 2 — the sketch
Save as range-alarm.ino:
// L02-24: Reversing-car style range alarm
const int TRIG = 9;
const int ECHO = 10;
const int BUZZ = 8;
const int FAR_CM = 200; // beyond this: silent
const int NEAR_CM = 20; // closer than this: continuous tone
const int SLOW_BEEP = 1000; // ms at FAR_CM
const int FAST_BEEP = 250; // ms at NEAR_CM
bool continuous = false;
unsigned long nextBeep = 0;
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;
}
int beepIntervalMs(float d) {
if (d < 0) return 0;
if (d > FAR_CM) return 0;
if (d < NEAR_CM) return -1;
int clipped = constrain((int)d, NEAR_CM, FAR_CM);
return map(clipped, NEAR_CM, FAR_CM, FAST_BEEP, SLOW_BEEP);
}
void updateBuzzer(int intervalMs) {
if (intervalMs == -1) {
if (!continuous) { tone(BUZZ, 2500); continuous = true; }
return;
}
if (continuous) { noTone(BUZZ); continuous = false; }
if (intervalMs == 0) return;
if (millis() >= nextBeep) {
tone(BUZZ, 1500, 60);
nextBeep = millis() + intervalMs;
}
}
void setup() {
pinMode(TRIG, OUTPUT);
pinMode(ECHO, INPUT);
pinMode(BUZZ, OUTPUT);
Serial.begin(9600);
}
void loop() {
if (millis() - lastRead >= 100) {
lastRead = millis();
float d = readDistanceCm();
int i = beepIntervalMs(d);
Serial.print("d="); Serial.print(d, 1);
Serial.print(" cm interval="); Serial.println(i);
updateBuzzer(i);
}
// updateBuzzer is also called every loop iteration so chirps fire on time
// even between sensor reads (we already updated this loop's interval above).
}Step 3 — upload & walk it through
Open Serial Monitor. With nothing in front of the sensor:
d=-1.0 cm interval=0 d=-1.0 cm interval=0
Silent. Now bring your hand slowly toward the sensor from 2 m away:
d=180.2 cm interval=910 (slow chirp) d=140.7 cm interval=755 d=98.3 cm interval=586 d=55.6 cm interval=388 (faster) d=29.4 cm interval=288 d=15.8 cm interval=-1 (continuous tone)
As you close in, the interval shrinks — chirps come faster. Cross 20 cm and the buzzer switches to a continuous tone. Pull back past 20 cm — tone stops, chirps resume. Past 200 cm — silence.
Step 4 — fix common issues
- Chirps stutter or sound "double": the
tone(... , 60)duration overlaps with the next iteration's call. Either shorten to 40 ms or lengthen theFAST_BEEPfloor to 300 ms. - Continuous tone never starts: NEAR_CM might be too low for your sensor (some readings < 5 cm bounce around). Try NEAR_CM = 15.
- Buzzer keeps chirping after you stop the sketch: if you stop the sketch mid-chirp,
tone()can leave the pin oscillating. Power-cycle the board.
Step 5 — feel the difference
Comment out the updateBuzzer calls and replace the chirp with a blocking version that uses delay():
tone(BUZZ, 1500, 60);
delay(beepInterval); // ← BAD: blocks the sensor for up to 1 secondRun it. Move toward the sensor quickly. Notice how the response lags — you can be at 20 cm before the buzzer realises it should switch to continuous mode. Now switch back to the millis() version: instant response. That feeling is what non-blocking timing gets you. We'll formalise the whole concept in Cluster F.
Try It Yourself 20 min
Goal: Add an LED on D3 (PWM-capable). Brighten the LED as the distance drops — full on at 20 cm, dim at 200 cm, off at no echo or beyond 200 cm.
Hint
int led = 0;
if (d >= NEAR_CM && d <= FAR_CM) {
led = map((int)d, FAR_CM, NEAR_CM, 30, 255);
} else if (d > 0 && d < NEAR_CM) {
led = 255;
}
analogWrite(3, led);The LED tells your eyes what the buzzer tells your ears. Together they make a much more usable interface.
Goal: Change the chirp pitch as well as the cadence. Far away: low chirp (800 Hz). Close: high chirp (2500 Hz). The pitch climbs as you approach, like a horror-movie soundtrack.
Hint
Add another map in beepIntervalMs (or factor out a separate beepPitchHz function):
int beepPitchHz(float d) {
if (d < 0 || d > FAR_CM) return 1500;
int clipped = constrain((int)d, NEAR_CM, FAR_CM);
return map(clipped, NEAR_CM, FAR_CM, 2500, 800);
}Pass the pitch into tone(BUZZ, pitch, 60) inside updateBuzzer. You'll need to thread the pitch through as a parameter — small refactor, big audible effect.
Goal: Add a button on D2 (with INPUT_PULLUP) that "arms" or "disarms" the alarm. Disarmed = no beep, no tone, no LED, no matter the distance. Press the button to toggle. Print "ARMED" / "disarmed" on each transition.
Hint
bool armed = true;
bool lastPress = false;
// in loop, before reading distance:
bool pressed = (digitalRead(2) == LOW);
if (pressed && !lastPress) {
armed = !armed;
Serial.println(armed ? "ARMED" : "disarmed");
noTone(BUZZ);
continuous = false;
}
lastPress = pressed;
if (!armed) return; // skip the rest of the loopThe pressed && !lastPress trick is the "state-change detection" pattern from L01-19 — react to the moment a press happens, not the whole duration it's held. We'll see it again in L02-37 (millis()-based debouncing).
Mini-Challenge · Three-zone product mode 15 min
Turn your alarm into a polished, product-ready demo with three named distance zones. Build it as a sellable thing — labelled, polished, with a top-of-file spec comment.
Zones:
- Safe (> 100 cm): slow blue LED breathing, no sound.
- Caution (30–100 cm): green-to-amber LED + slow chirps, intervals 800 ms → 300 ms.
- Danger (< 30 cm): red LED + continuous higher-pitch tone.
Each zone change should be marked clearly in the Serial output. Use three separate LEDs OR a single RGB LED.
It's ship-ready when:
- The top-of-file comment block names the product (e.g. "Kitchen counter proximity reminder") and explains who would use it.
- Every threshold is a named
constat the top of the file. - Zone transitions print clearly:
>> entering CAUTION/>> entering DANGER. - You can demonstrate the three zones on cue by moving your hand at different distances.
- It runs for 10 minutes unattended without false alarms or stuck states.
Reveal one valid sketch (single RGB LED variant)
// "Kitchen counter proximity reminder" — keeps cooking utensils away
// from the active hob. Three zones: SAFE (>100 cm, dim blue), CAUTION
// (30–100 cm, amber with chirp), DANGER (<30 cm, red with continuous tone).
//
// Pins: TRIG=D9 ECHO=D10 BUZZ=D8 RED=D3 GREEN=D5 BLUE=D6
//
const int TRIG = 9, ECHO = 10, BUZZ = 8;
const int RED = 3, GREEN = 5, BLUE = 6;
const int SAFE_CM = 100;
const int CAUTION_CM = 30;
const int SLOW_BEEP = 800;
const int FAST_BEEP = 300;
enum Zone { SAFE, CAUTION, DANGER, UNKNOWN };
Zone zone = UNKNOWN, prev = UNKNOWN;
bool continuous = false;
unsigned long nextBeep = 0;
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 UNKNOWN;
if (d < CAUTION_CM) return DANGER;
if (d < SAFE_CM) return CAUTION;
return SAFE;
}
void setColour(int r, int g, int b) {
analogWrite(RED, r); analogWrite(GREEN, g); analogWrite(BLUE, b);
}
void applyZone(Zone z, float d) {
switch (z) {
case DANGER:
setColour(255, 0, 0);
if (!continuous) { tone(BUZZ, 2500); continuous = true; }
break;
case CAUTION: {
int clipped = constrain((int)d, CAUTION_CM, SAFE_CM);
int interval = map(clipped, CAUTION_CM, SAFE_CM, FAST_BEEP, SLOW_BEEP);
int amber = map(clipped, CAUTION_CM, SAFE_CM, 255, 80);
setColour(amber, amber / 2, 0);
if (continuous) { noTone(BUZZ); continuous = false; }
if (millis() >= nextBeep) {
tone(BUZZ, 1500, 50);
nextBeep = millis() + interval;
}
break;
}
case SAFE:
case UNKNOWN:
setColour(0, 0, 60); // dim blue idle
if (continuous) { noTone(BUZZ); continuous = false; }
break;
}
}
void setup() {
pinMode(TRIG, OUTPUT);
pinMode(ECHO, INPUT);
pinMode(BUZZ, OUTPUT);
pinMode(RED, OUTPUT); pinMode(GREEN, OUTPUT); pinMode(BLUE, OUTPUT);
Serial.begin(9600);
Serial.println("# Kitchen Counter Proximity Reminder armed.");
}
void loop() {
if (millis() - lastRead >= 100) {
lastRead = millis();
float d = readDistanceCm();
zone = zoneFor(d);
if (zone != prev) {
Serial.print(">> entering ");
Serial.println(zone == DANGER ? "DANGER" : zone == CAUTION ? "CAUTION" : zone == SAFE ? "SAFE" : "(no echo)");
prev = zone;
}
applyZone(zone, d);
} else {
applyZone(zone, 0); // keep chirps firing between reads
}
}A few new shapes in this one: enum Zone gives our three states real names (cleaner than 0/1/2 ints); switch (z) dispatches on the enum; transitions are detected with the same prev != now pattern from L02-17. This is your most polished sketch so far — comments, named thresholds, named states, no magic numbers, runs unattended. Worth keeping as a personal template.
Recap 5 min
The range alarm is the canonical use of the distance helper from yesterday: a small program where the sensor reading drives a buzzer cadence directly. The hard part wasn't the maths — map() turns 20–200 cm into 250–1000 ms in one line — it was the timing. Writing the buzzer in non-blocking style (chirp now, set a nextBeep deadline, return; check the deadline next time) lets the sensor keep reading without freezing the loop. That same non-blocking shape is going to appear over and over in Cluster F, where we'll formalise it as "Blink Without Delay". Two lessons from now (L02-26) we put a servo on the same sketch to build the smart bin lid. Tomorrow (L02-25) we replace the buzzer with three coloured LEDs for a parking-zone display.
- Range-based alarm
- Any device whose output (sound, light, motion) changes as a function of the distance to a target. Reversing-car beepers, dishwasher fill-stop sensors, smart-bin lids — all the same shape.
- Sentinel-driven control
- Using special return values (here 0 = silent, -1 = continuous tone) to signal modes to the caller, rather than passing extra flags. Compact and readable when the sentinels are obviously not real data.
- Non-blocking timing
- Using
millis()deadlines to schedule events instead ofdelay(). Lets the loop run flat-out and check several timers in parallel without ever pausing. - State machine
- Code organised around a small number of named states (silent / chirping / continuous-tone) and the transitions between them.
enum+switchis the textbook implementation. map(x, fromLo, fromHi, toLo, toHi)- Linear rescaling. Used today four times — distance to interval, distance to pitch, distance to LED brightness, distance to RGB colour. The same primitive everywhere.
- Loop responsiveness
- How quickly the sketch reacts to a change in the input. Blocking calls (long
delay()s) destroy responsiveness;millis()-based scheduling preserves it. - Cluster D progression
- L02-21 physics → L02-22 timing → L02-23 helper → L02-24 alarm → L02-25 parking display → L02-26 smart bin. Each lesson layers a thin slice of new behaviour on top of the previous sketch.
Homework 5 min
Tune your alarm for a real use case. Pick a real place at home where a proximity alarm would actually help, and tune your sketch for it. Examples:
- A "don't walk past me at my desk" — alarm goes off if someone gets within 50 cm.
- A "water bottle nearly empty" — point the sensor down into a bottle, threshold based on how much water is left.
- A "cat is on the counter" — point sideways across the kitchen counter, trigger if anything taller than 5 cm appears.
For your chosen use case, write down:
- What "safe" vs "caution" vs "danger" mean in your context (specific cm thresholds).
- What sound or visual response makes sense (a chirp may be annoying for an always-on desk alarm; an LED-only version may be better).
- One thing that could go wrong (the cat sits perfectly still and isn't flagged; the water sloshes; ambient temperature changes throw off the readings).
Then implement your tuned version, save as hw-l02-24.ino, and run it for at least 15 minutes in its real location.
Bring back next class:
- Your one-paragraph use-case write-up.
- Your three sentence answers (zones, response, what could go wrong).
- Your
hw-l02-24.inosketch. - A short observation: did the alarm fire when you wanted it to? Any false positives or false negatives?