Practical case: Arduino UNO occupancy counter

Practical case: Arduino UNO occupancy counter — hero

Objective and use case

What you’ll build: A doorway visitor counter using an Arduino UNO R3, two IR break-beam sensors, and a TM1637 4-digit display. The system detects entry vs. exit by timing which beam breaks first, then updates the live occupancy count on the display with near-instant feedback, typically under 100–200 ms per event.

Why it matters / Use cases

  • Classroom or lab occupancy tracking: estimate how many people are inside a room by mounting the beams across a narrow doorway or small door-frame model.
  • Workshop access monitoring: show a quick live count at a makerspace entrance so students can judge whether the room is crowded before entering.
  • Pop-up event booth counting: measure approximate foot traffic by counting entries and exits during school demos or exhibitions.
  • Storage or equipment room supervision: monitor whether a restricted area is occupied without cameras, image capture, or networked video processing.
  • Embedded systems practice: learn direction detection, debounce handling, sensor spacing, timing windows, and simple real-time display updates on an 8-bit microcontroller using minimal power and 0% GPU.

Expected outcome

  • The TM1637 4-digit display shows the current occupancy count and updates immediately after each validated pass.
  • The system increases the count when beam A then beam B is triggered, and decreases it when beam B then beam A is triggered.
  • False triggers are reduced through debounce logic and a short direction-detection window, for example 300–800 ms depending on doorway width and walking speed.
  • The prototype runs fully on the Arduino UNO at simple loop-level responsiveness, with event handling effectively in real time and no camera, no FPS pipeline, and no GPU load.

Audience: students, makers, and beginner embedded developers building practical sensor-based prototypes; Level: beginner to intermediate

Architecture/flow: IR sensor 1 and IR sensor 2 feed digital signals to the Arduino UNO, which compares trigger order and timing, validates the movement direction, updates an internal occupancy variable, and sends the new count to the TM1637 display.

Educational validation note

Before publication, this case passed the Prometeo automated validation gate with status PASS. The validator checked the code blocks, article structure, copy/paste-safe commands and consistency with the supported device catalog.

Published validation evidence

  • Automatic result: PASS.
  • Parsed structure: 3 sections, 3 tables and 4 code blocks detected before publication.
  • Checked code: 1 Arduino/arduino-cli compile, 1 Bash/copy-paste checks.
  • Supported catalog: the article text was checked against Prometeo’s validation-capable device profiles, and unsupported stacks block publication.
  • Report findings: no blocking findings.

This validation confirms syntax and tool compatibility for the published material, but it does not replace physical testing on your exact hardware, wiring and runtime environment.

Educational safety note

This prototype is an educational occupancy counter, not a security-certified access-control device and not a safety system. It should not be used where miscounts could create hazards, legal compliance issues, emergency egress decisions, or critical operational consequences.

Specific limits to keep in mind:

  • It is designed for low-voltage USB-powered educational use only.
  • It should not be used to control door locks, gates, industrial machinery, or emergency systems.
  • It is best suited to one person at a time through a narrow path; closely grouped people can confuse the direction logic.
  • Direct sunlight or strong IR sources can reduce reliability.
  • The displayed count is an estimate based on beam interruptions, not a guaranteed headcount.
  • Secure all wiring and mounts so nothing can fall into the walking path or create a trip hazard.

Prerequisites

Before starting, you should have:

  • A computer with USB access to the Arduino UNO
  • Arduino CLI installed and available in your terminal
  • Basic understanding of:
  • digital input pins
  • millis() timing
  • Arduino sketch upload
  • simple breadboard wiring
  • A narrow test path where you can move a hand or object through the two IR beams in sequence

Recommended preparation:

  1. Install Arduino CLI.
  2. Confirm the UNO appears as a serial port on your system.
  3. Keep the two break-beam sensor pairs physically separated by a small distance such as 8 cm to 20 cm.
  4. Test in indoor lighting first before moving to a brighter environment.

Materials

Use the exact device family and model requested:

  • Arduino UNO R3 (ATmega328P)
  • Two IR break-beam sensors
  • Each sensor set normally includes an IR emitter and an IR receiver
  • TM1637 4-digit display
  • Breadboard
  • Jumper wires
  • USB cable for Arduino UNO
  • Optional:
  • Cardboard, foam board, or 3D-printed mini doorway frame
  • Double-sided tape or zip ties for sensor alignment

Suggested material roles

Part Role in the project Notes
Arduino UNO R3 (ATmega328P) Main controller Runs counting logic and updates display
IR break-beam sensor pair A First beam position Detects one side of crossing
IR break-beam sensor pair B Second beam position Detects second side of crossing
TM1637 4-digit display Local occupancy display Shows 0 to 9999 count
Breadboard and jumpers Prototyping interconnect Keep sensor wiring short and tidy

Setup/Connection

This project uses:

  • two digital inputs for the IR receivers
  • two digital outputs for the TM1637 display
  • 5 V and GND rails for all modules

Pin assignment

Use the following Arduino pins:

  • IR receiver A output -> D2
  • IR receiver B output -> D3
  • TM1637 CLK -> D4
  • TM1637 DIO -> D5

Power connections:

  • TM1637 VCC -> 5V
  • TM1637 GND -> GND
  • IR receiver A VCC -> 5V
  • IR receiver A GND -> GND
  • IR receiver B VCC -> 5V
  • IR receiver B GND -> GND
  • IR emitter A -> 5V and GND as required by its module
  • IR emitter B -> 5V and GND as required by its module

Important wiring notes

  1. Receiver outputs only go to Arduino input pins.
  2. Do not connect sensor outputs directly to the display.
  3. Most break-beam receivers behave as active-low outputs.
  4. That means:
    • beam intact -> input reads HIGH
    • beam broken -> input reads LOW
  5. The sketch below assumes this common behavior.
  6. Sensor alignment matters more than code.
  7. Place emitter and receiver directly facing each other.
  8. Confirm each beam changes state reliably before testing direction logic.
  9. Beam spacing affects accuracy.
  10. Start with 10 cm to 15 cm between beam A and beam B.
  11. Too close: direction can become ambiguous.
  12. Too far: a person may pause between beams and trigger timeouts.
  13. Use a narrow passage.
  14. This works best when only one person passes at a time.

Practical mounting idea

Build a simple educational prototype doorway:

  1. Make a cardboard arch or rectangular frame.
  2. Mount beam A on one side and its receiver opposite.
  3. Mount beam B parallel to A, slightly behind it along the walking direction.
  4. Label the outside side of the frame and the inside side.
  5. Define:
  6. A then B = entering
  7. B then A = leaving

That makes the object immediately useful as a room-entry counter.

Validated Code

dual_ir_beam_visitor_counter.ino

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

/*
  dual_ir_beam_visitor_counter.ino

  Target:
    Arduino UNO R3 (ATmega328P)

  Hardware:
    - 2x IR break-beam receivers on D2 and D3
    - TM1637 4-digit display on D4 (CLK) and D5 (DIO)

  Behavior:
    - Beam A then Beam B => ENTRY => count++
    - Beam B then Beam A => EXIT  => count-- (not below zero)
    - Serial output logs events for validation
    - Display shows occupancy count from 0 to 9999

  Notes:
    - Assumes receiver outputs are active-low:
        beam clear   -> HIGH
        beam broken  -> LOW
    - Uses INPUT_PULLUP for stable idle state
*/

#include <Arduino.h>

// -----------------------------
// Pin configuration
// -----------------------------
const uint8_t PIN_BEAM_A = 2;
const uint8_t PIN_BEAM_B = 3;
const uint8_t PIN_TM1637_CLK = 4;
const uint8_t PIN_TM1637_DIO = 5;

// -----------------------------
// Behavior configuration
// -----------------------------
const bool SENSOR_ACTIVE_LOW = true;
const unsigned long DEBOUNCE_MS = 20;
const unsigned long SEQUENCE_TIMEOUT_MS = 1200;
const unsigned long COOLDOWN_MS = 300;

// Display brightness: 0 to 7
const uint8_t DISPLAY_BRIGHTNESS = 4;

// -----------------------------
// Simple TM1637 driver
// -----------------------------
class SimpleTM1637 {
public:
  SimpleTM1637(uint8_t clkPin, uint8_t dioPin)
    : _clk(clkPin), _dio(dioPin) {}

  void begin() {
    pinMode(_clk, OUTPUT);
    pinMode(_dio, OUTPUT);
    digitalWrite(_clk, HIGH);
    digitalWrite(_dio, HIGH);
    setBrightness(DISPLAY_BRIGHTNESS, true);
  }

  void setBrightness(uint8_t brightness, bool on = true) {
    if (brightness > 7) brightness = 7;
    _brightnessCmd = 0x88 | (on ? brightness : 0);
  }

  void showNumber(int value) {
    if (value < 0) value = 0;
    if (value > 9999) value = 9999;

    uint8_t digits[4];
    digits[0] = encodeDigit((value / 1000) % 10);
    digits[1] = encodeDigit((value / 100) % 10);
    digits[2] = encodeDigit((value / 10) % 10);
    digits[3] = encodeDigit(value % 10);

    // Leading blanking except for zero itself
    if (value < 1000) digits[0] = 0x00;
    if (value < 100)  digits[1] = 0x00;
    if (value < 10)   digits[2] = 0x00;
    if (value == 0) {
      digits[0] = 0x00;
      digits[1] = 0x00;
      digits[2] = 0x00;
      digits[3] = encodeDigit(0);
    }

    setSegments(digits);
  }

  void showDashes() {
    uint8_t dash = 0x40;
    uint8_t digits[4] = {dash, dash, dash, dash};
    setSegments(digits);
  }

private:
  uint8_t _clk;
  uint8_t _dio;
  uint8_t _brightnessCmd = 0x8F;

  uint8_t encodeDigit(uint8_t digit) {
    static const uint8_t map[10] = {
      0x3F, // 0
      0x06, // 1
      0x5B, // 2
      0x4F, // 3
      0x66, // 4
      0x6D, // 5
      0x7D, // 6
      0x07, // 7
      0x7F, // 8
      0x6F  // 9
    };
    if (digit < 10) return map[digit];
    return 0x00;
  }

  void bitDelay() {
    delayMicroseconds(5);
  }

  void start() {
    pinMode(_dio, OUTPUT);
    digitalWrite(_dio, HIGH);
    digitalWrite(_clk, HIGH);
    bitDelay();
    digitalWrite(_dio, LOW);
  }

  void stop() {
    pinMode(_dio, OUTPUT);
    digitalWrite(_clk, LOW);
    bitDelay();
    digitalWrite(_dio, LOW);
    bitDelay();
    digitalWrite(_clk, HIGH);
    bitDelay();
    digitalWrite(_dio, HIGH);
  }

  bool writeByte(uint8_t b) {
    for (uint8_t i = 0; i < 8; i++) {
      digitalWrite(_clk, LOW);
      bitDelay();

      if (b & 0x01) {
        digitalWrite(_dio, HIGH);
      } else {
        digitalWrite(_dio, LOW);
      }

      bitDelay();
      digitalWrite(_clk, HIGH);
      bitDelay();
      b >>= 1;
    }

    // ACK
    digitalWrite(_clk, LOW);
    pinMode(_dio, INPUT_PULLUP);
    bitDelay();
    digitalWrite(_clk, HIGH);
    bitDelay();
    bool ack = (digitalRead(_dio) == LOW);
    digitalWrite(_clk, LOW);
    pinMode(_dio, OUTPUT);
    return ack;
  }

