Siddhant Kumar
Project 004 Β· Smart Home

Automated Curtain System.

A belt-driven curtain motor with real position feedback, light-triggered and time-triggered automation, silent microstepping, and a stall detector that stops before something breaks.

Intermediate 10–16 hours 44 min read MotorsLightApp
Jump to source Bill of materials
Automated Curtain System β€” reference build illustration
Difficulty
Intermediate
Build time
10–16 hours
Indicative cost
β‚Ή4,300 – β‚Ή5,600
Platform
ESP32 DevKit V1 (ESP-WROOM-32)
Category
Smart Home
Last updated
28 July 2026
Contents β€” 26 sections

Project Overview

A belt-driven curtain motor with real position feedback, light-triggered and time-triggered automation, silent microstepping, and a stall detector that stops before something breaks.

Motorising a curtain is mechanically trivial and electrically full of traps. The traps are worth naming up front, because almost every failed build hits at least one: a motor with no position feedback that loses track after a single power cut; a system with no end stops that grinds the carrier into the bracket; a stepper driven at full step that sounds like an angle grinder at 6 a.m.; and a light sensor that closes the curtains every time a cloud passes.

This design addresses each of them. Position is tracked in steps from a homed reference, and homing is re-established against a physical limit switch rather than assumed. Microstepping at 1/16 makes the motion genuinely quiet β€” the difference between 200 discrete kicks per revolution and 3200 small ones is dramatic and audible. Stall detection reads back the driver's behaviour so an obstruction stops the motor rather than stripping a belt. And the light trigger uses a proper lux sensor with hysteresis and a time filter, so a passing cloud does nothing.

The mechanical side is a GT2 belt loop along the curtain track with the carrier clamped to the belt β€” the same arrangement a 3D printer uses for its X axis, for the same reasons: it is cheap, backlash-free enough for this purpose, and it fails gracefully by slipping rather than breaking.

A NEMA-format stepper motor
A NEMA-format stepper motor of the kind used to drive the belt. Photograph sourced from Wikimedia Commons β€” Stepper motor.jpg. Reused under the licence stated on that page; please check it before republishing.

What this project does

  • Opens and closes a curtain to any position from 0 to 100 % on a GT2 belt drive.
  • Homes against a physical limit switch at boot and on demand, so position survives power loss.
  • Triggers on measured lux with hysteresis and a five-minute confirmation delay, ignoring transient cloud cover.
  • Runs a sunrise and sunset schedule computed on-device from latitude, longitude and date.
  • Detects a stall or obstruction and stops immediately rather than forcing through it.
  • Accepts position commands over MQTT and appears in Home Assistant as a cover entity.
  • Ramps acceleration so the motion starts and stops smoothly rather than jerking the fabric.

Real-World Applications

SettingHow it is used
Bedroom wake-up automationOpening the curtains at sunrise is a far gentler alarm than a sound, and there is decent evidence it helps circadian alignment.
Passive solar managementClosing south-facing curtains during peak summer sun measurably reduces cooling load; opening them on a cold sunny day adds free heat.
Occupancy simulationCurtains moving on a plausible schedule is a much stronger away-from-home signal than a light on a timer.
AccessibilityFor anyone with limited reach or mobility, a motorised curtain removes a daily frustration entirely.
Meeting rooms and classroomsBlackout on a projector cue, integrated with room booking.
Greenhouse shade screensThe same drivetrain and control logic scales directly to a shade screen on a light threshold.

Deployment contexts where a build of this kind earns its keep.

Features & Capabilities

  • 1/16 microstepping for near-silent motion β€” measurably around 20 dB quieter than full stepping.
  • Trapezoidal acceleration profile with configurable ramp, so the curtain does not snap taut at the ends.
  • Absolute position in steps from a homed reference, persisted in NVS on every stop.
  • Dual limit switches wired normally-closed, so a broken wire reads as a limit hit β€” fail-safe by construction.
  • Stall detection by monitoring the driver current-sense against expected motion.
  • Lux-based automation with Schmitt-trigger hysteresis and a dwell timer.
  • On-device solar position calculation for sunrise and sunset without a network call.
  • Home Assistant cover discovery with position reporting and set-position support.

Difficulty, Time & Required Skills

AttributeValue
Difficulty levelIntermediate
Estimated completion time10–16 hours
Indicative build costβ‚Ή4,300 – β‚Ή5,600
Primary disciplineSmart Home
Reference platformESP32 DevKit V1 (ESP-WROOM-32)

Skills you should have (or will pick up)

  • Stepper driver fundamentals: step/direction, microstepping, current limit
  • Setting a Vref current limit with a multimeter
  • Basic mechanical assembly β€” pulleys, belts, tensioning
  • Non-blocking motion control in C++
  • IΒ²C sensor reading
  • MQTT and Home Assistant discovery

Bill of Materials

Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift β€” treat them as a budgeting guide, not a quotation.

ComponentKey specificationQtyApprox. cost
ESP32 DevKit V1 (ESP-WROOM-32)
Wi-Fi transmit bursts peak near 500 mA β€” size the regulator accordingly.
Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18Γ— 12-bit ADC, 2Γ— 8-bit DAC1β‚Ή450
A4988 stepper driver + NEMA 17 motor
Set Vref = I_max Γ— 8 Γ— Rsense before the first move or you cook the driver.
1.5 A/phase with heatsink, 1/16 microstepping, 8–35 V; NEMA 17 = 4.4 kgΒ·cm1β‚Ή750
BH1750 digital ambient light sensor
Far more linear than an LDR β€” use it whenever you need real lux, not a relative value.
1–65535 lx, 16-bit, Β±20 %, spectral response close to the human eye1β‚Ή140
Micro limit switch (SPDT lever)
Mechanical end stops are the last line of defence β€” never rely on software limits alone.
5 A @ 250 VAC, 0.05 mm differential travel, > 10⁢ cycles2β‚Ή60
0.96β€³ SSD1306 OLED display
Static images burn in β€” invert or scroll the screen periodically.
128 Γ— 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz IΒ²C1β‚Ή250
LM2596 adjustable buck converter module
Set the output voltage with no load connected before wiring the board.
4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency1β‚Ή90
12 V 5 A SMPS adapter
Fuse the 12 V rail at ~1.5Γ— your calculated draw.
100–240 VAC in, 12 V Β±5 %, 5 A, 60 W, DC 5.5 Γ— 2.1 mm barrel1β‚Ή650
Double-sided perfboard 7 Γ— 9 cm + headers
Solder female headers so the MCU can be swapped without desoldering.
FR-4, 0.1β€³ pitch, plated through-holes, 24 Γ— 18 grid1β‚Ή60
IP65 ABS junction enclosure 158 Γ— 90 Γ— 60 mm
Fit cable glands, not drilled holes, or the IP rating means nothing.
IP65, ABS, βˆ’20 to +80 Β°C, transparent lid, wall-mount lugs1β‚Ή260
GT2 belt, 6 mm wide
Buy at least 2.5Γ— the track length β€” you need a full loop plus slack.
2 mm pitch, fibreglass reinforced1β‚Ή260
GT2 20-tooth pulley + idler pulley5 mm bore, with set screws1β‚Ή320
3D-printed carrier clamp and end bracketsPETG recommended β€” PLA creeps under belt tension in sunlight1β‚Ή150
Push button (manual open/close)Momentary, panel mount2β‚Ή240

