Community project

Dual H-Bridge Motor Control

ESP32
Photo of Dual H-Bridge Motor Control
Generated with AI

AntoniW-AW

Published October 19, 2026

This project builds a dual H-bridge motor control system for a bioreactor chamber, using an ESP32 to manage a brushed DC motor and relay-controlled air compressor. The system monitors water temperature via K-type thermocouple, tracks chamber temperature and humidity with an AHT20 sensor, and provides web-based control over a local WiFi network. Readers will receive a complete wiring diagram showing connections between the MAX6675 thermocouple module, IBT-2 motor driver, relay module, and ESP32, along with a detailed parts list and power distribution layout using dual buck converters.

The guide includes step-by-step assembly instructions for safely routing the air path, calibrating the power supplies, and integrating all sensors and actuators. Complete firmware is provided with configurable motor ramp rates, direction-reversal logic, and sensor polling intervals, allowing makers to adapt the system for environmental control applications like fungal cultivation or other bioreactor experiments.

Wiring diagram

Gather all the parts

QtyComponent
1

MAX6675 K-type Thermocouple Module

The small module that converts a K-type thermocouple probe's water temperature into a signal the ESP32 can read.

1

K-type Thermocouple Probe

The metal temperature probe placed in the water so the controller can measure water temperature.

1

2-Channel 5 V Relay Module

The relay board that uses one low-voltage control signal to switch the compressor's 12 V power line.

1

12 V Air Compressor

The 12 V air pump that provides air flow through the water vessel.

1

12 V 5 A DC Power Supply

The external low-voltage supply that powers the compressor and the 5 V converter.

1

Adjustable 12 V to 5 V Buck Converter

The small converter that reduces 12 V to a measured 5 V supply for the ESP32 and relay.

1

Inline Automotive Blade Fuse Holder

Fuse rating must be chosen from compressor label

The fuse holder placed close to the 12 V supply to stop dangerous current if a compressor wire faults.

1

AHT20 Temperature & Humidity Sensor

AHT20 low-cost I2C relative humidity and temperature sensor. Common breakout boards use address 0x38 and are a cheap digital replacement for DHT-style sensors when I2C is available.

1

Adjustable 12 V to 7.4 V Buck Converter

set to 7.4 V

An adjustable step-down converter that safely reduces the 12 V supply to the 7.4 V motor voltage.

1

IBT-2 BTS7960 43A High-Current H-Bridge Motor Driver

IBT-2 high-current H-bridge motor driver module built around BTS7960 half-bridge devices. Common modules are sold for 6V-27V motor supplies and 5V logic, with 43A as a module headline rating; practical continuous current depends on board cooling and wiring. Direction and speed are controlled with RPWM/LPWM PWM inputs plus R_EN/L_EN enable lines. Logic-side VCC is separate from the high-power motor supply, and grounds must be common.

1

380/390 Brushed DC Motor

7.4 V

A 7.4 V brushed DC motor that slowly moves the fungi grid back and forth.

Assemble it in 7 steps

1. Keep the air path safely vented

Keep the water jar and grow container vented through suitable filters; do not seal either glass container. Put the metal tip of thermocouple_1 into the water, but keep its cable joint dry.

  • A sealed glass jar can burst when air is pumped into it, causing flying glass and hot-water injuries.

2. Wire the water temperature probe

Connect thermocouple_1 `+` to max6675_1 `T+` (water-temperature signal) and `-` to `T-` (water-temperature signal). Connect MAX6675 `VCC` to ESP32 `3V3` (power), `GND` to ESP32 `GND` (ground), `SCK` to GPIO18 (clock), `SO` to GPIO19 (temperature data), and `CS` to GPIO4 (select signal).

3. Wire the chamber temperature and humidity sensor

Place aht20_1 where it senses chamber air but cannot be splashed or covered by condensation. Connect `VIN` to ESP32 `3V3` (power), `GND` to ESP32 `GND` (ground), `SDA` to GPIO21 (data), and `SCL` to GPIO22 (data timing).

  • Water on the sensor board can cause incorrect readings or damage it.

4. Set the 5 V controller power converter

