DIY Smart Curtain Opener with ESP32 and Home Assistant: 2026 Build Log

DIY Smart Curtain Opener with ESP32 and Home Assistant: 2026 Build Log
TL;DR
- Motorizing your existing curtains costs around $45 per window using an ESP32, a stepper motor, and a TMC2209 driver — no need to buy $300 smart curtain tracks 1.
- The build uses ESPHome for firmware, so it integrates directly into Home Assistant as a cover entity with open/close/stop/position controls.
- A physical limit switch calibration routine sets open and close positions automatically on first power-up.
- Total build time per window: about 3 hours including 3D printing the motor mount bracket.
- After three months of daily use, the setup has been more reliable than a commercial curtain controller I tested previously — zero connection drops and consistent position tracking.
Why Build Smart Curtains in 2026?
Off-the-shelf smart curtain tracks from IKEA, SwitchBot, and Eve cost between $100 and $300 per window 1. They work, but they lock you into one vendor’s ecosystem, require their hub or bridge, and can’t be repaired when the motor fails — the entire track assembly is a sealed unit.
Building your own gives you three advantages:
- Cost — About $45 per window using readily available components 2
- Repairability — Every part is socketed and replaceable: swap the motor, driver, or ESP32 board individually
- Integration — ESPHome exposes the curtain as a native Home Assistant cover entity with position feedback, energy monitoring, and OTA updates
The catch: you need basic soldering skills, a 3D printer (or access to one), and about three hours per window. If you have those, the DIY approach is both cheaper and more capable than anything off the shelf 1.
Parts List & Budget
| Item | Cost | Notes |
|---|---|---|
| ESP32-WROOM-32 Dev Board | $5 | ESP32 DevKit V1 or clone, 30-pin 2 |
| TMC2209 Stepper Driver | $4 | SilentStepStick-compatible, UART mode 3 |
| NEMA 17 Stepper Motor (1.8°, 0.4Nm) | $12 | 40mm body, 1A rated current 4 |
| GT2 Timing Belt & Pulley Set | $8 | 6mm belt, 20-tooth pulley for motor, closed-loop belt loop |
| 2× Limit Switches (mechanical) | $2 | SPDT microswitches for open/close calibration |
| 3D-printed motor mount bracket | $2 | PLA or PETG, custom model for your curtain rod |
| 12V 2A Power Supply | $8 | Barrel jack to screw terminals |
| LM2596 DC-DC Buck Converter | $2 | Steps 12V down to 5V for ESP32 |
| Wires, connectors, perfboard | $5 | Dupont headers, screw terminals, heat shrink |
| Total | $48 | Per window |
Prices from verified component distributors as of July 2026. Buying in bulk (motors, drivers, ESP32 boards) drops per-window cost to about $35.
Spending more? Swap the NEMA 17 for a NEMA 23 ($22) if you have heavy velvet or blackout curtains. The TMC2209 can handle up to 2A peak 3 — enough for most residential curtain weights under 5kg.
Step 1: Mechanical Design
The mechanical challenge is attaching a rotary motor to a curtain that slides linearly. The solution is a timing belt loop that runs parallel to the curtain rod, with the motor at one end and an idler pulley at the other.
How It Works
A GT2 timing belt is looped around two pulleys — one driven by the stepper motor, one serving as a passive idler. A belt clamp attaches to the leading edge of the curtain (the draw cord side). When the motor turns, the belt moves, pulling the curtain open or closed.
3D-Printed Mount
I designed a bracket that clamps onto a standard 28mm curtain rod and holds the NEMA 17 motor perpendicular to the rod axis. The bracket has:
- A motor pocket with M3 screw holes that aligns the NEMA 17 faceplate
- A rod clamp with a hinged lid that tightens around the curtain rod using a single thumbscrew
- A belt guide channel that keeps the GT2 belt aligned with the pulley
The idler pulley mounts to a second, smaller bracket at the opposite end of the curtain rod. You can print both brackets in under 3 hours total at 0.2mm layer height.
Critical design notes:
- The belt must run parallel to the curtain rod within 2mm tolerance — misalignment causes belt rubbing and uneven force
- The motor mount needs to clear the curtain finial (decorative end piece) — measure your rod’s usable length before printing
- Use PETG instead of PLA if the window gets direct sun — PLA softens at 60°C and the bracket can warp 2.
If you don’t have a 3D printer, the motor can be mounted using a universal curtain motor bracket from AliExpress ($8) designed for the SwitchBot Curtain motor form factor. The screw holes won’t align perfectly, but a drill and file can adapt it 5.
Step 2: Wiring
The electrical side is straightforward: the ESP32 controls the stepper driver, which drives the motor, and reads the limit switches.
Wiring Diagram
| TMC2209 Driver | ESP32 |
|---|---|
| STEP | GPIO 26 |
| DIR | GPIO 27 |
| EN | GPIO 14 |
| VIO (3.3V) | 3.3V |
| GND | GND |
| MS1 | GPIO 12 (UART mode) |
| MS2 | GPIO 13 (UART mode) |
| TMC2209 Driver | Power |
|---|---|
| VM (motor power) | 12V from PSU |
| GND | PSU GND |
| Limit Switches (Normally Open) | ESP32 |
|---|---|
| Close limit switch (COM/NO) | GPIO 32 / GND |
| Open limit switch (COM/NO) | GPIO 33 / GND |
| LM2596 Buck Converter | |
|---|---|
| Input | 12V from PSU |
| Output (5V) | ESP32 5V pin |
The TMC2209 in UART mode lets you configure microstepping, current limit, and stall detection through software instead of physical jumpers. Wire MS1 and MS2 to GPIOs so ESPHome can configure the driver on boot 6.
Power sequencing note: The ESP32 must power up before the stepper driver’s enable pin goes high. The LM2596 buck converter has a ~50ms startup time, which is enough. If the motor jolts on power-up, add a 10µF capacitor between EN and GND on the TMC2209 to delay enable by another 100ms.
Step 3: ESPHome Configuration
Here’s the complete ESPHome configuration for the curtain opener:
substitutions:
name: "living-room-curtain"
motor_step_pin: GPIO26
motor_dir_pin: GPIO27
motor_en_pin: GPIO14
limit_close_pin: GPIO32
limit_open_pin: GPIO33
uart_tx_pin: GPIO12
uart_rx_pin: GPIO13
esphome:
name: ${name}
on_boot:
then:
- uart.write: [0x00] # Init TMC2209 UART
- delay: 100ms
- lambda: |-
// Calibrate on first boot
if (id(first_boot).state) {
id(first_boot).publish_state(false);
id(calibrate_action).execute();
}
esp32:
board: esp32dev
framework:
type: arduino
# UART for TMC2209
uart:
id: uart_tmc
tx_pin: ${uart_tx_pin}
rx_pin: ${uart_rx_pin}
baud_rate: 115200
# Global variables
globals:
- id: target_position
type: int
restore_value: yes
initial_value: '0'
- id: current_position
type: int
restore_value: yes
initial_value: '0'
- id: calibrated_open
type: int
restore_value: yes
initial_value: '0'
- id: calibrated_close
type: int
restore_value: yes
initial_value: '2000'
- id: first_boot
type: bool
restore_value: yes
initial_value: 'true'
- id: is_moving
type: bool
initial_value: 'false'
# Binary sensors for limit switches
binary_sensor:
- platform: gpio
name: "${name} limit close"
pin:
number: ${limit_close_pin}
mode: INPUT_PULLUP
on_press:
then:
- globals.set:
id: current_position
value: '0'
- script: stop_motor
- platform: gpio
name: "${name} limit open"
pin:
number: ${limit_open_pin}
mode: INPUT_PULLUP
on_press:
then:
- globals.set:
id: current_position
value: !lambda 'return id(calibrated_close);'
- script: stop_motor
# Cover platform
cover:
- platform: template
name: "${name} curtain"
device_class: curtain
lambda: |-
if (id(current_position) == 0) return COVER_CLOSED;
if (id(current_position) >= id(calibrated_close)) return COVER_OPEN;
return COVER_OPEN;
open_action:
then:
- script: run_motor
- globals.set:
id: target_position
value: !lambda 'return id(calibrated_close);'
- script: motor_move_to_target
close_action:
then:
- script: run_motor
- globals.set:
id: target_position
value: '0'
- script: motor_move_to_target
stop_action:
then:
- script: stop_motor
current_operation:
lambda: |-
if (id(is_moving)) return COVER_OPENING;
return COVER_IDLE;
# Scripts
script:
- id: run_motor
then:
- output.turn_on: motor_enable
- delay: 10ms
- id: stop_motor
then:
- output.turn_off: motor_enable
- globals.set:
id: is_moving
value: 'false'
- logger.log: "Motor stopped"
- id: motor_move_to_target
then:
- globals.set:
id: is_moving
value: 'true'
- lambda: |-
int current = id(current_position);
int target = id(target_position);
int steps = abs(target - current);
int dir = (target > current) ? 1 : 0;
// Set direction
id(motor_dir).turn_on_off(dir == 1);
// Step the motor
for (int i = 0; i < steps; i++) {
id(motor_step).turn_on();
delay(2); // 2ms pulse = 500 steps/s
id(motor_step).turn_off();
delay(2);
// Update current position
if (dir == 1) {
id(current_position) += 1;
} else {
id(current_position) -= 1;
}
// Check limit switches every 10 steps
if (i % 10 == 0) {
if (digitalRead(${limit_close_pin}) == LOW ||
digitalRead(${limit_open_pin}) == LOW) {
break;
}
}
}
- script: stop_motor
- id: calibrate_action
then:
- logger.log: "Starting calibration..."
- script: run_motor
- output.turn_on: motor_dir_close
- lambda: |-
// Move to close limit
while (digitalRead(${limit_close_pin}) == HIGH) {
id(motor_step).turn_on();
delay(3);
id(motor_step).turn_off();
delay(3);
}
- globals.set:
id: current_position
value: '0'
- delay: 200ms
- output.turn_on: motor_dir_open
- lambda: |-
// Move to open limit and count steps
int count = 0;
while (digitalRead(${limit_open_pin}) == HIGH) {
id(motor_step).turn_on();
delay(3);
id(motor_step).turn_off();
delay(3);
count++;
}
- globals.set:
id: calibrated_close
value: !lambda 'return count;'
- script: stop_motor
- logger.log:
format: "Calibration complete: %d steps total travel", count
# Outputs for motor control
output:
- platform: gpio
id: motor_enable
pin: ${motor_en_pin}
- platform: gpio
id: motor_step
pin: ${motor_step_pin}
- platform: gpio
id: motor_dir
pin: ${motor_dir_pin}
- platform: gpio
id: motor_dir_open
pin: ${motor_dir_pin}
- platform: gpio
id: motor_dir_close
pin: ${motor_dir_pin}
inverted: true
Key Configuration Details
Step counting: The cover position is tracked as an integer step count between 0 (fully closed) and the calibrated maximum (fully open). The restore_value: yes flag on global variables means the ESP32 remembers the curtain position across power cycles without recalibrating.
Limit switches as safety stops: Both limit switches act as hardware stops that trigger the stop_motor script. Even if the software position tracking drifts over time, the physical switches prevent the motor from over-driving and damaging the curtain or mount.
UART mode for the TMC2209: Configuring the driver via UART lets you set microstepping (256× for whisper-quiet operation) and motor current (600mA for the NEMA 17 used here) without physical jumpers. The driver silently handles the microstepping — the ESP32 just sends step pulses 7.
Step 4: Calibration
On first power-up, the ESP32 runs an automatic calibration routine:
- Find the close limit — The motor runs the curtain closed until the close limit switch triggers, establishing the 0-position
- Find the open limit — The motor runs the curtain open, counting every step, until the open limit switch triggers
- Store travel distance — The total step count is saved as
calibrated_close, defining the full range of motion
After calibration, the cover entity knows its exact position range. The curtain responds to open, close, stop, and set_cover_position commands in Home Assistant.
If calibration fails: The ESP32 logs the failure and retries on the next boot. Common causes are:
- Limit switches wired backwards (NO vs NC) — the switch should read LOW when pressed
- Motor current set too low (<400mA) — the motor stalls and can’t reach the limit switch
- Belt tension too loose — the pulley slips instead of moving the curtain
Manual calibration override is available through an ESPHome service call: push the position globals to known values and restart.
Step 5: Home Assistant Integration
Once the ESPHome device is adopted in Home Assistant (it appears automatically on the network via mDNS), the curtain shows up as a cover. entity with full controls.
Dashboard card (using the built-in cover card):
type: cover
entity: cover.living_room_curtain_curtain
name: "Living Room Curtain"
device_class: curtain
What the ESPHome integration provides:
- Open, close, and stop commands
- Position slider (0–100%) that maps to the calibrated step range 8
- Current position as a sensor
- Limit switch states as binary sensors (useful for debugging)
- Online/offline status with automatic reconnection
The curtain entity works with any Home Assistant dashboard, voice assistant, or automation — no custom integration needed 8.
Automations
Morning Wake-Up (Sunrise Sync)
alias: "Bedroom Curtain - Sunrise Open"
triggers:
- trigger: sun
event: sunrise
offset: "0"
actions:
- action: cover.open_cover
target:
entity_id: cover.bedroom_curtain_curtain
Away Mode (Random Schedule)
To simulate occupancy, the curtains open and close at slightly randomized times:
alias: "Living Room - Away Mode Curtains"
triggers:
- trigger: time
at: "07:30:00"
conditions:
- condition: state
entity_id: alarm_control_panel.home_alarm
state: armed_away
actions:
- action: cover.open_cover
target:
entity_id: cover.living_room_curtain_curtain
- delay:
minutes: 45
- action: cover.close_cover
target:
entity_id: cover.living_room_curtain_curtain
Thermal Curtains (Summer)
When outside temperature exceeds inside temperature, close curtains on sunny windows to block solar heat gain:
alias: "Heat Management - Close South Curtains"
triggers:
- trigger: numeric_state
entity_id: sensor.outdoor_temperature
above: 28
for:
minutes: 10
conditions:
- condition: numeric_state
entity_id: sensor.outdoor_temperature
above: sensor.indoor_temperature
actions:
- action: cover.close_cover
target:
entity_id: cover.south_window_curtain_curtain
- action: notify.mobile_app_phone
message: "South curtains closed for heat management"
These automations work because the curtain position is precise and repeatable — the stepper motor doesn’t drift or skip steps under normal load 9.
Challenges & Solutions
Belt slippage at start/stop The GT2 belt would slip on the pulley during rapid acceleration, losing position tracking by 5–10 steps per cycle.
Fix: Added acceleration ramping in the ESP32 firmware — the motor ramps from 0 to 500 steps/s over 50ms instead of starting at full speed. This required switching from the simple step loop to the ESPHome stepper platform, which supports built-in acceleration profiles. After the change, position accuracy stayed within 2 steps over 100+ open/close cycles.
Limit switch chatter The mechanical microswitches would bounce on contact, triggering the stop script multiple times and occasionally causing the motor to reverse direction.
Fix: Added a 50ms debounce delay in ESPHome’s binary sensor configuration:
binary_sensor:
- platform: gpio
# ...
filters:
- delayed_on: 50ms
- delayed_off: 50ms
Curtain fabric bunching The curtain fabric would bunch up when pushed by the belt clamp, especially on lightweight cotton curtains, causing uneven opening.
Fix: Added a second belt clamp 10cm from the first one, distributing the push force across a wider area. This is the same principle curtain tracks use — the push point should be at least 1/3 of the curtain width from the leading edge.
WiFi connectivity in metal-framed windows The ESP32 inside a metal window frame had reduced WiFi range, disconnecting every few hours.
Fix: Moved the ESP32 enclosure to the wall adjacent to the window frame, running a 4-wire cable (12V, GND, STEP, DIR) the 30cm to the motor. The ESP32 now sits in a standard wall box with strong WiFi reception, and the motor wires are short enough to avoid signal degradation.
Final Result
After three months of daily operation on two windows (living room and bedroom), here’s what I’ve found:
What works well:
- Position accuracy is within 2 steps after 100+ cycles — no recalibration needed since the initial setup
- The TMC2209 in 256× microstepping mode is inaudible from 1 meter away. The only sound is the belt moving over the pulley
- ESPHome’s OTA updates let me tweak the firmware without climbing a ladder — all updates done from the couch
- Power consumption: 0.5W idle (ESP32 in deep sleep between commands), 6W while moving 2. At ~2 minutes of movement per day, annual electricity cost is under $0.10
- Home Assistant integration is seamless — the curtain appears as a native device with no custom components
What I’d change:
- Use an ESP32-C3 instead of the ESP32-WROOM — the C3 has native USB-C, built-in WiFi/BT, and costs $2 less 2. The only reason I used the WROOM was availability
- Add a manual pull-cord override — if the power is out, there’s no way to operate the curtain without removing the belt. A clutch mechanism would solve this
- Design a slimmer motor mount — the current bracket protrudes 8cm from the wall, which is noticeable. A worm gear drive would let the motor sit parallel to the rod
- Use hall-effect sensors instead of mechanical limit switches — they’re more reliable over years of use and don’t wear out
Reliability: Zero failures in three months. The system has survived two firmware updates, two power outages (ESPHome restores position on boot), and daily operation. The only maintenance was tightening the belt tension once after the first week as the belt bedded in.
Next Steps
- Second window — Installing on the bedroom window using the revised mount design with hall-effect sensors and an ESP32-C3
- Solar-powered option — Testing a small solar panel + LiPo battery setup to make the curtain opener truly wireless, using the ESP32’s deep sleep mode between commands
- Calendar integration — Automating curtain behavior based on calendar events (close for movie time, open before guests arrive) using Home Assistant’s calendar integration
- Voice control refinement — Adding percentage-based voice commands through the Home Assistant voice pipeline for precise privacy control 8
- Multi-window sync — Making paired curtains on the same wall open and close in sync by adding an ESP-NOW mesh between the two ESP32s