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.
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
| Setting | How it is used |
|---|---|
| Bedroom wake-up automation | Opening the curtains at sunrise is a far gentler alarm than a sound, and there is decent evidence it helps circadian alignment. |
| Passive solar management | Closing south-facing curtains during peak summer sun measurably reduces cooling load; opening them on a cold sunny day adds free heat. |
| Occupancy simulation | Curtains moving on a plausible schedule is a much stronger away-from-home signal than a light on a timer. |
| Accessibility | For anyone with limited reach or mobility, a motorised curtain removes a daily frustration entirely. |
| Meeting rooms and classrooms | Blackout on a projector cue, integrated with room booking. |
| Greenhouse shade screens | The 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
| Attribute | Value |
|---|---|
| Difficulty level | Intermediate |
| Estimated completion time | 10β16 hours |
| Indicative build cost | βΉ4,300 β βΉ5,600 |
| Primary discipline | Smart Home |
| Reference platform | ESP32 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.
| Component | Key specification | Qty | Approx. 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 DAC | 1 | βΉ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Β·cm | 1 | βΉ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 eye | 1 | βΉ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βΆ cycles | 2 | βΉ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Β²C | 1 | βΉ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 % efficiency | 1 | βΉ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 barrel | 1 | βΉ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 grid | 1 | βΉ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 lugs | 1 | βΉ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 reinforced | 1 | βΉ260 |
| GT2 20-tooth pulley + idler pulley | 5 mm bore, with set screws | 1 | βΉ320 |
| 3D-printed carrier clamp and end brackets | PETG recommended β PLA creeps under belt tension in sunlight | 1 | βΉ150 |
| Push button (manual open/close) | Momentary, panel mount | 2 | βΉ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
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| 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 DAC | 3.3 V logic / 5 V USB | UART, SPI, IΒ²C, IΒ²S, CAN, PWM | Datasheet |
| A4988 stepper driver + NEMA 17 motor | 1.5 A/phase with heatsink, 1/16 microstepping, 8β35 V; NEMA 17 = 4.4 kgΒ·cm | 8β35 V | STEP/DIR | Datasheet |
| BH1750 digital ambient light sensor | 1β65535 lx, 16-bit, Β±20 %, spectral response close to the human eye | 2.4β3.6 V | IΒ²C (0x23/0x5C) | Datasheet |
| Micro limit switch (SPDT lever) | 5 A @ 250 VAC, 0.05 mm differential travel, > 10βΆ cycles | passive | Digital with pull-up | Datasheet |
| 0.96β³ SSD1306 OLED display | 128 Γ 64 monochrome, 1.3β3.3 V logic, 100 kHzβ400 kHz IΒ²C | 3.3β5 V | IΒ²C (0x3C) | Datasheet |
| LM2596 adjustable buck converter module | 4.5β40 V in, 1.25β37 V out, 2 A (3 A peak), ~92 % efficiency | 4.5β40 V | Screw terminals + trimmer | Datasheet |
| 12 V 5 A SMPS adapter | 100β240 VAC in, 12 V Β±5 %, 5 A, 60 W, DC 5.5 Γ 2.1 mm barrel | 12 V | DC barrel | Datasheet |
| Double-sided perfboard 7 Γ 9 cm + headers | FR-4, 0.1β³ pitch, plated through-holes, 24 Γ 18 grid | β | β | Datasheet |
| IP65 ABS junction enclosure 158 Γ 90 Γ 60 mm | IP65, 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.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | 3.3 V logic / 5 V USB | 160 | Wi-Fi transmit bursts peak near 500 mA β size the regulator accordingly. |
| A4988 stepper driver + NEMA 17 motor | 8β35 V | 1500 | Set Vref = I_max Γ 8 Γ Rsense before the first move or you cook the driver. |
| BH1750 digital ambient light sensor | 2.4β3.6 V | 0.19 | Far more linear than an LDR β use it whenever you need real lux, not a relative value. |
| 0.96β³ SSD1306 OLED display | 3.3β5 V | 20 | Static images burn in β invert or scroll the screen periodically. |
| LM2596 adjustable buck converter module | 4.5β40 V | 8 | Set the output voltage with no load connected before wiring the board. |
| 12 V 5 A SMPS adapter | 12 V | 5000 | Fuse 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.jsonunder 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
dialoutgroup:sudo usermod -aG dialout $USERand 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
| Library | Why it is needed | Install |
|---|---|---|
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager β "PubSubClient" by Nick O'Leary |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager β "ArduinoJson" by Benoit Blanchon |
| BH1750 1.3.0 | Digital lux readings with selectable resolution modes. | Library Manager β "BH1750" by Christopher Laws |
| Adafruit SSD1306 + GFX 2.5.x | Framebuffer and text/graphics primitives for the OLED. | Library Manager β "Adafruit SSD1306" |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
| NTPClient / configTime bundled | Wall-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.
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.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| BH1750 light sensor | SDA / SCL | GPIO 21 / 22 | IΒ²C at 0x23 |
| Limit switch β closed end | NC contact | GPIO 34 | Normally closed to GND |
| Limit switch β open end | NC contact | GPIO 35 | Normally closed to GND |
| Manual buttons | NO contacts | GPIO 32 / 33 | Open / close, pull-up |
| A4988 driver | STEP | GPIO 25 | One pulse = one microstep |
| A4988 driver | DIR | GPIO 26 | Direction |
| A4988 driver | ENABLE | GPIO 27 | Active-low; high disables coils |
| A4988 driver | MS1/MS2/MS3 | 3V3 (all three) | Selects 1/16 microstepping |
| SSD1306 OLED | SDA / SCL | GPIO 21 / 22 | Shared 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.
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.
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
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
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
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.
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.
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.
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.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.
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
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(); }
}
Configuration & Calibration
Configuration steps
- Set
LAT,LONandTZ_Hfor 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
travelStepsis learned, then confirm the figure looks sane against the belt-travel calculation. - Tune
LUX_CLOSEandLUX_OPENagainst the readings you actually see. Overcast daylight indoors is roughly 500β2000 lx; direct sun through glass can exceed 20 000 lx. - Reduce
V_MAXif 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.
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.
Find the maximum reliable speed
Increase
V_MAXin 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.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.
| Test | What you should see |
|---|---|
| Measure Vref with the motor disconnected | Within 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 home | The carrier drives slowly to the closed end, the switch trips, it backs off about 800 steps, and the display reads 0 %. |
| Command 50 % over MQTT | Smooth accelerate-cruise-decelerate motion to the midpoint with no audible stepping buzz. |
| Block the carrier by hand mid-move | The stall detector aborts within about three seconds and the coils release. Nothing should grind or skip audibly for long. |
| Disconnect a limit switch wire | The 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 minutes | After the five-minute dwell expires, the curtain moves to the closed position. |
| Power-cycle and re-home | The homed position matches the previous run to within a few hundred steps β under 5 mm of travel. |
| Check the sunrise/sunset display | Within 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.
Troubleshooting: Common Errors & Fixes
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 amillis()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_twhere 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
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.
- A4988 DMOS Microstepping Driver with Translator β datasheetAllegro MicroSystems / Pololu
- Pololu A4988 carrier β current limit setting and the VMOT capacitor warningPololu
- BH1750FVI digital ambient light sensor β datasheetROHM Semiconductor
- NOAA Solar Calculator β equations for sunrise and sunsetNOAA Global Monitoring Laboratory
- GT2 timing belt specification and pulley geometryPfeifer Industries
- Home Assistant MQTT Cover integrationHome Assistant
- Microstepping: myths and realitiesMICROMO