With power disconnected, connect power_supply_12v_1 `+12V` to buck_converter_1 `IN+` (power) and supply `GND` to `IN-` (ground). Adjust and measure `OUT+` at exactly 5.0 V before connecting it to ESP32 `5V`/`VIN` (power) and `OUT-` to ESP32 `GND` (ground).

  • Do not connect 12 V directly to the ESP32 5V or 3V3 pins; it can permanently damage the board.

5. Set the 7.4 V motor supply

Connect power_supply_12v_1 `+12V` to motor_buck_7v4_1 `VIN+` (power) and supply `GND` to `VIN-` (ground). Before attaching the H-bridge, adjust and measure the converter from `VOUT+` to `VOUT-` at exactly 7.4 V. Connect `VOUT+` to motor_driver_hw039_1 `B+` (motor power) and `VOUT-` to `B-` (motor ground).

  • Do not send 12 V directly to the 7.4 V motor. Use a motor buck converter with enough current capacity for the motor's startup current.

6. Wire the HW-039 and grid motor

Connect motor_driver_hw039_1 `VCC` to the measured 5 V supply (logic power), `GND` to ESP32 `GND` (shared ground), `RPWM` to ESP32 GPIO25 (forward slow-speed signal), and `LPWM` to GPIO27 (reverse slow-speed signal). Connect `R_EN` and `L_EN` to ESP32 `3V3` (enable). Connect HW-039 `M+` to grid_motor_380_1 `M+` (motor wire) and HW-039 `M-` to grid_motor_380_1 `M-` (motor wire).

  • The software begins at a low speed, ramps gently, pauses before reversing, then changes direction every 30 seconds when automatic reversing is selected.
  • Secure the motor and grid so moving parts cannot catch wires.

7. Wire the relay and compressor

Connect relay_1 `VCC` to measured 5 V (power), relay `GND` to ESP32 `GND` (ground), and relay `IN1` to GPIO26 through a 3.3 V-compatible relay driver (compressor switch signal). Connect supply `+12V` to fuse_1 `IN` (protected power), fuse `OUT` to relay `COM1` (incoming switched power), relay `NO1` to compressor_1 `+12V` (power when switched on), and compressor `GND` to supply `GND` (ground).

  • Use `NO1`, not `NC1`, so loss of controller power turns the compressor off.
  • A 5 V relay input connected straight to GPIO26 can fail to switch or send an unsafe voltage into the ESP32.

Review all connections

1. Connections between "thermocouple_1" and "ESP32"

Functionthermocouple_1ESP32
analog+ → MAX6675 K-type Thermocouple Module T+EXT
analog- → MAX6675 K-type Thermocouple Module T-EXT

2. Connections between "max6675_1" and "ESP32"

Functionmax6675_1ESP32
powerVCC3V3
groundGNDGND
spiSCKGPIO 18
spiCSGPIO 4
spiSOGPIO 19

3. Connections between "power_supply_12v_1" and "ESP32"

Functionpower_supply_12v_1ESP32
power+12V → Inline Automotive Blade Fuse Holder INEXT
power+12V → Adjustable 12 V to 5 V Buck Converter IN+EXT
power+12V → Adjustable 12 V to 7.4 V Buck Converter VIN+EXT
groundGND → Adjustable 12 V to 5 V Buck Converter IN-EXT
groundGND → Adjustable 12 V to 7.4 V Buck Converter VIN-EXT

4. Connections between "fuse_1" and "ESP32"

Functionfuse_1ESP32
powerOUT → 2-Channel 5 V Relay Module COM1EXT

5. Connections between "relay_1" and "ESP32"

Functionrelay_1ESP32
powerNO1 → 12 V Air Compressor +12VEXT
powerVCC5V
groundGNDGND
digitalIN1GPIO 26

6. Connections between "compressor_1" and "ESP32"

Functioncompressor_1ESP32
groundGND → 12 V 5 A DC Power Supply GNDEXT

7. Connections between "buck_converter_1" and "ESP32"

Functionbuck_converter_1ESP32
powerOUT+5V
groundOUT-GND

8. Connections between "motor_buck_7v4_1" and "ESP32"

Functionmotor_buck_7v4_1ESP32
powerVOUT+ → IBT-2 BTS7960 43A High-Current H-Bridge Motor Driver B+EXT
groundVOUT- → IBT-2 BTS7960 43A High-Current H-Bridge Motor Driver B-EXT