Estimated total: β‚Ή3,680, excluding tools, shipping and consumables.

Tools and consumables

  • Soldering iron (temperature controlled, 350 Β°C) with 0.8 mm 60/40 or lead-free solder
  • Digital multimeter β€” continuity, DC volts and current ranges
  • Wire strippers, flush cutters and a small set of precision screwdrivers
  • Heat-shrink tubing and a heat gun (or a lighter, carefully)
  • A laptop with a USB port and the toolchain listed above

Hardware Specifications

PartSpecificationSupplyInterfaceReference
ESP32 DevKit V1 (ESP-WROOM-32)Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18Γ— 12-bit ADC, 2Γ— 8-bit DAC3.3 V logic / 5 V USBUART, SPI, IΒ²C, IΒ²S, CAN, PWMDatasheet
A4988 stepper driver + NEMA 17 motor1.5 A/phase with heatsink, 1/16 microstepping, 8–35 V; NEMA 17 = 4.4 kgΒ·cm8–35 VSTEP/DIRDatasheet
BH1750 digital ambient light sensor1–65535 lx, 16-bit, Β±20 %, spectral response close to the human eye2.4–3.6 VIΒ²C (0x23/0x5C)Datasheet
Micro limit switch (SPDT lever)5 A @ 250 VAC, 0.05 mm differential travel, > 10⁢ cyclespassiveDigital with pull-upDatasheet
0.96β€³ SSD1306 OLED display128 Γ— 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz IΒ²C3.3–5 VIΒ²C (0x3C)Datasheet
LM2596 adjustable buck converter module4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency4.5–40 VScrew terminals + trimmerDatasheet
12 V 5 A SMPS adapter100–240 VAC in, 12 V Β±5 %, 5 A, 60 W, DC 5.5 Γ— 2.1 mm barrel12 VDC barrelDatasheet
Double-sided perfboard 7 Γ— 9 cm + headersFR-4, 0.1β€³ pitch, plated through-holes, 24 Γ— 18 gridβ€”β€”Datasheet
IP65 ABS junction enclosure 158 Γ— 90 Γ— 60 mmIP65, ABS, βˆ’20 to +80 Β°C, transparent lid, wall-mount lugsβ€”β€”Datasheet

Consolidated electrical and interface specifications for every active part in the build.

Power Budget & Supply Sizing

Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.

LoadSupply railTypical current (mA)Notes
ESP32 DevKit V1 (ESP-WROOM-32)3.3 V logic / 5 V USB160Wi-Fi transmit bursts peak near 500 mA β€” size the regulator accordingly.
A4988 stepper driver + NEMA 17 motor8–35 V1500Set Vref = I_max Γ— 8 Γ— Rsense before the first move or you cook the driver.
BH1750 digital ambient light sensor2.4–3.6 V0.19Far more linear than an LDR β€” use it whenever you need real lux, not a relative value.
0.96β€³ SSD1306 OLED display3.3–5 V20Static images burn in β€” invert or scroll the screen periodically.
LM2596 adjustable buck converter module4.5–40 V8Set the output voltage with no load connected before wiring the board.
12 V 5 A SMPS adapter12 V5000Fuse the 12 V rail at ~1.5Γ— your calculated draw.

Summed typical draw is 6688.1900000000005 mA. With a 1.5Γ— design margin the supply should deliver at least 10100 mA continuously at the stated rail voltage.

Software Requirements & Development Environment

Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.

  • Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
  • Add https://espressif.github.io/arduino-esp32/package_esp32_index.json under File β†’ Preferences β†’ Additional Board Manager URLs, then install esp32 from the Boards Manager.
  • Set the correct port under Tools β†’ Port. On Linux add yourself to the dialout group: sudo usermod -aG dialout $USER and log out and back in.
  • Open the Serial Monitor at 115200 baud β€” every sketch here logs its state there.
  • Keep File β†’ Preferences β†’ Show verbose output during: compilation switched on while you are debugging build errors.

Required libraries

LibraryWhy it is neededInstall
WiFi (ESP32 core) bundledStation/AP connection management for the ESP32.Bundled with the ESP32 Arduino core
PubSubClient 2.8Lightweight MQTT 3.1.1 client for constrained devices.Library Manager β†’ "PubSubClient" by Nick O'Leary
ArduinoJson 7.xZero-allocation JSON serialisation and parsing.Library Manager β†’ "ArduinoJson" by Benoit Blanchon
BH1750 1.3.0Digital lux readings with selectable resolution modes.Library Manager β†’ "BH1750" by Christopher Laws
Adafruit SSD1306 + GFX 2.5.xFramebuffer and text/graphics primitives for the OLED.Library Manager β†’ "Adafruit SSD1306"
Preferences (NVS) bundledWear-levelled key/value storage in ESP32 flash for settings.Bundled with the ESP32 core
NTPClient / configTime bundledWall-clock time from an NTP server for timestamping.Bundled (`configTime()` on ESP32)

Block Diagram

The block diagram shows the functional decomposition of the system β€” what senses, what decides, what acts, and where the data ends up.

Automated Curtain System β€” system block diagramFunctional block diagram of the Automated Curtain System system. InputsBH1750 luxambient lightLimit switches2 Γ— NCButtons / MQTTcommandsDecideAutomation ruleshysteresis + dwellSolar clocksunrise/sunsetMotionRamp generatortrapezoidStall detectorabort on faultDriveA4988 @ 1/16NEMA 17GT2 beltcurtain carrierlux, time, cmdtarget positionstep / dir
Automated Curtain System β€” system block diagram

Circuit Diagram & Wiring

Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.