  void setSegments(uint8_t segments[4]) {
    start();
    writeByte(0x40); // auto increment mode
    stop();

    start();
    writeByte(0xC0); // address 0
    for (uint8_t i = 0; i < 4; i++) {
      writeByte(segments[i]);
    }
// ...

/*
  dual_ir_beam_visitor_counter.ino

  Target:
    Arduino UNO R3 (ATmega328P)

  Hardware:
    - 2x IR break-beam receivers on D2 and D3
    - TM1637 4-digit display on D4 (CLK) and D5 (DIO)

  Behavior:
    - Beam A then Beam B => ENTRY => count++
    - Beam B then Beam A => EXIT  => count-- (not below zero)
    - Serial output logs events for validation
    - Display shows occupancy count from 0 to 9999

  Notes:
    - Assumes receiver outputs are active-low:
        beam clear   -> HIGH
        beam broken  -> LOW
    - Uses INPUT_PULLUP for stable idle state
*/

#include <Arduino.h>

// -----------------------------
// Pin configuration
// -----------------------------
const uint8_t PIN_BEAM_A = 2;
const uint8_t PIN_BEAM_B = 3;
const uint8_t PIN_TM1637_CLK = 4;
const uint8_t PIN_TM1637_DIO = 5;

// -----------------------------
// Behavior configuration
// -----------------------------
const bool SENSOR_ACTIVE_LOW = true;
const unsigned long DEBOUNCE_MS = 20;
const unsigned long SEQUENCE_TIMEOUT_MS = 1200;
const unsigned long COOLDOWN_MS = 300;

// Display brightness: 0 to 7
const uint8_t DISPLAY_BRIGHTNESS = 4;

// -----------------------------
// Simple TM1637 driver
// -----------------------------
class SimpleTM1637 {
public:
  SimpleTM1637(uint8_t clkPin, uint8_t dioPin)
    : _clk(clkPin), _dio(dioPin) {}

  void begin() {
    pinMode(_clk, OUTPUT);
    pinMode(_dio, OUTPUT);
    digitalWrite(_clk, HIGH);
    digitalWrite(_dio, HIGH);
    setBrightness(DISPLAY_BRIGHTNESS, true);
  }

  void setBrightness(uint8_t brightness, bool on = true) {
    if (brightness > 7) brightness = 7;
    _brightnessCmd = 0x88 | (on ? brightness : 0);
  }

  void showNumber(int value) {
    if (value < 0) value = 0;
    if (value > 9999) value = 9999;

    uint8_t digits[4];
    digits[0] = encodeDigit((value / 1000) % 10);
    digits[1] = encodeDigit((value / 100) % 10);
    digits[2] = encodeDigit((value / 10) % 10);
    digits[3] = encodeDigit(value % 10);

    // Leading blanking except for zero itself
    if (value < 1000) digits[0] = 0x00;
    if (value < 100)  digits[1] = 0x00;
    if (value < 10)   digits[2] = 0x00;
    if (value == 0) {
      digits[0] = 0x00;
      digits[1] = 0x00;
      digits[2] = 0x00;
      digits[3] = encodeDigit(0);
    }

    setSegments(digits);
  }

  void showDashes() {
    uint8_t dash = 0x40;
    uint8_t digits[4] = {dash, dash, dash, dash};
    setSegments(digits);
  }

private:
  uint8_t _clk;
  uint8_t _dio;
  uint8_t _brightnessCmd = 0x8F;

  uint8_t encodeDigit(uint8_t digit) {
    static const uint8_t map[10] = {
      0x3F, // 0
      0x06, // 1
      0x5B, // 2
      0x4F, // 3
      0x66, // 4
      0x6D, // 5
      0x7D, // 6
      0x07, // 7
      0x7F, // 8
      0x6F  // 9
    };
    if (digit < 10) return map[digit];
    return 0x00;
  }

  void bitDelay() {
    delayMicroseconds(5);
  }

  void start() {
    pinMode(_dio, OUTPUT);
    digitalWrite(_dio, HIGH);
    digitalWrite(_clk, HIGH);
    bitDelay();
    digitalWrite(_dio, LOW);
  }

  void stop() {
    pinMode(_dio, OUTPUT);
    digitalWrite(_clk, LOW);
    bitDelay();
    digitalWrite(_dio, LOW);
    bitDelay();
    digitalWrite(_clk, HIGH);
    bitDelay();
    digitalWrite(_dio, HIGH);
  }

  bool writeByte(uint8_t b) {
    for (uint8_t i = 0; i < 8; i++) {
      digitalWrite(_clk, LOW);
      bitDelay();

      if (b & 0x01) {
        digitalWrite(_dio, HIGH);
      } else {
        digitalWrite(_dio, LOW);
      }

      bitDelay();
      digitalWrite(_clk, HIGH);
      bitDelay();
      b >>= 1;
    }

    // ACK
    digitalWrite(_clk, LOW);
    pinMode(_dio, INPUT_PULLUP);
    bitDelay();
    digitalWrite(_clk, HIGH);
    bitDelay();
    bool ack = (digitalRead(_dio) == LOW);
    digitalWrite(_clk, LOW);
    pinMode(_dio, OUTPUT);
    return ack;
  }

  void setSegments(uint8_t segments[4]) {
    start();
    writeByte(0x40); // auto increment mode
    stop();

    start();
    writeByte(0xC0); // address 0
    for (uint8_t i = 0; i < 4; i++) {
      writeByte(segments[i]);
    }
    stop();

    start();
    writeByte(_brightnessCmd);
    stop();
  }
};

SimpleTM1637 display(PIN_TM1637_CLK, PIN_TM1637_DIO);

// -----------------------------
// Debounced sensor input
// -----------------------------
struct DebouncedInput {
  uint8_t pin;
  bool stableState;
  bool lastRawState;
  unsigned long lastChangeMs;

  void begin(uint8_t p) {
    pin = p;
    pinMode(pin, INPUT_PULLUP);
    stableState = digitalRead(pin);
    lastRawState = stableState;
    lastChangeMs = millis();
  }

  void update() {
    bool raw = digitalRead(pin);
    unsigned long now = millis();

    if (raw != lastRawState) {
      lastRawState = raw;
      lastChangeMs = now;
    }

    if ((now - lastChangeMs) >= DEBOUNCE_MS) {
      stableState = lastRawState;
    }
  }

  bool isBroken() const {
    if (SENSOR_ACTIVE_LOW) {
      return stableState == LOW;
    } else {
      return stableState == HIGH;
    }
  }
};

DebouncedInput beamA;
DebouncedInput beamB;

// -----------------------------
// Direction state machine
// -----------------------------
enum CountState {
  IDLE,
  WAIT_B_AFTER_A,
  WAIT_A_AFTER_B,
  COOLDOWN
};

CountState countState = IDLE;
unsigned long stateStartMs = 0;
unsigned int occupancyCount = 0;

bool prevBrokenA = false;
bool prevBrokenB = false;

void enterState(CountState newState) {
  countState = newState;
  stateStartMs = millis();
}

void logState(const char* msg) {
  Serial.print("[");
  Serial.print(millis());
  Serial.print(" ms] ");
  Serial.println(msg);
}

void handleEvents() {
  bool brokenA = beamA.isBroken();
  bool brokenB = beamB.isBroken();

  bool risingBreakA = (!prevBrokenA && brokenA);
  bool risingBreakB = (!prevBrokenB && brokenB);

  unsigned long now = millis();

  switch (countState) {
    case IDLE:
      if (risingBreakA && !brokenB) {
        enterState(WAIT_B_AFTER_A);
        logState("Sequence start: A first");
      } else if (risingBreakB && !brokenA) {
        enterState(WAIT_A_AFTER_B);
        logState("Sequence start: B first");
      }
      break;

    case WAIT_B_AFTER_A:
      if ((now - stateStartMs) > SEQUENCE_TIMEOUT_MS) {
        enterState(IDLE);
        logState("TIMEOUT after A first");
      } else if (risingBreakB) {
        if (occupancyCount < 9999) {
          occupancyCount++;
        }
        display.showNumber(occupancyCount);
        Serial.print("[");
        Serial.print(now);
        Serial.print(" ms] ENTRY, count=");
        Serial.println(occupancyCount);
        enterState(COOLDOWN);
      }
      break;

    case WAIT_A_AFTER_B:
      if ((now - stateStartMs) > SEQUENCE_TIMEOUT_MS) {
        enterState(IDLE);
        logState("TIMEOUT after B first");
      } else if (risingBreakA) {
        if (occupancyCount > 0) {
          occupancyCount--;
        }
        display.showNumber(occupancyCount);
        Serial.print("[");
        Serial.print(now);
        Serial.print(" ms] EXIT, count=");
        Serial.println(occupancyCount);
        enterState(COOLDOWN);
      }
      break;

    case COOLDOWN:
      // Wait until both beams are clear or cooldown expires
      if ((!brokenA && !brokenB) && ((now - stateStartMs) > COOLDOWN_MS)) {
        enterState(IDLE);
      } else if ((now - stateStartMs) > (COOLDOWN_MS + 1000)) {
        enterState(IDLE);
      }
      break;
  }

  prevBrokenA = brokenA;
  prevBrokenB = brokenB;
}

void setup() {
  Serial.begin(115200);

  beamA.begin(PIN_BEAM_A);
  beamB.begin(PIN_BEAM_B);

  display.begin();
  display.showNumber(occupancyCount);

  prevBrokenA = beamA.isBroken();
  prevBrokenB = beamB.isBroken();

  logState("Dual IR beam visitor counter started");
  Serial.println("Rule: A then B = ENTRY, B then A = EXIT");
  Serial.println("Initial count=0");
}

void loop() {
  beamA.update();
  beamB.update();

  handleEvents();

  static unsigned long lastRefreshMs = 0;
  unsigned long now = millis();
  if ((now - lastRefreshMs) >= 100) {
    display.showNumber(occupancyCount);
    lastRefreshMs = now;
  }
}

Optional serial monitor reference output

This is not code you upload, but it helps you know what normal behavior looks like:

[12 ms] Dual IR beam visitor counter started
Rule: A then B = ENTRY, B then A = EXIT
Initial count=0
[5021 ms] Sequence start: A first
[5288 ms] ENTRY, count=1
[9140 ms] Sequence start: B first
[9412 ms] EXIT, count=0
[12040 ms] Sequence start: A first
[13270 ms] TIMEOUT after A first

Build/Flash/Run commands

Use Arduino CLI exactly as required.

Command table

Task Command
Update board index arduino-cli core update-index
Install AVR core arduino-cli core install arduino:avr
Compile sketch arduino-cli compile --fqbn arduino:avr:uno dual_ir_beam_visitor_counter
Upload sketch arduino-cli upload --fqbn arduino:avr:uno --port <PORT> dual_ir_beam_visitor_counter
Open serial monitor arduino-cli monitor --port <PORT> --config 115200

Short workflow

  1. Create the sketch folder:
    bash
    mkdir -p dual_ir_beam_visitor_counter
  2. Save the .ino file as:
    text
    dual_ir_beam_visitor_counter/dual_ir_beam_visitor_counter.ino
  3. Run the command table from the folder that contains the sketch directory.
  4. Replace <PORT> with your actual device port, for example:
  5. Linux: /dev/ttyACM0
  6. macOS: /dev/cu.usbmodem14101
  7. Windows: COM4
  8. After upload, open the serial monitor and walk a hand or object through the beams.

Step-by-step Validation

Use these checkpoints in order. They are intentionally practical and easy to reproduce on a workbench.

1. Power and display check

Action
– Connect the UNO by USB.
– Upload the sketch.
– Observe the TM1637 display immediately after reset.

Expected observation
– The display shows 0.
– The serial monitor prints startup text.

Pass condition
– The display is stable and readable.
– The serial monitor shows the start banner without random resets.

2. Single-sensor alignment check

Action
– Keep the other beam untouched.
– Break only beam A with your finger or a card.
– Then clear it.
– Repeat with only beam B.

Expected observation
– You may see sequence-start messages in Serial when one beam is broken first.
– If you do not complete the second beam, a timeout message should appear after about 1.2 seconds.

Pass condition
– Each beam can be individually interrupted in a repeatable way.
– No permanent stuck state remains after clearing the beam.

3. Entry direction check

Action
– Move an object through the path in the A then B direction.
– Do this slowly and clearly for 5 passes, one pass at a time.

Expected observation
– For each clean crossing, Serial prints:
Sequence start: A first
– then ENTRY, count=<n>
– The display increments by 1 each time.

Pass condition
– The displayed count increases exactly once per clean A-then-B crossing.

4. Exit direction check

Action
– Now move through the path in the B then A direction.
– Perform 5 clean passes.

Expected observation
– For each clean crossing, Serial prints:
Sequence start: B first
– then EXIT, count=<n>
– The display decreases by 1 each time, but not below zero.

Pass condition
– The display decreases correctly and never shows a negative number.

5. Timeout and false-trigger check

Action
– Break beam A, hold for longer than the timeout, then remove your hand without crossing beam B.
– Repeat with beam B only.
– Test with quick accidental taps near only one beam.

Expected observation
– Serial prints timeout messages.
– The display count does not change.

Pass condition
– Incomplete crossings do not change occupancy.

6. Repeated use check

Action
– Perform 10 mixed crossings in known order, for example:
– Entry, Entry, Exit, Entry, Exit, Exit, Entry, Entry, Entry, Exit
– Manually compute the expected final count.

Expected observation
– The display matches your manual total.
– Serial logs correspond to the crossing order.

Pass condition
– Final displayed occupancy equals the expected value after the full test sequence.

Troubleshooting

Symptom Likely cause Fix
Display stays blank TM1637 VCC/GND reversed, wrong CLK/DIO pins, bad jumper Recheck power and confirm CLK -> D4, DIO -> D5
Display powers but never changes from 0 Sensor outputs not connected, sensors misaligned, wrong sensor logic Confirm receiver outputs go to D2 and D3; realign emitters/receivers; verify active-low behavior
Count increases when leaving and decreases when entering Beam labels A and B reversed physically Swap the physical beam positions or swap D2 and D3 connections
One crossing produces two counts Beams too close, reflections, person lingering, insufficient cooldown Increase spacing to 10-15 cm, narrow the path, reduce reflections, keep one-at-a-time passage
Random counts with nobody crossing Ambient IR interference, loose wires, unstable sensor modules Move away from direct sunlight, tighten connections, improve mounting
Serial monitor shows timeouts frequently Beams too far apart or movement too slow Reduce beam spacing or increase SEQUENCE_TIMEOUT_MS slightly
Count never decreases below 0 but exits seem ignored Starting count is already zero Perform a few entries first, then test exits
Upload fails Wrong port or missing Arduino core Re-run arduino-cli core update-index, arduino-cli core install arduino:avr, and verify the correct port

Improvements

Better physical reliability

  • Build a more rigid doorway frame from acrylic, wood, or 3D-printed brackets.
  • Add side shields around the IR receivers to reduce ambient light interference.
  • Use a narrower passage so only one person can cross at a time.

Better software behavior

  • Store the count in EEPROM periodically so it survives power cycling.
  • Add a long-press reset button to clear the count when the room is known to be empty.
  • Tune:
  • DEBOUNCE_MS
  • SEQUENCE_TIMEOUT_MS
  • COOLDOWN_MS
    to match your doorway and walking speed.

Better system usability

  • Add a buzzer or LED feedback on each valid entry/exit event.
  • Add a serial command such as reset or set 25 for manual calibration after setup.
  • Add a second display mode that alternates between occupancy and total entries for the day.

Final Checklist

  • [ ] I used exactly Arduino UNO R3 (ATmega328P) + two IR break-beam sensors + TM1637 4-digit display.
  • [ ] Beam A receiver output is connected to D2.
  • [ ] Beam B receiver output is connected to D3.
  • [ ] TM1637 CLK -> D4 and DIO -> D5.
  • [ ] All modules share 5V and GND correctly.
  • [ ] I saved the sketch as dual_ir_beam_visitor_counter/dual_ir_beam_visitor_counter.ino.
  • [ ] I ran:
  • [ ] arduino-cli core update-index
  • [ ] arduino-cli core install arduino:avr
  • [ ] arduino-cli compile --fqbn arduino:avr:uno dual_ir_beam_visitor_counter
  • [ ] arduino-cli upload --fqbn arduino:avr:uno --port <PORT> dual_ir_beam_visitor_counter
  • [ ] The display shows 0 at startup.
  • [ ] A then B increments the displayed count.
  • [ ] B then A decrements the displayed count.
  • [ ] Incomplete crossings produce timeout logs but do not change the count.
  • [ ] The prototype is mounted in a narrow, realistic doorway path for practical use.

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What is the main controller used in the doorway visitor counter system?




Question 2: How many IR break-beam sensors are used in the project?




Question 3: What display module shows the occupancy count?




Question 4: How does the system determine direction of movement?




Question 5: What does the system update after detecting entry or exit?




Question 6: What is the typical feedback time per event mentioned in the article?




Question 7: Which use case is listed for this project?




Question 8: Which restricted area example is mentioned as a possible monitoring target?




Question 9: What important feature does the system avoid using?




Question 10: What kind of microcontroller platform is highlighted for this embedded systems practice?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:


Practical case: relay timer on Arduino UNO

Practical case: relay timer on Arduino UNO — hero

Objective and use case

What you’ll build: A standalone workshop countdown relay timer using an Arduino UNO R3, pushbuttons, a 1-channel relay module, and a piezo buzzer. Users can set a duration, start a non-blocking countdown with millis(), keep a 5 V or 12 V load energized through the relay while time remains, and trigger an audible alert when the timer reaches 0.

Why it matters / Use cases

  • Automatically shut off small bench loads such as a 5 V fan, miniature pump, or 12 V test lamp after a fixed 30 s to 10 min work cycle.
  • Improve repeatability for workshop tasks like adhesive warming, PCB fume extraction, cleaner runs, or jig activation where consistent timing matters more than manual estimation.
  • Teach core embedded skills in one build: button reading, debounce timing in the 10–50 ms range, relay control, buzzer feedback, and finite-state logic without using delay().
  • Run fully standalone after upload, with near-instant button response under typical loop times of a few milliseconds and no PC connection required.

Expected outcome

  • A timer that lets the user increase or decrease duration with pushbuttons, then start or stop the countdown reliably.
  • A relay output that stays active for the programmed interval and switches off immediately when time expires or the cycle is canceled.
  • A buzzer pattern at completion, for example 2–3 short beeps over 1–2 s, to clearly signal the end of the timed run.
  • A responsive low-load Arduino implementation: no meaningful GPU usage, very low CPU demand on the ATmega328P, and sub-10 ms control latency for normal button and output events.

Audience: Arduino beginners, makers, students, and electronics workshop users; Level: beginner to lower-intermediate

Architecture/flow: Pushbuttons feed the UNO digital inputs; the sketch debounces presses, updates the selected time, and tracks countdown state with millis(); the UNO drives the relay input to energize the external load during the active window and toggles the piezo buzzer when the countdown completes.

Educational validation note

Before publication, this case passed the Prometeo automated validation gate with status PASS. The validator checked the code blocks, article structure, copy/paste-safe commands and consistency with the supported device catalog.

Published validation evidence

  • Automatic result: PASS.
  • Parsed structure: 3 sections, 3 tables and 2 code blocks detected before publication.
  • Checked code: 1 Arduino/arduino-cli compile, 1 Bash/copy-paste checks.
  • Supported catalog: the article text was checked against Prometeo’s validation-capable device profiles, and unsupported stacks block publication.
  • Report findings: no blocking findings.

This validation confirms syntax and tool compatibility for the published material, but it does not replace physical testing on your exact hardware, wiring and runtime environment.

Educational safety note

This prototype is intended for education and bench-level experimentation. Its limits are important:

  • Do not use this beginner build to switch household mains voltage unless you are trained and your lab explicitly permits it.
  • Relay modules can expose dangerous voltages on their contact terminals.
  • For classroom validation, use a low-voltage DC load only.
  • The Arduino side and relay contact side are different electrical domains. Treat the relay contact wiring carefully even if the control side is only 5 V.
  • Do not use this timer to control:
  • safety-critical tools
  • unattended heating systems
  • medical devices
  • vehicle systems
  • high-power machinery
  • If you later connect motors, solenoids, or other inductive loads, additional protection and proper power design may be required.
  • Keep wiring insulated, stable, and strain-relieved. Loose relay wiring can create unreliable switching or hazardous shorts.
  • The piezo buzzer is only an alert aid. It is not guaranteed to be heard in a noisy workshop.
  • This tutorial demonstrates a useful educational prototype, not a certified product.

Prerequisites

Before starting, make sure you have:

  • Basic familiarity with the Arduino IDE concept, even though this tutorial uses Arduino CLI
  • A USB cable for the Arduino UNO R3
  • A computer with:
  • Arduino CLI installed
  • A serial terminal available, or the Arduino IDE Serial Monitor if preferred
  • Basic understanding of:
  • Digital inputs with pull-up resistors
  • Digital outputs
  • Safe use of relay modules
  • A low-voltage load for testing the relay output
  • Recommended: a small DC lamp, LED load module, low-voltage fan, or small DC motor supply line through the relay contacts
  • Do not start with household mains loads in a beginner lab

Materials

Use the exact platform below.

  • Arduino UNO R3 (ATmega328P)
  • 3 pushbuttons
    Suggested labels:
  • ADD
  • START/PAUSE
  • CANCEL/RESET
  • 1-channel relay module
  • 5 V coil/module version compatible with Arduino logic
  • Prefer a module with transistor driver and input indicator LED
  • Piezo buzzer
  • Passive or active buzzer; this tutorial assumes a simple piezo buzzer that can be driven from a digital pin
  • Breadboard
  • Jumper wires
  • USB cable for Arduino UNO R3
  • Optional but recommended:
  • Small low-voltage DC lamp or fan as the relay-switched test load
  • External low-voltage supply for that test load if needed by the load

Setup/Connection

This project uses the Arduino’s internal pull-up resistors for all pushbuttons. That means each button is wired between the input pin and GND. When not pressed, the input reads HIGH. When pressed, it reads LOW.

Pin assignment

Function Arduino pin Wiring details
ADD button D2 One side of pushbutton to D2, other side to GND
START/PAUSE button D3 One side of pushbutton to D3, other side to GND
CANCEL/RESET button D4 One side of pushbutton to D4, other side to GND
Relay input D8 Relay module IN to D8
Buzzer D9 Buzzer signal to D9, buzzer ground to GND
Relay VCC 5V Relay module VCC to Arduino 5V
Relay GND GND Relay module GND to Arduino GND
Buzzer GND GND Buzzer ground to Arduino GND
USB power/data USB Arduino to computer

Connection notes

  1. Pushbuttons
  2. Connect one terminal of each button to the assigned digital pin.
  3. Connect the other terminal of each button to GND.
  4. No external resistors are needed because the sketch enables INPUT_PULLUP.

  5. Relay module

  6. Connect:
    • VCC -> 5V
    • GND -> GND
    • IN -> D8
  7. Many relay modules are active-low, meaning the relay turns on when the Arduino pin outputs LOW.
  8. The code below is written for a common active-low relay module and includes one constant you can change if your module is active-high.

  9. Piezo buzzer

  10. Connect the positive/signal pin to D9.
  11. Connect the negative pin to GND.
  12. For many small piezo buzzers, direct connection is acceptable for a basic educational prototype.

  13. Relay contact side

  14. The relay contact terminals are separate from the Arduino side.
  15. For a timer-controlled output, use the relay’s COM and NO terminals if you want the load to turn on only while timing is active.
  16. Keep your first test simple:
    • Use a low-voltage load
    • Use a known safe supply
    • Verify the relay switches correctly before connecting anything more complex

Suggested operating behavior

  • ADD button: increases preset time by 1 minute, from 1 to 60 minutes, then wraps back to 1
  • START/PAUSE button: starts the timer if idle; pauses/resumes if already running
  • CANCEL/RESET button: stops the timer and returns to the current preset value
  • Buzzer feedback:
  • Short beep on valid button actions
  • Distinct multi-beep pattern when time completes

Validated Code

workshop_countdown_relay_timer.ino

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

/*
  workshop_countdown_relay_timer.ino

  Device model:
  Arduino UNO R3 (ATmega328P) + pushbuttons + 1-channel relay module + piezo buzzer

  Project goal:
  workshop-countdown-relay-timer

  Behavior:
  - ADD button: increase preset time by 1 minute (1..60, wrap around)
  - START/PAUSE button:
      * if idle -> start countdown
      * if running -> pause
      * if paused -> resume
  - CANCEL/RESET button:
      * stop countdown
      * relay off
      * restore remaining time to preset
  - Relay is active while countdown is running
  - Buzzer gives short feedback on actions and a completion pattern at the end

  Notes:
  - Buttons use INPUT_PULLUP, so pressed = LOW
  - Relay modules are often active-low; set RELAY_ACTIVE_LOW accordingly
*/

const byte PIN_BTN_ADD = 2;
const byte PIN_BTN_START = 3;
const byte PIN_BTN_CANCEL = 4;
const byte PIN_RELAY = 8;
const byte PIN_BUZZER = 9;

const bool RELAY_ACTIVE_LOW = true;

const unsigned long DEBOUNCE_MS = 35;
const unsigned long STATUS_PRINT_MS = 1000;
const unsigned long MINUTES_MAX = 60;
const unsigned long DEFAULT_PRESET_MIN = 5;

enum TimerState {
  IDLE,
  RUNNING,
  PAUSED,
  FINISHED
};

TimerState timerState = IDLE;

unsigned long presetMinutes = DEFAULT_PRESET_MIN;
unsigned long remainingMs = DEFAULT_PRESET_MIN * 60UL * 1000UL;
unsigned long lastTickMs = 0;
unsigned long lastStatusPrintMs = 0;

struct Button {
  byte pin;
  bool stableState;
  bool lastReading;
  unsigned long lastChangeMs;
};

Button btnAdd    = {PIN_BTN_ADD, HIGH, HIGH, 0};
Button btnStart  = {PIN_BTN_START, HIGH, HIGH, 0};
Button btnCancel = {PIN_BTN_CANCEL, HIGH, HIGH, 0};

void setRelay(bool on) {
  if (RELAY_ACTIVE_LOW) {
    digitalWrite(PIN_RELAY, on ? LOW : HIGH);
  } else {
    digitalWrite(PIN_RELAY, on ? HIGH : LOW);
  }
}

void beep(unsigned int frequency, unsigned long durationMs) {
  tone(PIN_BUZZER, frequency, durationMs);
}

void shortBeep() {
  beep(2200, 70);
}

void doubleBeep() {
  beep(2000, 60);
  delay(100);
  beep(2600, 80);
}

void finishBeepPattern() {
  for (int i = 0; i < 3; i++) {
    beep(1800, 120);
    delay(180);
  }
  beep(2600, 350);
  delay(400);
}

bool buttonPressedEvent(Button &button) {
  bool reading = digitalRead(button.pin);

  if (reading != button.lastReading) {
    button.lastChangeMs = millis();
    button.lastReading = reading;
  }

  if ((millis() - button.lastChangeMs) > DEBOUNCE_MS) {
    if (reading != button.stableState) {
      button.stableState = reading;

      // Because INPUT_PULLUP is used, LOW means pressed
      if (button.stableState == LOW) {
        return true;
      }
    }
  }

  return false;
}

void printHelp() {
  Serial.println(F("Workshop Countdown Relay Timer"));
  Serial.println(F("Buttons:"));
  Serial.println(F("  ADD         -> +1 minute preset (1..60)"));
  Serial.println(F("  START/PAUSE -> start, pause, resume"));
  Serial.println(F("  CANCEL      -> stop and reset to preset"));
  Serial.println();
}

void printStateLine() {
  unsigned long totalSeconds = remainingMs / 1000UL;
  unsigned int minutesPart = totalSeconds / 60UL;
  unsigned int secondsPart = totalSeconds % 60UL;

  Serial.print(F("Preset="));
  Serial.print(presetMinutes);
  Serial.print(F(" min, Remaining="));
  if (minutesPart < 10) Serial.print('0');
  Serial.print(minutesPart);
  Serial.print(':');
  if (secondsPart < 10) Serial.print('0');
  Serial.print(secondsPart);
  Serial.print(F(", State="));

  switch (timerState) {
    case IDLE: Serial.print(F("IDLE")); break;
    case RUNNING: Serial.print(F("RUNNING")); break;
    case PAUSED: Serial.print(F("PAUSED")); break;
    case FINISHED: Serial.print(F("FINISHED")); break;
  }
// ...

/*
  workshop_countdown_relay_timer.ino

  Device model:
  Arduino UNO R3 (ATmega328P) + pushbuttons + 1-channel relay module + piezo buzzer

  Project goal:
  workshop-countdown-relay-timer

  Behavior:
  - ADD button: increase preset time by 1 minute (1..60, wrap around)
  - START/PAUSE button:
      * if idle -> start countdown
      * if running -> pause
      * if paused -> resume
  - CANCEL/RESET button:
      * stop countdown
      * relay off
      * restore remaining time to preset
  - Relay is active while countdown is running
  - Buzzer gives short feedback on actions and a completion pattern at the end

  Notes:
  - Buttons use INPUT_PULLUP, so pressed = LOW
  - Relay modules are often active-low; set RELAY_ACTIVE_LOW accordingly
*/

const byte PIN_BTN_ADD = 2;
const byte PIN_BTN_START = 3;
const byte PIN_BTN_CANCEL = 4;
const byte PIN_RELAY = 8;
const byte PIN_BUZZER = 9;

const bool RELAY_ACTIVE_LOW = true;

const unsigned long DEBOUNCE_MS = 35;
const unsigned long STATUS_PRINT_MS = 1000;
const unsigned long MINUTES_MAX = 60;
const unsigned long DEFAULT_PRESET_MIN = 5;

enum TimerState {
  IDLE,
  RUNNING,
  PAUSED,
  FINISHED
};

TimerState timerState = IDLE;

unsigned long presetMinutes = DEFAULT_PRESET_MIN;
unsigned long remainingMs = DEFAULT_PRESET_MIN * 60UL * 1000UL;
unsigned long lastTickMs = 0;
unsigned long lastStatusPrintMs = 0;

struct Button {
  byte pin;
  bool stableState;
  bool lastReading;
  unsigned long lastChangeMs;
};

Button btnAdd    = {PIN_BTN_ADD, HIGH, HIGH, 0};
Button btnStart  = {PIN_BTN_START, HIGH, HIGH, 0};
Button btnCancel = {PIN_BTN_CANCEL, HIGH, HIGH, 0};

void setRelay(bool on) {
  if (RELAY_ACTIVE_LOW) {
    digitalWrite(PIN_RELAY, on ? LOW : HIGH);
  } else {
    digitalWrite(PIN_RELAY, on ? HIGH : LOW);
  }
}

void beep(unsigned int frequency, unsigned long durationMs) {
  tone(PIN_BUZZER, frequency, durationMs);
}

void shortBeep() {
  beep(2200, 70);
}

void doubleBeep() {
  beep(2000, 60);
  delay(100);
  beep(2600, 80);
}

void finishBeepPattern() {
  for (int i = 0; i < 3; i++) {
    beep(1800, 120);
    delay(180);
  }
  beep(2600, 350);
  delay(400);
}

bool buttonPressedEvent(Button &button) {
  bool reading = digitalRead(button.pin);

  if (reading != button.lastReading) {
    button.lastChangeMs = millis();
    button.lastReading = reading;
  }

  if ((millis() - button.lastChangeMs) > DEBOUNCE_MS) {
    if (reading != button.stableState) {
      button.stableState = reading;

      // Because INPUT_PULLUP is used, LOW means pressed
      if (button.stableState == LOW) {
        return true;
      }
    }
  }

  return false;
}

void printHelp() {
  Serial.println(F("Workshop Countdown Relay Timer"));
  Serial.println(F("Buttons:"));
  Serial.println(F("  ADD         -> +1 minute preset (1..60)"));
  Serial.println(F("  START/PAUSE -> start, pause, resume"));
  Serial.println(F("  CANCEL      -> stop and reset to preset"));
  Serial.println();
}

void printStateLine() {
  unsigned long totalSeconds = remainingMs / 1000UL;
  unsigned int minutesPart = totalSeconds / 60UL;
  unsigned int secondsPart = totalSeconds % 60UL;

  Serial.print(F("Preset="));
  Serial.print(presetMinutes);
  Serial.print(F(" min, Remaining="));
  if (minutesPart < 10) Serial.print('0');
  Serial.print(minutesPart);
  Serial.print(':');
  if (secondsPart < 10) Serial.print('0');
  Serial.print(secondsPart);
  Serial.print(F(", State="));

  switch (timerState) {
    case IDLE: Serial.print(F("IDLE")); break;
    case RUNNING: Serial.print(F("RUNNING")); break;
    case PAUSED: Serial.print(F("PAUSED")); break;
    case FINISHED: Serial.print(F("FINISHED")); break;
  }

  Serial.print(F(", Relay="));
  Serial.println((timerState == RUNNING) ? F("ON") : F("OFF"));
}

void resetToPreset() {
  remainingMs = presetMinutes * 60UL * 1000UL;
  lastTickMs = millis();
}

void handleAddButton() {
  // For simplicity in a basic project, allow preset changes only when not running
  if (timerState == RUNNING) {
    doubleBeep();
    Serial.println(F("ADD ignored while running. Pause or cancel first."));
    return;
  }

  presetMinutes++;
  if (presetMinutes > MINUTES_MAX) {
    presetMinutes = 1;
  }

  resetToPreset();

  if (timerState == FINISHED) {
    timerState = IDLE;
  }

  shortBeep();
  Serial.print(F("Preset changed to "));
  Serial.print(presetMinutes);
  Serial.println(F(" minute(s)."));
  printStateLine();
}

void handleStartButton() {
  if (timerState == IDLE || timerState == FINISHED) {
    resetToPreset();
    timerState = RUNNING;
    setRelay(true);
    lastTickMs = millis();
    shortBeep();
    Serial.println(F("Countdown started."));
    printStateLine();
    return;
  }

  if (timerState == RUNNING) {
    timerState = PAUSED;
    setRelay(false);
    shortBeep();
    Serial.println(F("Countdown paused."));
    printStateLine();
    return;
  }

  if (timerState == PAUSED) {
    timerState = RUNNING;
    setRelay(true);
    lastTickMs = millis();
    shortBeep();
    Serial.println(F("Countdown resumed."));
    printStateLine();
    return;
  }
}

void handleCancelButton() {
  timerState = IDLE;
  setRelay(false);
  resetToPreset();
  doubleBeep();
  Serial.println(F("Countdown canceled and reset to preset."));
  printStateLine();
}

void updateTimer() {
  if (timerState != RUNNING) {
    return;
  }

  unsigned long now = millis();
  unsigned long elapsed = now - lastTickMs;
  lastTickMs = now;

  if (elapsed >= remainingMs) {
    remainingMs = 0;
    timerState = FINISHED;
    setRelay(false);
    Serial.println(F("Countdown complete. Relay turned OFF."));
    printStateLine();
    finishBeepPattern();
    return;
  }

  remainingMs -= elapsed;
}

void setup() {
  pinMode(PIN_BTN_ADD, INPUT_PULLUP);
  pinMode(PIN_BTN_START, INPUT_PULLUP);
  pinMode(PIN_BTN_CANCEL, INPUT_PULLUP);

  pinMode(PIN_RELAY, OUTPUT);
  pinMode(PIN_BUZZER, OUTPUT);

  setRelay(false);

  Serial.begin(9600);
  delay(300);

  printHelp();
  resetToPreset();
  printStateLine();
}

void loop() {
  if (buttonPressedEvent(btnAdd)) {
    handleAddButton();
  }

  if (buttonPressedEvent(btnStart)) {
    handleStartButton();
  }

  if (buttonPressedEvent(btnCancel)) {
    handleCancelButton();
  }

  updateTimer();

  unsigned long now = millis();
  if (now - lastStatusPrintMs >= STATUS_PRINT_MS) {
    lastStatusPrintMs = now;
    printStateLine();
  }
}

What the code is doing

This sketch uses a state machine with four states:

  • IDLE – waiting for user input
  • RUNNING – countdown active, relay on
  • PAUSED – countdown frozen, relay off
  • FINISHED – countdown reached zero, relay off, buzzer alert completed

Important beginner-friendly design choices:

  • No blocking countdown loop
    The timer uses millis() instead of a long delay(), so button presses stay responsive.
  • Debounced buttons
    Each button is filtered with a small debounce interval to reduce false triggers from contact bounce.
  • Preset and remaining time are separate
    presetMinutes stores the chosen duration, while remainingMs tracks what is left during a run.
  • Serial status output
    The Arduino prints regular updates, which makes troubleshooting much easier during validation.

Build/Flash/Run commands

Command table

Purpose Command
Update board index arduino-cli core update-index
Install AVR core arduino-cli core install arduino:avr
Compile sketch arduino-cli compile --fqbn arduino:avr:uno workshop_countdown_relay_timer
Upload sketch arduino-cli upload --fqbn arduino:avr:uno --port <PORT> workshop_countdown_relay_timer

Example terminal session

arduino-cli core update-index
arduino-cli core install arduino:avr
arduino-cli compile --fqbn arduino:avr:uno workshop_countdown_relay_timer
arduino-cli upload --fqbn arduino:avr:uno --port <PORT> workshop_countdown_relay_timer

Short workflow

  1. Create a folder named workshop_countdown_relay_timer.
  2. Save the sketch as:
  3. workshop_countdown_relay_timer/workshop_countdown_relay_timer.ino
  4. Open a terminal in the parent directory of that folder.
  5. Run the compile command.
  6. Connect the Arduino UNO R3 by USB.
  7. Replace <PORT> with your actual serial port and run the upload command.
  8. Linux example: /dev/ttyACM0
  9. Windows example: COM4
  10. macOS example: /dev/cu.usbmodem14101
  11. Open a serial monitor at 9600 baud to observe timer status.

Step-by-step Validation

Use these checkpoints in order. The goal is to validate the exact workshop-countdown-relay-timer behavior.

1. Power-up and idle status

Action
– Connect the Arduino by USB.
– Open the serial monitor at 9600 baud.

Expected observation
– You should see a startup banner like:
Workshop Countdown Relay Timer
– You should also see a status line with:
Preset=5 min
State=IDLE
Relay=OFF

Pass condition
– The relay is not energized at startup.
– The serial monitor shows the preset time and idle state without random characters.

2. Preset adjustment using the ADD button

Action
– Press the ADD button once.
– Then press it several more times.

Expected observation
– Each press increases the preset by 1 minute.
– The serial monitor reports:
Preset changed to 6 minute(s).
– and updated state lines
– After 60 minutes, the next press wraps to 1 minute.

Pass condition
– One button press produces one preset change.
– No frequent double-counting occurs from switch bounce.
– The timer remains in IDLE and the relay stays off during preset adjustment.

3. Start countdown and confirm relay activation

Action
– Set a short test preset such as 1 minute.
– Press START/PAUSE once.

Expected observation
– Serial output says:
Countdown started.
State=RUNNING
Relay=ON
– The relay module indicator LED, if present, should change state.
– Your connected low-voltage test load should turn on through the relay.

Pass condition
– Relay activates only when the timer enters RUNNING.
– The remaining time decreases once per second in the serial monitor.

4. Pause, resume, and cancel behavior

Action
– While running, press START/PAUSE again.
– Press it once more to resume.
– Then press CANCEL/RESET.

Expected observation
– On pause:
State=PAUSED
Relay=OFF
– On resume:
State=RUNNING
Relay=ON
– On cancel:
Countdown canceled and reset to preset.
State=IDLE
Relay=OFF

Pass condition
– Pause stops the countdown without losing the remaining time.
– Resume continues from the paused remaining time.
– Cancel immediately turns off the relay and restores the full preset value.

5. End-of-countdown completion test

Action
– Set the preset to 1 minute and let it run to zero without pressing buttons.

Expected observation
– At completion:
– Serial output reports Countdown complete. Relay turned OFF.
– State changes to FINISHED
– The buzzer sounds a recognizable completion pattern
– The low-voltage test load turns off at the same moment the relay deactivates.

Pass condition
– Relay turns off automatically at timeout.
– Completion buzzer sounds once per countdown completion event.
– The system does not restart by itself.

Troubleshooting

Symptom Likely cause Fix
Button press does nothing Button wired to 5V instead of GND, wrong pin, poor breadboard contact Verify each button goes between pin and GND, and matches D2/D3/D4
Relay is always on Relay module active-low behavior not matched, or wiring error Check RELAY_ACTIVE_LOW; if needed change to false and re-upload
Relay clicks but load does not switch COM/NO/NC contacts wired incorrectly Use relay contact terminals correctly; for normal timed-on behavior use COM and NO
Buzzer silent Buzzer polarity reversed, wrong pin, or incompatible buzzer type Confirm buzzer is on D9 and GND; try another piezo buzzer
Serial monitor shows unreadable text Wrong baud rate Set serial monitor to 9600 baud
Timer resets unexpectedly USB power unstable or load noise affecting supply Use a stable USB cable, keep relay/load wiring tidy, and avoid powering noisy loads from the Arduino 5V rail
One press causes two actions Switch bounce or loose connection Re-seat wires, use better pushbuttons, keep button wiring short
Upload fails Wrong port, missing board core, cable issue Re-run core install, verify port, use a known data-capable USB cable

Improvements

Usability upgrades

  • Add a 4-digit 7-segment display or LCD to show remaining time without using the serial monitor.
  • Add a long-press feature on the ADD button for faster time setting.
  • Store the last preset in EEPROM so the timer remembers it after power loss.

Control and timing features

  • Add a 10-second adjustment mode for short process timing.
  • Add a post-run buzzer mute option so the timer can be used in quieter classrooms.
  • Add a relay overrun mode, where a fan keeps running for an extra minute after a work cycle ends.

Electrical robustness

  • Use a proper project enclosure with labeled buttons.
  • Add screw terminals for the load side and strain relief for wires.
  • If switching inductive DC loads, add the right suppression methods on the load side according to the module and load type.

Final Checklist

  • [ ] I used Arduino UNO R3 (ATmega328P)
  • [ ] I used 3 pushbuttons, 1-channel relay module, and piezo buzzer
  • [ ] Buttons are wired from input pin to GND
  • [ ] Relay module is connected to 5V, GND, and D8
  • [ ] Buzzer is connected to D9 and GND
  • [ ] I saved the file as workshop_countdown_relay_timer/workshop_countdown_relay_timer.ino
  • [ ] I ran arduino-cli core update-index
  • [ ] I ran arduino-cli core install arduino:avr
  • [ ] I compiled with arduino-cli compile --fqbn arduino:avr:uno workshop_countdown_relay_timer
  • [ ] I uploaded with arduino-cli upload --fqbn arduino:avr:uno --port <PORT> workshop_countdown_relay_timer
  • [ ] Serial monitor at 9600 baud shows startup status
  • [ ] ADD changes the preset time
  • [ ] START begins countdown and turns relay on
  • [ ] PAUSE turns relay off and preserves remaining time
  • [ ] CANCEL stops the timer and restores the preset
  • [ ] Countdown completion turns relay off and triggers the buzzer pattern

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What is the main controller used in the workshop countdown relay timer project?




Question 2: Which function is specifically mentioned for creating a non-blocking countdown?




Question 3: What happens to the relay while time still remains in the countdown?




Question 4: What kind of alert is triggered when the timer reaches 0?




Question 5: Which of the following is listed as a possible timed load?




Question 6: What duration range is mentioned for the fixed work cycle examples?




Question 7: Which embedded skill does this build help teach without using delay()?




Question 8: How does the system operate after the program is uploaded?




Question 9: What button-related technique is mentioned as part of the build?




Question 10: Besides the Arduino and relay, which input component is explicitly included in the project?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:


Practical case: solar battery monitor, Arduino UNO

Practical case: solar battery monitor, Arduino UNO — hero

Objective and use case

What you’ll build: An Arduino-based solar battery monitor that reads 12 V battery voltage through a voltage divider, measures charge or load current with an ACS712 sensor, and displays both values on a 16×2 HD44780 LCD. The system gives near real-time local readings with a simple refresh around 2–5 updates/sec and typical display latency under 500 ms.

Why it matters / Use cases

  • Confirm a small solar panel is actually charging by checking for positive current, for example +0.3 A to +1.8 A in direct sun on a shed, camping box, or classroom demo setup.
  • Reduce battery damage by spotting low voltage early, such as a 12 V lead-acid battery dropping toward 11.8–12.0 V while powering LED lights, a router backup, or a small DC fan.
  • Measure real load current by placing the ACS712 in series with a lamp, pump, or USB converter and verifying expected draw, such as 0.15 A for LEDs or 0.8 A for a small pump.
  • Use it as a reusable bench monitor beside a charge controller or battery box for quick maintenance checks without needing a laptop, app, or cloud dashboard.
  • Learn practical embedded measurement, including analog sensing, calibration, ADC scaling, and sensor noise handling in a low-power Arduino project.

Expected outcome

  • A working LCD readout showing battery voltage and current continuously, for example “12.6 V” and “+0.42 A”.
  • Voltage readings scaled from the divider and current readings interpreted from the ACS712 with basic calibration for more stable values.
  • A compact monitor suitable for small 12 V solar experiments, with no meaningful GPU usage and no FPS dependency beyond the LCD refresh rate.
  • A practical baseline you can extend with alarms, data logging, state-of-charge estimates, or charge/discharge direction indicators.

Audience: Arduino beginners, students, makers, and hobbyists working with small solar or battery projects; Level: Beginner to intermediate

Architecture/flow: Arduino reads battery voltage from a resistor divider and current from the ACS712 analog output, converts both ADC values into engineering units, then refreshes the 16×2 HD44780 LCD every 200–500 ms with live voltage/current status.

Educational validation note

Before publication, this case passed the Prometeo automated validation gate with status PASS. The validator checked the code blocks, article structure, copy/paste-safe commands and consistency with the supported device catalog.

Published validation evidence

  • Automatic result: PASS.
  • Parsed structure: 3 sections, 4 tables and 2 code blocks detected before publication.
  • Checked code: 1 Arduino/arduino-cli compile, 1 Bash/copy-paste checks.
  • Supported catalog: the article text was checked against Prometeo’s validation-capable device profiles, and unsupported stacks block publication.
  • Report findings: no blocking findings.

This validation confirms syntax and tool compatibility for the published material, but it does not replace physical testing on your exact hardware, wiring and runtime environment.

Educational safety note

This is an educational low-voltage monitoring prototype, not a certified battery instrument.

Important limits and precautions:

  • Work only on low-voltage DC educational setups.
  • Do not connect the Arduino analog pin directly to a battery; always use the stated voltage divider.
  • Do not use this tutorial directly on large solar arrays, high-current battery banks, vehicle electrical systems, or mains-connected equipment.
  • The ACS712 module current path and terminal rating must match your actual current. Do not exceed the module rating.
  • Batteries can deliver high fault current even at low voltage:
  • avoid short circuits
  • fuse the battery line if possible
  • keep exposed conductors insulated
  • If your solar system includes a charge controller, understand the controller wiring before inserting the current sensor in series.
  • Do not rely on this prototype alone to protect a battery from overcharge, deep discharge, overheating, or wiring faults.
  • During startup calibration, ensure no current is flowing through the ACS712, otherwise the zero point will be wrong and the current reading will be biased.

Prerequisites

Before starting, you should have:

  • A computer with Arduino CLI installed
  • A USB cable for the Arduino UNO R3
  • Basic familiarity with:
  • uploading sketches
  • reading pin labels
  • using a multimeter
  • A small low-voltage solar-battery setup under test, such as:
  • a 12 V sealed lead-acid battery with a small solar charge controller
  • a 12 V LiFePO4 educational setup with proper protection
  • a bench DC source acting as a solar simulator for testing

For a first build, it is strongly recommended to test with a bench supply and a small DC load before connecting to a real outdoor solar system.

Materials

Use exactly this device model as the core of the project:

Arduino UNO R3 (ATmega328P) + voltage divider + ACS712 current sensor + 16×2 HD44780 LCD

Recommended parts list:

Item Suggested specification Notes
Arduino board Arduino UNO R3 (ATmega328P) Required
LCD 16×2 HD44780 compatible, parallel interface Required
Current sensor ACS712 module, preferably 5 A version 5 A version gives better sensitivity
Voltage divider resistor R1 47 kOhm, 1/4 W Top resistor from battery positive to analog pin
Voltage divider resistor R2 10 kOhm, 1/4 W Bottom resistor from analog pin to GND
LCD contrast potentiometer 10 kOhm trimmer Required for visible text
Breadboard and jumper wires Standard Required
Small DC battery under test Typically 12 V Keep within divider input range
Small DC load for testing 12 V lamp or resistor load Useful for validation
Multimeter Digital Strongly recommended

Why these resistor values?

With 47 kOhm and 10 kOhm, the analog input sees:

V_A0 = V_battery x (10 / (47 + 10)) = V_battery x 0.1754

That keeps the analog input under about 5 V up to roughly 28.5 V. This is comfortable for 12 V systems and gives useful headroom for charging voltages. Even so, this tutorial is intended for low-voltage educational battery systems, not large or high-energy installations.

Setup/Connection

Measurement concept

This monitor uses two analog channels:

  • A0 reads battery voltage through the resistor divider
  • A1 reads the ACS712 analog output

The LCD shows:

  • battery voltage
  • current
  • power
  • a simple state message such as CHARGING, DISCHARGE, or IDLE

Important current-path choice

Choose one current path to monitor with the ACS712:

  • Option A: charging current
  • Place ACS712 in series between the charge controller output and battery positive path, if your low-power educational setup allows it.
  • Option B: load current
  • Place ACS712 in series between battery positive and the DC load.

For a beginner build, Option B is usually easier and safer to understand:
– current near zero when load is off
– current increases when load is on

Arduino and LCD connections

Use the standard LiquidCrystal wiring below.

Function LCD pin Connect to
VSS 1 Arduino GND
VDD 2 Arduino 5V
VO 3 Middle pin of 10 kOhm potentiometer
RS 4 Arduino D7
RW 5 Arduino GND
E 6 Arduino D8
D4 11 Arduino D9
D5 12 Arduino D10
D6 13 Arduino D11
D7 14 Arduino D12
A (backlight +) 15 5V through module resistor if required
K (backlight -) 16 GND

Potentiometer wiring:
– one outer pin -> 5V
– other outer pin -> GND
– middle pin -> LCD pin 3 (VO)

Analog measurement connections

Voltage divider

  • Battery positive -> 47 kOhm resistor -> node -> A0
  • Same node -> 10 kOhm resistor -> GND
  • Battery negative -> Arduino GND

This common ground is essential. Without it, the ADC reading will be meaningless.

ACS712

Most ACS712 modules have:
VCC
GND
OUT
– two screw terminals for the measured current path

Connect:
VCC -> Arduino 5V
GND -> Arduino GND
OUT -> Arduino A1

For the current path:
– connect the monitored positive wire so current flows through the ACS712 terminals
– note the module arrow or printed direction if present
– if the current sign looks reversed, swap the high-current terminals or invert the sign in software

Powering the Arduino during setup

For initial development:
– power the Arduino from USB
– keep the battery measurement and current sensor connected with common ground

After testing, you may later power the Arduino from a stable regulated 5 V source, but do not power the UNO directly from an unregulated solar panel.

Validated Code

Complete Arduino sketch: solar-battery-lcd-monitor.ino

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

#include <LiquidCrystal.h>

// LCD pins: RS, E, D4, D5, D6, D7
LiquidCrystal lcd(7, 8, 9, 10, 11, 12);

// Analog pins
const uint8_t PIN_BATTERY = A0;
const uint8_t PIN_CURRENT = A1;

// ADC reference
const float ADC_REF_VOLTAGE = 5.0;
const int ADC_MAX = 1023;

// Voltage divider values
const float R1 = 47000.0; // ohms, top resistor
const float R2 = 10000.0; // ohms, bottom resistor

// ACS712 settings
// Set according to your module:
// 5A module  -> 185.0 mV/A
// 20A module -> 100.0 mV/A
// 30A module -> 66.0  mV/A
const float ACS712_MV_PER_AMP = 185.0;

// Calibration values
float currentZeroVoltage = 2.50;   // Will be refined during startup calibration
float voltageCalibrationFactor = 1.000; // Adjust after comparing to multimeter if needed
float currentCalibrationFactor = 1.000; // Adjust after comparing to multimeter if needed

// Sampling
const int NUM_SAMPLES_VOLTAGE = 20;
const int NUM_SAMPLES_CURRENT = 100;

// Timing
unsigned long lastDisplayUpdate = 0;
const unsigned long DISPLAY_INTERVAL_MS = 500;

// Optional status thresholds
const float LOW_BATTERY_VOLTAGE = 11.8;
const float IDLE_CURRENT_THRESHOLD = 0.08;

float readAverageVoltageAtPin(uint8_t pin, int samples) {
  long sum = 0;
  for (int i = 0; i < samples; i++) {
    sum += analogRead(pin);
    delay(2);
  }
  float adc = sum / (float)samples;
  return (adc * ADC_REF_VOLTAGE) / ADC_MAX;
}

float readBatteryVoltage() {
  float dividedVoltage = readAverageVoltageAtPin(PIN_BATTERY, NUM_SAMPLES_VOLTAGE);
  float batteryVoltage = dividedVoltage * ((R1 + R2) / R2);
  batteryVoltage *= voltageCalibrationFactor;
  return batteryVoltage;
}

float readCurrent() {
  float sensorVoltage = readAverageVoltageAtPin(PIN_CURRENT, NUM_SAMPLES_CURRENT);
  float deltaVoltage = sensorVoltage - currentZeroVoltage; // volts
  float current = (deltaVoltage * 1000.0) / ACS712_MV_PER_AMP; // amps
  current *= currentCalibrationFactor;
  return current;
}

void calibrateCurrentZero() {
  // Assumes no current is flowing through ACS712 during startup.
  float total = 0.0;
  const int rounds = 10;
  for (int i = 0; i < rounds; i++) {
    total += readAverageVoltageAtPin(PIN_CURRENT, 50);
    delay(20);
  }
  currentZeroVoltage = total / rounds;
}
// ...

#include <LiquidCrystal.h>

// LCD pins: RS, E, D4, D5, D6, D7
LiquidCrystal lcd(7, 8, 9, 10, 11, 12);

// Analog pins
const uint8_t PIN_BATTERY = A0;
const uint8_t PIN_CURRENT = A1;

// ADC reference
const float ADC_REF_VOLTAGE = 5.0;
const int ADC_MAX = 1023;

// Voltage divider values
const float R1 = 47000.0; // ohms, top resistor
const float R2 = 10000.0; // ohms, bottom resistor

// ACS712 settings
// Set according to your module:
// 5A module  -> 185.0 mV/A
// 20A module -> 100.0 mV/A
// 30A module -> 66.0  mV/A
const float ACS712_MV_PER_AMP = 185.0;

// Calibration values
float currentZeroVoltage = 2.50;   // Will be refined during startup calibration
float voltageCalibrationFactor = 1.000; // Adjust after comparing to multimeter if needed
float currentCalibrationFactor = 1.000; // Adjust after comparing to multimeter if needed

// Sampling
const int NUM_SAMPLES_VOLTAGE = 20;
const int NUM_SAMPLES_CURRENT = 100;

// Timing
unsigned long lastDisplayUpdate = 0;
const unsigned long DISPLAY_INTERVAL_MS = 500;

// Optional status thresholds
const float LOW_BATTERY_VOLTAGE = 11.8;
const float IDLE_CURRENT_THRESHOLD = 0.08;

float readAverageVoltageAtPin(uint8_t pin, int samples) {
  long sum = 0;
  for (int i = 0; i < samples; i++) {
    sum += analogRead(pin);
    delay(2);
  }
  float adc = sum / (float)samples;
  return (adc * ADC_REF_VOLTAGE) / ADC_MAX;
}

float readBatteryVoltage() {
  float dividedVoltage = readAverageVoltageAtPin(PIN_BATTERY, NUM_SAMPLES_VOLTAGE);
  float batteryVoltage = dividedVoltage * ((R1 + R2) / R2);
  batteryVoltage *= voltageCalibrationFactor;
  return batteryVoltage;
}

float readCurrent() {
  float sensorVoltage = readAverageVoltageAtPin(PIN_CURRENT, NUM_SAMPLES_CURRENT);
  float deltaVoltage = sensorVoltage - currentZeroVoltage; // volts
  float current = (deltaVoltage * 1000.0) / ACS712_MV_PER_AMP; // amps
  current *= currentCalibrationFactor;
  return current;
}

void calibrateCurrentZero() {
  // Assumes no current is flowing through ACS712 during startup.
  float total = 0.0;
  const int rounds = 10;
  for (int i = 0; i < rounds; i++) {
    total += readAverageVoltageAtPin(PIN_CURRENT, 50);
    delay(20);
  }
  currentZeroVoltage = total / rounds;
}

const char* stateText(float current, float batteryVoltage) {
  if (current > IDLE_CURRENT_THRESHOLD) {
    return "CHARGING";
  } else if (current < -IDLE_CURRENT_THRESHOLD) {
    return "DISCHARGE";
  } else if (batteryVoltage < LOW_BATTERY_VOLTAGE) {
    return "LOW BAT";
  } else {
    return "IDLE";
  }
}

void printPadded(String text, uint8_t width) {
  if (text.length() >= width) {
    lcd.print(text.substring(0, width));
  } else {
    lcd.print(text);
    for (uint8_t i = text.length(); i < width; i++) {
      lcd.print(' ');
    }
  }
}

void setup() {
  lcd.begin(16, 2);
  Serial.begin(9600);

  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print("Solar Battery");
  lcd.setCursor(0, 1);
  lcd.print("Monitor Start");
  delay(1200);

  calibrateCurrentZero();

  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print("Zero I V=");
  lcd.print(currentZeroVoltage, 3);
  delay(1500);

  Serial.println("solar-battery-lcd-monitor");
  Serial.print("Current zero voltage = ");
  Serial.println(currentZeroVoltage, 4);
}

void loop() {
  if (millis() - lastDisplayUpdate >= DISPLAY_INTERVAL_MS) {
    lastDisplayUpdate = millis();

    float batteryVoltage = readBatteryVoltage();
    float current = readCurrent();
    float power = batteryVoltage * current;
    const char* state = stateText(current, batteryVoltage);

    // LCD line 1: Voltage and current
    lcd.setCursor(0, 0);
    String line1 = "V:";
    line1 += String(batteryVoltage, 2);
    line1 += " I:";
    line1 += String(current, 2);
    printPadded(line1, 16);

    // LCD line 2: Power and state
    lcd.setCursor(0, 1);
    String line2 = "P:";
    line2 += String(power, 1);
    line2 += " ";
    line2 += state;
    printPadded(line2, 16);

    // Serial output for validation
    Serial.print("Battery_V=");
    Serial.print(batteryVoltage, 3);
    Serial.print(", Current_A=");
    Serial.print(current, 3);
    Serial.print(", Power_W=");
    Serial.print(power, 3);
    Serial.print(", State=");
    Serial.println(state);
  }
}

Notes on calibration values

You may need small adjustments after comparing with a multimeter:

  • voltageCalibrationFactor
  • Example: if LCD shows 12.40 V but multimeter shows 12.55 V, set factor near 12.55 / 12.40 = 1.012
  • currentCalibrationFactor
  • Example: if known current is 0.90 A but display shows 0.84 A, set factor near 0.90 / 0.84 = 1.071

For the first upload, leave both at 1.000.

Build/Flash/Run commands

Command table

Step Command
Update board index arduino-cli core update-index
Install AVR core arduino-cli core install arduino:avr
Compile sketch arduino-cli compile --fqbn arduino:avr:uno ~/arduino/solar-battery-lcd-monitor
Upload sketch arduino-cli upload --fqbn arduino:avr:uno --port <PORT> ~/arduino/solar-battery-lcd-monitor

Workflow

  1. Create a project folder:
  2. ~/arduino/solar-battery-lcd-monitor/
  3. Save the sketch as:
  4. ~/arduino/solar-battery-lcd-monitor/solar-battery-lcd-monitor.ino
  5. Run the commands below in order.
arduino-cli core update-index
arduino-cli core install arduino:avr
arduino-cli compile --fqbn arduino:avr:uno ~/arduino/solar-battery-lcd-monitor
arduino-cli upload --fqbn arduino:avr:uno --port <PORT> ~/arduino/solar-battery-lcd-monitor

Replace <PORT> with your board port, for example:
– Linux: /dev/ttyACM0
– macOS: /dev/cu.usbmodem14101
– Windows: COM3

Run behavior

After upload:
1. The LCD should briefly show a startup message.
2. The sketch calibrates the ACS712 zero-current voltage.
3. The LCD begins updating every 0.5 seconds.
4. Open a serial monitor at 9600 baud if you want text logs for validation.

Step-by-step Validation

Use a multimeter and a small, low-power test setup. Perform the checks in this order.

1. LCD and startup check

Action
– Power the Arduino by USB.
– Leave the ACS712 current path open or with no active current.
– Adjust the LCD contrast potentiometer slowly.

Expected observation
– A readable startup message appears.
– After startup, the monitor shows voltage/current fields instead of random blocks.

Pass condition
– Text is visible and stable on both LCD lines.

2. Zero-current calibration check

Action
– Make sure no current is flowing through the ACS712 during reset or power-up.
– Observe the serial output or LCD after startup.

Expected observation
– Current should be close to 0.00 A, with small noise around zero.
– State should usually be IDLE, unless low battery voltage triggers LOW BAT.

Pass condition
– Idle current remains small and does not drift wildly, typically a few hundredths of an ampere around zero.

3. Battery voltage validation

Action
– Measure the battery voltage directly with a multimeter.
– Compare it to the LCD and serial output.

Expected observation
– The displayed battery voltage is close to the multimeter reading.
– Small differences are normal before calibration.

Pass condition
– The reading is logically correct and close enough that a small voltageCalibrationFactor can align it.
– If the multimeter reads 12.6 V and the display says 6 V or 20 V, the divider or grounding is wrong.

4. Current direction and magnitude check

Action
– Connect a small DC load through the ACS712.
– Turn the load on and off.
– If testing a charging path, expose the panel to light or use a bench source through a suitable controller.

Expected observation
– Current changes noticeably when the load or charging state changes.
– Sign should be consistent with your chosen wiring direction.
– Power should change in the same direction as current.

Pass condition
– Turning the load on produces a repeatable current reading.
– If sign is opposite from what you want, swap ACS712 current terminals or invert the sign convention.

5. State message check

Action
– Observe the second LCD line while changing operating conditions:
– no current
– active load
– active charge current
– low battery voltage if available

Expected observation
CHARGING for positive current
DISCHARGE for negative current
IDLE near zero current
LOW BAT when voltage is below the threshold and current is near zero

Pass condition
– The state label follows the measured electrical condition logically.

Troubleshooting

Symptom Likely cause Fix
LCD backlight on but no text Contrast not adjusted or wrong LCD wiring Adjust potentiometer; recheck RS, E, D4-D7 pins
Random characters on LCD Wrong pin mapping in code or loose wires Match code pins to physical connections exactly
Battery voltage reads 0 V Divider node not connected to A0 or no common ground Recheck A0 node and battery negative to Arduino GND
Battery voltage far too high or low Wrong resistor values or divider formula mismatch Confirm 47 kOhm and 10 kOhm placement; verify code constants
Current always near zero ACS712 output not connected, no current path, or wrong test method Check A1, VCC, GND, and that load current actually passes through the sensor terminals
Current value unstable Sensor noise, poor wiring, or switching load noise Shorten wires, improve grounding, average more samples
Current sign reversed Sensor installed in opposite direction Swap current terminals or multiply current by -1 in code
Upload fails Wrong port or missing AVR core Install arduino:avr core and verify <PORT>
Display resets when load changes Power supply instability or shared noisy supply Use stable 5 V for Arduino and ensure common ground

Improvements

  • Better measurement quality
  • Add a small capacitor, such as 100 nF, from A0 to GND near the Arduino to reduce divider noise.
  • Increase current averaging if your load is steady.
  • Add a software calibration mode that stores factors in EEPROM.

  • More useful field behavior

  • Add a buzzer or LED warning for low battery.
  • Add a second page on the LCD that alternates between:
    • voltage/current/power
    • min/max voltage
    • accumulated amp-hour estimate over time
  • Add a pushbutton to toggle between charge-current monitoring and load-current monitoring labels.

  • Packaging and deployment

  • Move the circuit from breadboard to perfboard.
  • Install the monitor in a small plastic enclosure near a low-voltage battery box.
  • Use screw terminals for the divider input and ACS712 current path to make the prototype reusable.

Final Checklist

  • [ ] I used Arduino UNO R3 (ATmega328P).
  • [ ] I used a 47 kOhm / 10 kOhm voltage divider to battery voltage.
  • [ ] I connected battery negative, Arduino GND, LCD GND, and ACS712 GND together.
  • [ ] I connected the ACS712 OUT pin to A1.
  • [ ] I connected the voltage divider node to A0.
  • [ ] I wired the 16×2 HD44780 LCD to pins D7, D8, D9, D10, D11, D12 as used in the sketch.
  • [ ] I adjusted the LCD contrast potentiometer until text became visible.
  • [ ] I compiled with:
  • arduino-cli compile --fqbn arduino:avr:uno ~/arduino/solar-battery-lcd-monitor
  • [ ] I uploaded with:
  • arduino-cli upload --fqbn arduino:avr:uno --port <PORT> ~/arduino/solar-battery-lcd-monitor
  • [ ] I powered up with no current through the ACS712 for zero calibration.
  • [ ] I compared battery voltage with a multimeter.
  • [ ] I tested current response using a small DC load or a controlled charging condition.
  • [ ] I updated calibration factors only after measurement comparison.
  • [ ] I understand this is a basic educational monitor, not a certified protection device.

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What is the main device being built in the article?




Question 2: Which display is used to show the voltage and current readings?




Question 3: How is the 12 V battery voltage measured by the system?




Question 4: Which sensor is used to measure charge or load current?




Question 5: What refresh rate is mentioned for the monitor?




Question 6: What does a positive current reading indicate in the example use case?




Question 7: What battery voltage range can warn of a low 12 V lead-acid battery?




Question 8: How should the ACS712 be connected to measure real load current?




Question 9: What is the typical display latency mentioned for the system?




Question 10: What is one benefit of using this monitor as a bench tool?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:


Practical case: Arduino UNO incubator thermostat

Practical case: Arduino UNO incubator thermostat — hero

case-device-block-diagram,
.prometeo-educational-note,
.prometeo-device-postcode-section,
.prometeo-device-section-card {
margin: 2.4rem 0;
padding: 1.45rem 1.55rem;
border: 1px solid rgba(148, 163, 184, 0.30);
border-radius: 14px;
background:
linear-gradient(135deg, rgba(30, 41, 59, 0.50), rgba(15, 23, 42, 0.18)),
rgba(17, 24, 39, 0.48);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.20);
}
.case-objective > h2:first-of-type,
.case-device-block-diagram > h2:first-of-type,
.prometeo-device-postcode-section > h2:first-of-type,
.prometeo-device-section-card > h2:first-of-type {
margin-top: 0;
}
.prometeo-device-section-card > :last-child,
.case-objective > :last-child,
.case-device-block-diagram > :last-child,
.prometeo-device-postcode-section > :last-child {
margin-bottom: 0;
}
.prometeo-device-section-card.prometeo-device-section-card-code {
border-left: 4px solid rgba(56, 189, 248, 0.86);
background:
linear-gradient(135deg, rgba(8, 47, 73, 0.42), rgba(15, 23, 42, 0.18)),
rgba(15, 23, 42, 0.58);
}
.prometeo-device-section-card.prometeo-device-section-card-compact {
padding: 1.2rem 1.35rem;
}
.prometeo-device-section-card pre,
.case-objective pre,
.case-device-block-diagram pre,
.prometeo-device-postcode-section pre {
max-width: 100%;
}
.prometeo-device-postcode-section .prometeo-device-flow-item {
background:
linear-gradient(135deg, rgba(15, 23, 42, 0.50), rgba(30, 41, 59, 0.28)),
rgba(15, 23, 42, 0.28);
}
@media print {
.case-objective,
.case-device-block-diagram,
.prometeo-educational-note,
.prometeo-device-postcode-section,
.prometeo-device-section-card {
background: #fff;
color: #111827;
box-shadow: none;
break-inside: avoid;
page-break-inside: avoid;
}
}

Objective and use case

What you’ll build: A basic thermostat controller for a small educational incubator or warm chamber using an Arduino UNO R3, LM35 temperature sensor, 1-channel relay, and 16×2 HD44780 LCD. It reads temperature about 2-5 times per second, shows live values on the LCD, and switches a heater on/off with hysteresis to hold a target band with roughly 1-2°C stability, depending on enclosure size and heater power.

Why it matters / Use cases

  • Educational incubator prototype: Demonstrates closed-loop control in a realistic classroom build, such as a small egg incubator mock-up or seed-germination box.
  • DIY warm chamber for lab exercises: Maintains a small enclosure near a chosen setpoint, for example 30-37°C for materials or electronics demonstrations.
  • Relay-control practice: Shows how a digital output drives a real load and why hysteresis prevents rapid relay chatter and premature wear.
  • Sensor-to-actuator integration: Combines analog sensing, real-time display, and actuator control in one embedded workflow similar to many industrial basics.
  • Fault-aware monitoring: LCD and serial logs help spot overshoot, slow heating, disconnected sensors, or unstable readings with response latency typically under 500 ms per update cycle.

Expected outcome

  • A working thermostat that turns the heater ON below a lower threshold and OFF above an upper threshold.
  • Live LCD readout of current temperature and heater state, refreshed every 200-500 ms.
  • Stable temperature control in a small chamber, typically within ±0.5 to ±1°C after warm-up when hysteresis is tuned correctly.
  • Serial output for calibration and debugging, useful for comparing LM35 readings against a reference thermometer.
  • A clear foundation for future upgrades such as buttons for setpoint entry, buzzer alarms, or data logging.

Audience: Arduino beginners, students, and educators building entry-level control systems; Level: beginner to intermediate

Architecture/flow: LM35 analog voltage → Arduino UNO ADC conversion → temperature calculation and threshold/hysteresis logic → relay toggles heater → 16×2 LCD and serial monitor display current temperature and heater status; no FPS or %GPU apply because this is a microcontroller-based control project.

Conceptual block diagram

High-level view: what enters the system, what each block processes, and what comes out.

Functional architecture

LM35 analog voltage

Arduino UNO ADC conversion

temperature calculation and threshold/hys…

relay toggles heater

16×2 LCD and serial monitor display curre…

Conceptual signal and responsibility flow between device blocks.

Validation path

Sketch

arduino-cli compile

Upload

Functional test

Conceptual summary of the tools used to check the published material.

Educational validation note

Before publication, this case passed the Prometeo automated validation gate with status PASS. The validator checked the code blocks, article structure, copy/paste-safe commands and consistency with the supported device catalog.

Published validation evidence

  • Automatic result: PASS.
  • Parsed structure: 3 sections, 3 tables and 3 code blocks detected before publication.
  • Checked code: 1 Arduino/arduino-cli compile, 1 Bash/copy-paste checks.
  • Supported catalog: the article text was checked against Prometeo’s validation-capable device profiles, and unsupported stacks block publication.
  • Report findings: no blocking findings.

This validation confirms syntax and tool compatibility for the published material, but it does not replace physical testing on your exact hardware, wiring and runtime environment.

Educational safety note

This project is an educational prototype thermostat, not a certified temperature-control product.

Important limits and safety rules:

  • Do not treat it as fail-safe. A software bug, wiring fault, stuck relay, bad sensor, or loose connection can cause overheating or loss of control.
  • Prefer low-voltage heaters for beginner work. A low-voltage resistive heating element or lamp powered by an appropriate external supply is much safer for learning.
  • Mains voltage warning: If your relay switches mains-powered heating, that introduces shock and fire risk. Beginners should not wire mains loads alone. Use qualified supervision and proper insulated enclosures, fusing, strain relief, and legal electrical practices.
  • Do not power the heater from the Arduino. The UNO is only for control signals and low-power electronics.
  • Never leave the prototype unattended while testing, especially inside an enclosed chamber.
  • Keep the sensor away from direct heater contact. If the LM35 touches the heater, it may read heater surface temperature rather than chamber air temperature, causing poor control.
  • Expect limited precision. The LM35, simple hysteresis control, relay switching, and enclosure design all limit stability.
  • No safety certification: This build is not for industrial, food-production, commercial incubation, or any situation where failure could cause harm, fire, or property damage.

Prerequisites

Before starting, you should be comfortable with:

  • Uploading a sketch to an Arduino UNO R3 (ATmega328P).
  • Reading a breadboard layout in text form.
  • Using a USB cable and a serial monitor.
  • Basic DC wiring safety.
  • Understanding that a relay is a switch, not a power source.

Recommended preparation:

  1. Install Arduino CLI on your computer.
  2. Have a known-good USB cable for the UNO.
  3. Decide whether your heater is:
  4. a low-voltage DC heating element controlled through the relay, or
  5. a mains-powered heater, which is not recommended for beginners without qualified supervision.

For an educational basic build, a small low-voltage heater is strongly preferred.

Materials

Use exactly this hardware set as the core device family and model:

  • Arduino UNO R3 (ATmega328P) + LM35 analog temperature sensor + 1-channel relay module + 16×2 HD44780 LCD

Suggested full parts list:

Item Exact/Recommended Model Qty Notes
Microcontroller board Arduino UNO R3 (ATmega328P) 1 Required
Temperature sensor LM35 analog temperature sensor 1 Required
Relay board 1-channel relay module, 5 V coil, Arduino-compatible 1 Required
Display 16×2 HD44780 LCD, parallel interface 1 Required
Potentiometer 10 kOhm trimmer 1 For LCD contrast
Resistor 220 Ohm 1 LCD backlight current limiting if needed
Breadboard Standard solderless breadboard 1 For wiring
Jumper wires Male-to-male 20+ For all connections
USB cable USB A to USB B 1 For UNO programming
Heater/load Small low-voltage heater or lamp used as a heating source 1 Educational prototype load
External supply for heater Matched to the heater 1 Do not power heater from the UNO
Optional enclosure Small insulated box 1 For realistic incubator testing
Optional thermometer Separate reference thermometer 1 Helpful for validation

Setup/Connection

This project uses:

  • LM35 for analog temperature measurement
  • relay module for heater switching
  • 16×2 HD44780 LCD in 4-bit mode for status display

Pin plan

Use this exact Arduino pin assignment in the sketch below:

  • LM35 output -> A0
  • Relay input -> D8
  • LCD RS -> D12
  • LCD EN -> D11
  • LCD D4 -> D5
  • LCD D5 -> D4
  • LCD D6 -> D3
  • LCD D7 -> D2

LM35 wiring

With the flat face of a typical TO-92 LM35 facing you and pins downward, the usual pinout is:

  1. Left pin -> +5V
  2. Middle pin -> A0
  3. Right pin -> GND

Always confirm your sensor’s datasheet or vendor labeling before powering it.

Relay module wiring

Most 1-channel relay modules have VCC, GND, and IN.

  • VCC -> 5V
  • GND -> GND
  • IN -> D8

Relay load-side terminals are usually:

  • COM
  • NO (Normally Open)
  • NC (Normally Closed)

For a heater that should be off by default, use:

  • power source line -> COM
  • heater input -> NO

The heater returns to its supply return path according to its own power circuit.

LCD wiring in 4-bit mode

Typical LCD pins:

  1. VSS -> GND
  2. VDD -> 5V
  3. VO -> middle pin of 10 kOhm potentiometer
  4. RS -> D12
  5. RW -> GND
  6. E -> D11
  7. D0 -> not connected
  8. D1 -> not connected
  9. D2 -> not connected
  10. D3 -> not connected
  11. D4 -> D5
  12. D5 -> D4
  13. D6 -> D3
  14. D7 -> D2
  15. A or LED+ -> 5V through 220 Ohm resistor if needed
  16. K or LED- -> GND

Potentiometer for contrast:

  • one outer pin -> 5V
  • other outer pin -> GND
  • middle pin -> LCD VO pin 3

Power and grounding rules

  • The UNO, LM35, relay input side, and LCD must share a common ground.
  • Do not power the heater from the Arduino 5 V pin.
  • Use a separate power supply for the heater if required by the load.
  • If you are controlling anything beyond low-voltage educational loads, get qualified supervision.

Control logic used in this build

This thermostat uses a hysteresis window:

  • Turn heater on when temperature is below 36.5 C
  • Turn heater off when temperature is above 37.5 C

That avoids relay chatter near the setpoint. You can change the thresholds in the code for your enclosure and heater.

Validated Code

lm35_incubator_thermostat.ino

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

#include <LiquidCrystal.h>

// LCD pins: RS, E, D4, D5, D6, D7
LiquidCrystal lcd(12, 11, 5, 4, 3, 2);

const int LM35_PIN = A0;
const int RELAY_PIN = 8;

// Adjust these thresholds for your chamber.
// Hysteresis prevents rapid relay toggling.
const float TEMP_ON_C = 36.5;   // Heater turns ON below this
const float TEMP_OFF_C = 37.5;  // Heater turns OFF above this

// Set this to true if your relay module is active LOW.
// Many Arduino relay boards energize the relay when IN is LOW.
const bool RELAY_ACTIVE_LOW = true;

bool heaterOn = false;
unsigned long lastSampleMs = 0;
const unsigned long SAMPLE_INTERVAL_MS = 1000;

float readTemperatureC() {
  const int samples = 10;
  long sum = 0;

  for (int i = 0; i < samples; i++) {
    sum += analogRead(LM35_PIN);
    delay(5);
  }

  float averageAdc = sum / (float)samples;
  float voltage = averageAdc * (5.0 / 1023.0);

  // LM35 output is 10 mV per degree C
  float temperatureC = voltage * 100.0;
  return temperatureC;
}

void setRelay(bool on) {
  heaterOn = on;

  if (RELAY_ACTIVE_LOW) {
    digitalWrite(RELAY_PIN, on ? LOW : HIGH);
  } else {
    digitalWrite(RELAY_PIN, on ? HIGH : LOW);
  }
}

void updateControl(float temperatureC) {
  if (!heaterOn && temperatureC < TEMP_ON_C) {
    setRelay(true);
  } else if (heaterOn && temperatureC > TEMP_OFF_C) {
    setRelay(false);
  }
}
// ...

#include <LiquidCrystal.h>

// LCD pins: RS, E, D4, D5, D6, D7
LiquidCrystal lcd(12, 11, 5, 4, 3, 2);

const int LM35_PIN = A0;
const int RELAY_PIN = 8;

// Adjust these thresholds for your chamber.
// Hysteresis prevents rapid relay toggling.
const float TEMP_ON_C = 36.5;   // Heater turns ON below this
const float TEMP_OFF_C = 37.5;  // Heater turns OFF above this

// Set this to true if your relay module is active LOW.
// Many Arduino relay boards energize the relay when IN is LOW.
const bool RELAY_ACTIVE_LOW = true;

bool heaterOn = false;
unsigned long lastSampleMs = 0;
const unsigned long SAMPLE_INTERVAL_MS = 1000;

float readTemperatureC() {
  const int samples = 10;
  long sum = 0;

  for (int i = 0; i < samples; i++) {
    sum += analogRead(LM35_PIN);
    delay(5);
  }

  float averageAdc = sum / (float)samples;
  float voltage = averageAdc * (5.0 / 1023.0);

  // LM35 output is 10 mV per degree C
  float temperatureC = voltage * 100.0;
  return temperatureC;
}

void setRelay(bool on) {
  heaterOn = on;

  if (RELAY_ACTIVE_LOW) {
    digitalWrite(RELAY_PIN, on ? LOW : HIGH);
  } else {
    digitalWrite(RELAY_PIN, on ? HIGH : LOW);
  }
}

void updateControl(float temperatureC) {
  if (!heaterOn && temperatureC < TEMP_ON_C) {
    setRelay(true);
  } else if (heaterOn && temperatureC > TEMP_OFF_C) {
    setRelay(false);
  }
}

void updateDisplay(float temperatureC) {
  lcd.setCursor(0, 0);
  lcd.print("Temp: ");
  lcd.print(temperatureC, 1);
  lcd.print((char)223); // degree symbol
  lcd.print("C   ");

  lcd.setCursor(0, 1);
  lcd.print("Set ");
  lcd.print(TEMP_ON_C, 1);
  lcd.print("-");
  lcd.print(TEMP_OFF_C, 1);
  lcd.print(" ");

  lcd.setCursor(12, 1);
  if (heaterOn) {
    lcd.print("ON ");
  } else {
    lcd.print("OFF");
  }
}

void printSerial(float temperatureC) {
  Serial.print("Temperature_C=");
  Serial.print(temperatureC, 2);
  Serial.print(", Heater=");
  Serial.println(heaterOn ? "ON" : "OFF");
}

void setup() {
  pinMode(RELAY_PIN, OUTPUT);

  // Start with heater off
  setRelay(false);

  lcd.begin(16, 2);
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print("Incubator Ctrl");
  lcd.setCursor(0, 1);
  lcd.print("Starting...");

  Serial.begin(9600);
  delay(1500);
  lcd.clear();
}

void loop() {
  unsigned long now = millis();

  if (now - lastSampleMs >= SAMPLE_INTERVAL_MS) {
    lastSampleMs = now;

    float temperatureC = readTemperatureC();
    updateControl(temperatureC);
    updateDisplay(temperatureC);
    printSerial(temperatureC);
  }
}

Optional serial log pattern for comparison

This is not a second program. It is an example of what a healthy run may look like in the serial monitor while the sensor warms up:

Temperature_C=29.86, Heater=ON
Temperature_C=30.10, Heater=ON
Temperature_C=31.02, Heater=ON
Temperature_C=34.88, Heater=ON
Temperature_C=36.42, Heater=ON
Temperature_C=37.61, Heater=OFF
Temperature_C=37.43, Heater=OFF
Temperature_C=36.28, Heater=ON

Interpretation:

  • The heater stays ON until the reading rises above TEMP_OFF_C.
  • It stays OFF until the reading falls below TEMP_ON_C.
  • This confirms the hysteresis behavior.

Build/Flash/Run commands

Use Arduino CLI, not PlatformIO.

Command table

Task Command
Update board index arduino-cli core update-index
Install AVR core arduino-cli core install arduino:avr
Compile sketch arduino-cli compile --fqbn arduino:avr:uno ./lm35_incubator_thermostat
Upload sketch arduino-cli upload --fqbn arduino:avr:uno --port <PORT> ./lm35_incubator_thermostat
Open serial monitor arduino-cli monitor --port <PORT> --config baudrate=9600

Short workflow

  1. Create a project folder named lm35_incubator_thermostat.
  2. Save the sketch as:
  3. lm35_incubator_thermostat/lm35_incubator_thermostat.ino
  4. Run the commands in order:
arduino-cli core update-index
arduino-cli core install arduino:avr
arduino-cli compile --fqbn arduino:avr:uno ./lm35_incubator_thermostat
arduino-cli upload --fqbn arduino:avr:uno --port <PORT> ./lm35_incubator_thermostat
arduino-cli monitor --port <PORT> --config baudrate=9600
  1. Replace <PORT> with your actual serial port:
  2. Linux example: /dev/ttyACM0
  3. Windows example: COM4
  4. macOS example: /dev/cu.usbmodem14101

Step-by-step Validation

Use these checkpoints in order. They are grouped to keep validation practical and repeatable.

1. Power-up and LCD bring-up

What to do:
– Power the UNO from USB.
– Adjust the LCD contrast potentiometer slowly.

Expected observation:
– The LCD first shows:
Incubator Ctrl
Starting...
– Then it updates to temperature and heater status.

Pass condition:
– The LCD is readable and continues updating once per second.
– If the backlight is on but no text appears, the contrast setting or wiring needs correction.

2. Sensor sanity check

What to do:
– Leave the LM35 at room temperature for 30-60 seconds.
– Open the serial monitor at 9600 baud.
– Compare the displayed value to room conditions or a separate thermometer if available.

Expected observation:
– The reading should be plausible for the room, typically around 20-35 C depending on your environment.
– Serial output should print one line every second.

Pass condition:
– The temperature does not stay stuck at 0.0 C, 50+ C in a normal room, or jump randomly by very large amounts.
– LCD and serial values should match closely.

3. Controlled warming test

What to do:
– Warm the LM35 gently using your fingers, or move it near warm air without overheating it.
– Watch the LCD and serial output.

Expected observation:
– The measured temperature rises gradually.
– Once it crosses 37.5 C, the relay state changes to OFF if it was previously ON.

Pass condition:
– The temperature responds to warming in a smooth, believable way.
– The relay changes state when the threshold is crossed.

4. Controlled cooling test

What to do:
– Let the LM35 cool naturally back toward room temperature.
– If needed, remove the heat source and wait several minutes.

Expected observation:
– The temperature falls gradually.
– When it goes below 36.5 C, the relay changes to ON.

Pass condition:
– The relay does not rapidly chatter on and off near one exact temperature.
– It follows the hysteresis rule: off above the upper threshold, on below the lower threshold.

5. Chamber behavior test with real heater load

What to do:
– Place the LM35 in the same air space as the heater inside a small enclosure.
– Run the system for 10-20 minutes using a low-voltage educational heater setup.

Expected observation:
– Temperature increases when heating starts.
– Relay eventually turns the heater off near the upper threshold.
– Temperature then drifts downward until the heater turns back on.

Pass condition:
– The enclosure cycles between the two thresholds.
– The system behaves as a thermostat, not as a one-time switch.

Troubleshooting

Symptom Likely cause Fix
LCD backlight on but no text Contrast misadjusted, RW not tied to GND, wrong pin wiring Adjust potentiometer, verify LCD pins 4/5/6/11/12/13/14, ensure RW is GND
LCD shows text but temperature is 0.0 C LM35 output not reaching A0, wrong LM35 orientation Recheck sensor pinout and A0 connection
Temperature reading is far too high or unstable Floating ground, noisy wiring, wrong sensor pinout Confirm common ground, shorten sensor leads, recheck LM35 orientation
Relay never changes state Wrong relay input polarity Toggle RELAY_ACTIVE_LOW between true and false
Relay clicks but heater does not turn on Wrong COM/NO wiring on load side, heater power supply issue Recheck relay terminal wiring and external heater power path
Relay chatters rapidly Thresholds too close, chamber too small, sensor too near heater Increase hysteresis gap, move sensor away from direct radiant heat
Serial upload fails Wrong port or board selected Use correct <PORT> and arduino:avr:uno FQBN
Temperature overshoots too much Heater too powerful, poor airflow, sensor placement issue Reduce heater power, add gentle airflow, move sensor to representative air location

Improvements

Control and measurement improvements

  • Adjustable setpoint: Add buttons or a rotary encoder so the target range can be changed without editing code.
  • Calibration offset: Add a software offset if your LM35 consistently differs from a reference thermometer by a small amount.
  • Longer averaging: Increase the sample count or use a moving average to reduce display noise.

Enclosure and thermal design improvements

  • Better sensor placement: Put the LM35 in the air stream or center of the chamber, not touching the heater or chamber wall.
  • Air circulation: A small low-voltage fan can improve temperature uniformity inside the incubator.
  • Insulation: Better enclosure insulation reduces relay cycling and makes the chamber more stable.

Usability and reliability improvements

  • Alarm indication: Add a buzzer or LED if temperature goes far above or below the intended range.
  • Minimum relay off-time: Add timing rules to reduce wear if your heater is very responsive.
  • Data logging: Send serial data to a PC for recording temperature trends over time.

Final Checklist

Use this checklist before calling the project complete:

  • [ ] I used Arduino UNO R3 (ATmega328P) exactly.
  • [ ] I used LM35 analog temperature sensor + 1-channel relay module + 16×2 HD44780 LCD exactly.
  • [ ] The LCD wiring matches the sketch pin mapping.
  • [ ] The LM35 output goes to A0, with correct power and ground orientation.
  • [ ] The relay input goes to D8.
  • [ ] The UNO, relay input side, LM35, and LCD share a common ground.
  • [ ] The heater uses its own proper power path and is not powered from the UNO.
  • [ ] The sketch compiles with:
  • [ ] arduino-cli core update-index
  • [ ] arduino-cli core install arduino:avr
  • [ ] arduino-cli compile --fqbn arduino:avr:uno ./lm35_incubator_thermostat
  • [ ] The sketch uploads with:
  • [ ] arduino-cli upload --fqbn arduino:avr:uno --port <PORT> ./lm35_incubator_thermostat
  • [ ] The LCD shows temperature and heater state.
  • [ ] The serial monitor prints one reading per second at 9600 baud.
  • [ ] Warming the LM35 raises the displayed temperature.
  • [ ] The relay turns off above the upper threshold and on below the lower threshold.
  • [ ] The chamber cycles within the hysteresis band during a supervised test.
  • [ ] I understand this is an educational prototype and not a fail-safe incubator controller.

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What is the main controller board used in the thermostat project?




Question 2: Which temperature sensor is specified in the article?




Question 3: What is the purpose of hysteresis in this thermostat system?




Question 4: How often does the system read temperature according to the article?




Question 5: What display module is mentioned for showing live values?




Question 6: When should the heater turn ON in a hysteresis-based thermostat?




Question 7: What actuator interface is used to switch the heater on and off?




Question 8: For debugging and calibration, what extra output does the system provide?




Question 9: What stability is the project expected to achieve, depending on enclosure size and heater power?




Question 10: Which use case example is given for the warm chamber?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:


Practical case: water leak shutoff, Arduino UNO

Practical case: water leak shutoff, Arduino UNO — hero

Objective and use case

What you’ll build: A basic water leak shutoff relay controller using an Arduino UNO R3, water leak sensor, 1-channel relay module, and piezo buzzer. When water is detected, the Arduino switches the relay within roughly 10–50 ms to shut off a connected low-voltage control circuit and immediately sounds a local alarm.

Why it matters / Use cases

  • Under-sink protection: Detect drips under a kitchen or bathroom sink and cut a 5–12 V valve or pump-enable line before standing water spreads through the cabinet.
  • Washing machine area monitoring: Place the sensor in a tray near the washer so a floor leak triggers an audible buzzer and opens or closes the relay to disable a water solenoid control path.
  • Basement or utility room response: Catch early leaks near a water heater, filter, or sump plumbing and send a fast shutoff signal while providing immediate on-site alerting.
  • Educational automation prototype: Learn digital sensing, relay-safe switching, and event-driven control on an 8-bit microcontroller with near-zero idle latency and typically <1% UNO CPU load for this simple loop.

Expected outcome

  • An Arduino sketch that continuously reads the leak sensor and changes relay state as soon as water is detected.
  • A buzzer alarm that activates at the same time as the shutoff event for clear local notification.
  • A low-cost prototype suitable for bench testing with 5 V electronics and low-voltage valve, pump, or controller interlock circuits.
  • A repeatable response flow with practical timing: sensor wetting to relay action in tens of milliseconds, with no GPU usage and no FPS dependency.

Audience: Arduino beginners, makers, and building-automation learners; Level: Beginner

Architecture/flow: Water leak sensor detects moisture → Arduino UNO reads digital/thresholded input in the main loop → relay energizes or de-energizes based on fail-safe wiring choice → buzzer sounds until the leak clears or the system is reset.

Educational validation note

Before publication, this case passed the Prometeo automated validation gate with status PASS. The validator checked the code blocks, article structure, copy/paste-safe commands and consistency with the supported device catalog.

Published validation evidence

  • Automatic result: PASS.
  • Parsed structure: 3 sections, 4 tables and 3 code blocks detected before publication.
  • Checked code: 1 Arduino/arduino-cli compile, 1 C/C++ static checks, 1 Bash/copy-paste checks.
  • Supported catalog: the article text was checked against Prometeo’s validation-capable device profiles, and unsupported stacks block publication.
  • Report findings: no blocking findings.

This validation confirms syntax and tool compatibility for the published material, but it does not replace physical testing on your exact hardware, wiring and runtime environment.

Educational safety note

This project is an educational prototype, not a certified building protection system. Treat it as a learning controller and bench-scale leak response aid.

Important limits and precautions:

  • Do not connect mains voltage directly unless you are qualified to design and test mains-safe relay systems. For beginner use, keep the relay on a low-voltage circuit only.
  • The relay module in this tutorial should be used to switch:
  • low-voltage valve control wiring
  • a low-voltage enable input
  • a demonstration load
    not household AC plumbing equipment unless properly engineered.
  • Water and electronics must be kept physically separated:
  • only the leak sensor should be exposed to water
  • keep the Arduino, relay board, USB cable, and buzzer dry
  • A corroded or contaminated leak sensor may trigger false alarms or fail to detect properly. Periodic inspection is required.
  • USB power from a computer is acceptable for lab testing, but a field installation would require a more deliberate power design and enclosure strategy.
  • This design has no certified fail-safe guarantee, no battery backup, and no tamper detection.
  • If used near pumps, valves, or higher-current actuators, confirm that the relay contacts are rated for the actual load and that inductive loads are suppressed appropriately.
  • Never rely on this prototype alone to prevent property damage in high-risk environments.

Conceptual block diagram

High-level view: what enters the system, what each block processes, and what comes out.

Functional architecture

Water leak sensor detects moisture

Arduino UNO reads digital/thresholded inp…

relay energizes or de-energizes based on…

buzzer sounds until the leak clears or th…

Conceptual signal and responsibility flow between device blocks.

Validation path

Sketch

arduino-cli compile

Upload

Functional test

Conceptual summary of the tools used to check the published material.

Prerequisites

Before starting, you should have:

  • Basic familiarity with:
  • plugging jumper wires into Arduino pins
  • selecting a board and serial port
  • uploading an Arduino sketch
  • A computer with:
  • Arduino CLI installed
  • USB cable for the Arduino UNO R3
  • A dry workspace and a small container or paper towel for controlled water testing
  • A low-voltage load or control loop to connect to the relay contacts for demonstration
  • Examples:
    • a 12 V DC valve control line
    • a small DC pump enable line
    • an LED test lamp powered separately
  • For a beginner lab, an LED or buzzer on the relay contact side is safer than connecting any real plumbing hardware at first

Materials

Use exactly this device family/model combination:

Arduino UNO R3 (ATmega328P) + water leak sensor + 1-channel relay module + piezo buzzer

Recommended material list:

Item Exact/Recommended Type Purpose
Main controller Arduino UNO R3 (ATmega328P) Runs the leak detection and relay logic
Sensor Water leak sensor module with analog and/or digital output Detects presence of water on exposed traces
Relay output 1-channel relay module, 5 V coil, Arduino-compatible input Switches the external shutoff control circuit
Alarm Piezo buzzer, 5 V compatible Local audible warning
Wiring Male-male jumper wires Connections between modules
USB cable USB A to USB B Power and programming
Test load Low-voltage lamp, LED module, or a disabled valve control loop Safe demonstration of relay action
Optional Small towel, cup, spray bottle Controlled water testing
Optional Breadboard Cleaner buzzer wiring if needed

Notes about the exact model combination

  • The Arduino UNO R3 (ATmega328P) is the only controller used here.
  • The water leak sensor may expose:
  • AO: analog output
  • DO: digital threshold output from an onboard comparator
  • In this tutorial, we will use the analog output for more stable and understandable filtering.
  • The relay module is assumed to have:
  • VCC
  • GND
  • IN
  • screw terminals such as COM, NO, and NC
  • The piezo buzzer is treated as a simple two-wire buzzer.

Setup/Connection

This project has two parts:

  1. Logic side: Arduino, sensor, relay input, buzzer
  2. Relay contact side: the external low-voltage circuit that you want to interrupt or enable

Pin assignment used in this tutorial

Arduino UNO pin Connected device pin Function
5V Sensor VCC Power for leak sensor
GND Sensor GND Common ground
A0 Sensor AO Analog leak reading
5V Relay VCC Power for relay module
GND Relay GND Common ground
D8 Relay IN Relay control signal
D9 Buzzer positive Buzzer drive pin
GND Buzzer negative Buzzer return

Sensor connection steps

  1. Connect the water leak sensor VCC to 5V on the Arduino.
  2. Connect the sensor GND to GND on the Arduino.
  3. Connect the sensor AO to A0 on the Arduino.
  4. If your sensor also has DO, leave it unused in this tutorial.

Relay module connection steps

  1. Connect relay VCC to Arduino 5V.
  2. Connect relay GND to Arduino GND.
  3. Connect relay IN to Arduino D8.

Piezo buzzer connection steps

  1. Connect the buzzer positive lead to D9.
  2. Connect the buzzer negative lead to GND.

Relay contact side: how to use the shutoff function

The relay module provides isolated switch contacts, usually:

  • COM: common
  • NO: normally open
  • NC: normally closed

For a water-leak-shutoff-relay objective, the most practical beginner setup is:

  • Put the relay in series with the low-voltage control line of the device you want to stop.
  • Choose COM + NC if you want the circuit connected during normal dry operation and disconnected when leak alarm activates.
  • Choose COM + NO if you want the circuit disconnected during normal dry operation and connected only during alarm. This is less common for shutoff.

Recommended beginner contact arrangement

For a simple shutoff controller:

  • External low-voltage source/control wire -> COM
  • Relay NC -> load/control input

Result:

  • Dry state: COM and NC are connected, so the load/control circuit is allowed.
  • Leak state: relay switches away from NC, opening the control path and creating a shutoff action.

Important setup note about relay logic

Some relay modules are active LOW:

  • writing LOW to the input turns relay ON
  • writing HIGH turns relay OFF

Others are active HIGH.
The sketch below supports this using one configuration constant so you do not have to rewrite the logic.

Validated Code

Complete Arduino sketch: water_leak_shutoff_relay.ino

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

/*
  water_leak_shutoff_relay.ino

  Device model:
  Arduino UNO R3 (ATmega328P) + water leak sensor + 1-channel relay module + piezo buzzer

  Goal:
  Detect water using analog input, confirm the leak with simple timing/filter logic,
  trigger an audible alarm, and switch a relay for shutoff control.

  Serial output:
  9600 baud
*/

const int SENSOR_PIN = A0;
const int RELAY_PIN = 8;
const int BUZZER_PIN = 9;

// Relay module behavior:
// true  = relay input is active LOW
// false = relay input is active HIGH
const bool RELAY_ACTIVE_LOW = true;

// Sensor thresholds:
// Lower analog readings often mean "more water" on many common modules,
// but some modules behave differently. Validate with Serial Monitor first.
const int LEAK_THRESHOLD_WET = 500;   // Reading at or below this means "wet"
const int LEAK_THRESHOLD_DRY = 560;   // Reading at or above this means "dry again"

// Timing filters to avoid false triggers from noise or tiny splashes
const unsigned long WET_CONFIRM_MS = 400;
const unsigned long DRY_CONFIRM_MS = 1500;
const unsigned long STATUS_PRINT_MS = 500;

// Buzzer pattern timing
const unsigned long BUZZER_ON_MS = 180;
const unsigned long BUZZER_OFF_MS = 220;

bool leakAlarm = false;

unsigned long wetStartMs = 0;
unsigned long dryStartMs = 0;
unsigned long lastStatusPrintMs = 0;
unsigned long buzzerToggleMs = 0;
bool buzzerState = false;

void setRelayAlarmState(bool alarmActive) {
  // For shutoff wiring using COM + NC:
  // alarmActive should cause the relay to switch away from NC.
  bool relayEnergized = alarmActive;

  if (RELAY_ACTIVE_LOW) {
    digitalWrite(RELAY_PIN, relayEnergized ? LOW : HIGH);
  } else {
    digitalWrite(RELAY_PIN, relayEnergized ? HIGH : LOW);
  }
}

void setBuzzer(bool on) {
  if (on) {
    tone(BUZZER_PIN, 2400);  // 2.4 kHz tone
  } else {
    noTone(BUZZER_PIN);
  }
}

void printState(const char* stateName, int sensorValue) {
  Serial.print("State: ");
  Serial.print(stateName);
  Serial.print(" | sensor=");
  Serial.print(sensorValue);
  Serial.print(" | relay_alarm=");
  Serial.println(leakAlarm ? "ON" : "OFF");
}
// ...

/*
  water_leak_shutoff_relay.ino

  Device model:
  Arduino UNO R3 (ATmega328P) + water leak sensor + 1-channel relay module + piezo buzzer

  Goal:
  Detect water using analog input, confirm the leak with simple timing/filter logic,
  trigger an audible alarm, and switch a relay for shutoff control.

  Serial output:
  9600 baud
*/

const int SENSOR_PIN = A0;
const int RELAY_PIN = 8;
const int BUZZER_PIN = 9;

// Relay module behavior:
// true  = relay input is active LOW
// false = relay input is active HIGH
const bool RELAY_ACTIVE_LOW = true;

// Sensor thresholds:
// Lower analog readings often mean "more water" on many common modules,
// but some modules behave differently. Validate with Serial Monitor first.
const int LEAK_THRESHOLD_WET = 500;   // Reading at or below this means "wet"
const int LEAK_THRESHOLD_DRY = 560;   // Reading at or above this means "dry again"

// Timing filters to avoid false triggers from noise or tiny splashes
const unsigned long WET_CONFIRM_MS = 400;
const unsigned long DRY_CONFIRM_MS = 1500;
const unsigned long STATUS_PRINT_MS = 500;

// Buzzer pattern timing
const unsigned long BUZZER_ON_MS = 180;
const unsigned long BUZZER_OFF_MS = 220;

bool leakAlarm = false;

unsigned long wetStartMs = 0;
unsigned long dryStartMs = 0;
unsigned long lastStatusPrintMs = 0;
unsigned long buzzerToggleMs = 0;
bool buzzerState = false;

void setRelayAlarmState(bool alarmActive) {
  // For shutoff wiring using COM + NC:
  // alarmActive should cause the relay to switch away from NC.
  bool relayEnergized = alarmActive;

  if (RELAY_ACTIVE_LOW) {
    digitalWrite(RELAY_PIN, relayEnergized ? LOW : HIGH);
  } else {
    digitalWrite(RELAY_PIN, relayEnergized ? HIGH : LOW);
  }
}

void setBuzzer(bool on) {
  if (on) {
    tone(BUZZER_PIN, 2400);  // 2.4 kHz tone
  } else {
    noTone(BUZZER_PIN);
  }
}

void printState(const char* stateName, int sensorValue) {
  Serial.print("State: ");
  Serial.print(stateName);
  Serial.print(" | sensor=");
  Serial.print(sensorValue);
  Serial.print(" | relay_alarm=");
  Serial.println(leakAlarm ? "ON" : "OFF");
}

void setup() {
  pinMode(RELAY_PIN, OUTPUT);
  pinMode(BUZZER_PIN, OUTPUT);

  // Default safe startup for this tutorial:
  // no alarm until a confirmed leak is detected.
  leakAlarm = false;
  setRelayAlarmState(leakAlarm);
  setBuzzer(false);

  Serial.begin(9600);
  delay(300);
  Serial.println("Water leak shutoff relay controller starting...");
  Serial.println("Using analog leak sensing on A0");
  Serial.println("Check dry and wet readings before final installation.");

  // Initialize timing references
  wetStartMs = 0;
  dryStartMs = 0;
  lastStatusPrintMs = 0;
  buzzerToggleMs = 0;
  buzzerState = false;
}

void loop() {
  unsigned long now = millis();
  int sensorValue = analogRead(SENSOR_PIN);

  bool wetCandidate = (sensorValue <= LEAK_THRESHOLD_WET);
  bool dryCandidate = (sensorValue >= LEAK_THRESHOLD_DRY);

  // Leak detection state machine with hysteresis and timing confirmation
  if (!leakAlarm) {
    if (wetCandidate) {
      if (wetStartMs == 0) {
        wetStartMs = now;
      }
      if (now - wetStartMs >= WET_CONFIRM_MS) {
        leakAlarm = true;
        dryStartMs = 0;
        setRelayAlarmState(leakAlarm);
        Serial.println("LEAK DETECTED: relay shutoff activated");
        printState("LEAK", sensorValue);
      }
    } else {
      wetStartMs = 0;
    }
  } else {
    if (dryCandidate) {
      if (dryStartMs == 0) {
        dryStartMs = now;
      }
      if (now - dryStartMs >= DRY_CONFIRM_MS) {
        leakAlarm = false;
        wetStartMs = 0;
        setRelayAlarmState(leakAlarm);
        setBuzzer(false);
        buzzerState = false;
        Serial.println("RESET TO DRY: relay returned to normal");
        printState("DRY", sensorValue);
      }
    } else {
      dryStartMs = 0;
    }
  }

  // Buzzer pattern during active alarm
  if (leakAlarm) {
    if (now - buzzerToggleMs >= (buzzerState ? BUZZER_ON_MS : BUZZER_OFF_MS)) {
      buzzerState = !buzzerState;
      setBuzzer(buzzerState);
      buzzerToggleMs = now;
    }
  } else {
    if (buzzerState) {
      buzzerState = false;
      setBuzzer(false);
    }
  }

  // Periodic status prints
  if (now - lastStatusPrintMs >= STATUS_PRINT_MS) {
    printState(leakAlarm ? "LEAK" : "DRY", sensorValue);
    lastStatusPrintMs = now;
  }

  delay(20);
}

How to calibrate the threshold if your sensor behaves differently

Many low-cost leak sensor boards change analog value when wet, but the direction and range can vary.
Before final use:

  1. Upload the sketch as-is.
  2. Open the serial monitor.
  3. Note the analog readings:
  4. fully dry
  5. slightly damp
  6. definitely wet
  7. Adjust:
  8. LEAK_THRESHOLD_WET
  9. LEAK_THRESHOLD_DRY

Use a gap between them to create hysteresis, which helps prevent rapid switching.

Example of a threshold edit if your sensor is dry near 850 and wet near 300:

const int LEAK_THRESHOLD_WET = 450;
const int LEAK_THRESHOLD_DRY = 700;

Build/Flash/Run commands

Command table

Task Command
Update board index arduino-cli core update-index
Install AVR core arduino-cli core install arduino:avr
Compile sketch arduino-cli compile --fqbn arduino:avr:uno ./water_leak_shutoff_relay
Upload sketch arduino-cli upload --fqbn arduino:avr:uno --port <PORT> ./water_leak_shutoff_relay
Optional board list arduino-cli board list
Optional serial monitor arduino-cli monitor --port <PORT> --config 9600

Workflow

  1. Create a project folder named water_leak_shutoff_relay.
  2. Save the sketch as:
  3. water_leak_shutoff_relay/water_leak_shutoff_relay.ino
  4. Run the required commands:
arduino-cli core update-index
arduino-cli core install arduino:avr
arduino-cli compile --fqbn arduino:avr:uno ./water_leak_shutoff_relay
arduino-cli upload --fqbn arduino:avr:uno --port <PORT> ./water_leak_shutoff_relay
  1. Find your serial port if needed:
  2. Linux example: /dev/ttyACM0
  3. Windows example: COM4
  4. macOS example: /dev/cu.usbmodem14101
  5. Open a serial monitor at 9600 baud to observe readings and state changes.

Step-by-step Validation

Use these checkpoints in order. Each checkpoint includes the expected observation and pass condition.

1. Power-up and idle-state check

  • Action:
  • Power the Arduino by USB.
  • Keep the sensor dry and not touching metal or water.
  • Expected observation:
  • Serial Monitor prints startup text.
  • Repeating status lines show State: DRY.
  • The buzzer stays silent.
  • The relay remains in its normal non-alarm state.
  • Pass condition:
  • No alarm occurs while the sensor is dry for at least 30 seconds.

2. Dry reading baseline capture

  • Action:
  • Watch the printed sensor= values for 10 to 20 seconds.
  • Write down the typical dry range.
  • Expected observation:
  • Sensor values stay fairly consistent with small fluctuations.
  • Pass condition:
  • Dry readings remain clearly on one side of LEAK_THRESHOLD_DRY.
  • If not, you must recalibrate thresholds before continuing.

3. Controlled wet trigger test

  • Action:
  • Put a few drops of water on the sensing area, or touch it with a damp paper towel.
  • Expected observation:
  • Sensor value crosses the wet threshold.
  • After about WET_CONFIRM_MS, the serial monitor prints LEAK DETECTED.
  • The buzzer starts pulsing.
  • The relay changes state.
  • Pass condition:
  • Alarm is not instantaneous from noise, but does trigger reliably when the sensor is truly wet.

4. Shutoff relay behavior verification

  • Action:
  • Connect a safe low-voltage test circuit through the relay contacts, preferably using COM + NC for normal-through, alarm-open operation.
  • Repeat the wet trigger test.
  • Expected observation:
  • In dry state, the external low-voltage circuit is complete.
  • In leak state, the circuit is interrupted.
  • Pass condition:
  • The relay contacts perform the intended shutoff function consistently for at least 5 test cycles.

5. Dry recovery and anti-chatter check

  • Action:
  • Remove water and dry the sensor thoroughly.
  • Observe whether the system resets only after stable dryness.
  • Expected observation:
  • The buzzer continues until the sensor returns above LEAK_THRESHOLD_DRY for about DRY_CONFIRM_MS.
  • Then serial prints RESET TO DRY.
  • Relay returns to normal state.
  • Pass condition:
  • No rapid relay clicking or repeated false toggling during the drying transition.

Troubleshooting

Symptom Likely cause Fix
Relay seems backward: shutoff happens in dry state Relay input polarity or contact choice is opposite of expected Change RELAY_ACTIVE_LOW, or move the load from NO to NC as appropriate
Sensor always reads wet Threshold too high, sensor contaminated, or water already bridging traces Clean and dry the sensor, inspect readings, lower wet threshold
Sensor never triggers on water Threshold too low, wrong pin used, or sensor output type misunderstood Confirm AO is connected to A0, watch serial values while wet, adjust thresholds
Buzzer does not sound Wrong buzzer polarity, passive/active type mismatch, or wiring error Recheck D9 and GND, test buzzer separately, ensure tone()-compatible buzzer
Relay chatters near threshold Noisy sensor values or insufficient hysteresis Increase gap between wet and dry thresholds; increase confirm times
Upload fails Wrong port or missing AVR core Run arduino-cli board list, verify <PORT>, rerun core install
Serial output is unreadable Wrong baud rate Set monitor to 9600 baud
Relay module resets Arduino or behaves oddly Power draw/noise issue or poor wiring Keep wiring short, ensure solid 5 V/GND connections, test modules individually

Improvements

Reliability and installation quality

  • Add a sensor tray or mount the leak sensor at the lowest point where water would collect first.
  • Use a second leak sensor in parallel logic for two-zone coverage, such as one under a sink and one behind an appliance.
  • Add a latched alarm mode so that once a leak is detected, a manual reset is required even after drying.
  • Put the Arduino and relay in a small enclosure, with the sensor on a cable extending to the monitored area.

Better control behavior

  • Add a manual test button to simulate a leak without using water.
  • Add a mute button that silences the buzzer but keeps the relay in shutoff state.
  • Log event counts or timestamps over serial so a student can document repeated test cycles.

Safer real-world integration

  • Use the relay to control only a low-voltage valve or control interface, not unknown high-power wiring.
  • Replace the piezo buzzer with a panel-mounted buzzer for clearer audible alerting.
  • If a real shutoff valve is later added, choose a valve that has a clear fail-safe behavior and compatible control voltage.

Final Checklist

  • [ ] I used Arduino UNO R3 (ATmega328P) + water leak sensor + 1-channel relay module + piezo buzzer.
  • [ ] The sensor is wired to A0, relay input to D8, buzzer to D9.
  • [ ] I connected all grounds together.
  • [ ] I saved the sketch as water_leak_shutoff_relay.ino.
  • [ ] I ran:
  • [ ] arduino-cli core update-index
  • [ ] arduino-cli core install arduino:avr
  • [ ] arduino-cli compile --fqbn arduino:avr:uno ./water_leak_shutoff_relay
  • [ ] arduino-cli upload --fqbn arduino:avr:uno --port <PORT> ./water_leak_shutoff_relay
  • [ ] I verified dry-state serial readings.
  • [ ] I calibrated LEAK_THRESHOLD_WET and LEAK_THRESHOLD_DRY if needed.
  • [ ] I tested leak detection with a controlled small amount of water.
  • [ ] I confirmed the buzzer pattern activates during a leak.
  • [ ] I confirmed the relay interrupts the intended low-voltage control path during a leak.
  • [ ] I dried the sensor and verified clean recovery to normal state.
  • [ ] I understand this is an educational prototype, not a certified safety system.

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What is the main function of the Arduino project described in the article?




Question 2: Which board is specifically mentioned for this project?




Question 3: What happens immediately when water is detected?




Question 4: What type of relay module is included in the build?




Question 5: In the under-sink protection use case, what kind of line may be cut?




Question 6: Where is the sensor suggested to be placed for washing machine monitoring?




Question 7: What is one educational benefit of this prototype?




Question 8: What is the typical CPU load on the UNO for this simple loop?




Question 9: What timing is described for relay switching after water detection?




Question 10: What kind of circuit does the relay shut off in this project?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:


Practical case: 12V protector for Arduino UNO

Practical case: 12V protector for Arduino UNO — hero

Objective and use case

What you’ll build: A 12 V low-battery load disconnect controller using an Arduino UNO R3, voltage divider, 1-channel relay module, and 16×2 HD44780 LCD. It continuously samples battery voltage, displays the live reading on the LCD, and cuts power to the load when the battery drops below a safe threshold such as 11.8 V, then reconnects only after recovery with hysteresis.

Why it matters / Use cases

  • Protect a small 12 V lead-acid battery in a solar shed, lighting box, or portable power setup by disconnecting loads like LED strips, a 5–20 W fan, or small DC accessories before damaging deep discharge occurs.
  • Prevent “mystery dead battery” problems in hobby projects that run overnight by placing the controller between the battery and load, preserving usable charge for the next day.
  • Provide an educational battery monitor for labs and workshops where students can watch live voltage updates on the LCD, test cutoff points, and observe how hysteresis prevents rapid relay chatter near the threshold.

Expected outcome

  • Voltage measurement refreshed about 2–5 times per second on the LCD with practical monitoring latency under 500 ms.
  • Automatic relay cutoff when battery voltage falls below a defined limit, for example 11.8 V, with reconnect above a higher threshold such as 12.4 V.
  • Stable switching behavior with minimal Arduino UNO load, typically well under 10% CPU usage and no meaningful GPU usage.
  • A reusable protection module that can be retuned for different 12 V systems by adjusting divider calibration and voltage thresholds.

Audience: Arduino beginners, students, and hobbyists building battery-powered systems; Level: beginner to intermediate

Architecture/flow: 12 V battery → voltage divider → Arduino analog input for scaled sensing; Arduino → 16×2 LCD for live voltage/status display; Arduino digital output → 1-channel relay module → load disconnect/reconnect based on threshold and hysteresis logic.

Educational validation note

Before publication, this case passed the Prometeo automated validation gate with status PASS. The validator checked the code blocks, article structure, copy/paste-safe commands and consistency with the supported device catalog.

Published validation evidence

  • Automatic result: PASS.
  • Parsed structure: 3 sections, 4 tables and 3 code blocks detected before publication.
  • Checked code: 1 Arduino/arduino-cli compile, 1 Bash/copy-paste checks.
  • Supported catalog: the article text was checked against Prometeo’s validation-capable device profiles, and unsupported stacks block publication.
  • Report findings: no blocking findings.

This validation confirms syntax and tool compatibility for the published material, but it does not replace physical testing on your exact hardware, wiring and runtime environment.

Educational safety note

This project is a low-voltage educational prototype, not a certified battery protection product. Its limits must be understood clearly.

  • Battery safety
  • Even at 12 V, batteries can deliver very high current.
  • A wiring mistake can overheat wires, damage the battery, or cause sparks.
  • Use an inline fuse on the battery positive lead when moving beyond breadboard demonstration.

  • Relay contact safety

  • The relay module controls the load path, but its contact rating must match the actual load.
  • Do not switch loads that exceed the module’s current or voltage specification.
  • Do not assume the relay module is suitable for inductive, motor, or surge-heavy loads without proper protection.

  • No mains voltage

  • This tutorial should be used only for low-voltage DC educational work.
  • Do not use the relay module to switch household mains unless you have formal training, proper isolation practices, and compliant hardware. That is outside the scope of this basic tutorial.

  • Breadboard limits

  • Breadboards are fine for logic and light testing, but they are not ideal for higher current battery/load paths.
  • For a real reusable prototype, move the load wiring to proper terminals, thicker wires, and a protected enclosure.

  • Battery chemistry limits

  • The example thresholds here are educational defaults for a simple 12 V demonstration.
  • Different batteries require different voltage limits and charging/discharging rules.
  • Before practical use, set thresholds appropriate for your battery type and use case.

  • Prototype limits

  • This project is useful as a teaching tool and a simple hobby battery guard, but it is not a substitute for a professionally designed battery management system.
  • It should not be used for critical infrastructure, vehicles, unattended high-power installations, or safety-critical equipment.

Conceptual block diagram

High-level view: what enters the system, what each block processes, and what comes out.

Functional architecture

12 V battery

voltage divider

Arduino analog input for scaled sensing…

16×2 LCD for live voltage/status display…

1-channel relay module

load disconnect/reconnect based on thresh…

Conceptual signal and responsibility flow between device blocks.

Validation path

Sketch

arduino-cli compile

Upload

Functional test

Conceptual summary of the tools used to check the published material.

Prerequisites

Before starting, the student should be comfortable with:

  • Uploading a sketch to an Arduino UNO R3
  • Using a breadboard and jumper wires
  • Measuring DC voltage with a multimeter
  • Understanding that:
  • analog input pins measure 0 to 5 V only
  • a voltage divider is required for a 12 V battery
  • a relay is a switch controlled by the Arduino, not a power regulator

Recommended background knowledge:

  • Ohm’s law at a basic level
  • Digital outputs (HIGH / LOW)
  • Analog reading with analogRead()

Also prepare a safe low-voltage battery source, such as:

  • a small 12 V sealed lead-acid battery,
  • or a current-limited bench power supply adjusted to 10.5 V to 13.5 V for testing.

Materials

Use exactly this device model and family:

  • Arduino UNO R3 (ATmega328P) + voltage divider + 1-channel relay module + 16×2 HD44780 LCD

Suggested parts list:

Item Exact / Suggested specification Purpose
Main controller Arduino UNO R3 (ATmega328P) Reads voltage, controls relay, updates LCD
Display 16×2 HD44780-compatible LCD, parallel interface Shows voltage and load status
Relay board 1-channel 5 V relay module Connects/disconnects the load
Divider resistor R1 30 kOhm, 1/4 W, 1% preferred Upper resistor for battery measurement
Divider resistor R2 7.5 kOhm, 1/4 W, 1% preferred Lower resistor for battery measurement
Optional filter capacitor 100 nF ceramic Noise filtering at analog input
Battery/load side 12 V battery or current-limited bench supply Test source
Test load 12 V lamp, small fan, resistor load, or LED strip segment Demonstrates disconnect function
Wires Jumper wires and battery leads Interconnection
Breadboard Full-size or half-size Assembly
USB cable USB A to B for UNO Programming and power
Multimeter Digital multimeter Verification of divider and battery voltage

Why 30 kOhm and 7.5 kOhm?

This divider scales the battery voltage by:

[
V_{A0} = V_{BAT} \times \frac{7.5}{30 + 7.5} = V_{BAT} \times 0.2
]

So:

  • 12.0 V battery becomes about 2.4 V at A0
  • 15.0 V battery becomes about 3.0 V at A0

That stays safely below the Arduino’s 5 V analog limit.


Setup/Connection

This section explains the wiring only with text and tables, as requested.

1) Power arrangement

For beginner safety and clarity:

  • Power the Arduino UNO from the USB cable
  • Power the relay module from the Arduino 5 V and GND
  • Use the battery only for:
  • the voltage divider measurement
  • and the switched load path through the relay contacts

This keeps the logic side simple during testing.

2) Voltage divider wiring

Connect the battery measurement divider like this:

  • Battery positive -> R1 (30 kOhm) -> divider midpoint
  • Divider midpoint -> R2 (7.5 kOhm) -> battery negative
  • Divider midpoint -> Arduino A0
  • Battery negative -> Arduino GND

Optional noise reduction:

  • Place a 100 nF capacitor from A0 to GND

Important:

  • The Arduino must share ground with the battery negative for voltage measurement.
  • Never connect battery positive directly to A0.

3) Relay module wiring

Most 1-channel relay modules have these low-voltage pins:

  • VCC
  • GND
  • IN

Connect:

  • Relay VCC -> Arduino 5V
  • Relay GND -> Arduino GND
  • Relay IN -> Arduino digital pin 8

For the switched load contacts, most modules provide:

  • COM
  • NO (normally open)
  • NC (normally closed)

Use the relay so the load is powered only when the battery is healthy:

  • Battery positive -> relay COM
  • Relay NO -> load positive
  • Load negative -> battery negative

This means:

  • when relay is activated and the system allows the load, COM connects to NO
  • when cutoff happens, the connection opens and the load turns off