9. Connections between "motor_driver_hw039_1" and "ESP32"

Functionmotor_driver_hw039_1ESP32
powerVCC5V
groundGNDGND
pwmRPWMGPIO 25
pwmLPWMGPIO 27
powerR_EN3V3
powerL_EN3V3
dataM+ → 380/390 Brushed DC Motor M+EXT
dataM- → 380/390 Brushed DC Motor M-EXT

10. Connections between "aht20_1" and "ESP32"

Functionaht20_1ESP32
powerVIN3V3
groundGNDGND
i2cSDAGPIO 21
i2cSCLGPIO 22

Deploy the firmware

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <max6675.h>
#include <Adafruit_AHTX0.h>

// These values match the physical wiring in this project.
constexpr int THERMO_SCK_PIN = 18;
constexpr int THERMO_CS_PIN = 4;
constexpr int THERMO_SO_PIN = 19;
constexpr int COMPRESSOR_RELAY_PIN = 26;
constexpr int GRID_MOTOR_AIN1_PIN = 25;
constexpr int GRID_MOTOR_AIN2_PIN = 27;
constexpr int AHT20_SDA_PIN = 21;
constexpr int AHT20_SCL_PIN = 22;
constexpr int GRID_MOTOR_PWM_FREQUENCY_HZ = 20000;
constexpr int GRID_MOTOR_PWM_RESOLUTION_BITS = 8;
// Keep this small gearmotor gentle. The phone page limits the setting to 5–30%.
constexpr int GRID_MOTOR_DEFAULT_PERCENT = 20;
constexpr int GRID_MOTOR_MIN_PERCENT = 5;
constexpr int GRID_MOTOR_MAX_PERCENT = 30;
constexpr int GRID_MOTOR_RAMP_STEP = 3;
constexpr unsigned long GRID_MOTOR_RAMP_STEP_MS = 30;
constexpr unsigned long GRID_MOTOR_REVERSE_INTERVAL_MS = 30000;
constexpr unsigned long GRID_MOTOR_DIRECTION_CHANGE_PAUSE_MS = 100;
constexpr unsigned long SENSOR_INTERVAL_MS = 2000;

// The ESP32 creates this local network. Change this password before a public deployment.
const char *WIFI_AP_NAME = "Fungi-Bioreactor";
const char *WIFI_AP_PASSWORD = "FungiControl2026";

// Most 5 V relay modules switch on when their input is pulled LOW.
constexpr bool RELAY_ON_LEVEL = LOW;
constexpr bool RELAY_OFF_LEVEL = HIGH;

MAX6675 waterThermocouple(THERMO_SCK_PIN, THERMO_CS_PIN, THERMO_SO_PIN);
Adafruit_AHTX0 chamberSensor;
WebServer server(80);

float waterTemperatureC = NAN;
float chamberTemperatureC = NAN;
float chamberHumidityPercent = NAN;
bool chamberSensorReady = false;
bool motorEnabled = true;
bool motorForward = true;
bool compressorEnabled = false;
bool autoReverse = true;
bool reversalPauseActive = false;
int motorPercent = GRID_MOTOR_DEFAULT_PERCENT;
int appliedMotorDuty = 0;
unsigned long lastSensorReadMs = 0;
unsigned long lastMotorRampMs = 0;
unsigned long lastMotorDirectionChangeMs = 0;
unsigned long reversalPauseStartedMs = 0;

int targetMotorDuty() {
  return motorEnabled && !reversalPauseActive ? map(motorPercent, 0, 100, 0, 255) : 0;
}

void applyMotorDrive(int duty) {
  // HW-039 / BTS7960: drive only one PWM input at a time.
  // RPWM is forward and LPWM is reverse, so the 7.4 V grid motor sees one polarity at a time.
  if (motorForward) {
    ledcWrite(GRID_MOTOR_AIN1_PIN, duty);
    ledcWrite(GRID_MOTOR_AIN2_PIN, 0);
  } else {
    ledcWrite(GRID_MOTOR_AIN1_PIN, 0);
    ledcWrite(GRID_MOTOR_AIN2_PIN, duty);
  }
}