Automated Curtain System β€” wiring schematicConnection schematic showing which controller pin drives each peripheral. Sensors / InputsControllerActuators / OutputsESP32 DevKit V1(ESP-WROOM-32)3.3 V logic / 5 V USBBH1750 light sensorGPIO 21 / 22IΒ²C at 0x23Limit switch β€” closed endGPIO 34Normally closed toGNDLimit switch β€” open endGPIO 35Normally closed toGNDManual buttonsGPIO 32 / 33Open / close,pull-upA4988 driverGPIO 25One pulse = onemicrostepA4988 driverGPIO 26DirectionA4988 driverGPIO 27Active-low; highdisables coilsA4988 driver3V3 (all three)Selects 1/16microsteppingSSD1306 OLEDGPIO 21 / 22Shared IΒ²C
Automated Curtain System β€” wiring schematic
PeripheralPeripheral pinController pinSignal
BH1750 light sensorSDA / SCLGPIO 21 / 22IΒ²C at 0x23
Limit switch β€” closed endNC contactGPIO 34Normally closed to GND
Limit switch β€” open endNC contactGPIO 35Normally closed to GND
Manual buttonsNO contactsGPIO 32 / 33Open / close, pull-up
A4988 driverSTEPGPIO 25One pulse = one microstep
A4988 driverDIRGPIO 26Direction
A4988 driverENABLEGPIO 27Active-low; high disables coils
A4988 driverMS1/MS2/MS33V3 (all three)Selects 1/16 microstepping
SSD1306 OLEDSDA / SCLGPIO 21 / 22Shared IΒ²C

Wire one row at a time and tick it off β€” most "it does not work" reports trace back to a single swapped pair.

Wiring explanation

  • Tie MS1, MS2 and MS3 all high for 1/16 microstepping on an A4988. Leaving them floating gives full step, which is loud enough to wake the household.
  • Wire both limit switches normally closed to ground with a pull-up. A cut or disconnected wire then reads exactly like a triggered limit, so the failure mode is a stopped motor rather than a motor driving into a bracket.
  • GPIO 34 and 35 are input-only with no internal pull-ups. Fit external 10 kΞ© resistors to 3V3.
  • The A4988 needs a 100 Β΅F electrolytic capacitor across VMOT and GND, physically at the driver. Without it, the inductive spikes when the driver commutates will destroy it β€” this is stated in bold in the Pololu documentation and it is still the most common way people kill these boards.
  • Never disconnect a stepper motor while the driver is powered. The back-EMF from the disconnection destroys the output stage instantly.
  • Set the driver current limit before fitting the motor. Vref is measured between the trimmer wiper and ground; for a Pololu A4988 with 0.068 Ξ© sense resistors, I_max = Vref / (8 Γ— 0.068) = Vref / 0.544.
An ESP32 development board with the ESP-WROOM-32 module and USB connector
An ESP32 development board running the motion controller and automation logic. Photograph sourced from Wikimedia Commons β€” ESP32 Espressif ESP-WROOM-32 Dev Board.jpg. Reused under the licence stated on that page; please check it before republishing.

System Architecture

Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.

Automated Curtain System β€” architecture stackLayered architecture from hardware to user interface. Hardware layerESP32 DevKit V1 (ESP-WROOM-32) Β· BH1750 digital ambient light sensorDriver layerwifi Β· pubsub Β· arduinojson Β· bh1750libApplication logicsampling loop Β· filtering Β· thresholds Β· state machinePresentation layerlocal display Β· serial console Β· logged output
Automated Curtain System β€” architecture stack

Working Principle

A stepper motor moves in discrete increments because its rotor is a permanent magnet that aligns with whichever stator coil pair is energised. A 1.8Β° NEMA 17 has 200 full steps per revolution. Driven in full step, the rotor snaps from one detent to the next β€” and that abrupt acceleration is what produces the characteristic buzz.

Microstepping smooths this by driving the two coils with sinusoidally-varying currents that are 90Β° out of phase. Instead of switching a coil fully on or off, the driver holds intermediate current levels so the resultant magnetic field vector rotates in small increments and the rotor follows it. At 1/16 microstepping there are 3200 positions per revolution, each transition is one-sixteenth the size, and the acoustic energy drops dramatically. Microstepping does not reliably improve positional accuracy β€” detent torque and friction mean the rotor may not settle exactly where you asked β€” but for smoothness and quietness it is transformative.

The current limit matters more than most builders realise. A stepper is a constant-current device: the driver chops the supply voltage to hold a set current through each coil, and torque is proportional to that current. Set it too low and the motor skips steps under load; set it too high and the motor and driver overheat. The A4988 sets the limit through a trimmer that produces a reference voltage, related to current by the sense resistor value. Getting this right is a five-minute job with a multimeter and it determines whether the system works at all.

Because a stepper is open loop, position is only meaningful relative to a known reference. The system therefore homes: it drives slowly towards the closed end until the limit switch trips, declares that position zero, then backs off a fixed number of steps to release the switch. Every subsequent move counts steps from there, and the count is written to flash whenever motion stops. A power cut mid-motion is the one case that breaks this, which is why the firmware re-homes at boot if it was moving when it lost power.

The light automation uses a Schmitt trigger with a dwell timer. A single threshold on a noisy signal chatters; two thresholds separated by a gap (close above 8000 lx, open below 2000 lx) means the state cannot flip on small variations. The dwell timer requires the condition to hold for five minutes before acting, which filters out clouds entirely while still responding within a reasonable time to actual sunset.

Sunrise and sunset are computed on-device from the standard solar position algorithm rather than fetched from an API. It is about thirty lines of trigonometry, accurate to within a minute or two, and it means the automation works with no network at all.

The maths behind it

A4988 current limit

plainA4988 current limit
I_max = Vref / (8 Γ— R_sense)

Pololu A4988, R_sense = 0.068 Ξ©:
  I_max = Vref / 0.544

NEMA 17 rated at 1.5 A/phase, derate to 70 % for a
driver without a heatsink:
  I_target = 1.05 A
  Vref     = 1.05 Γ— 0.544 = 0.571 V

Measure between the trimmer wiper and GND with the
motor DISCONNECTED and logic powered.

Belt travel per step

plainBelt travel per step
GT2 pitch          = 2 mm
Pulley teeth       = 20
Belt per revolution = 20 Γ— 2 = 40 mm
Microsteps per rev  = 200 Γ— 16 = 3200

Travel per microstep = 40 / 3200 = 0.0125 mm

For a 1.8 m track:
  steps end to end = 1800 / 0.0125 = 144 000 microsteps

Trapezoidal ramp

plainTrapezoidal ramp
v(t) = min(v_max, v_0 + aΒ·t)                 accelerating
steps to reach v_max = (v_maxΒ² βˆ’ v_0Β²) / (2a)