4) LCD wiring

This tutorial uses the parallel 4-bit mode with the standard LiquidCrystal library.

Connect LCD pins as follows:

  • LCD VSS -> GND
  • LCD VDD -> 5V
  • LCD VO -> contrast control
  • Best practice: connect to the middle pin of a 10 kOhm potentiometer
  • Other ends of pot -> 5V and GND
  • LCD RS -> Arduino pin 12
  • LCD RW -> GND
  • LCD E -> Arduino pin 11
  • LCD D4 -> Arduino pin 5
  • LCD D5 -> Arduino pin 4
  • LCD D6 -> Arduino pin 3
  • LCD D7 -> Arduino pin 2
  • LCD A (backlight +) -> 5V through suitable resistor if your module requires it
  • LCD K (backlight -) -> GND

5) Complete connection summary

Function Module pin Arduino / battery connection
Battery measurement input Divider midpoint A0
Divider bottom R2 lower end GND and battery negative
Relay control IN D8
Relay power VCC 5V
Relay ground GND GND
LCD RS RS D12
LCD Enable E D11
LCD data D4 D5
LCD data D5 D4
LCD data D6 D3
LCD data D7 D2
LCD power VDD 5V
LCD ground VSS GND
LCD RW RW GND

6) Threshold strategy

For a 12 V battery demonstration:

  • Disconnect threshold: 11.80 V
  • Reconnect threshold: 12.40 V

