Community project
Humidity-Temperature Motor Control
This project builds a smart motor controller that responds to environmental conditions by reading temperature and humidity from a DHT11 sensor and light levels from a digital light sensor. The system displays real-time readings on a 16x2 LCD screen and automatically triggers a 12V brushed DC motor based on the sensor data, making it ideal for applications like automated window blinds, ventilation fans, or other climate-responsive mechanisms.
The guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for integrating the BTS7960 high-current motor driver with the ESP32 microcontroller. Included is the full Arduino firmware that handles sensor sampling, LCD display updates, and motor control logic with timing management for smooth operation.
Wiring diagram

Gather all the parts
Assemble it in 7 steps
1. Turn all power off
Unplug the ESP32 USB cable and switch off or unplug the 12 V adapter before moving any wires. This prevents the motor from starting unexpectedly while you rewire it.
- Do not work on the motor wires with the 12 V adapter connected — the motor can start suddenly and a loose wire can short the supply.
2. Keep the sensor wires connected
Connect DHT11 VCC to ESP32 3V3 (power), DHT11 GND to ESP32 GND (ground), and DHT11 DATA to GPIO4 (temperature and humidity signal). Connect the light sensor VCC to ESP32 3V3 (power), GND to ESP32 GND (ground), and DO to GPIO23 (light signal).
- Power the light sensor from 3.3 V so its signal is safe for the ESP32.
3. Keep the LCD level shifter connected
Connect LCD VCC to the regulated 5 V buck output (power) and LCD GND to shared GND (ground). On the LCD level shifter, connect LV to ESP32 3V3 (power), HV to regulated 5 V (power), and GND to shared GND (ground). Connect LV1 to GPIO21 (LCD data), HV1 to LCD SDA (data), LV2 to GPIO22 (LCD clock), and HV2 to LCD SCL (clock).
- Do not connect a 5 V LCD SDA or SCL wire directly to GPIO21 or GPIO22 — 5 V can damage the ESP32.
4. Remove the old motor driver and shifter
Remove the HC-160A motor driver and the motor-control BSS138 level-shifter from the motor circuit. The BTS7960 replaces both of them, so do not reuse the old PA, A, B, or BSS138 motor-control wires.
- Keep the separate LCD level shifter installed; only the motor-control shifter is removed.
5. Connect the BTS7960 control side
Connect BTS7960 VCC to regulated 5 V (logic power), BTS7960 GND to ESP32 GND (shared ground), RPWM to GPIO27 (UP command), and LPWM to GPIO14 (DOWN command). Connect both R_EN and L_EN to ESP32 3V3 (enable signals).
- Do not connect 5 V or 12 V to GPIO27 or GPIO14 — those ESP32 pins accept only 3.3 V signals.
6. Connect the motor and 12 V supply
Connect the 12 V adapter +12V lead to BTS7960 B+ (motor power) and the adapter GND lead to BTS7960 B− (motor ground). Connect the motor’s two leads to BTS7960 M+ and M− (motor outputs). Connect the same adapter +12V to buck VIN+ (power) and adapter GND to buck VIN− (ground).
- Never connect the 12 V adapter to ESP32 VIN, 3V3, or any GPIO pin — 12 V will damage the board.
7. Set the 5 V output and power up
Use a meter to set the buck converter output to exactly 5.0 V before connecting it. Connect buck VOUT+ to ESP32 VIN/5V (power) and buck VOUT− to ESP32 GND (ground). Then connect USB to the ESP32 and turn on the 12 V adapter.
- A buck output above 5.0 V can damage the ESP32 and the BTS7960 logic side.
Review all connections
1. Connections between "dht11_1" and "ESP32"
2. Connections between "light_sensor_1" and "ESP32"
3. Connections between "lcd_16x2_1" and "ESP32"
4. Connections between "level_shifter_lcd_1" and "ESP32"
5. Connections between "supply_12v_1" and "ESP32"
6. Connections between "buck_5v_1" and "ESP32"
7. Connections between "bts7960_1" and "ESP32"
Deploy the firmware
#include <Arduino.h>
#include <Wire.h>
#include <DHT.h>
#include <LiquidCrystal_I2C.h>
constexpr uint8_t DHT_PIN = 4;
constexpr uint8_t LIGHT_SENSOR_PIN = 23;
// BTS7960: RPWM drives UP and LPWM drives DOWN. The opposite PWM pin is held LOW.
constexpr uint8_t MOTOR_RPWM_PIN = 27;
constexpr uint8_t MOTOR_LPWM_PIN = 14;
constexpr uint8_t I2C_SDA_PIN = 21;
constexpr uint8_t I2C_SCL_PIN = 22;
constexpr uint8_t DHT_TYPE = DHT11;
constexpr uint8_t LCD_ADDRESS = 0x27;
constexpr unsigned long SAMPLE_INTERVAL_MS = 2000;
constexpr unsigned long MOTOR_MOVE_TIME_MS = 1000;
constexpr uint8_t MOTOR_PWM_50_PERCENT = 128;
constexpr unsigned long STARTUP_ANIMATION_INTERVAL_MS = 300;
constexpr unsigned long STARTUP_DURATION_MS = 3000;
constexpr unsigned long MOTOR_MESSAGE_INTERVAL_MS = 160;
// Keep the movement notice visible for three seconds, even though the motor runs for one second.
constexpr unsigned long MOTOR_MESSAGE_DURATION_MS = 3000;
constexpr unsigned long IDLE_TITLE_INTERVAL_MS = 220;
constexpr unsigned long IDLE_TITLE_PERIOD_MS = 10000;
constexpr unsigned long IDLE_TITLE_DURATION_MS = 3000;
constexpr char STARTUP_TITLE[] = "Smart Power Window Controller";
DHT dht(DHT_PIN, DHT_TYPE);
LiquidCrystal_I2C lcd(LCD_ADDRESS, 16, 2);
unsigned long lastSampleMs = 0;
unsigned long motorStartMs = 0;
unsigned long lastStartupAnimationMs = 0;
unsigned long startupStartMs = 0;
bool motorMoving = false;
bool startupActive = true;
uint8_t startupTitleOffset = 0;
bool startupFlashOn = false;
unsigned long lastMotorMessageMs = 0;
unsigned long motorMessageStartMs = 0;
bool motorMessageActive = false;
uint8_t motorMessageOffset = 0;
unsigned long lastMotorOutputChangeMs = 0;
unsigned long idleTitleStartMs = 0;
unsigned long lastIdleTitleAnimationMs = 0;
bool idleTitleActive = false;
bool idleTitleFlashOn = false;
uint8_t idleTitleOffset = 0;
bool haveValidReading = false;
enum class MotorState { STOP, UP, DOWN };
// At power-up the mechanism is treated as already being at its UP position.
MotorState lastRequestedPosition = MotorState::UP;
MotorState motorMessageState = MotorState::UP;
void setMotor(MotorState state) {
// BTS7960 direction is selected by applying PWM to exactly one input.
// Keeping the other PWM input at zero prevents a conflicting command.
if (state == MotorState::UP) {
analogWrite(MOTOR_RPWM_PIN, MOTOR_PWM_50_PERCENT);
analogWrite(MOTOR_LPWM_PIN, 0);
} else if (state == MotorState::DOWN) {
analogWrite(MOTOR_RPWM_PIN, 0);
analogWrite(MOTOR_LPWM_PIN, MOTOR_PWM_50_PERCENT);
} else {
analogWrite(MOTOR_RPWM_PIN, 0);
analogWrite(MOTOR_LPWM_PIN, 0);
}
}
void stopMotor() {
const bool wasMoving = motorMoving;
setMotor(MotorState::STOP);
motorMoving = false;
if (wasMoving) {
lastMotorOutputChangeMs = millis();
idleTitleActive = false;
}
}
void printLine(uint8_t row, const char *text) {
char padded[17];
snprintf(padded, sizeof(padded), "%-16.16s", text);
lcd.setCursor(0, row);
lcd.print(padded);
}
const char *motorStateName(MotorState state) {
if (state == MotorState::UP) return "UP";
if (state == MotorState::DOWN) return "DOWN";
return "STOP";
}
void startMotorConditionMessage(MotorState state, unsigned long now) {
motorMessageState = state;
motorMessageOffset = 0;
motorMessageStartMs = now;
motorMessageActive = true;
lastMotorMessageMs = now - MOTOR_MESSAGE_INTERVAL_MS;
}
void updateMotorConditionMessage(unsigned long now) {
if (!motorMessageActive) return;
if (now - motorMessageStartMs >= MOTOR_MESSAGE_DURATION_MS) {
motorMessageActive = false;
return;
}
if (now - lastMotorMessageMs < MOTOR_MESSAGE_INTERVAL_MS) return;
lastMotorMessageMs = now;
const char *message = motorMessageState == MotorState::UP
? "<<< MOTOR CONDITION: UP >>> "
: "<<< MOTOR CONDITION: DOWN >>> ";
const size_t messageLength = strlen(message);
char line[17];
for (uint8_t column = 0; column < 16; ++column) {
line[column] = message[(motorMessageOffset + column) % messageLength];
}
line[16] = '\0';
printLine(0, line);
printLine(1, motorMessageState == MotorState::UP ? "Moving up..." : "Moving down...");
motorMessageOffset = (motorMessageOffset + 1) % messageLength;
}
void startIdleTitle(unsigned long now) {
idleTitleStartMs = now;
lastIdleTitleAnimationMs = now - IDLE_TITLE_INTERVAL_MS;
idleTitleActive = true;
idleTitleFlashOn = false;
idleTitleOffset = 0;
}
void updateIdleTitle(unsigned long now) {
if (!idleTitleActive) return;
if (now - idleTitleStartMs >= IDLE_TITLE_DURATION_MS) {
idleTitleActive = false;
// Start the ten-second quiet period after the three-second title finishes.
lastMotorOutputChangeMs = now;
return;
}
if (now - lastIdleTitleAnimationMs < IDLE_TITLE_INTERVAL_MS) return;
lastIdleTitleAnimationMs = now;
const size_t titleLength = strlen(STARTUP_TITLE);
char titleLine[17];
for (uint8_t column = 0; column < 16; ++column) {
titleLine[column] = STARTUP_TITLE[(idleTitleOffset + column) % titleLength];
}
titleLine[16] = '\0';
printLine(0, titleLine);
printLine(1, idleTitleFlashOn ? " System ready *" : " System ready ");
idleTitleOffset = (idleTitleOffset + 1) % titleLength;
idleTitleFlashOn = !idleTitleFlashOn;
}
void showReadings(float temperatureC, float humidity, MotorState requestedPosition, bool lightDetected) {
char line1[17];
char line2[17];
snprintf(line1, sizeof(line1), "T:%.1fC H:%.0f%%", temperatureC, humidity);
snprintf(line2, sizeof(line2), "L:%s M:%s", lightDetected ? "BRIGHT" : "DARK", motorStateName(requestedPosition));
printLine(0, line1);
printLine(1, line2);
}
void showSensorError(bool lightDetected) {
printLine(0, "DHT11 read failed");
printLine(1, lightDetected ? "L:BRIGHT M:STOP" : "L:DARK M:STOP");
}
void updateStartupAnimation(unsigned long now) {
if (!startupActive || now - lastStartupAnimationMs < STARTUP_ANIMATION_INTERVAL_MS) return;
lastStartupAnimationMs = now;
char titleLine[17];
const size_t titleLength = strlen(STARTUP_TITLE);
for (uint8_t column = 0; column < 16; ++column) {
titleLine[column] = STARTUP_TITLE[(startupTitleOffset + column) % titleLength];
}
titleLine[16] = '\0';
printLine(0, titleLine);
printLine(1, startupFlashOn ? " Starting... *" : " Starting... ");
startupTitleOffset = (startupTitleOffset + 1) % titleLength;
startupFlashOn = !startupFlashOn;
}
MotorState chooseRequestedPosition(float temperatureC, float humidity, bool lightDetected) {
if (temperatureC > 30.0f) return MotorState::DOWN;
if (temperatureC <= 28.0f) return MotorState::UP;
if (humidity < 70.0f) return MotorState::DOWN;
if (humidity >= 75.0f) return MotorState::UP;
return lightDetected ? MotorState::DOWN : MotorState::UP;
}
void setup() {
Serial.begin(115200);
pinMode(MOTOR_RPWM_PIN, OUTPUT);
pinMode(MOTOR_LPWM_PIN, OUTPUT);
analogWriteResolution(MOTOR_RPWM_PIN, 8);
analogWriteResolution(MOTOR_LPWM_PIN, 8);
analogWriteFrequency(MOTOR_RPWM_PIN, 20000);
analogWriteFrequency(MOTOR_LPWM_PIN, 20000);
pinMode(LIGHT_SENSOR_PIN, INPUT);
stopMotor();
dht.begin();
Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);
lcd.init();
lcd.backlight();
lcd.clear();
startupStartMs = millis();
lastMotorOutputChangeMs = startupStartMs;
lastStartupAnimationMs = startupStartMs - STARTUP_ANIMATION_INTERVAL_MS;
updateStartupAnimation(startupStartMs);
}
void loop() {
const unsigned long now = millis();
// This check runs every pass through loop, so a move lasts one second, not one sample period.
if (motorMoving && now - motorStartMs >= MOTOR_MOVE_TIME_MS) {
stopMotor();
}
updateMotorConditionMessage(now);
if (startupActive) {
updateStartupAnimation(now);
if (now - startupStartMs < STARTUP_DURATION_MS) return;
startupActive = false;
// Take the first sensor reading as soon as the three-second title motion ends.
lastSampleMs = now - SAMPLE_INTERVAL_MS;
}
if (!motorMoving && !motorMessageActive && haveValidReading) {
if (!idleTitleActive && now - lastMotorOutputChangeMs >= IDLE_TITLE_PERIOD_MS) {
startIdleTitle(now);
}
updateIdleTitle(now);
}
if (now - lastSampleMs < SAMPLE_INTERVAL_MS) return;
lastSampleMs = now;
// Typical LM393 light-sensor modules pull DO LOW at the selected light threshold.
const bool lightDetected = digitalRead(LIGHT_SENSOR_PIN) == LOW;
const float humidity = dht.readHumidity();
const float temperatureC = dht.readTemperature();
if (isnan(humidity) || isnan(temperatureC)) {
haveValidReading = false;
stopMotor();
Serial.printf("DHT11 read failed; light: %s; motor OFF\n", lightDetected ? "BRIGHT" : "DARK");
showSensorError(lightDetected);
return;
}
haveValidReading = true;
const MotorState requestedPosition = chooseRequestedPosition(temperatureC, humidity, lightDetected);
// At power-up the mechanism is assumed to already be UP. Therefore, the first
// DOWN request is a real change and moves the motor; an initial UP request does not.
if (requestedPosition != lastRequestedPosition) {
lastRequestedPosition = requestedPosition;
idleTitleActive = false;
lastMotorOutputChangeMs = now;
setMotor(requestedPosition);
motorStartMs = now;
motorMoving = true;
startMotorConditionMessage(requestedPosition, now);
updateMotorConditionMessage(now);
Serial.printf("Position changed to %s; motor runs for 1 second\n", motorStateName(requestedPosition));
}
if (!motorMoving && !motorMessageActive && !idleTitleActive) {
showReadings(temperatureC, humidity, requestedPosition, lightDetected);
}
}Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