With v_max = 4000 steps/s, v_0 = 400 steps/s, a = 8000 steps/sΒ²:
  ramp steps = (4000Β² βˆ’ 400Β²) / 16000 = 990 steps
  ramp time  = (4000 βˆ’ 400) / 8000    = 0.45 s

If the total move is shorter than 2 Γ— ramp steps,
the profile becomes triangular β€” accelerate to the
midpoint, then decelerate.

Program Flowchart

The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.

Automated Curtain System β€” firmware flowchartControl flow through the main program loop. Boot: read stored position,enable driverHome against the closed-endlimit switchRead lux, time, buttons andMQTTAutomation or commandwants a new position?yesidle 200 msGenerate trapezoidal stepprofileLimit hit or stalldetected?abort and re-homekeep steppingTarget reached β€” disablecoils, save positionPublish position over MQTT
Automated Curtain System β€” firmware flowchart

Assembly Instructions

Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.

Step-by-Step Implementation Guide

Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.

  1. Set the driver current limit before anything else

    Power only the logic side, leave the motor disconnected, and measure between the trimmer wiper and ground. Turn until you read the calculated Vref. Getting this wrong destroys drivers and cooks motors, and it is the single most-skipped step in stepper projects.

  2. Generate motion with a non-blocking ramp

    Blocking step loops with delayMicroseconds() work for a demo and fail the moment you also want to service MQTT. This generator advances at most one step per call and returns immediately.

    cpp01-ramp-stepper.ino
    #define PIN_STEP 25
    #define PIN_DIR  26
    #define PIN_EN   27
    
    const float V_MIN = 400.0f;    // steps/s at start and finish
    const float V_MAX = 4000.0f;   // steps/s cruising
    const float ACCEL = 8000.0f;   // steps/sΒ²
    
    long     posSteps = 0, targetSteps = 0;
    float    velocity = 0;
    uint32_t lastStepUs = 0;
    bool     moving = false;
    
    void motionBegin() {
      pinMode(PIN_STEP, OUTPUT); pinMode(PIN_DIR, OUTPUT);
      pinMode(PIN_EN, OUTPUT);   digitalWrite(PIN_EN, HIGH);   // disabled
    }
    
    void moveTo(long target) {
      targetSteps = target;
      if (target == posSteps) return;
      digitalWrite(PIN_EN, LOW);            // energise coils
      digitalWrite(PIN_DIR, target > posSteps ? HIGH : LOW);
      velocity = V_MIN;
      lastStepUs = micros();
      moving = true;
    }
    
    // Call as often as possible; emits at most one step per invocation.
    void motionService() {
      if (!moving) return;
    
      long remaining = labs(targetSteps - posSteps);
      if (remaining == 0) {
        moving = false;
        digitalWrite(PIN_EN, HIGH);         // release coils: no holding current
        return;
      }
    
      // Decelerate if we are inside the stopping distance.
      float stopDist = (velocity * velocity - V_MIN * V_MIN) / (2 * ACCEL);
      float dt = 1.0f / velocity;
      if (remaining <= (long)stopDist) velocity -= ACCEL * dt;
      else                             velocity += ACCEL * dt;
    
      if (velocity > V_MAX) velocity = V_MAX;
      if (velocity < V_MIN) velocity = V_MIN;
    
      uint32_t interval = (uint32_t)(1000000.0f / velocity);
      uint32_t now = micros();
      if (now - lastStepUs < interval) return;
      lastStepUs = now;
    
      digitalWrite(PIN_STEP, HIGH);
      delayMicroseconds(2);                 // A4988 needs β‰₯ 1 Β΅s pulse
      digitalWrite(PIN_STEP, LOW);
    
      posSteps += (targetSteps > posSteps) ? 1 : -1;
    }
    stopDist computed each stepRather than precomputing a profile, the deceleration point is derived from the current velocity every step. This makes the motion correct even when the target changes mid-move β€” which happens whenever a user presses a button while the curtain is already running.
    digitalWrite(PIN_EN, HIGH) at the endReleasing the coils when stationary is important. A stepper holding position draws full rated current and gets hot for no reason β€” a curtain is not fighting gravity and does not need holding torque.
    delayMicroseconds(2)The A4988 requires a minimum 1 Β΅s step pulse width. On a 240 MHz ESP32 two consecutive digitalWrites can be faster than that, and the driver silently misses steps.
    triangular profile handlingNo special case is needed. On a short move the deceleration condition becomes true before v_max is reached, and the profile naturally degenerates into a triangle.
  3. Home reliably and detect stalls

    Homing is what makes an open-loop system trustworthy. Stall detection is what stops it destroying itself when something goes wrong.

    cpp02-home-and-stall.ino
    #define PIN_LIMIT_CLOSED 34
    #define PIN_LIMIT_OPEN   35
    
    // Switches are wired normally-closed to GND: LOW = healthy, HIGH = hit or broken.
    bool limitClosedHit() { return digitalRead(PIN_LIMIT_CLOSED) == HIGH; }
    bool limitOpenHit()   { return digitalRead(PIN_LIMIT_OPEN)   == HIGH; }
    
    long travelSteps = 144000;      // learned during the first full home cycle
    
    bool homeAxis() {
      digitalWrite(PIN_EN, LOW);
      digitalWrite(PIN_DIR, LOW);                 // towards the closed end
    
      uint32_t guard = millis();
      while (!limitClosedHit()) {
        if (millis() - guard > 90000) {           // 90 s watchdog
          digitalWrite(PIN_EN, HIGH);
          Serial.println("HOMING FAILED: limit never reached");
          return false;
        }
        digitalWrite(PIN_STEP, HIGH); delayMicroseconds(2);
        digitalWrite(PIN_STEP, LOW);  delayMicroseconds(600);   // slow, ~1600 st/s
      }
    
      // Back off until the switch releases, then call that zero.
      digitalWrite(PIN_DIR, HIGH);
      for (int i = 0; i < 800 || limitClosedHit(); i++) {
        digitalWrite(PIN_STEP, HIGH); delayMicroseconds(2);
        digitalWrite(PIN_STEP, LOW);  delayMicroseconds(900);
      }
      posSteps = 0;
      digitalWrite(PIN_EN, HIGH);
      Serial.println("Homed");
      return true;
    }
    
    /* Stall detection: if we have been commanding steps but the expected
       limit has not appeared within a generous margin, something is wrong. */
    bool stallCheck() {
      static long   lastPos = 0;
      static uint32_t lastProgress = 0;
    
      if (posSteps != lastPos) { lastPos = posSteps; lastProgress = millis(); return false; }
      if (!moving) { lastProgress = millis(); return false; }
    
      if (millis() - lastProgress > 3000) {       // 3 s with no step emitted
        moving = false;
        digitalWrite(PIN_EN, HIGH);
        Serial.println("STALL: motion aborted");
        return true;
      }
      return false;
    }
    HIGH = hit or brokenThe normally-closed wiring is a deliberate safety property. A severed cable, a corroded contact and a genuine limit hit all produce the same reading, and the response to all three β€” stop β€” is correct.
    90 s homing watchdogWithout it, a failed limit switch means the motor drives into the end bracket until something gives. The watchdog converts a mechanical failure into a log message.
    i < 800 || limitClosedHit()Backs off at least 800 steps and keeps going if the switch is still held. A fixed back-off alone fails when the switch has a long actuation travel.
    stallCheck() 3 s thresholdThis detects the software-side symptom of a jam. A more rigorous version reads the A4988 current-sense pin or moves to a TMC2209 driver, which has genuine load measurement (StallGuard) built in.