This creates hysteresis. Without hysteresis, the relay might chatter around one threshold.

A short cutoff delay is also useful. In this project:

  • voltage must remain below disconnect threshold for several seconds before the relay opens

That helps ignore temporary dips.


Validated Code

low_battery_load_disconnect.ino

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

#include <LiquidCrystal.h>

// LCD pins: RS, E, D4, D5, D6, D7
LiquidCrystal lcd(12, 11, 5, 4, 3, 2);

// Pin assignments
const int PIN_BATTERY = A0;
const int PIN_RELAY = 8;

// Relay module behavior:
// Many modules are ACTIVE LOW.
// Set RELAY_ON_LEVEL and RELAY_OFF_LEVEL to match your hardware.
const int RELAY_ON_LEVEL = LOW;
const int RELAY_OFF_LEVEL = HIGH;

// Voltage divider values in ohms
const float R1 = 30000.0;  // battery+ to A0
const float R2 = 7500.0;   // A0 to GND

// ADC reference for standard UNO powered by USB
const float ADC_REF_VOLTAGE = 5.0;
const int ADC_MAX = 1023;

// Battery thresholds
const float DISCONNECT_VOLTAGE = 11.80;
const float RECONNECT_VOLTAGE  = 12.40;

// Timing
const unsigned long SAMPLE_INTERVAL_MS = 250;
const unsigned long LCD_INTERVAL_MS = 500;
const unsigned long SERIAL_INTERVAL_MS = 1000;
const unsigned long LOW_VOLTAGE_DELAY_MS = 5000;

