Project, a Pedestrian Crossing

Draft. This tutorial has not been checked against the physical product yet. Confirm wiring, pin assignments and voltage limits against the product documentation before building it.
DifficultyBeginner
CategoryProjects
Supported boardsArduino Uno R3 and compatible ATmega328P boards
Time needed90 minutes
PrerequisitesLED Traffic Light Module
Tested boardNot yet verified
Tested onNot yet verified
Arduino IDE versionNot yet verified
LibraryNot yet verified

What you will build

A working pedestrian crossing. The traffic light sits on green. Press the button and nothing happens for a moment, then amber, then red, a beep while the crossing is clear, and back to green after a sensible pause.

It is the first project in the library and it is deliberately modest. Three parts you already know and no new wiring tricks. What it teaches is the thing every project after it needs: how to make several components behave as one sequence without delay() freezing the button.

Before you begin

Parts

Part Siratek product Its own tutorial
Arduino Uno 2219 Getting Started with Arduino Uno
LED traffic light module 49920 LED Traffic Light Module
Tactile push button 49488 Read a Push Button
Passive buzzer 8051 Play Tones with a Passive Buzzer
Breadboard and jumper wires Using a Breadboard

Build each of those three on its own first if you have not. This page assumes all three work separately, and the first stage below checks that assumption rather than trusting it.

What this project is really teaching

A crossing is a state machine. At any moment it is in exactly one of a handful of states, and each state knows what comes next and when.

That sounds grand for a set of lights. It matters because the obvious approach does not work:

// The obvious version, which cannot work.
if (buttonPressed()) {
  digitalWrite(green, LOW);
  digitalWrite(amber, HIGH);
  delay(3000);                 // the button is ignored for three seconds
  digitalWrite(amber, LOW);
  digitalWrite(red, HIGH);
  delay(8000);                 // and for eight more
  // ...
}

During those eleven seconds the sketch is blind. It cannot beep in a pattern, cannot watch the button, cannot do anything. Real crossings do all of that at once.

The state machine solves it with no cleverness: a variable says which state we are in, a timestamp says when we entered it, and loop() asks each pass whether it is time to move on. It is the millis() pattern from Timing Without delay(), applied to a sequence rather than one LED.

How it works

Five states, and the rules between them:

State Lights What ends it
TRAFFIC_GREEN Green A button press, and at least 5 s since the last crossing
TRAFFIC_AMBER Amber 3 seconds
PEDESTRIAN_CROSS Red 8 seconds, with the buzzer beeping
PEDESTRIAN_CLEAR Red, buzzer faster 4 seconds
TRAFFIC_RETURN Amber 2 seconds, then back to green

Two design decisions are worth naming, because they are the difference between a toy and something that behaves properly.

The minimum green time. Without it, holding the button down cycles the crossing continuously and traffic never moves. Five seconds of guaranteed green after a crossing finishes is what a real installation does, and it is one comparison in the code.

The press is remembered, not acted on. Pressing during the red phase does not queue a second crossing. The request is recorded, and it is cleared when the crossing begins.

The five states of the crossing with the transitions between them, showing the button press and minimum green time that leave the green state
The minimum green time is what stops a held button blocking traffic for ever.

Build it in stages

Each stage ends with something you can see. Do not move on until you have seen it.

Stage 1: prove the three parts

Wire all three and run this. It does nothing clever; it proves the wiring before any logic exists.

// Siratek: stage 1. Each part, one at a time.

const int redPin = 8;
const int amberPin = 9;
const int greenPin = 10;
const int buttonPin = 2;
const int buzzerPin = 11;

void setup() {
  pinMode(redPin, OUTPUT);
  pinMode(amberPin, OUTPUT);
  pinMode(greenPin, OUTPUT);
  pinMode(buzzerPin, OUTPUT);
  pinMode(buttonPin, INPUT_PULLUP);

  Serial.begin(9600);
  Serial.println("Siratek: red, amber, green, beep, then the button.");

  int pins[] = {redPin, amberPin, greenPin};
  for (int i = 0; i < 3; i++) {
    digitalWrite(pins[i], HIGH);
    delay(600);
    digitalWrite(pins[i], LOW);
  }

  tone(buzzerPin, 1000, 300);
}

void loop() {
  Serial.println(digitalRead(buttonPin) == LOW ? "pressed" : "up");
  delay(300);
}

Each light in turn, one beep, then the button state printed. If any of those is wrong, fix it now: it will be far harder to find once there is a state machine on top.

Stage 2: the sequence, without the button

Replace loop() with a sequence that runs on a timer, so you can watch the timings and adjust them before the button complicates things.