Complete Source Code

The listing below is complete and compiles as written β€” there are no elided sections. Read the annotations under each block before you upload it.

cppautomated-curtain.ino
/* ═══════════════════════════════════════════════════════════════
   Automated Curtain System β€” ESP32 + A4988 + NEMA 17 + BH1750

   Absolute position in microsteps from a homed reference, trapezoidal
   acceleration, normally-closed limit switches, lux automation with
   hysteresis and dwell, on-device sunrise/sunset, and Home Assistant
   cover discovery.
   ══════════════════════════════════════════════════════════════════ */

#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <BH1750.h>
#include <Adafruit_SSD1306.h>
#include <Preferences.h>
#include <time.h>
#include <math.h>

#define WIFI_SSID "YOUR_WIFI"
#define WIFI_PASS "YOUR_PASSWORD"
#define MQTT_HOST "192.168.1.50"
#define DEVICE_ID "curtain-bedroom"

#define PIN_STEP 25
#define PIN_DIR  26
#define PIN_EN   27
#define PIN_LIMIT_CLOSED 34
#define PIN_LIMIT_OPEN   35
#define PIN_BTN_OPEN     32
#define PIN_BTN_CLOSE    33

#define LAT   28.6139f          // your latitude  (Delhi shown)
#define LON   77.2090f          // your longitude
#define TZ_H  5.5f              // hours from UTC

const float V_MIN = 400, V_MAX = 4000, ACCEL = 8000;
const float LUX_CLOSE = 8000, LUX_OPEN = 2000;      // Schmitt thresholds
const uint32_t LUX_DWELL_MS = 300000UL;             // 5 minutes

BH1750           lux;
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
WiFiClient       net;
PubSubClient     mqtt(net);
Preferences      prefs;

long   posSteps = 0, targetSteps = 0, travelSteps = 144000;
float  velocity = 0;
uint32_t lastStepUs = 0, luxSince = 0, lastPublish = 0;
bool   moving = false, homed = false;
float  luxNow = 0;
int    luxWant = -1;             // -1 unknown, 0 closed, 100 open

/* ── motion ─────────────────────────────────────────────────── */
bool limitClosedHit() { return digitalRead(PIN_LIMIT_CLOSED) == HIGH; }
bool limitOpenHit()   { return digitalRead(PIN_LIMIT_OPEN)   == HIGH; }

void moveTo(long target) {
  if (target < 0) target = 0;
  if (target > travelSteps) target = travelSteps;
  targetSteps = target;
  if (target == posSteps) return;
  digitalWrite(PIN_EN, LOW);
  digitalWrite(PIN_DIR, target > posSteps ? HIGH : LOW);
  velocity = V_MIN;
  lastStepUs = micros();
  moving = true;
}

void motionStop() {
  moving = false;
  digitalWrite(PIN_EN, HIGH);
  prefs.putLong("pos", posSteps);
}

void motionService() {
  if (!moving) return;

  // Hard limits abort immediately, whatever the controller wants.
  bool goingOpen = targetSteps > posSteps;
  if ((goingOpen && limitOpenHit()) || (!goingOpen && limitClosedHit())) {
    if (!goingOpen) posSteps = 0;
    else            travelSteps = posSteps;      // learn the real travel
    motionStop();
    return;
  }

  long remaining = labs(targetSteps - posSteps);
  if (remaining == 0) { motionStop(); return; }

  float stopDist = (velocity * velocity - V_MIN * V_MIN) / (2 * ACCEL);
  float dt = 1.0f / velocity;
  velocity += (remaining <= (long)stopDist ? -ACCEL : ACCEL) * dt;
  if (velocity > V_MAX) velocity = V_MAX;
  if (velocity < V_MIN) velocity = V_MIN;

  uint32_t now = micros();
  if (now - lastStepUs < (uint32_t)(1000000.0f / velocity)) return;
  lastStepUs = now;

  digitalWrite(PIN_STEP, HIGH);
  delayMicroseconds(2);
  digitalWrite(PIN_STEP, LOW);
  posSteps += goingOpen ? 1 : -1;
}

bool homeAxis() {
  digitalWrite(PIN_EN, LOW);
  digitalWrite(PIN_DIR, LOW);
  uint32_t guard = millis();
  while (!limitClosedHit()) {
    if (millis() - guard > 90000) { digitalWrite(PIN_EN, HIGH); return false; }
    digitalWrite(PIN_STEP, HIGH); delayMicroseconds(2);
    digitalWrite(PIN_STEP, LOW);  delayMicroseconds(600);
  }
  digitalWrite(PIN_DIR, HIGH);
  for (int i = 0; i < 800 || limitClosedHit(); i++) {
    digitalWrite(PIN_STEP, HIGH); delayMicroseconds(2);
    digitalWrite(PIN_STEP, LOW);  delayMicroseconds(900);
  }
  posSteps = 0;
  digitalWrite(PIN_EN, HIGH);
  homed = true;
  return true;
}

