Learning Goals 5 min
Cluster A ends with a real product. You'll turn the servo from L03-03 into an analog dial — a physical needle that points to a real-world value the way a fuel gauge or volume meter does. By the end of this lesson you will:
- Map an arbitrary sensor range (a potentiometer reading 0–1023, or a TMP36 voltage, or a soil-moisture reading) to a servo angle range, using
map()andconstrain(). - Build a complete "sensor → angle → needle" sketch with non-blocking timing, calibration constants and a printed dial face — the kind of finished mini-instrument you could show off to a non-programmer.
- Add a slew limiter so the needle moves smoothly even when the sensor jumps wildly — a key polish trick for any analog gauge.
Warm-Up 10 min
You should have a hand-drawn dial face from homework. If not, sketch a quick 0-10 scale on cardstock now: a 180° arc, tick marks every 18° (= 10 divisions), labels 0 through 10. Tape a 5–7 cm cardboard arrow to your servo horn — it should be light, balanced, and just long enough to reach the outer tick marks when the servo is mounted behind the dial.
Three quick questions
- The dial reads 0 to 10. The servo travels 0° to 180°. If the sensor reads "5", what angle should the needle point at?
- If the dial reads "0" should the servo be at 0°, or at 180°? (Hint: look at how you'll physically mount the dial behind the servo.)
- If the sensor reads a slightly different number every loop (jitter of ±1), what would the needle look like?
Reveal
- 5 of 10 is the halfway point, so 90°. The exact answer comes from
map(5, 0, 10, 0, 180) = 90. - It depends on which side of the dial the servo is on, but most analog gauges read left-to-right (0 on the left, max on the right). With the servo above the dial, "0" usually maps to the servo's far-right travel (180°) and "max" to the far-left (0°). The fix is one line: swap the args to
map()—map(value, 0, max, 180, 0). - The needle would visibly twitch on every loop. Two fixes: smooth the sensor reading (L02-08 running average) and / or slew-limit the angle change (§4 below). We'll do both today.
New Concept · Mapping + slew-rate limiting 20 min
Mapping refresher
map(value, fromLo, fromHi, toLo, toHi) is your friend. From L01-41 and L02-10 you know it scales a number from one range to another:
int raw = analogRead(A0); // 0..1023 (10-bit ADC)
int angle = map(raw, 0, 1023, 0, 180);
myServo.write(angle);That's the "Hello, world!" of analog indicators — twist a pot, the needle moves. Three problems to fix before we ship it.
Problem 1 · The pot's real range isn't exactly 0–1023
From L02-09 you know pots and sensors often hit 8–1015 in practice. map() doesn't clamp — feed it 1100 and it'll return an angle > 180. Solution: calibrate the input range and add constrain():
const int RAW_MIN = 8; // measured during calibration
const int RAW_MAX = 1015;
const int ANGLE_MIN = 5; // a few degrees of margin from the end-stops
const int ANGLE_MAX = 175;
int raw = analogRead(A0);
raw = constrain(raw, RAW_MIN, RAW_MAX);
int angle = map(raw, RAW_MIN, RAW_MAX, ANGLE_MIN, ANGLE_MAX);
myServo.write(angle);The ANGLE_MIN = 5 and ANGLE_MAX = 175 (not 0 and 180) keep the servo away from its hard end-stops — protects the gears from accidental over-driving and reduces the "hard stop" buzz.
Problem 2 · Noisy sensor → twitchy needle
Smooth the raw reading with the running average from L02-08 before mapping:
const int WINDOW = 8;
int buf[WINDOW];
int bufIndex = 0;
long bufSum = 0;
int smoothed(int newValue) {
bufSum -= buf[bufIndex];
buf[bufIndex] = newValue;
bufSum += newValue;
bufIndex = (bufIndex + 1) % WINDOW;
return bufSum / WINDOW;
}Tradeoff: bigger window = smoother but laggier needle. Window of 4–8 is usually right for an indicator.
Problem 3 · Sudden jumps cause the needle to snap
Even with smoothing, if you flick the pot fast the needle can travel 180° in 300 ms — visually jarring. A slew-rate limiter caps how many degrees the angle can change per second, no matter what the input does:
const int MAX_SPEED_DEG_PER_SEC = 120; // e.g. full sweep takes ~1.5 s
int currentAngle = 90;
unsigned long lastSlewTime = 0;
int slewToward(int target) {
unsigned long now = millis();
unsigned long dt = now - lastSlewTime;
lastSlewTime = now;
int maxStep = (int)((long)MAX_SPEED_DEG_PER_SEC * dt / 1000);
int diff = target - currentAngle;
if (diff > maxStep) diff = maxStep;
if (diff < -maxStep) diff = -maxStep;
currentAngle += diff;
return currentAngle;
}The result: even if target jumps from 0° to 180° instantly, the needle will take 1.5 s (180° ÷ 120°/s) to get there — smooth, lifelike motion. This is the same idea you'll meet in L04-25/26 as a "low-pass control" — limiting how fast a setpoint can change.
Worked Example · Pot-driven volume meter 25 min
Step 1 — wiring
| Component | UNO pin |
|---|---|
| Potentiometer wiper | A0 |
| Pot outer pins | 5V and GND |
| SG90 signal | D9 |
| SG90 +5 V | 5V (with 100 µF cap) |
| SG90 GND | GND |
Mount the servo behind your dial face. Tape the arrow to the horn. Power up but don't run the sketch yet.
Step 2 — calibrate the pot
Upload this throwaway sketch first:
void setup() { Serial.begin(9600); }
void loop() { Serial.println(analogRead(A0)); delay(200); }Turn the pot from one extreme to the other while watching the Serial Monitor. Write down the lowest and highest numbers you see — those become your RAW_MIN and RAW_MAX. (For a clean 10 kΩ pot you should see something close to 0 and 1023; cheap pots wander a bit at the extremes — that's fine, just use the values you actually measured.)
Step 3 — the production sketch
// L03-04 · Indicator Needle — pot-driven analog volume meter.
// Pot wiper -> A0; servo signal -> D9.
#include <Servo.h>
const int POT_PIN = A0;
const int SERVO_PIN = 9;
// --- calibration (replace with measured values) ---
const int RAW_MIN = 8;
const int RAW_MAX = 1015;
// --- mechanical limits (keep clear of end-stops) ---
const int ANGLE_MIN = 5;
const int ANGLE_MAX = 175;
// --- smoothing (running average) ---
const int WINDOW = 8;
int buf[WINDOW];
int bufIndex = 0;
long bufSum = 0;
// --- slew limit (deg / s) ---
const int MAX_SPEED_DEG_PER_SEC = 120;
int currentAngle = 90;
unsigned long lastSlewTime = 0;
// --- update interval ---
const unsigned long UPDATE_MS = 20;
unsigned long lastUpdate = 0;
Servo needle;
int smoothed(int newValue) {
bufSum -= buf[bufIndex];
buf[bufIndex] = newValue;
bufSum += newValue;
bufIndex = (bufIndex + 1) % WINDOW;
return bufSum / WINDOW;
}
int slewToward(int target) {
unsigned long now = millis();
unsigned long dt = now - lastSlewTime;
lastSlewTime = now;
int maxStep = (int)((long)MAX_SPEED_DEG_PER_SEC * dt / 1000);
if (maxStep < 1) maxStep = 1; // always allow at least one step
int diff = target - currentAngle;
if (diff > maxStep) diff = maxStep;
if (diff < -maxStep) diff = -maxStep;
currentAngle += diff;
return currentAngle;
}
void setup() {
Serial.begin(9600);
needle.attach(SERVO_PIN);
for (int i = 0; i < WINDOW; i++) buf[i] = analogRead(POT_PIN);
bufSum = (long)buf[0] * WINDOW;
needle.write(currentAngle);
Serial.println("# Indicator Needle armed.");
}
void loop() {
if (millis() - lastUpdate < UPDATE_MS) return;
lastUpdate = millis();
int raw = analogRead(POT_PIN);
int s = smoothed(raw);
s = constrain(s, RAW_MIN, RAW_MAX);
int target = map(s, RAW_MIN, RAW_MAX, ANGLE_MAX, ANGLE_MIN); // flipped!
int angle = slewToward(target);
needle.write(angle);
}Two details:
- Pre-filling
bufinsetup()with the first reading means the needle doesn't drift up from 0 over the first second as the running average fills up. (Compare L02-08 — the same trick.) - The
map(...)argument order is flipped (ANGLE_MAX, ANGLE_MINinstead ofANGLE_MIN, ANGLE_MAX). This handles the "dial is upside-down behind the servo" case. If your dial reads in the wrong direction after upload, flip these two arguments.
Step 4 — try it
Turn the pot. The needle should follow smoothly across the full 0-to-10 dial. Flick the pot fast — the needle catches up over ~1.5 s instead of snapping. Stop touching the pot — the needle holds steady (no jitter) thanks to the smoothing.
Step 5 — sanity prints
Add a Serial output every 10 updates so you can see what's happening:
static int tick = 0;
if (++tick >= 10) {
tick = 0;
Serial.print("raw="); Serial.print(raw);
Serial.print(" sm="); Serial.print(s);
Serial.print(" tgt="); Serial.print(target);
Serial.print(" cur="); Serial.println(angle);
}You should see raw jumping ±3–5 while you hold the pot still, sm changing barely at all, tgt changing slowly, and cur tracking tgt at the slew limit. This output is exactly the kind you'll bring to a teacher when the dial "doesn't feel right".
Try It Yourself 15 min
Goal: Replace the pot with a real sensor — your choice. Light meter (LDR), soil moisture, or temperature. Calibrate RAW_MIN / RAW_MAX for the new sensor, then label your dial with the real units (lux, %, °C).
Hint
The sketch doesn't need to change at all — just the constants. Use the throwaway calibration sketch from §4 to find the real min/max in your environment. For a temperature dial, e.g. TMP36 from 10 °C to 35 °C, work out the raw ADC values for those two temperatures (from L02-13's formula) and use those as your RAW_MIN/RAW_MAX.
Goal: Add a small LED that lights when the needle is in the "danger zone" (e.g. above 8 / 10 on the dial). Use an LED on D6 with a 220 Ω resistor.
Hint
const int DANGER_RAW = 800; // raw threshold that means "8 on the dial"
bool inDanger = (s > DANGER_RAW);
digitalWrite(LED_PIN, inDanger);For a smarter version, compute the threshold from the dial value instead of the raw, then any change to RAW_MAX automatically updates the danger point. (Bonus: hysteresis — light goes on at 8, off at 7 — prevents flicker near the threshold.)
Goal: Replace the slew limiter with a tapered one: full slew speed when far from the target, slower when within ±5° (the "arrival" phase). This makes the needle ease into position rather than slam.
Hint
int diff = target - currentAngle;
int absDiff = abs(diff);
int maxStep;
if (absDiff > 5) {
maxStep = (int)((long)MAX_SPEED_DEG_PER_SEC * dt / 1000);
} else {
// ease in — half-speed when within 5 degrees
maxStep = (int)((long)(MAX_SPEED_DEG_PER_SEC / 2) * dt / 1000);
}
if (maxStep < 1) maxStep = 1;This is a discrete approximation of an "ease-out" animation curve. The smooth version would use a quadratic or cubic falloff — same idea, more maths.
Mini-Challenge · Ship the gauge 10 min
Turn the demo into a finished mini-instrument.
- Mount it. Glue the servo to a small card / wooden base. Glue the dial face above it so the needle clears by 5–10 mm.
- Print or hand-letter the dial — clean tick marks, evenly spaced, with units.
- On power-up: sweep the needle from 0 to full and back once, as a "self-test". Real instruments do this. (Hint: call
slewToward(ANGLE_MIN)thenslewToward(ANGLE_MAX)insetup()with a small wait.) - Photograph and video the finished gauge responding to the pot or sensor.
Ship-ready test: hand the gauge to a younger sibling, a parent, or a classmate. Can they:
- Read the value off the dial at a glance?
- Tell when it's "low" / "in range" / "high"?
- Make the needle move by interacting with the input?
If yes to all three, you've shipped a usable instrument — the first real-world product of Cluster A. Same as for the smart-bin lid (L02-26), the gauge isn't code on a screen; it's a thing on a table.
Recap 5 min
An analog indicator is one of the simplest, most satisfying outputs you'll ever build. The recipe is short: sensor → smoothing → calibration → map() → slew limit → servo.write(). Each stage is one we've met before, individually, in earlier lessons; today we wired them together. The slew limiter is the new idea worth keeping: never let a setpoint change instantly, always cap the rate. That's a control-theory principle you'll meet again in L04-26 (PID tuning) and L04-39 (low-power sensor nodes). Cluster A is done. Tomorrow we leave servos behind and meet the wilder cousin: plain DC motors and the H-bridges that let us drive them properly.
- Indicator / gauge
- Any analog output that translates a number into a visible position — a needle, a thermometer column, a fuel gauge. The opposite of a digital readout.
map(value, fromLo, fromHi, toLo, toHi)- Linearly maps a number from one range to another. Doesn't clamp — use
constrain()after if the input might be out of range. - Slew-rate limit
- A cap on how fast a controlled value is allowed to change per unit of time. Smooths sudden jumps in the target. Measured in "units per second".
- Calibration constants
- Named values at the top of the sketch that capture your specific hardware (this pot, this servo, this dial). Re-running the calibration sketch is faster than guessing.
- Running average
- A simple filter: each new reading replaces the oldest one in a fixed-size window; the output is the mean of the window. Reduces noise; introduces a small lag.
- End-stop margin
- Keeping the servo a few degrees clear of 0° and 180° so it never bangs into its mechanical limits. Protects the gears and stops the "hard-stop" buzz.
- Self-test sweep
- Moving the needle through its full range once at power-up, as a visual confirmation that the gauge is alive and working. A "ship-ready" polish.
Homework 5 min
Finish the gauge. Build, polish, document.
- Mount the servo + dial + arrow into a finished "product" — even a piece of cardstock with a few labels is enough. Add a small base so it stands on a desk.
- Pick one real sensor (LDR, TMP36, soil moisture, knock sensor) and calibrate the dial for that sensor's real-world range, with units.
- Take three photos: the gauge at minimum, in the middle, at maximum. A short video of the slew-limited motion is bonus.
- Save the final sketch as
indicator-gauge.ino. You'll re-use the smoothing, mapping and slew-limit ideas in the L3 robotics builds — this is reusable code.
Bring back next class:
- The physical gauge.
- Your three photos + optional video.
- One sentence in your notebook: "what surprised me about polishing this build versus the bench-test rig of L03-02".