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:

  1. Cost — About $45 per window using readily available components 2
  2. Repairability — Every part is socketed and replaceable: swap the motor, driver, or ESP32 board individually
  3. 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:

  1. Find the close limit — The motor runs the curtain closed until the close limit switch triggers, establishing the 0-position
  2. Find the open limit — The motor runs the curtain open, counting every step, until the open limit switch triggers
  3. 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

  1. Second window — Installing on the bedroom window using the revised mount design with hall-effect sensors and an ESP32-C3
  2. 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
  3. Calendar integration — Automating curtain behavior based on calendar events (close for movie time, open before guests arrive) using Home Assistant’s calendar integration
  4. Voice control refinement — Adding percentage-based voice commands through the Home Assistant voice pipeline for precise privacy control 8
  5. 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

Sources

← Back to guides