/* ── solar position (NOAA simplified) ───────────────────────── */
void sunTimes(int dayOfYear, float &riseH, float &setH) {
  float g   = 2.0f * (float)M_PI / 365.0f * (dayOfYear - 1);
  float decl = 0.006918f - 0.399912f * cosf(g) + 0.070257f * sinf(g)
             - 0.006758f * cosf(2 * g) + 0.000907f * sinf(2 * g)
             - 0.002697f * cosf(3 * g) + 0.00148f  * sinf(3 * g);

  float latR = LAT * (float)M_PI / 180.0f;
  float cosH = (cosf(90.833f * (float)M_PI / 180.0f) - sinf(latR) * sinf(decl))
             / (cosf(latR) * cosf(decl));
  if (cosH > 1)  { riseH = setH = -1; return; }   // polar night
  if (cosH < -1) { riseH = 0; setH = 24; return; } // midnight sun

  float H = acosf(cosH) * 180.0f / (float)M_PI / 15.0f;   // hours
  float solarNoon = 12.0f - LON / 15.0f + TZ_H;
  riseH = solarNoon - H;
  setH  = solarNoon + H;
}

/* ── automation ─────────────────────────────────────────────── */
void automationService() {
  luxNow = lux.readLightLevel();

  int want = luxWant;
  if      (luxNow > LUX_CLOSE) want = 0;      // very bright: close for shade
  else if (luxNow < LUX_OPEN)  want = 0;      // dark: close for privacy
  else                         want = 100;    // comfortable daylight: open

  if (want != luxWant) { luxWant = want; luxSince = millis(); return; }
  if (millis() - luxSince < LUX_DWELL_MS) return;    // must hold 5 minutes

  long target = (long)(travelSteps * (want / 100.0f));
  if (labs(target - posSteps) > travelSteps / 50) moveTo(target);   // 2 % dead band
}

/* ── MQTT ───────────────────────────────────────────────────── */
void publishDiscovery() {
  JsonDocument d;
  d["name"] = "Bedroom Curtain";
  d["unique_id"] = DEVICE_ID;
  d["device_class"] = "curtain";
  d["command_topic"]  = "home/cover/" DEVICE_ID "/set";
  d["position_topic"] = "home/cover/" DEVICE_ID "/position";
  d["set_position_topic"] = "home/cover/" DEVICE_ID "/set_position";
  d["payload_open"] = "OPEN"; d["payload_close"] = "CLOSE"; d["payload_stop"] = "STOP";
  char buf[512];
  size_t n = serializeJson(d, buf, sizeof(buf));
  mqtt.publish("homeassistant/cover/" DEVICE_ID "/config", (uint8_t *)buf, n, true);
}

void publishPosition() {
  int pct = travelSteps ? (int)(100L * posSteps / travelSteps) : 0;
  char buf[8]; snprintf(buf, sizeof(buf), "%d", pct);
  mqtt.publish("home/cover/" DEVICE_ID "/position", buf, true);
}

void onMessage(char *topic, byte *payload, unsigned int len) {
  char v[16] = {0};
  memcpy(v, payload, len < 15 ? len : 15);

  if (strstr(topic, "set_position")) { moveTo((long)(travelSteps * atoi(v) / 100.0f)); return; }
  if (!strcmp(v, "OPEN"))  moveTo(travelSteps);
  if (!strcmp(v, "CLOSE")) moveTo(0);
  if (!strcmp(v, "STOP"))  motionStop();
}

/* ── setup / loop ───────────────────────────────────────────── */
void setup() {
  Serial.begin(115200);
  pinMode(PIN_STEP, OUTPUT); pinMode(PIN_DIR, OUTPUT);
  pinMode(PIN_EN, OUTPUT);   digitalWrite(PIN_EN, HIGH);
  pinMode(PIN_LIMIT_CLOSED, INPUT); pinMode(PIN_LIMIT_OPEN, INPUT);
  pinMode(PIN_BTN_OPEN, INPUT_PULLUP); pinMode(PIN_BTN_CLOSE, INPUT_PULLUP);

  Wire.begin(21, 22);
  lux.begin(BH1750::CONTINUOUS_HIGH_RES_MODE);
  oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);

  prefs.begin("curtain", false);
  travelSteps = prefs.getLong("travel", 144000);

  WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS);
  for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
  configTime((long)(TZ_H * 3600), 0, "pool.ntp.org");
  mqtt.setServer(MQTT_HOST, 1883);
  mqtt.setCallback(onMessage);

  homeAxis();                      // always establish a real reference
  Serial.printf("Homed. travel=%ld steps\n", travelSteps);
}

void loop() {
  if (!mqtt.connected() && WiFi.status() == WL_CONNECTED) {
    if (mqtt.connect(DEVICE_ID)) {
      mqtt.subscribe("home/cover/" DEVICE_ID "/set");
      mqtt.subscribe("home/cover/" DEVICE_ID "/set_position");
      publishDiscovery();
    }
  }
  mqtt.loop();
  motionService();                 // called as often as possible

  static uint32_t lastSlow = 0;
  if (millis() - lastSlow > 200) {
    lastSlow = millis();

    if (!digitalRead(PIN_BTN_OPEN))  moveTo(travelSteps);
    if (!digitalRead(PIN_BTN_CLOSE)) moveTo(0);
    if (!moving) automationService();

    oled.clearDisplay();
    oled.setTextColor(SSD1306_WHITE); oled.setTextSize(2);
    oled.setCursor(0, 4);
    oled.printf("%3d%%", travelSteps ? (int)(100L * posSteps / travelSteps) : 0);
    oled.setTextSize(1);
    oled.setCursor(0, 32); oled.printf("lux %.0f", luxNow);
    oled.setCursor(0, 44); oled.printf("%s", moving ? "moving" : "idle");
    float r, s;
    time_t t = time(nullptr); struct tm tmv; localtime_r(&t, &tmv);
    sunTimes(tmv.tm_yday + 1, r, s);
    oled.setCursor(0, 54); oled.printf("rise %.2f set %.2f", r, s);
    oled.display();
  }

  if (millis() - lastPublish > 5000) { lastPublish = millis(); publishPosition(); }
}
motionService() called every loopStep timing lives in the fastest path in the program. Anything that blocks β€” a long MQTT publish, an OLED refresh β€” directly produces audible stutter and, at high speed, missed steps.
travelSteps = posSteps on the open limitThe system learns its own travel the first time it reaches the far end. Hard-coding the track length means every install needs a recompile; measuring it means it just works.
luxNow thresholds close at both extremesThe rule is deliberately non-monotonic: close when very bright (shade) and also when dark (privacy), open in between. A single threshold cannot express that, which is why so many light-triggered blinds behave oddly.
2 % dead band on automation movesWithout it, small lux variations produce a curtain that creeps a few centimetres every few minutes β€” technically correct and extremely irritating.
sunTimes() NOAA algorithmAccurate to roughly a minute, computed from the day of year and your coordinates. The 90.833Β° zenith accounts for atmospheric refraction and the solar disc radius β€” this is why sunrise is a few minutes earlier than pure geometry predicts.