void updateMotor(unsigned long now) {
  if (reversalPauseActive && now - reversalPauseStartedMs >= GRID_MOTOR_DIRECTION_CHANGE_PAUSE_MS) {
    motorForward = !motorForward;
    reversalPauseActive = false;
    lastMotorDirectionChangeMs = now;
  }

  if (autoReverse && motorEnabled && !reversalPauseActive &&
      now - lastMotorDirectionChangeMs >= GRID_MOTOR_REVERSE_INTERVAL_MS) {
    reversalPauseActive = true;
    reversalPauseStartedMs = now;
  }

  if (now - lastMotorRampMs < GRID_MOTOR_RAMP_STEP_MS) return;
  lastMotorRampMs = now;
  const int target = targetMotorDuty();
  if (appliedMotorDuty < target) appliedMotorDuty = min(appliedMotorDuty + GRID_MOTOR_RAMP_STEP, target);
  if (appliedMotorDuty > target) appliedMotorDuty = max(appliedMotorDuty - GRID_MOTOR_RAMP_STEP, target);
  applyMotorDrive(appliedMotorDuty);
}

String numberOrNull(float value, unsigned int decimals) {
  return isnan(value) ? "null" : String(value, decimals);
}

void sendState() {
  String json = "{\"waterC\":" + numberOrNull(waterTemperatureC, 1) +
                ",\"airC\":" + numberOrNull(chamberTemperatureC, 1) +
                ",\"humidity\":" + numberOrNull(chamberHumidityPercent, 1) +
                ",\"sensorReady\":" + String(chamberSensorReady ? "true" : "false") +
                ",\"motorEnabled\":" + String(motorEnabled ? "true" : "false") +
                ",\"compressorEnabled\":" + String(compressorEnabled ? "true" : "false") +
                ",\"forward\":" + String(motorForward ? "true" : "false") +
                ",\"autoReverse\":" + String(autoReverse ? "true" : "false") +
                ",\"speed\":" + String(motorPercent) + "}";
  server.send(200, "application/json", json);
}

void handleMotorControl() {
  if (server.hasArg("enabled")) motorEnabled = server.arg("enabled") == "1";
  if (server.hasArg("speed")) motorPercent = constrain(server.arg("speed").toInt(), GRID_MOTOR_MIN_PERCENT, GRID_MOTOR_MAX_PERCENT);
  if (server.hasArg("direction")) {
    motorForward = server.arg("direction") == "forward";
    autoReverse = false;
    reversalPauseActive = false;
    lastMotorDirectionChangeMs = millis();
  }
  if (server.hasArg("auto")) {
    autoReverse = server.arg("auto") == "1";
    lastMotorDirectionChangeMs = millis();
  }
  sendState();
}

void handleCompressorControl() {
  // This is only an on/off command. It does not make a glass vessel pressure-safe.
  compressorEnabled = server.arg("enabled") == "1";
  digitalWrite(COMPRESSOR_RELAY_PIN, compressorEnabled ? RELAY_ON_LEVEL : RELAY_OFF_LEVEL);
  sendState();
}

