Automatic Chicken Coop Door: Stepper Motor, A4988 and Limit Switches
The heart of our chicken_door project: how the ESP32 raises and lowers the door through an A4988 driver, stops at the limit switches and never leaves the motor running when something goes wrong.
About the project
chicken_door is our automatic chicken coop door: it opens after sunrise, closes in the evening, and can be controlled from a phone through a web page. Inside: an ESP32-C3, an A4988 stepper driver, a NEMA17 motor with a lead screw and two limit switches — "open" and "closed".
This article covers the most important part: wiring the motor and a basic program that moves the door safely. The sun schedule, web UI and camera stay out of scope.
What you need
| Part | Why |
|---|---|
| ESP32 (DevKit or ESP32-C3 SuperMini) | the brain |
| A4988 driver (Pololu board or a clone) | turns STEP pulses into rotation |
| NEMA17 stepper + screw/cord | lifts the door |
| 2 limit switches (micro switches) | stop the motor at both ends |
| 12 V ≥ 2 A power supply | motor supply (VMOT) |
| 100 µF 35 V capacitor at VMOT | absorbs voltage spikes — without it the A4988 can burn |
| 2 × 10 kΩ resistors | keep STEP low and the driver off while the ESP32 boots |
Wiring
12 V (+) ──┬──────────── VMOT ┌──────────┐ 1A ─┐ coil A
100 µF ═╪═ GND │ A4988 │ 1B ─┘
12 V (−) ──┴──┬───────── GND │ │ 2A ─┐ coil B NEMA17
│ 3V3 ─── VDD │ │ 2B ─┘
ESP32 GND ─────┘ │ │
GPIO26 ──┬────────────── STEP │ │ RESET ─┐ join with
10k┴ GND │ │ SLEEP ─┘ a jumper
GPIO27 ───────────────── DIR │ │
GPIO25 ──┬────────────── EN │ │
10k┴ 3V3 └──────────┘
GPIO32 ──── "OPEN" limit switch ──── GND
GPIO33 ──── "CLOSED" limit switch ──── GND
The 12 V supply ground and the ESP32 ground must be shared.
| Signal | ESP32 DevKit (this article) | ESP32-C3 SuperMini (chicken_door board) |
|---|---|---|
| STEP | GPIO26 | GPIO4 |
| DIR | GPIO27 | GPIO5 |
| EN | GPIO25 | GPIO6 |
| "Open" limit switch | GPIO32 | GPIO7 |
| "Closed" limit switch | GPIO33 | GPIO10 |
How it works
- STEP — each HIGH→LOW pulse turns the shaft by one step (1/200 of a turn in full-step mode). The pause between pulses sets the speed: 3 ms gives a calm travel.
- DIR — direction: HIGH opens, LOW closes.
- EN — active LOW. While the door is idle the driver is switched off, so the motor neither heats up nor hums. The A4988 board pulls this input to ground, so without an external 10 kΩ pull-up to 3.3 V the motor switches on by itself while the ESP32 boots.
- Limit switches connect the pin to GND (
INPUT_PULLUP, pressed = LOW). So that a noise spike can't stop the door, the motion stops only after three readings in a row. - Timeout — the main safety net: if a switch never triggers (broken wire, jammed door), the motor stops after
MAX_MOVE_TIME_MS. Set it 20–30 % above the real travel time.
Basic program
Commands come from buttons or the Serial Monitor: open, close, stop.
// Chicken coop door — basic motor control (from the chicken_door project)
// A4988 stepper driver + two limit switches; commands from buttons or Serial.
const int STEP_PIN = 26;
const int DIR_PIN = 27;
const int ENABLE_PIN = 25; // active LOW, external 10k pull-up to 3.3 V
const int OPEN_LIMIT_PIN = 32; // switch to GND: pressed = LOW
const int CLOSE_LIMIT_PIN = 33;
const int BTN_OPEN = 18;
const int BTN_CLOSE = 19;
const int DIR_OPEN = HIGH;
const int DIR_CLOSE = LOW;
const unsigned long STEP_INTERVAL_US = 3000; // bigger = slower
const unsigned long MAX_MOVE_TIME_MS = 15000; // real travel time + 20–30 %
const unsigned long DEBOUNCE_MS = 20;
class LimitSwitch {
public:
LimitSwitch(int pin) : pin(pin) {}
void begin() {
pinMode(pin, INPUT_PULLUP);
raw = stable = pressed();
changedAt = millis();
}
// accept a new level only once it has held for DEBOUNCE_MS
void update() {
bool now = pressed();
if (now != raw) { raw = now; changedAt = millis(); }
else if (raw != stable && millis() - changedAt >= DEBOUNCE_MS) stable = raw;
}
bool active() const { return stable; }
private:
int pin;
bool raw = false, stable = false;
unsigned long changedAt = 0;
bool pressed() { return digitalRead(pin) == LOW; }
};
enum Motion { STOPPED, OPENING, CLOSING };
LimitSwitch openLimit(OPEN_LIMIT_PIN);
LimitSwitch closeLimit(CLOSE_LIMIT_PIN);
Motion motion = STOPPED;
unsigned long moveStartedAt = 0;
unsigned long lastStepUs = 0;
int limitReads = 0;
void setDriver(bool on) { digitalWrite(ENABLE_PIN, on ? LOW : HIGH); }
void stopMotor(const char* reason) {
if (motion != STOPPED) Serial.printf("Stop: %s\n", reason);
motion = STOPPED;
setDriver(false); // an unpowered motor neither heats up nor hums
}
void startMoving(Motion dir) {
if (motion == dir) return;
stopMotor("new command");
if (openLimit.active() && closeLimit.active()) {
Serial.println("Both limit switches are pressed - check the wiring");
return;
}
LimitSwitch &target = dir == OPENING ? openLimit : closeLimit;
if (target.active()) {
Serial.println(dir == OPENING ? "Already open" : "Already closed");
return;
}
digitalWrite(DIR_PIN, dir == OPENING ? DIR_OPEN : DIR_CLOSE);
setDriver(true);
delayMicroseconds(20); // DIR must settle before the first STEP
limitReads = 0;
moveStartedAt = millis();
lastStepUs = micros();
motion = dir;
Serial.println(dir == OPENING ? "Opening..." : "Closing...");
}
// One step when it's time; stop once the target switch reads pressed 3 times in a row
void stepMotor() {
if (motion == STOPPED || micros() - lastStepUs < STEP_INTERVAL_US) return;
lastStepUs += STEP_INTERVAL_US;
int limitPin = motion == OPENING ? OPEN_LIMIT_PIN : CLOSE_LIMIT_PIN;
if (digitalRead(limitPin) == LOW) {
if (++limitReads >= 3) {
stopMotor(motion == OPENING ? "OPEN limit" : "CLOSED limit");
return;
}
} else {
limitReads = 0;
}
digitalWrite(STEP_PIN, HIGH);
delayMicroseconds(5); // the A4988 needs at least 1 us
digitalWrite(STEP_PIN, LOW);
}
void setup() {
Serial.begin(115200);
digitalWrite(ENABLE_PIN, HIGH); // driver off before the pin even becomes an output
pinMode(ENABLE_PIN, OUTPUT);
pinMode(STEP_PIN, OUTPUT);
pinMode(DIR_PIN, OUTPUT);
pinMode(BTN_OPEN, INPUT_PULLUP);
pinMode(BTN_CLOSE, INPUT_PULLUP);
openLimit.begin();
closeLimit.begin();
Serial.println("Commands: open, close, stop");
}
void loop() {
openLimit.update();
closeLimit.update();
stepMotor();
if (motion != STOPPED && millis() - moveStartedAt >= MAX_MOVE_TIME_MS) {
stopMotor("TIMEOUT - check the door for obstructions");
}
// buttons: react to the moment of pressing
static int lastOpen = HIGH, lastClose = HIGH;
int o = digitalRead(BTN_OPEN), c = digitalRead(BTN_CLOSE);
if (o == LOW && lastOpen == HIGH) startMoving(OPENING);
if (c == LOW && lastClose == HIGH) startMoving(CLOSING);
lastOpen = o;
lastClose = c;
if (Serial.available()) {
String cmd = Serial.readStringUntil('\n');
cmd.trim();
if (cmd == "open") startMoving(OPENING);
else if (cmd == "close") startMoving(CLOSING);
else if (cmd == "stop") stopMotor("command");
}
}
Tuning
- A4988 current: set it with the trimmer before the first power-up:
Vref = I × 8 × Rs. For most clones with R100 (0.1 Ω) sense resistors that isVref ≈ 0.8 × I, for genuine Pololu boards with 0.068 Ω it isVref ≈ 0.54 × I. Check the resistor marking on your board. - NC (normally closed) switches are more reliable: a broken wire looks like "pressed", so the motor stops instead of driving on. Compare with
HIGHin the code then. - Holding: if the door slides down while the motor is off, don't disable the driver in
stopMotor()— but then the motor and driver stay warm all the time. - Speed: a smaller
STEP_INTERVAL_USis faster, but a loaded motor skips pulses that come too often.
What the full project adds
Here steps are made in loop(). The chicken_door firmware generates them from a hardware timer interrupt and checks the limit switch in the same interrupt: however long a web request or a camera frame takes, the motor never stutters or overruns its end position. On top of that come the sunrise/sunset schedule, the web UI, a watchdog and a restart if Wi-Fi stays down for too long.