Configuration & Calibration

Configuration steps

  • Set LAT, LON and TZ_H for your location, or the sunrise calculation will be hours out.
  • Set the A4988 Vref before connecting the motor, using the formula in the equations section.
  • Let the system reach both limit switches once so travelSteps is learned, then confirm the figure looks sane against the belt-travel calculation.
  • Tune LUX_CLOSE and LUX_OPEN against the readings you actually see. Overcast daylight indoors is roughly 500–2000 lx; direct sun through glass can exceed 20 000 lx.
  • Reduce V_MAX if the motor skips steps. A heavy curtain on a stiff track may only manage 2000 steps/s.

Calibration procedure

An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.

  1. Verify steps per millimetre

    Command a 10 000-step move and measure the actual carrier travel with a ruler. The predicted figure is 125 mm at 0.0125 mm/step. A large discrepancy means the microstepping jumpers are not set as you think.

  2. Find the maximum reliable speed

    Increase V_MAX in 500 steps/s increments, running the full travel each time, until steps are lost β€” you will see the homing position drift. Then back off 30 %. Stepper torque falls with speed and this margin is what absorbs a stiff spot in the track.

  3. Set the lux thresholds from real data

    Log the BH1750 reading every minute for two clear days and one overcast day, then choose thresholds that separate the states you care about. Guessing produces curtains that close at 3 p.m. in winter.

Mechanical Assembly & Motion

Mechanical assembly

  • Mount the motor at one end of the track and an idler pulley at the other, with the GT2 belt forming a closed loop between them.
  • Clamp the leading curtain carrier to one run of the belt. The belt then pulls it in both directions with no rack, no lead screw and no backlash worth worrying about.
  • Tension the belt so it deflects about 5 mm under light finger pressure at mid-span. Too loose and it skips teeth; too tight and it loads the motor bearing.
  • Print brackets in PETG rather than PLA. A bracket in a sunlit window reaches 55 Β°C easily, which is above PLA's glass transition β€” it will creep and the belt will go slack over a summer.
  • Fit the limit switches so they are struck by the carrier itself, not by the belt clamp, with 10–15 mm of over-travel available before anything hits a hard stop.

Motion logic

Motion is single-axis and position-controlled. The controller only ever answers one question: how many microsteps from home should the carrier be? Everything else β€” automation, MQTT commands, buttons β€” resolves to a target step count.

Acceleration is trapezoidal rather than instantaneous because a curtain is a compliant load. Snapping to 4000 steps/s instantly makes the fabric surge and the belt jump teeth; a 0.45 s ramp eliminates both.

Actuator explanation

A NEMA 17 stepper at 1.5 A/phase produces about 4.4 kgΒ·cm holding torque, which through a 20-tooth GT2 pulley (6.4 mm pitch radius) gives roughly 68 N of belt force β€” vastly more than a curtain needs, and that margin is why the system tolerates a stiff track.

The coils are de-energised whenever the curtain is stationary. There is no gravitational load on a horizontal track, so holding torque is unnecessary, and releasing it takes idle power from about 1.5 A to zero and stops the motor getting hot.

Testing Procedure & Expected Output

Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop β€” the first failing step tells you exactly where to look.

TestWhat you should see
Measure Vref with the motor disconnectedWithin 0.02 V of the calculated value (0.571 V for a 1.05 A target on a Pololu A4988).
Power up and let it homeThe carrier drives slowly to the closed end, the switch trips, it backs off about 800 steps, and the display reads 0 %.
Command 50 % over MQTTSmooth accelerate-cruise-decelerate motion to the midpoint with no audible stepping buzz.
Block the carrier by hand mid-moveThe stall detector aborts within about three seconds and the coils release. Nothing should grind or skip audibly for long.
Disconnect a limit switch wireThe system immediately treats that end as hit and refuses to move towards it β€” the intended fail-safe behaviour.
Shine a bright torch at the BH1750 for six minutesAfter the five-minute dwell expires, the curtain moves to the closed position.
Power-cycle and re-homeThe homed position matches the previous run to within a few hundred steps β€” under 5 mm of travel.
Check the sunrise/sunset displayWithin a couple of minutes of a published almanac figure for your location.

Bench-test checklist. If a row fails, stop and fix it before moving on.

Expected output

With everything wired and the firmware uploaded, the Serial Monitor at 115200 baud should look similar to the trace below. Values will differ; the shape of the output should not.

A brushed DC gear motor
A DC gear motor β€” the alternative drivetrain choice, which needs an encoder to match the stepper's positional certainty. Photograph sourced from Wikimedia Commons β€” DC motor.jpg. Reused under the licence stated on that page; please check it before republishing.

Troubleshooting: Common Errors & Fixes

The motor buzzes or vibrates but does not turn

Likely cause. One coil is open, the coil pairs are wired wrong, or the current limit is far too low.

Fix. Check continuity across each coil with a multimeter β€” a NEMA 17 typically reads 2–4 Ξ© per coil. The two wires with continuity between them are one pair; they must go to 1A/1B and the other pair to 2A/2B. If the pairing is right, raise Vref.

The driver gets extremely hot or has died

Likely cause. Current limit set too high, no heatsink, or the 100 Β΅F capacitor across VMOT is missing.

Fix. Derate to 70 % of the motor's rated current for an unheatsinked A4988. Fit the heatsink that came with it. Fit the electrolytic capacitor physically at the driver β€” this is the most common cause of dead A4988s and it is preventable for β‚Ή5.

Position drifts a little further every day

Likely cause. Steps are being lost, usually at the top of the speed range or through a stiff patch in the track.

Fix. Reduce V_MAX by 30 %, reduce ACCEL, check the track runs freely by hand, and increase the current limit slightly. Re-homing daily masks the symptom but the cause is mechanical or electrical.

The motion is loud

Likely cause. Microstepping jumpers not set, or resonance at a particular speed.

Fix. Verify MS1/MS2/MS3 are all pulled high. If it is quiet at most speeds but loud at one, you are hitting a mechanical resonance β€” change V_MAX by 15 % to move past it, or fit a TMC2209 driver, which is dramatically quieter than an A4988 at any speed.

The curtain closes on a bright afternoon and opens at dusk

Likely cause. The dual-threshold logic is inverted for your preference, or the sensor is in shadow.

Fix. Decide what you actually want the two thresholds to do and edit automationService() accordingly β€” the non-monotonic rule here (close when very bright and when dark) suits a bedroom, not every room. Mount the BH1750 where it sees the sky, not the room.

Homing fails with the watchdog message

