A sketch that remembers
The same traffic light as a table of phases and two variables: which row is showing, and when it started. loop() asks one question each time round, has this phase had its time, and never waits for the answer. Adding a phase becomes adding a row.
Remember, then check the clock
The first sequence knew which light was on only by where it was in the code:
during the red, it was inside the red's delay(). Take the delays out and
that knowledge has to live somewhere else. It goes in a variable.
A sketch that keeps its situation in a variable, and changes it when
something happens, is called a state machine. Here the state is two numbers:
phase, which row of the table is showing, and phaseStart, the millis()
reading when that row began.
A table and a clock
The table is the sketch's PHASES array, row for row. The highlighted row is
phase. The bar underneath is millis() - phaseStart, filling towards that
row's time.
Every pass of loop() asks one question: is the bar full? Almost always the
answer is no, and loop() carries straight on. When it is yes, phase moves
to the next row, phaseStart becomes now, and show() sets the three pins
from the new row. After the last row, % PHASE_COUNT takes phase back to 0.
Switch the figure to Red and yellow first. That is the UK-style cycle, and
it is one more row in the table. Nothing in loop() changes, because loop()
never knew how many phases there were.
Three details that matter
unsigned longfor every variable that holds a time.millis()outgrows anintin about half a minute on an Uno.now - phaseStart >= PHASES[phase].ms, written as a subtraction. It keeps working whenmillis()wraps back to zero after about 49 days. Blinking without stopping goes through why.- Nothing else in
loop()may block. Onedelay()left anywhere brings the old problem back.
What you should see
Exactly what the first sequence did: red for five seconds, green for five, yellow for two, with each name printed as it changes. From outside, the two sketches cannot be told apart. The difference is that this one is never waiting, so it has room to do something else.
The code
No library. PHASES is the traffic light as a table. phase is the row showing, phaseStart the millis() reading when it began. loop() moves to the next row when the current one has had its time.
/*
Traffic Light - a state machine with millis() TK03 / /p/tk03
Wiring. Count from the square pad on the TinkerBlock board, LEDs up,
header at the bottom:
GND -> GND
NC -> nothing (both NC pins are unconnected on the board)
RED -> D9 on an Uno, GPIO 25 on an ESP32, GPIO 4 on an ESP32-S3,
GP13 on a Raspberry Pi Pico
YELLOW -> D10 on an Uno, GPIO 26 on an ESP32, GPIO 5 on an ESP32-S3,
GP14 on a Pico
GREEN -> D11 on an Uno, GPIO 27 on an ESP32, GPIO 6 on an ESP32-S3,
GP15 on a Pico
Arduino IDE
Tools > Board your board, e.g. ESP32S3 Dev Module
Tools > Port the one that appears when you plug in
Tools > USB CDC On Boot Enabled (ESP32-S3 only)
No library needed.
*/
// GPIO numbers. Uno: 9, 10, 11. ESP32: 25, 26, 27. ESP32-S3: 4, 5, 6.
// Pico: 13, 14, 15.
const int RED_PIN = 4;
const int YELLOW_PIN = 5;
const int GREEN_PIN = 6;
struct Phase {
bool red, yellow, green;
unsigned long ms;
const char *name;
};
// The whole traffic light. One row per phase, in order.
const Phase PHASES[] = {
// RED YELLOW GREEN ms
{ true, false, false, 5000, "red" },
{ false, false, true, 5000, "green" },
{ false, true, false, 2000, "yellow" },
};
const int PHASE_COUNT = sizeof(PHASES) / sizeof(PHASES[0]);
int phase = 0; // which row is showing
unsigned long phaseStart = 0; // millis() when it started
void show(int p) {
digitalWrite(RED_PIN, PHASES[p].red ? HIGH : LOW);
digitalWrite(YELLOW_PIN, PHASES[p].yellow ? HIGH : LOW);
digitalWrite(GREEN_PIN, PHASES[p].green ? HIGH : LOW);
Serial.println(PHASES[p].name);
}
void setup() {
Serial.begin(115200);
pinMode(RED_PIN, OUTPUT);
pinMode(YELLOW_PIN, OUTPUT);
pinMode(GREEN_PIN, OUTPUT);
show(phase);
phaseStart = millis();
}
void loop() {
unsigned long now = millis();
// Has this phase had its time? If not, carry straight on.
if (now - phaseStart >= PHASES[phase].ms) {
phase = (phase + 1) % PHASE_COUNT; // next row; after the last, 0
phaseStart = now;
show(phase);
}
// Anything else goes here. None of it waits for the light.
}loop() now comes round thousands of times a second, and every pass is a chance to do something else: read a button, a sensor or the serial port. The next article uses that to add a second mode without touching the timing.
The same state machine in MicroPython: PHASES is a list of rows, ticks_ms is the clock and ticks_diff subtracts two readings of it, so the loop never sleeps.
"""
Traffic Light - a state machine, MicroPython TK03 / /p/tk03
Wiring. Count from the square pad on the TinkerBlock board, LEDs up,
header at the bottom:
GND -> GND
NC -> nothing (both NC pins are unconnected on the board)
RED -> GPIO 25 on an ESP32, GPIO 4 on an ESP32-S3, GP13 on a Pico
YELLOW -> GPIO 26 on an ESP32, GPIO 5 on an ESP32-S3, GP14 on a Pico
GREEN -> GPIO 27 on an ESP32, GPIO 6 on an ESP32-S3, GP15 on a Pico
Thonny
Run > Configure interpreter MicroPython (ESP32) or
MicroPython (Raspberry Pi Pico)
Save it to the board as main.py to run it on every power-up.
Nothing to install: machine and time are built in.
"""
from machine import Pin
import time
# GPIO numbers. ESP32: 25, 26, 27. ESP32-S3: 4, 5, 6. Pico: 13, 14, 15.
RED_PIN = 4
YELLOW_PIN = 5
GREEN_PIN = 6
red = Pin(RED_PIN, Pin.OUT, value=0)
yellow = Pin(YELLOW_PIN, Pin.OUT, value=0)
green = Pin(GREEN_PIN, Pin.OUT, value=0)
# The whole traffic light. One row per phase, in order.
PHASES = [
# RED YELLOW GREEN ms name
(1, 0, 0, 5000, "red"),
(0, 0, 1, 5000, "green"),
(0, 1, 0, 2000, "yellow"),
]
def show(p):
r, y, g, _, name = PHASES[p]
red.value(r)
yellow.value(y)
green.value(g)
print(name)
phase = 0 # which row is showing
show(phase)
phase_start = time.ticks_ms() # when it started
while True:
now = time.ticks_ms()
# Has this phase had its time? If not, carry straight on.
if time.ticks_diff(now, phase_start) >= PHASES[phase][3]:
phase = (phase + 1) % len(PHASES) # next row; after last, 0
phase_start = now
show(phase)
# Anything else goes here. None of it waits for the light.Use ticks_diff, never a plain subtraction. ticks_ms wraps round much sooner than Arduino's millis, and ticks_diff gives the right answer across the wrap. The loop comes round as fast as the board can run it.
When it does not work
millis() counts milliseconds since the board started and outgrows an int in about half a minute on an Uno. An unsigned long lasts about 49 days, and the subtraction now - phaseStart still gives the right answer when it wraps back to zero.
Check the times in the table: each is in milliseconds, so 5000 is five seconds. Then check that phaseStart is set to now in the same place phase changes. If it is set anywhere else, the next phase is timed from the wrong moment.
Add a row after red: true, true, false, 2000, named red and yellow. PHASE_COUNT counts the rows for you, so loop() needs no change. That is the cycle used in the UK and some other countries.
Something else in loop() is blocking: a delay() left over from the old sketch, or a long Serial print at a low baud rate. The pattern only keeps time if loop() comes round often. Find the call that waits and give it the same treatment.
A second mode, switched from the serial monitor, that only works because the sketch never waits.
Flashing yellow at night →Edit this page — content/books/traffic-light/a-sketch-that-remembers.mdx
Questions about this product
See what other owners have asked, and read their solutions.
Traffic Light
Loading discussions…
Discuss this article
Ask about this page. The answer stays here, on the page it belongs to, for whoever hits the same wall next.