Five subsystems, one Arduino loop: why blocking delays broke our smart home
Counting the worst-case loop time of a five-subsystem Arduino build, and rewriting it as non-blocking state machines with millis().
Rayhan M. Project: Dec 2024 Written 4 min read Source on GitHub
This was the third and largest of our EE108 Arduino projects at Maynooth: five independent home automation features running on one Arduino Uno. Our group of five prototyped it in Tinkercad and then built it on a breadboard. I did the hardware assembly and worked on the code.
Each subsystem worked fine on its own. Putting all five on one processor is where the real problem showed up. The sketch and circuit are on GitHub.

The five subsystems
| Function | Input | Output | Behaviour |
|---|---|---|---|
lights() | PIR motion sensor | 3 LEDs | Motion plays a 3-bit binary count across the LEDs |
curtains() | LDR | DC motor | Raises or lowers the curtain when light crosses a threshold |
waterSensor() | Potentiometer as water level | Servo | Closes the valve above a threshold |
openedDoor() | HC-SR04 ultrasonic | Buzzer | Sounds if the door distance exceeds a threshold |
soundLights() | Sound sensor | LED | A loud sound toggles the light |
loop() just calls all five, one after another:
void loop() {
waterSensor();
lights();
curtains(curtainsup);
openedDoor();
soundLights();
}
Counting the worst case
On a microcontroller with no operating system, delay() stops everything. Nothing else runs until it returns. So the useful question is: in the worst case, how long is one pass through loop()?
lights()counts from 0 to 7 with adelay(500)after each step: 4,000 ms.curtains()runs the motor withdelay(400): 400 ms.soundLights()waitsdelay(200)as a crude debounce: 200 ms.openedDoor()callspulseIn(), which waits up to 1,000 ms by default if no echo comes back.
Added up, a single pass can take over five and a half seconds. In practice, if someone walks past the motion sensor, the door alarm can’t respond until the light pattern finishes four seconds later. For a security feature, that’s the bug that matters.
A neat trick inside lights()
The light pattern itself is a nice bit of code (lightly condensed here). It turns a counter into three LED states by masking off one bit at a time:
for (int i = 0; i < 8; i++) {
int arrayPosition = 0;
for (int j = 4; j >= 1; j = j / 2) { // masks 4, 2, 1
data[arrayPosition] = (i & j) > 0;
arrayPosition++;
}
digitalWrite(LIGHTS1, data[0]);
digitalWrite(LIGHTS2, data[1]);
digitalWrite(LIGHTS3, data[2]);
delay(500);
}
i & 4, i & 2 and i & 1 pick out each bit of the counter, so the three LEDs show 000, 001, 010 and so on up to 111. The logic is fine. The delay(500) inside the loop is the problem.
The fix: each subsystem keeps its own clock
The standard fix is to never wait. Instead, each subsystem remembers when it last acted and checks millis() on every pass, doing its next step only once enough time has passed. Here’s how lights() looks rewritten that way (my rewrite, not the submitted code):
void lights() {
static uint8_t step = 0;
static unsigned long lastStep = 0;
static bool running = false;
if (!running && digitalRead(IR_SENSOR) == HIGH) {
running = true;
step = 0;
lastStep = millis() - 500; // show the first pattern straight away
}
if (!running) return;
if (millis() - lastStep >= 500) {
lastStep += 500;
digitalWrite(LIGHTS1, step & 4);
digitalWrite(LIGHTS2, step & 2);
digitalWrite(LIGHTS3, step & 1);
if (++step == 8) running = false;
}
}
Now lights() returns within microseconds on every call, so the other four subsystems get checked thousands of times per second. millis() - lastStep also keeps working correctly when millis() wraps around after about 49 days, because unsigned subtraction wraps too.
The same pattern fixes the curtain motor (start it, and stop it 400 ms later on a later pass) and the sound toggle (ignore new triggers for a lockout period instead of sleeping through them). Adding a timeout to pulseIn() caps the ultrasonic read.
Other things I’d change
- The curtain is open-loop. The motor runs for a fixed 400 ms with no limit switch or encoder, so how far the curtain actually moves depends on load and battery voltage, and it drifts over repeated cycles. Limit switches would close the loop.
- The sound toggle needs a real debounce. A 200 ms pause isn’t enough: a sustained noise retriggers on the next pass, so the light flickers instead of switching once.
The biggest lesson: building five subsystems is easy, but getting them to share one processor is the real engineering problem.
- #arduino
- #embedded-systems
- #real-time
- #sensors