// Averaging
const int NUM_SAMPLES = 20;

// State variables
bool loadConnected = true;
bool lowVoltagePending = false;
unsigned long lowVoltageStartMs = 0;
unsigned long lastSampleMs = 0;
unsigned long lastLcdMs = 0;
unsigned long lastSerialMs = 0;
float filteredBatteryVoltage = 0.0;

// Read battery voltage using averaging
float readBatteryVoltage() {
  long total = 0;

  for (int i = 0; i < NUM_SAMPLES; i++) {
    total += analogRead(PIN_BATTERY);
    delay(2);
  }

  float adc = total / (float)NUM_SAMPLES;
  float vA0 = adc * ADC_REF_VOLTAGE / ADC_MAX;
  float batteryVoltage = vA0 * ((R1 + R2) / R2);

  return batteryVoltage;
}

void setLoadConnected(bool enabled) {
  loadConnected = enabled;
  digitalWrite(PIN_RELAY, enabled ? RELAY_ON_LEVEL : RELAY_OFF_LEVEL);
}

void updateControlLogic(float vbat, unsigned long nowMs) {
  if (loadConnected) {
    if (vbat < DISCONNECT_VOLTAGE) {
      if (!lowVoltagePending) {
        lowVoltagePending = true;
        lowVoltageStartMs = nowMs;
      } else if ((nowMs - lowVoltageStartMs) >= LOW_VOLTAGE_DELAY_MS) {
        setLoadConnected(false);
        lowVoltagePending = false;
      }
    } else {
      lowVoltagePending = false;
    }
  } else {
    // Load is disconnected; reconnect only after voltage rises enough
    if (vbat > RECONNECT_VOLTAGE) {
      setLoadConnected(true);
      lowVoltagePending = false;
    }
// ...

#include <LiquidCrystal.h>

// LCD pins: RS, E, D4, D5, D6, D7
LiquidCrystal lcd(12, 11, 5, 4, 3, 2);

// Pin assignments
const int PIN_BATTERY = A0;
const int PIN_RELAY = 8;

// Relay module behavior:
// Many modules are ACTIVE LOW.
// Set RELAY_ON_LEVEL and RELAY_OFF_LEVEL to match your hardware.
const int RELAY_ON_LEVEL = LOW;
const int RELAY_OFF_LEVEL = HIGH;

// Voltage divider values in ohms
const float R1 = 30000.0;  // battery+ to A0
const float R2 = 7500.0;   // A0 to GND

// ADC reference for standard UNO powered by USB
const float ADC_REF_VOLTAGE = 5.0;
const int ADC_MAX = 1023;

// Battery thresholds
const float DISCONNECT_VOLTAGE = 11.80;
const float RECONNECT_VOLTAGE  = 12.40;

// Timing
const unsigned long SAMPLE_INTERVAL_MS = 250;
const unsigned long LCD_INTERVAL_MS = 500;
const unsigned long SERIAL_INTERVAL_MS = 1000;
const unsigned long LOW_VOLTAGE_DELAY_MS = 5000;

// Averaging
const int NUM_SAMPLES = 20;

// State variables
bool loadConnected = true;
bool lowVoltagePending = false;
unsigned long lowVoltageStartMs = 0;
unsigned long lastSampleMs = 0;
unsigned long lastLcdMs = 0;
unsigned long lastSerialMs = 0;
float filteredBatteryVoltage = 0.0;

// Read battery voltage using averaging
float readBatteryVoltage() {
  long total = 0;

  for (int i = 0; i < NUM_SAMPLES; i++) {
    total += analogRead(PIN_BATTERY);
    delay(2);
  }

  float adc = total / (float)NUM_SAMPLES;
  float vA0 = adc * ADC_REF_VOLTAGE / ADC_MAX;
  float batteryVoltage = vA0 * ((R1 + R2) / R2);

  return batteryVoltage;
}

void setLoadConnected(bool enabled) {
  loadConnected = enabled;
  digitalWrite(PIN_RELAY, enabled ? RELAY_ON_LEVEL : RELAY_OFF_LEVEL);
}

void updateControlLogic(float vbat, unsigned long nowMs) {
  if (loadConnected) {
    if (vbat < DISCONNECT_VOLTAGE) {
      if (!lowVoltagePending) {
        lowVoltagePending = true;
        lowVoltageStartMs = nowMs;
      } else if ((nowMs - lowVoltageStartMs) >= LOW_VOLTAGE_DELAY_MS) {
        setLoadConnected(false);
        lowVoltagePending = false;
      }
    } else {
      lowVoltagePending = false;
    }
  } else {
    // Load is disconnected; reconnect only after voltage rises enough
    if (vbat > RECONNECT_VOLTAGE) {
      setLoadConnected(true);
      lowVoltagePending = false;
    }
  }
}

void updateLcd(float vbat, unsigned long nowMs) {
  if (nowMs - lastLcdMs < LCD_INTERVAL_MS) {
    return;
  }
  lastLcdMs = nowMs;

  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print("Bat:");
  lcd.print(vbat, 2);
  lcd.print("V");

  lcd.setCursor(0, 1);

  if (loadConnected) {
    if (lowVoltagePending) {
      unsigned long elapsed = nowMs - lowVoltageStartMs;
      unsigned long remain = 0;
      if (elapsed < LOW_VOLTAGE_DELAY_MS) {
        remain = (LOW_VOLTAGE_DELAY_MS - elapsed + 999) / 1000;
      }
      lcd.print("LOW WAIT ");
      lcd.print(remain);
      lcd.print("s");
    } else {
      lcd.print("LOAD ON");
    }
  } else {
    lcd.print("CUT OFF");
  }
}

void printSerialStatus(float vbat, unsigned long nowMs) {
  if (nowMs - lastSerialMs < SERIAL_INTERVAL_MS) {
    return;
  }
  lastSerialMs = nowMs;

  Serial.print("Vbat=");
  Serial.print(vbat, 3);
  Serial.print(" V, Load=");
  Serial.print(loadConnected ? "ON" : "OFF");
  Serial.print(", Pending=");
  Serial.println(lowVoltagePending ? "YES" : "NO");
}

void setup() {
  pinMode(PIN_RELAY, OUTPUT);
  setLoadConnected(true);

  lcd.begin(16, 2);
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print("Battery Guard");
  lcd.setCursor(0, 1);
  lcd.print("Starting...");

  Serial.begin(9600);
  delay(1500);

  filteredBatteryVoltage = readBatteryVoltage();
}

void loop() {
  unsigned long nowMs = millis();

  if (nowMs - lastSampleMs >= SAMPLE_INTERVAL_MS) {
    lastSampleMs = nowMs;

    float rawV = readBatteryVoltage();

    // Light smoothing filter
    filteredBatteryVoltage = 0.8 * filteredBatteryVoltage + 0.2 * rawV;

    updateControlLogic(filteredBatteryVoltage, nowMs);
    updateLcd(filteredBatteryVoltage, nowMs);
    printSerialStatus(filteredBatteryVoltage, nowMs);
  }
}

Notes about the sketch

  • The code assumes a common active-low relay module, where pulling IN low activates the relay.
  • If your relay behaves the opposite way:
  • change:
    • const int RELAY_ON_LEVEL = LOW;
    • const int RELAY_OFF_LEVEL = HIGH;
  • to:
    • const int RELAY_ON_LEVEL = HIGH;
    • const int RELAY_OFF_LEVEL = LOW;

Optional serial monitor reference output

Vbat=12.681 V, Load=ON, Pending=NO
Vbat=12.503 V, Load=ON, Pending=NO
Vbat=11.752 V, Load=ON, Pending=YES
Vbat=11.741 V, Load=ON, Pending=YES
Vbat=11.730 V, Load=OFF, Pending=NO
Vbat=12.452 V, Load=ON, Pending=NO

Build/Flash/Run commands

Use Arduino CLI exactly as required.

Command table

Task Command
Update board index arduino-cli core update-index
Install AVR core arduino-cli core install arduino:avr
Compile sketch arduino-cli compile --fqbn arduino:avr:uno low_battery_load_disconnect
Upload sketch arduino-cli upload --fqbn arduino:avr:uno --port <PORT> low_battery_load_disconnect

Example terminal session

arduino-cli core update-index
arduino-cli core install arduino:avr
arduino-cli compile --fqbn arduino:avr:uno low_battery_load_disconnect
arduino-cli upload --fqbn arduino:avr:uno --port <PORT> low_battery_load_disconnect

Short workflow

  1. Create a folder named low_battery_load_disconnect.
  2. Save the sketch as low_battery_load_disconnect.ino inside that folder.
  3. Connect the Arduino UNO R3 by USB.
  4. Run the compile command.
  5. Replace <PORT> with your real serial port:
  6. Linux example: /dev/ttyACM0
  7. Windows example: COM4
  8. macOS example: /dev/cu.usbmodem14101
  9. Upload the sketch.
  10. Open a serial monitor at 9600 baud if you want extra diagnostics.

Step-by-step Validation

The goal here is to validate the project as a practical low-battery-load-disconnect prototype, not just verify that code uploads.

1) Power-up and display check

Action
– Connect the Arduino by USB.
– Power the battery measurement side and connect the test load through the relay path.
– Adjust the LCD contrast potentiometer until text becomes readable.

Expected observation
– LCD first shows startup text such as Battery Guard.
– Then it shows a voltage reading on line 1.
– Line 2 shows either LOAD ON, LOW WAIT, or CUT OFF.

Pass condition
– LCD is readable and updates automatically.
– No random blocks or blank screen after contrast adjustment.


2) Divider and measurement accuracy check