// Siratek: stage 2. The sequence, triggered every 15 seconds.

enum State { TRAFFIC_GREEN, TRAFFIC_AMBER, PEDESTRIAN_CROSS, PEDESTRIAN_CLEAR, TRAFFIC_RETURN };

State state = TRAFFIC_GREEN;
unsigned long stateSince = 0;

void enter(State next) {
  state = next;
  stateSince = millis();

  digitalWrite(redPin, state == PEDESTRIAN_CROSS || state == PEDESTRIAN_CLEAR);
  digitalWrite(amberPin, state == TRAFFIC_AMBER || state == TRAFFIC_RETURN);
  digitalWrite(greenPin, state == TRAFFIC_GREEN);
}

unsigned long elapsed() {
  return millis() - stateSince;
}

void loop() {
  switch (state) {
    case TRAFFIC_GREEN:
      if (elapsed() >= 15000) enter(TRAFFIC_AMBER);
      break;
    case TRAFFIC_AMBER:
      if (elapsed() >= 3000) enter(PEDESTRIAN_CROSS);
      break;
    case PEDESTRIAN_CROSS:
      if (elapsed() >= 8000) enter(PEDESTRIAN_CLEAR);
      break;
    case PEDESTRIAN_CLEAR:
      if (elapsed() >= 4000) enter(TRAFFIC_RETURN);
      break;
    case TRAFFIC_RETURN:
      if (elapsed() >= 2000) enter(TRAFFIC_GREEN);
      break;
  }
}

Watch a full cycle. Adjust the numbers until the rhythm feels right. Notice that enter() sets all three lights from the state, so no state can accidentally leave a light on: that is worth more than it looks.

Stage 3: the button and the buzzer

Now add the request, the minimum green time, and the beeping. That is the complete sketch below.

The complete sketch

// Siratek: a pedestrian crossing.
// Traffic runs on green until someone asks to cross.

const int redPin = 8;
const int amberPin = 9;
const int greenPin = 10;
const int buttonPin = 2;
const int buzzerPin = 11;

// Timings, in milliseconds. Adjust to taste.
const unsigned long AMBER_TIME    = 3000;
const unsigned long CROSS_TIME    = 8000;
const unsigned long CLEAR_TIME    = 4000;
const unsigned long RETURN_TIME   = 2000;
const unsigned long MINIMUM_GREEN = 5000;   // traffic gets at least this long

// Beep rates: slow while crossing, urgent while clearing.
const unsigned long CROSS_BEEP = 700;
const unsigned long CLEAR_BEEP = 220;

enum State { TRAFFIC_GREEN, TRAFFIC_AMBER, PEDESTRIAN_CROSS, PEDESTRIAN_CLEAR, TRAFFIC_RETURN };

State state = TRAFFIC_GREEN;
unsigned long stateSince = 0;

bool requested = false;

// Button debouncing, as in Making a Button Behave.
int lastReading = HIGH;
unsigned long lastChange = 0;
const unsigned long DEBOUNCE = 40;

unsigned long lastBeep = 0;

void enter(State next) {
  state = next;
  stateSince = millis();

  digitalWrite(redPin,   state == PEDESTRIAN_CROSS || state == PEDESTRIAN_CLEAR);
  digitalWrite(amberPin, state == TRAFFIC_AMBER    || state == TRAFFIC_RETURN);
  digitalWrite(greenPin, state == TRAFFIC_GREEN);

  noTone(buzzerPin);

  Serial.print("state: ");
  switch (state) {
    case TRAFFIC_GREEN:     Serial.println("traffic green"); break;
    case TRAFFIC_AMBER:     Serial.println("amber"); break;
    case PEDESTRIAN_CROSS:  Serial.println("cross"); break;
    case PEDESTRIAN_CLEAR:  Serial.println("clearing"); break;
    case TRAFFIC_RETURN:    Serial.println("returning"); break;
  }
}

unsigned long elapsed() {
  return millis() - stateSince;
}

void readButton() {
  int reading = digitalRead(buttonPin);

  if (reading != lastReading) {
    lastChange = millis();
    lastReading = reading;
    return;
  }

  // Act on a settled press, once.
  if (millis() - lastChange > DEBOUNCE && reading == LOW && !requested) {
    requested = true;
    Serial.println("  request noted");
  }
}

void beepEvery(unsigned long interval) {
  if (millis() - lastBeep >= interval) {
    lastBeep = millis();
    tone(buzzerPin, 1800, 80);
  }
}