void handleRoot() {
  const char page[] PROGMEM = R"HTML(
<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fungi Bioreactor</title><style>
body{font-family:Arial,sans-serif;background:#10231c;color:#eff8f0;margin:0;padding:18px}main{max-width:620px;margin:auto}.card{background:#1c382c;border-radius:14px;padding:16px;margin:12px 0}.reading{font-size:1.5rem;margin:10px 0}button{background:#55b87a;border:0;border-radius:8px;padding:12px;margin:4px;color:#07150d;font-weight:bold;font-size:1rem}button.off{background:#e88979}input{width:100%}small{color:#c5d8cb}</style></head>
<body><main><h1>Fungi Bioreactor</h1><div class="card"><h2>Conditions</h2><div class="reading">Water: <b id="water">--</b> °C</div><div class="reading">Chamber: <b id="air">--</b> °C</div><div class="reading">Humidity: <b id="humidity">--</b> % RH</div><small id="sensor"></small></div>
<div class="card"><h2>Grid motor</h2><p id="motorState">Loading…</p><button id="run" onclick="motor('enabled='+ (state.motorEnabled?0:1))">Start / stop</button><button onclick="motor('direction=forward')">Forward</button><button onclick="motor('direction=reverse')">Reverse</button><button onclick="motor('auto='+(state.autoReverse?0:1))">Auto reverse</button><p>Slow speed: <b id="speedLabel">20</b>%</p><input id="speed" type="range" min="5" max="30" value="20" oninput="speedLabel.textContent=this.value" onchange="motor('speed='+this.value)"><small>The motor gently ramps up, pauses before each reversal, and reverses every 30 seconds while Auto reverse is on.</small></div>
<div class="card"><h2>Compressor</h2><p id="compressorState">Loading…</p><button id="compressor" onclick="compressorControl()">Turn compressor on</button><small>Use only for gentle airflow through vented containers. This button is not a pressure-safety control.</small></div></main>
<script>let state={};function show(v){return v===null?'--':v.toFixed(1)}async function get(){state=await (await fetch('/api/state')).json();water.textContent=show(state.waterC);air.textContent=show(state.airC);humidity.textContent=show(state.humidity);sensor.textContent=state.sensorReady?'AHT20 sensor connected.':'Check the AHT20 sensor wiring.';motorState.textContent=state.motorEnabled?'Running slowly at '+state.speed+'% — '+(state.forward?'forward':'reverse')+(state.autoReverse?' — auto reverse on':''):'Stopped';run.textContent=state.motorEnabled?'Stop motor':'Start motor';run.className=state.motorEnabled?'off':'';speed.value=state.speed;speedLabel.textContent=state.speed;compressorState.textContent=state.compressorEnabled?'Compressor is on.':'Compressor is off.';compressor.textContent=state.compressorEnabled?'Turn compressor off':'Turn compressor on';compressor.className=state.compressorEnabled?'off':''}async function motor(q){state=await (await fetch('/api/motor?'+q)).json();get()}async function compressorControl(){state=await (await fetch('/api/compressor?enabled='+(state.compressorEnabled?0:1))).json();get()}get();setInterval(get,2000);</script></body></html>
)HTML";
  server.send(200, "text/html", page);
}

void readSensors(unsigned long now) {
  if (now - lastSensorReadMs < SENSOR_INTERVAL_MS) return;
  lastSensorReadMs = now;
  waterTemperatureC = waterThermocouple.readCelsius();
  if (chamberSensorReady) {
    sensors_event_t humidityEvent, temperatureEvent;
    chamberSensor.getEvent(&humidityEvent, &temperatureEvent);
    chamberTemperatureC = temperatureEvent.temperature;
    chamberHumidityPercent = humidityEvent.relative_humidity;
  }
}

void setup() {
  Serial.begin(115200);

  // Start with compressor power disconnected after every reset.
  pinMode(COMPRESSOR_RELAY_PIN, OUTPUT);
  digitalWrite(COMPRESSOR_RELAY_PIN, RELAY_OFF_LEVEL);

  ledcAttach(GRID_MOTOR_AIN1_PIN, GRID_MOTOR_PWM_FREQUENCY_HZ, GRID_MOTOR_PWM_RESOLUTION_BITS);
  ledcAttach(GRID_MOTOR_AIN2_PIN, GRID_MOTOR_PWM_FREQUENCY_HZ, GRID_MOTOR_PWM_RESOLUTION_BITS);
  applyMotorDrive(0);
  lastMotorDirectionChangeMs = millis();

  Wire.begin(AHT20_SDA_PIN, AHT20_SCL_PIN);
  chamberSensorReady = chamberSensor.begin();

  WiFi.mode(WIFI_AP);
  WiFi.softAP(WIFI_AP_NAME, WIFI_AP_PASSWORD);
  server.on("/", HTTP_GET, handleRoot);
  server.on("/api/state", HTTP_GET, sendState);
  server.on("/api/motor", HTTP_GET, handleMotorControl);
  server.on("/api/compressor", HTTP_GET, handleCompressorControl);
  server.onNotFound([]() { server.send(404, "text/plain", "Not found"); });
  server.begin();

  Serial.println("Fungi controller started.");
  Serial.print("Connect to Wi-Fi: ");
  Serial.println(WIFI_AP_NAME);
  Serial.println("Open http://192.168.4.1 in a phone browser.");
}

void loop() {
  const unsigned long now = millis();
  server.handleClient();
  readSensors(now);
  updateMotor(now);
  delay(1);
}

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.

Open in Schematik