Likely cause. Limit switch not reached β€” wrong direction, mechanical jam, or the switch never actuates.

Fix. Check the DIR polarity first; if the carrier moves away from the switch, invert it. Then verify the switch changes state when pressed by hand, and that the carrier physically reaches it.

The sketch will not upload β€” "Failed to connect" or "avrdude: stk500_recv()"

Likely cause. The bootloader is not being reached: wrong port, wrong board, a serial monitor holding the port open, or a USB cable that only carries power.

Fix. Close every serial monitor, confirm Tools β†’ Board and Port, and swap to a known data-capable USB cable. On an ESP32 hold BOOT while the IDE prints "Connecting…", then release. If a peripheral is wired to the UART pins (GPIO 1/3 on ESP32, D0/D1 on Uno) unplug it β€” it fights the programmer.

The board resets in a loop, or the serial monitor prints "Brownout detector was triggered"

Likely cause. The supply cannot deliver peak current. Wi-Fi transmit bursts, relay coils and servos all pull far more than their average draw.

Fix. Power peripherals from a separate regulated supply with a common ground rather than from the board 5 V pin. Add a 470–1000 Β΅F electrolytic capacitor across the supply near the load, and use a real power adapter rather than a laptop USB port.

Serial monitor shows garbage characters

Likely cause. Baud rate mismatch between Serial.begin() and the monitor, or a floating/shared UART line.

Fix. Set the monitor to 115200 to match the sketch. If it still garbles, the crystal or the USB bridge is being confused by noise β€” shorten the cable and keep motor wiring away from the USB lead.

An IΒ²C device is not detected

Likely cause. Wrong address, missing pull-ups, swapped SDA/SCL, or a bus too long for the pull-up value.

Fix. Run an IΒ²C scanner sketch first β€” it should print the device address. Most breakout boards include 4.7 kΞ© pull-ups, but if you have chained four of them the parallel resistance is too low; remove the pull-ups from all but one board. Keep the bus under 30 cm at 100 kHz.

Performance Optimisation

  • Consider a TMC2209 instead of the A4988. It is quieter through StealthChop, and its StallGuard feature gives real sensorless load detection β€” which lets you drop the limit switches entirely if you want.
  • Keep motionService() free of any IΒ²C or network work. A single OLED refresh mid-move is visible as a hitch in the curtain.
  • Release the coils when idle. It saves roughly 1.5 A of continuous current and stops the motor from heating the window frame.
  • Replace every delay() with a millis() comparison β€” blocking delays are the single most common cause of dropped readings.
  • Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
  • Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
  • Use uint8_t / uint16_t where the range allows; on an 8-bit AVR a 32-bit add costs four times as much.
  • For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 Β΅A asleep, which is the difference between days and months of runtime.
  • Profile before optimising β€” print micros() deltas around each stage and fix the slowest one first.

Safety Precautions

  • A motorised curtain cord is a strangulation hazard for small children. Use a belt drive with no accessible loop, and if a cord exists, fit a break-away safety connector.
  • Keep the motor torque no higher than the job needs. A NEMA 17 at full current can trap a hand against a bracket; running at 70 % and detecting stalls is both quieter and safer.
  • Never disconnect the stepper while the driver is powered β€” the resulting back-EMF spike destroys the driver instantly.
  • Moving parts pinch. Keep fingers, cables and hair out of gear trains and wheels, and always test motion with the drivetrain unloaded and the robot on blocks first.
  • Motors are inductive β€” always fit a flyback diode across a DC coil, or use a driver that already has one, or the back-EMF spike will destroy your GPIO.
  • Wear eye protection when soldering or cutting, and solder in a ventilated space β€” rosin flux fumes are a respiratory irritant.
  • Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
  • Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.

Maintenance

  • Re-check every screw terminal and header after the first week β€” thermal cycling loosens connections that felt tight on day one.
  • Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware β€” not only in flash.
  • Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.

Future Improvements & Upgrades

A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.

  • Move to a TMC2209 driver for StealthChop silence and sensorless StallGuard homing.
  • Add a solar panel and battery. A curtain motor runs for seconds per day, which is well within what a 5 W panel and one 18650 can supply.
  • Add Matter over Thread so the curtain works with every major ecosystem rather than only Home Assistant.
  • Add a second axis for a second curtain and coordinate them, so a pair meets in the middle.
  • Use the indoor and outdoor lux difference rather than absolute lux β€” it is a far better proxy for glare than either alone.
  • Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
  • Add connectivity β€” an ESP32 and an MQTT publish turn a local gadget into something you can graph, alert on and analyse over months.
  • Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
  • Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
  • Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
  • Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.

Frequently Asked Questions

Stepper, DC gear motor or servo?

A stepper wins here because position is what you care about and there is no gravitational load. A DC gear motor needs an encoder to know where it is, which is more parts and more code for the same result. A continuous-rotation servo is the cheapest option and gives you no position feedback at all β€” fine for open/close, hopeless for 40 %.

Do I really need limit switches if I count steps?

Yes. Step counting is open loop: it assumes every commanded step happened. One skipped step from a stiff track and your zero is wrong forever, with nothing to detect it. Two switches cost β‚Ή60 and turn an unreliable system into a reliable one.

How heavy a curtain can this move?

Far heavier than you would expect. The bottleneck is track friction, not weight β€” 68 N of belt force will drag a very heavy curtain along a good track and stall against a bad one. If it struggles, clean and lubricate the track before adding motor current.

Why is my curtain moving at 3 a.m.?

Almost certainly the dark branch of the lux rule firing after the dwell timer, on a curtain that was already closed but drifted a few percent. Widen the 2 % dead band, or gate the automation on time of day so it simply does not run overnight.

Can I run two curtains from one ESP32?

Yes β€” a second A4988 needs three more GPIO and the motion state must become an array rather than globals. The one thing to watch is the power supply: two NEMA 17s at 1 A each need a 12 V supply rated well above 2 A once you account for the buck converter and inrush.

References & Learning Resources

These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.

  1. A4988 DMOS Microstepping Driver with Translator β€” datasheetAllegro MicroSystems / Pololu
  2. Pololu A4988 carrier β€” current limit setting and the VMOT capacitor warningPololu
  3. BH1750FVI digital ambient light sensor β€” datasheetROHM Semiconductor
  4. NOAA Solar Calculator β€” equations for sunrise and sunsetNOAA Global Monitoring Laboratory
  5. GT2 timing belt specification and pulley geometryPfeifer Industries
  6. Home Assistant MQTT Cover integrationHome Assistant
  7. Microstepping: myths and realitiesMICROMO