void setup() {
  pinMode(redPin, OUTPUT);
  pinMode(amberPin, OUTPUT);
  pinMode(greenPin, OUTPUT);
  pinMode(buzzerPin, OUTPUT);
  pinMode(buttonPin, INPUT_PULLUP);

  Serial.begin(9600);
  Serial.println("Siratek: pedestrian crossing.");

  enter(TRAFFIC_GREEN);
}

void loop() {
  readButton();            // every pass, whatever state we are in

  switch (state) {
    case TRAFFIC_GREEN:
      // Honour the request, but not before traffic has had its minimum.
      if (requested && elapsed() >= MINIMUM_GREEN) {
        enter(TRAFFIC_AMBER);
      }
      break;

    case TRAFFIC_AMBER:
      if (elapsed() >= AMBER_TIME) {
        requested = false;          // the request has been granted
        enter(PEDESTRIAN_CROSS);
      }
      break;

    case PEDESTRIAN_CROSS:
      beepEvery(CROSS_BEEP);
      if (elapsed() >= CROSS_TIME) {
        enter(PEDESTRIAN_CLEAR);
      }
      break;

    case PEDESTRIAN_CLEAR:
      beepEvery(CLEAR_BEEP);
      if (elapsed() >= CLEAR_TIME) {
        enter(TRAFFIC_RETURN);
      }
      break;

    case TRAFFIC_RETURN:
      if (elapsed() >= RETURN_TIME) {
        enter(TRAFFIC_GREEN);
      }
      break;
  }
}

Why readButton() is called first, every pass

Because the button must work in every state, not only on green. Pressing during the crossing should be noticed and simply have no effect, rather than being missed entirely.

That is also why the request is cleared when the crossing starts, not when the button is read. A press during the amber phase is part of the crossing already under way; a press during the crossing itself sets the flag again and will be honoured next time.

Why tone() and not digitalWrite()

A passive buzzer needs a changing signal, and tone() with a duration is non-blocking: it starts the note and returns immediately. beepEvery() therefore costs nothing in the loop.

Remember that tone() uses Timer2, so PWM on pins 3 and 11 is unavailable. Pin 11 is the buzzer here, which is fine, but it is why the lights are on 8, 9 and 10.

Testing it

Work through these deliberately. Each one checks a decision rather than the wiring.

Try this It should
Leave it alone Stay on green indefinitely
Press once Amber, then red with beeping, then back to green
Press, then press again during red Nothing extra; one crossing
Press immediately after a crossing ends Wait for the minimum green, then go
Hold the button down One crossing per minimum-green period, not continuous
Press during amber No effect; the crossing is already starting

The fifth is the one that catches a careless implementation, and the one a real crossing has to get right.

Making it yours

  • A waiting light. A fourth LED lit from the moment the request is noted until the crossing begins. That is what tells a person their press registered, and it is one digitalWrite() in readButton() and one in enter().
  • A night mode. Add an LDR, and after dark make the amber flash rather than running the full sequence. Measuring Light with an LDR has the threshold work.
  • Two directions. A second light module for the other road, red when the first is green. The state machine barely changes; enter() sets six pins instead of three.
  • A countdown. A 7-segment display showing the seconds left to cross. Driving a 4-Digit 7-Segment Display covers the multiplexing, and the number comes from CROSS_TIME - elapsed().

Troubleshooting

Symptom Likely cause Check or fix
A light stays on in the wrong state Set outside enter() All three are set there, from the state
Button does nothing Not INPUT_PULLUP, or wrong pin Stage 1 checks this
One press causes several crossings Not debounced, or the flag not cleared Clear requested when the crossing starts
Holding the button blocks traffic No minimum green That comparison is the fix
Buzzer silent Active buzzer, not passive tone() needs a passive one
Buzzer sounds but an LED stopped dimming tone() has taken Timer2 Pins 3 and 11 lose PWM
Beeping is irregular delay() crept back in Nothing in loop() may block
Sequence drifts over hours Comparing millis() wrongly Always subtract, as elapsed() does
Lights dim All three on at once, or no resistors The module has its own; check the module tutorial
Works, then stops after seven weeks millis() rollover Subtraction survives it; comparison does not

Sources and attribution

All text, wiring guidance and code in this project are original Siratek work.

  • The component behaviour is as described in LED Traffic Light Module, Read a Push Button and Play Tones with a Passive Buzzer, including the module’s onboard current-limiting resistors and the tone() timer conflict.

The state machine and the timings here are a teaching arrangement, not a specification for a real road crossing, which is a regulated installation with requirements this project does not attempt to meet. No third-party tutorial text, diagrams, photographs or code has been reproduced here.

Product used in this tutorial: LED Traffic Light Module
Shop
Sidebar
0 Wishlist
0 Cart