Action
– Measure the actual battery voltage with a multimeter directly across the battery terminals.
– Compare it to the voltage shown on the LCD and, optionally, the serial monitor.

Expected observation
– LCD/serial voltage is close to the multimeter reading.
– Small differences are normal because:
– USB 5 V may not be exactly 5.000 V
– resistor tolerances affect scaling
– ADC readings have some noise

Pass condition
– The reading tracks voltage changes correctly and is reasonably close to the meter.
– If it is consistently off, calibration can be improved later by adjusting ADC_REF_VOLTAGE or resistor values in the code.


3) Normal-operation relay test

Action
– Set the battery or bench supply to a healthy level above 12.40 V.
– Observe relay state and test load behavior.

Expected observation
– Relay is energized in the “load enabled” state.
– LCD shows LOAD ON.
– The load receives battery power through relay COM to NO.

Pass condition
– The load turns on and stays on steadily above the reconnect threshold.


4) Low-voltage pending and timed cutoff test

Action
– Slowly lower the battery supply below 11.80 V.
– Keep it below threshold for more than 5 seconds.

Expected observation
– LCD changes to LOW WAIT with a countdown or brief remaining time display.
– After the delay, the relay changes state and the load disconnects.
– LCD then shows CUT OFF.

Pass condition
– The relay does not trip immediately on a brief dip.
– The relay does disconnect after voltage remains low for the full delay.


