Community project
ESP32 Motion-Controlled Stepper
This project builds a motion-activated motorized curtain controller using an ESP32 microcontroller, a TMC2209 stepper driver, and a NEMA 17 motor. The system detects motion via a PIR sensor, displays status on a 7-inch HMI touchscreen, and can operate in manual or automatic modes through a selector switch. A limit switch provides homing feedback, while an opto-isolated relay controls auxiliary lighting.
The guide provides a complete wiring diagram, detailed parts list, and step-by-step assembly instructions for the carrier PCB. Firmware is included to handle motion detection, stepper control, display communication, and state management. Builders will learn how to integrate motion sensing, stepper motor control, and HMI display communication on a single coordinated system.
Wiring diagram

Gather all the parts
| Qty | Component |
|---|---|
| 1 | Set current limit for the actual motor ADI Trinamic TMC2209 silent two-phase stepper motor driver with STEP/DIR control and single-wire UART configuration. |
| 1 | 5–20 V PIR module Passive Infrared (PIR) motion detection module with adjustable sensitivity and delay potentiometers. Operates on 5V supply; digital output is nominally 3.3V or 5V depending on module variant (verify before connecting directly to ESP32 3.3V GPIO — use a voltage divider if output is 5V). Outputs HIGH when motion is detected, LOW when idle. Ideal for triggering countdown timer resets in Focus Mode applications. No firmware library required — output read via standard GPIO digitalRead(). |
| 1 | DWIN DMG80480C070_15WTR HMI Display 7-inch, 800×480, 12 V The 7-inch touch screen that shows curtain controls, Wi-Fi setup, clock, and weather. |
| 1 | MAX3232 3.3 V TTL-to-RS232 Converter Module 3.3 V logic configuration The voltage-converter module that safely translates the ESP32’s 3.3 V serial signals to the DWIN screen’s RS-232 signals. |
| 1 | 12 V, 2 A minimum; increase for motor current 12 V / 2 A wall adapter with a 5.5 mm / 2.1 mm barrel jack. Used to power motor drivers, LED strips, or boards that need a higher rail. |
| 1 | Adjust to exactly 5.0 V Adjustable buck (step-down) DC-DC converter, 4.5-28 V in -> 0.8-20 V out, ~3 A. Configured to 5 V to step a 9 V / 12 V supply or battery pack down to the board's 5 V rail. |
| 1 | Match current rating to TMC2209 cooling NEMA 17-size bipolar stepper motor for motion axes. Typical hobby modules are 42mm-frame, 4-wire/two-phase motors with 1.8 degree step angle (200 steps/rev); use with a current-limited stepper driver such as TMC2209/A4988/DRV8825 rather than direct GPIO. |
| 1 | Normally-Closed Open-End Limit Switch Use COM and NC terminals The physical safety switch that tells the controller when the curtain is fully open. |
| 1 | 3-Position Maintained Mode Selector Switch SP3T, break-before-make The physical selector that requests permanently open, permanently closed, or automatic curtain operation. |
| 1 | 3.3 V Active-High Opto-Isolated Relay Module Must be reset-safe on GPIO2 A relay module that switches the separate curtain light circuit without loading the ESP32’s boot-sensitive pins. |
| 1 | 12 V inline blade-fuse holder 3 A slow-blow blade fuse; select from actual motor current A replaceable fuse holder that disconnects the controller if a wiring fault draws too much current. |
| 1 | 100 µF 25 V electrolytic capacitor 100 µF, 25 V minimum A polarized capacitor placed beside the motor-driver supply to absorb switching spikes from the stepper motor. |
| 1 | 100 nF 50 V ceramic capacitor 100 nF, 50 V minimum A small non-polarized capacitor placed beside the motor driver to reduce high-frequency electrical noise. |
| 1 | 10 kΩ resistor 10 kΩ, 0.25 W A resistor that lowers the PIR module's 5 V output to a safe voltage for the ESP32 input. |
| 1 | 20 kΩ resistor 20 kΩ, 0.25 W A resistor that completes the PIR output voltage divider and pulls the ESP32 input safely toward ground. |
Assemble it in 7 steps
1. Mount and label the carrier PCB
Fit the carrier PCB inside a non-metal enclosure. Place the 12 V input and motor terminals at one edge, the TMC2209 socket immediately behind them, and the ESP32-C3, MAX3232, and sensor headers on the opposite side. Label every connector on the board before fitting it in the enclosure.
- Use 2-layer FR-4, 1.6 mm thick, with 1 oz copper or heavier.
- Keep the ESP32 antenna end at least 15 mm from copper pours, the metal enclosure, motor wires, and the DWIN cable.
- Do not mount this controller where condensation can reach the terminals or PCB.
- Keep any mains-light wiring physically separate from this low-voltage PCB; a relay contact is not an enclosure.
2. Build the protected 12 V entrance
Route the positive wire from the 12 V input terminal to the fuse holder IN pin, then route the fuse holder OUT pin as the protected 12 V rail. Connect the 12 V supply negative wire to the ground plane. Put labels `+12V IN`, `GND`, and `FUSED +12V` beside these terminals.
- Use a 3 A slow-blow fuse only as a starting point; choose the final fuse from the measured running and startup current of the actual stepper motor and display.
- Use wide traces or copper pours for the fused 12 V and motor-return paths.
- Do not replace a blowing fuse with a larger fuse until the wiring fault or excessive motor current has been found.
- Disconnect the wall adapter before changing a fuse or moving any motor wire.
3. Place the motor driver and its capacitors
Place the TMC2209 socket close to the motor terminal. Connect VM to fused 12 V (motor power) and GND to the nearby ground return. Fit the 100 µF capacitor with its long positive lead to VM and its striped negative lead to GND, then fit the 100 nF ceramic capacitor across the same two points. Keep both capacitors within a few centimetres of the driver socket.
- Make the four motor traces short and wide, and keep them away from the ESP32 antenna and the UART wires.
- Provide a clearly marked 4-pin motor terminal: A+, A−, B+, B−.
- A reversed electrolytic capacitor can burst when power is applied.
- Never connect or disconnect the motor while the 12 V supply is connected; that can destroy the TMC2209.
4. Fit the plug-in low-voltage modules
Fit headers for the ESP32-C3 SuperMini, MP1584 buck module, MAX3232 module, and the relay module rather than soldering those modules permanently to the board. Connect the MP1584 input to fused 12 V and ground, then use its adjusted 5 V output for the ESP32, PIR, and relay. Use the ESP32 3.3 V pin for MAX3232 VCC and TMC2209 VIO.
- Before plugging in the ESP32, power only the buck module and adjust its output to exactly 5.0 V with a multimeter.
- Mark the MAX3232 header `3V3 ONLY` directly on the PCB.
- A 5 V-powered MAX3232 can send 5 V signals into the ESP32 and damage it.
- GPIO2 must connect only to an active-high, high-impedance relay input so the relay cannot hold it low during ESP32 startup.
5. Fit the low-voltage control connectors
Install keyed or screw-terminal connectors for the open limit switch, three-position mode switch, PIR, and relay input. Wire the open switch COM to GND (ground) and NC to GPIO7 (end-stop signal). Wire the mode switch common to GND (ground), then its OPEN, CLOSE, and AUTO contacts to GPIO0, GPIO1, and GPIO6 (signals). Wire PIR OUT to GPIO3 through a 3.3 V-safe interface.
- Print the connector labels on the PCB: `ENDSTOP GND/NC`, `MODE GND/OPEN/CLOSE/AUTO`, `PIR 5V/GND/OUT`, and `RELAY 5V/GND/IN`.
- Keep the endstop and PIR cables away from the motor terminal and motor cable.
- The common HC-SR501 version can output nearly 5 V. Do not connect that directly to GPIO3; use a 3.3 V-safe PIR output version or a level-shifter/divider.
- GPIO0 and GPIO1 affect startup. The selector must be break-before-make and must not short two contacts together.
6. Fit the DWIN display connection
Install a 4-pin or keyed display connector carrying fused 12 V, GND, RS232_TX, and RS232_RX. Connect ESP32 GPIO21 to MAX3232 TTL_RX (data) and GPIO20 to MAX3232 TTL_TX (data). Connect MAX3232 RS232_TX to DWIN UART2_RX (data), and MAX3232 RS232_RX to DWIN UART2_TX (data).
- Write `DWIN RS-232 — NOT TTL` beside the connector.
- Keep the RS-232 pair away from the motor terminal and route them together over the ground plane.
- Never connect GPIO20 or GPIO21 directly to the DWIN RS-232 connector; RS-232 voltage levels can damage the ESP32.
- Confirm the DWIN display connector’s actual pin order from the display documentation before plugging it in.
7. Inspect, power, and test the PCB
Before installing the ESP32 or TMC2209, inspect for solder bridges and verify that fused 12 V is not shorted to ground. Set the buck module to 5.0 V, then install the modules. With the curtain disconnected from the mechanism, connect power and confirm the controller homes toward the open switch.
- Test the 12 V, 5 V, and 3.3 V rails with a multimeter before connecting the screen or motor.
- If homing travels away from the endstop, reverse the direction in firmware or reverse one complete motor coil pair.
- Keep fingers, hair, and curtain fabric away from the drive while testing.
- Stop immediately if the motor stalls, chatters, or the TMC2209 becomes too hot to touch; continued stalling can damage the motor or driver.
Review all connections
1. Connections between "power_12v_1" and "ESP32"
| Function | power_12v_1 | ESP32 |
|---|---|---|
| ground | GND | GND |
| power | +12V → 12 V inline blade-fuse holder IN | EXT |
2. Connections between "buck_5v_1" and "ESP32"
| Function | buck_5v_1 | ESP32 |
|---|---|---|
| ground | GND | GND |
| power | VOUT | 5V |
| power | VIN → 12 V inline blade-fuse holder OUT | EXT |
3. Connections between "tmc2209_1" and "ESP32"
| Function | tmc2209_1 | ESP32 |
|---|---|---|
| power | VIO | 3V3 |
| ground | GND | GND |
| data | STEP | GPIO 4 |
| data | DIR | GPIO 5 |
| data | EN | GPIO 10 |
| digital | A+ → Nema 17 A+ | EXT |
| digital | A- → Nema 17 A- | EXT |
| digital | B+ → Nema 17 B+ | EXT |
| digital | B- → Nema 17 B- | EXT |
| power | PDN_UART | 3V3 |
4. Connections between "pir_1" and "ESP32"
| Function | pir_1 | ESP32 |
|---|---|---|
| power | VCC | 5V |
| ground | GND | GND |
| digital | OUT → 10 kΩ resistor 1 | EXT |
5. Connections between "open_endstop_1" and "ESP32"
| Function | open_endstop_1 | ESP32 |
|---|---|---|
| ground | COM | GND |
| digital | NC | GPIO 7 |
6. Connections between "mode_switch_1" and "ESP32"
| Function | mode_switch_1 | ESP32 |
|---|---|---|
| ground | COMMON | GND |
| digital | OPEN | GPIO 0 |
| digital | CLOSE | GPIO 1 |
| digital | AUTO | GPIO 6 |
7. Connections between "max3232_1" and "ESP32"
| Function | max3232_1 | ESP32 |
|---|---|---|
| power | VCC | 3V3 |
| ground | GND | GND |
| uart | TTL_RX | GPIO 21 |
| uart | TTL_TX | GPIO 20 |
| uart | RS232_TX → DWIN DMG80480C070_15WTR HMI Display UART2_RX | EXT |
| uart | RS232_RX → DWIN DMG80480C070_15WTR HMI Display UART2_TX | EXT |
8. Connections between "dwin_hmi_1" and "ESP32"
| Function | dwin_hmi_1 | ESP32 |
|---|---|---|
| ground | GND | GND |
| power | VCC → 12 V inline blade-fuse holder OUT | EXT |
9. Connections between "light_relay_1" and "ESP32"
| Function | light_relay_1 | ESP32 |
|---|---|---|
| power | VCC | 5V |
| ground | GND | GND |
| digital | IN | GPIO 2 |
| power | COM → Off-board light supply feed | EXT |
| power | NO → Off-board light load return | EXT |
10. Connections between "fuse_12v_1" and "ESP32"
| Function | fuse_12v_1 | ESP32 |
|---|---|---|
| power | OUT → TMC2209 Stepper Driver VM | EXT |
11. Connections between "motor_bulk_cap_1" and "ESP32"
| Function | motor_bulk_cap_1 | ESP32 |
|---|---|---|
| power | + → TMC2209 Stepper Driver VM | EXT |
| ground | - → TMC2209 Stepper Driver GND | EXT |
12. Connections between "motor_ceramic_cap_1" and "ESP32"
| Function | motor_ceramic_cap_1 | ESP32 |
|---|---|---|
| power | 1 → TMC2209 Stepper Driver VM | EXT |
| ground | 2 → TMC2209 Stepper Driver GND | EXT |
13. Connections between "pir_divider_top_1" and "ESP32"
| Function | pir_divider_top_1 | ESP32 |
|---|---|---|
| digital | 2 | GPIO 3 |
14. Connections between "pir_divider_bottom_1" and "ESP32"
| Function | pir_divider_bottom_1 | ESP32 |
|---|---|---|
| digital | 1 → 10 kΩ resistor 2 | EXT |
| ground | 2 | GND |
Deploy the firmware
#include <Arduino.h>
#include <AccelStepper.h>
// Hardware matches the final low-voltage carrier PCB. GPIO2 replaces GPIO8 for
// the relay because GPIO8 is boot-sensitive and has the SuperMini's built-in LED attached.
enum class State : uint8_t { HOMING, OPENING, CLOSING, OPEN, CLOSED, STOPPED, ERROR };
// Forward declarations
bool endstopActive();
bool modeOpen();
bool modeClose();
bool modeAuto();
void motorEnable(bool enabled);
void sendDwinPage(uint16_t page);
void homeCurtain();
void openCurtain();
void closeCurtain();
void stopCurtain();
void updateMotion();
void updatePhysicalSelector();
constexpr uint8_t STEP_PIN = 4;
constexpr uint8_t DIR_PIN = 5;
constexpr uint8_t ENABLE_PIN = 10; // TMC2209 EN: active LOW
constexpr uint8_t PIR_PIN = 3;
constexpr uint8_t OPEN_ENDSTOP_PIN = 7; // NC switch to GND, INPUT_PULLUP
constexpr uint8_t MODE_OPEN_PIN = 0;
constexpr uint8_t MODE_CLOSE_PIN = 1;
constexpr uint8_t MODE_AUTO_PIN = 6;
constexpr uint8_t LIGHT_PIN = 2; // active-HIGH, reset-safe relay interface
constexpr uint8_t DWIN_RX_PIN = 20;
constexpr uint8_t DWIN_TX_PIN = 21;
constexpr long TRAVEL_STEPS = 32400;
constexpr long HOMING_EXTRA_STEPS = 1200;
constexpr float MAX_SPEED = 19500.0F;
constexpr float ACCELERATION = 3000.0F;
constexpr uint32_t MOVE_TIMEOUT_MS = 60000UL;
HardwareSerial DwinSerial(1);
AccelStepper curtain(AccelStepper::DRIVER, STEP_PIN, DIR_PIN);
State state = State::HOMING;
bool positionKnown = false;
uint32_t movementStartedMs = 0;
bool endstopActive() { return digitalRead(OPEN_ENDSTOP_PIN) == LOW; }
bool modeOpen() { return digitalRead(MODE_OPEN_PIN) == LOW; }
bool modeClose() { return digitalRead(MODE_CLOSE_PIN) == LOW; }
bool modeAuto() { return digitalRead(MODE_AUTO_PIN) == LOW; }
void motorEnable(bool enabled) {
digitalWrite(ENABLE_PIN, enabled ? LOW : HIGH);
}
void sendDwinPage(uint16_t page) {
const uint8_t frame[] = {0x5A, 0xA5, 0x07, 0x82, 0x00, 0x84, 0x5A, 0x01,
static_cast<uint8_t>(page >> 8), static_cast<uint8_t>(page)};
DwinSerial.write(frame, sizeof(frame));
}
void homeCurtain() {
motorEnable(true);
digitalWrite(LIGHT_PIN, LOW);
movementStartedMs = millis();
curtain.setMaxSpeed(3500.0F);
curtain.setAcceleration(1200.0F);
curtain.moveTo(curtain.currentPosition() - TRAVEL_STEPS - HOMING_EXTRA_STEPS);
state = State::HOMING;
}
void openCurtain() {
if (endstopActive()) {
curtain.setCurrentPosition(0);
positionKnown = true;
motorEnable(false);
digitalWrite(LIGHT_PIN, HIGH);
state = State::OPEN;
return;
}
if (!positionKnown) { homeCurtain(); return; }
motorEnable(true);
digitalWrite(LIGHT_PIN, HIGH);
movementStartedMs = millis();
curtain.setMaxSpeed(MAX_SPEED);
curtain.setAcceleration(ACCELERATION);
curtain.moveTo(-HOMING_EXTRA_STEPS);
state = State::OPENING;
}
void closeCurtain() {
if (!positionKnown) { homeCurtain(); return; }
motorEnable(true);
movementStartedMs = millis();
curtain.setMaxSpeed(MAX_SPEED);
curtain.setAcceleration(ACCELERATION);
curtain.moveTo(TRAVEL_STEPS);
state = State::CLOSING;
}
void stopCurtain() {
curtain.stop();
state = State::STOPPED;
}
void updateMotion() {
curtain.run();
if ((state == State::HOMING || state == State::OPENING) && endstopActive()) {
curtain.setCurrentPosition(0);
positionKnown = true;
motorEnable(false);
digitalWrite(LIGHT_PIN, HIGH);
state = State::OPEN;
} else if (state == State::CLOSING && curtain.distanceToGo() == 0) {
curtain.setCurrentPosition(TRAVEL_STEPS);
motorEnable(false);
digitalWrite(LIGHT_PIN, LOW);
state = State::CLOSED;
} else if (state == State::STOPPED && curtain.distanceToGo() == 0) {
motorEnable(false);
}
if ((state == State::HOMING || state == State::OPENING || state == State::CLOSING) &&
millis() - movementStartedMs > MOVE_TIMEOUT_MS) {
motorEnable(false);
positionKnown = false;
state = State::ERROR;
}
}
void updatePhysicalSelector() {
static uint8_t previous = 255;
const uint8_t active = static_cast<uint8_t>(modeOpen()) + static_cast<uint8_t>(modeClose()) + static_cast<uint8_t>(modeAuto());
const uint8_t selection = active == 1 ? (modeOpen() ? 1 : modeClose() ? 2 : 3) : 0;
if (selection == previous) return;
previous = selection;
if (selection == 1) openCurtain();
else if (selection == 2) closeCurtain();
else if (selection == 3) {
if (digitalRead(PIR_PIN) == HIGH) closeCurtain();
else openCurtain();
} else if (active > 1) stopCurtain();
}
void setup() {
pinMode(ENABLE_PIN, OUTPUT);
pinMode(LIGHT_PIN, OUTPUT);
pinMode(PIR_PIN, INPUT);
pinMode(OPEN_ENDSTOP_PIN, INPUT_PULLUP);
pinMode(MODE_OPEN_PIN, INPUT_PULLUP);
pinMode(MODE_CLOSE_PIN, INPUT_PULLUP);
pinMode(MODE_AUTO_PIN, INPUT_PULLUP);
motorEnable(false);
digitalWrite(LIGHT_PIN, LOW);
curtain.setMinPulseWidth(3);
curtain.setMaxSpeed(MAX_SPEED);
curtain.setAcceleration(ACCELERATION);
Serial.begin(115200);
DwinSerial.begin(115200, SERIAL_8N1, DWIN_RX_PIN, DWIN_TX_PIN);
sendDwinPage(0);
homeCurtain();
}
void loop() {
updateMotion();
updatePhysicalSelector();
if (modeAuto() && !((state == State::HOMING) || (state == State::OPENING) || (state == State::CLOSING))) {
if (digitalRead(PIR_PIN) == HIGH && state != State::CLOSED) closeCurtain();
if (digitalRead(PIR_PIN) == LOW && state != State::OPEN) openCurtain();
}
yield();
}Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