5) Hysteresis and recovery test

Action
– After cutoff, raise the supply again.
– First try a value between 11.80 V and 12.40 V, then raise it above 12.40 V.

Expected observation
– Between thresholds, the relay stays off.
– Once voltage rises above reconnect threshold, the relay reconnects and the LCD returns to LOAD ON.

Pass condition
– Relay does not chatter near one threshold.
– Reconnect happens only above the higher threshold.


Troubleshooting

Symptom Likely cause Fix
LCD lights but no text is visible Contrast pin not adjusted correctly Use a 10 kOhm pot on LCD VO and adjust slowly
LCD shows random characters Wrong RS/E/D4-D7 wiring Recheck LCD pin mapping against the tutorial
Voltage shown is much too high or too low Divider values wrong or common ground missing Verify resistor values with a meter and connect battery negative to Arduino GND
Analog reading jumps a lot Noisy wiring or floating measurement point Keep wires short, add 100 nF from A0 to GND, ensure solid ground
Relay never switches Relay input logic opposite of code Swap RELAY_ON_LEVEL and RELAY_OFF_LEVEL
Relay clicks but load stays off Wrong contact terminal used Use COM and NO for normal-on-when-healthy behavior
Load never disconnects Threshold too low or measurement calibration off Print serial values, compare with meter, adjust thresholds or ADC_REF_VOLTAGE
Arduino resets when relay switches Power disturbance or wiring issue Keep logic wiring neat, power UNO by stable USB, avoid powering large loads from Arduino 5 V
Battery voltage on LCD is zero or near zero A0 not connected to divider midpoint Check A0 wire and divider midpoint continuity

Improvements

Better measurement quality

  • Replace 5 V USB reference assumptions with:
  • a measured calibration constant,
  • or a more advanced reference method if your lesson later covers ADC calibration.
  • Use 1% resistors for the divider.
  • Add a larger averaging window or median filtering if your battery source is noisy.

More robust battery protection behavior

  • Add a buzzer for low-battery warning before cutoff.
  • Add a manual reset button so the user must acknowledge the disconnect before reconnecting.
  • Store the last cutoff event in EEPROM for simple usage tracking.

Better enclosure and field usability

  • Mount the Arduino, relay, and LCD in a small project box.
  • Add screw terminals for:
  • battery input,
  • load output,
  • and fused wiring.
  • Put labels on:
  • BAT +
  • BAT -
  • LOAD +
  • LOAD -

A very practical student upgrade is turning this into a portable battery guard module for a camping light box, a small solar demonstrator, or an educational alarm battery pack.


Final Checklist

  • [ ] I used Arduino UNO R3 (ATmega328P).
  • [ ] I used a voltage divider and did not connect battery voltage directly to A0.
  • [ ] I used a 1-channel relay module with a shared ground to the Arduino.
  • [ ] I wired the 16×2 HD44780 LCD in 4-bit mode as listed.
  • [ ] I installed the Arduino AVR core with Arduino CLI.
  • [ ] The sketch compiled with:
  • arduino-cli compile --fqbn arduino:avr:uno low_battery_load_disconnect
  • [ ] The sketch uploaded with:
  • arduino-cli upload --fqbn arduino:avr:uno --port <PORT> low_battery_load_disconnect
  • [ ] The LCD shows live battery voltage.
  • [ ] The relay keeps the load on above the reconnect threshold.
  • [ ] The relay disconnects the load after voltage stays below the disconnect threshold for the delay period.
  • [ ] The load reconnects only after voltage rises above the higher threshold.
  • [ ] I compared the reading with a multimeter.
  • [ ] I understand this is an educational low-voltage prototype, not a certified protection device.

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What is the main purpose of the 12 V low-battery load disconnect controller?




Question 2: Which board is specifically mentioned for this project?




Question 3: What component is used to show the live battery voltage reading?




Question 4: What happens when the battery voltage drops below a safe threshold such as 11.8 V?




Question 5: What is the purpose of hysteresis in this controller?




Question 6: When does the system reconnect the load?




Question 7: How often is the voltage measurement expected to refresh on the LCD?




Question 8: Which type of battery is explicitly mentioned as a use case for protection?




Question 9: What kind of loads are listed as examples for this controller?




Question 10: Which additional component is mentioned along with the Arduino, relay module, and LCD?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me: