feat: add ESP32 BTS7960 motor controller with web interface

- Implement MotorController class with PWM speed control (0-100%)
- Add bidirectional control (forward/reverse) via LEDC PWM at 20kHz
- Include optional current sensing and stall detection
- Create responsive web UI with speed slider and direction buttons
- Configure WiFi with static IP (10.81.2.185)
- Use built-in WebServer library for ESP32 Arduino 3.x compatibility
This commit is contained in:
devdesk
2026-02-05 15:08:47 +02:00
commit 6ccbc7faf5
15 changed files with 646 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
# Architect Mode Rules
## Project Structure
- PlatformIO standard structure: `src/main.cpp`, `include/`, `lib/`
- Consider separate motor control library in `lib/` for reusability
- BTS7960 can drive two motors independently - plan for dual motor control interface

View File

@@ -0,0 +1,7 @@
# Ask Mode Rules
## Documentation Context
- Main hardware reference: https://deepbluembedded.com/arduino-bts7960-dc-motor-driver/
- ESP32 LOLIN32 pinout differs from standard ESP32 DevKit - verify pin assignments
- BTS7960 datasheet needed for current sensing calculations

View File

@@ -0,0 +1,8 @@
# Code Mode Rules
## ESP32/PlatformIO Specifics
- Use `LEDC` peripheral for PWM on ESP32 (not analogWrite)
- BTS7960 PWM frequency: 1-25kHz optimal, 20kHz recommended to reduce motor noise
- Enable pins (R_EN, L_EN) must be HIGH before PWM signals work
- Current sense pins (R_IS, L_IS) output analog voltage proportional to motor current

View File

@@ -0,0 +1,7 @@
# Debug Mode Rules
## ESP32 Debugging
- Use `pio device monitor -b 115200` for serial debugging
- BTS7960 current sense pins can diagnose motor stall/overload conditions
- Check R_IS/L_IS for overcurrent (>43A causes fault)

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

10
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

31
AGENTS.md Normal file
View File

@@ -0,0 +1,31 @@
# AGENTS.md
This file provides guidance to agents when working with code in this repository.
## Project Overview
PlatformIO project for ESP32 LOLIN32 Rev1 controlling BTS7960 dual H-bridge motor driver (12V DC).
## Build Commands
```bash
pio run # Build
pio run -t upload # Build and upload
pio device monitor # Serial monitor (115200 baud)
```
## Hardware-Specific Notes
- **Board**: ESP32 LOLIN32 Rev1 (use `lolin32` in platformio.ini)
- **Motor Driver**: BTS7960 dual H-bridge - requires 4 GPIO pins (RPWM=25, LPWM=26, R_EN=27, L_EN=14)
- **Power**: 12V DC input to motor driver - logic level is 3.3V compatible with ESP32
- **WiFi**: Connects to SSID 'tami' with static IP 10.81.2.185
- **Reference**: https://deepbluembedded.com/arduino-bts7960-dc-motor-driver/
## Non-Obvious Notes
- Use built-in `WebServer` library (NOT ESPAsyncWebServer - has enum compatibility issues with ESP32 Arduino 3.x)
- WebServer requires `handleClient()` call in loop() - unlike async version
- LEDC PWM at 20kHz reduces motor noise
- Enable pins (R_EN, L_EN) must be HIGH before PWM works
- **BTS7960 is 3.3V compatible**: Inputs are TTL/CMOS compatible (VIL<0.8V, VIH>2.0V) - ESP32 GPIOs work directly without level shifters (per BTN7960 datasheet section 5.4.1)

42
include/config.h Normal file
View File

@@ -0,0 +1,42 @@
#ifndef CONFIG_H
#define CONFIG_H
// WiFi Configuration
#define WIFI_SSID "tami"
#define WIFI_PASSWORD ""
// Static IP Configuration
#define STATIC_IP IPAddress(10, 81, 2, 185)
#define GATEWAY IPAddress(10, 81, 2, 1)
#define SUBNET IPAddress(255, 255, 255, 0)
#define DNS IPAddress(10, 81, 2, 1)
// BTS7960 Pin Definitions
#define RPWM_PIN 25 // Right PWM (Forward)
#define LPWM_PIN 26 // Left PWM (Reverse)
#define R_EN_PIN 27 // Right Enable
#define L_EN_PIN 14 // Left Enable
#define R_IS_PIN 34 // Right Current Sense (ADC input only)
#define L_IS_PIN 35 // Left Current Sense (ADC input only)
// PWM Configuration
#define PWM_FREQ 20000 // 20kHz - reduces motor noise
#define PWM_RESOLUTION 8 // 8-bit resolution (0-255)
#define PWM_CHANNEL_R 0 // LEDC channel for RPWM
#define PWM_CHANNEL_L 1 // LEDC channel for LPWM
// Current Sense Configuration
// BTS7960 current sense ratio: 8500:1 (kilo-amps)
// With 1kΩ resistor on IS pin: V = I_motor / 8500
// ESP32 ADC: 12-bit (0-4095), 0-3.3V
#define CURRENT_SENSE_RATIO 8500.0f // Amps to sense current ratio
#define SENSE_RESISTOR 1000.0f // 1kΩ sense resistor (ohms)
#define ADC_MAX 4095.0f // 12-bit ADC max value
#define ADC_VREF 3.3f // ADC reference voltage
#define STALL_CURRENT_THRESHOLD 5.0f // Current (amps) that indicates stall
#define STALL_DETECT_TIME_MS 500 // Time to confirm stall (ms)
// Web Server
#define HTTP_PORT 80
#endif

41
include/motor.h Normal file
View File

@@ -0,0 +1,41 @@
#ifndef MOTOR_H
#define MOTOR_H
#include <Arduino.h>
#include "config.h"
class MotorController {
public:
void begin();
void setSpeed(int speed); // 0-100 percentage
void setDirection(int dir); // -1=reverse, 0=stop, 1=forward
void stop();
void update(); // Call in loop() for stall detection
int getSpeed();
int getDirection();
float getCurrentRight(); // Current in amps (forward direction)
float getCurrentLeft(); // Current in amps (reverse direction)
float getCurrentActive(); // Current from active direction
bool isStalled(); // True if stall detected
// Callback for stall events
void setStallCallback(void (*callback)(float current));
private:
int _speed = 0;
int _direction = 0;
float _currentRight = 0;
float _currentLeft = 0;
bool _stalled = false;
unsigned long _stallStartTime = 0;
void (*_stallCallback)(float current) = nullptr;
void applyMotorState();
float readCurrentSense(int pin);
void checkStall();
};
extern MotorController motor;
#endif

7
include/webserver.h Normal file
View File

@@ -0,0 +1,7 @@
#ifndef WEBSERVER_H
#define WEBSERVER_H
void setupWebServer();
void handleWebServer();
#endif

14
platformio.ini Normal file
View File

@@ -0,0 +1,14 @@
; PlatformIO Project Configuration File
; ESP32 LOLIN32 Rev1 with BTS7960 Motor Driver
[env:lolin32]
platform = espressif32
board = lolin32
framework = arduino
monitor_speed = 115200
; No external libraries needed - using built-in WebServer
; Build flags
build_flags =
-D CORE_DEBUG_LEVEL=0

8
readme.md Normal file
View File

@@ -0,0 +1,8 @@
### inital prompt
start platformio project.
using esp32 lolin32 rev1.
we will connect a dual h-bdrige module based on BTS7960 module.
12v dc input.
module guide - https://deepbluembedded.com/arduino-bts7960-dc-motor-driver/

62
src/main.cpp Normal file
View File

@@ -0,0 +1,62 @@
#include <Arduino.h>
#include <WiFi.h>
#include "config.h"
#include "motor.h"
#include "webserver.h"
void setupWiFi() {
Serial.println("Connecting to WiFi...");
// Configure static IP
if (!WiFi.config(STATIC_IP, GATEWAY, SUBNET, DNS)) {
Serial.println("Static IP configuration failed!");
}
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nWiFi connected!");
Serial.print("IP Address: ");
Serial.println(WiFi.localIP());
} else {
Serial.println("\nWiFi connection failed!");
Serial.println("Restarting in 5 seconds...");
delay(5000);
ESP.restart();
}
}
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n=============================");
Serial.println(" BTS7960 Motor Controller");
Serial.println("=============================\n");
// Initialize motor controller
motor.begin();
// Connect to WiFi
setupWiFi();
// Start web server
setupWebServer();
Serial.println("\nReady! Access the control panel at:");
Serial.print("http://");
Serial.println(WiFi.localIP());
}
void loop() {
handleWebServer();
motor.update(); // Update current sensing and stall detection
delay(1);
}

182
src/motor.cpp Normal file
View File

@@ -0,0 +1,182 @@
#include "motor.h"
// Set to true to enable current sensing (requires R_IS and L_IS connected)
#define CURRENT_SENSING_ENABLED false
MotorController motor;
void MotorController::begin() {
// Configure enable pins as outputs
pinMode(R_EN_PIN, OUTPUT);
pinMode(L_EN_PIN, OUTPUT);
// Enable the H-bridge
digitalWrite(R_EN_PIN, HIGH);
digitalWrite(L_EN_PIN, HIGH);
// Configure LEDC PWM channels for ESP32
ledcSetup(PWM_CHANNEL_R, PWM_FREQ, PWM_RESOLUTION);
ledcSetup(PWM_CHANNEL_L, PWM_FREQ, PWM_RESOLUTION);
// Attach PWM channels to GPIO pins
ledcAttachPin(RPWM_PIN, PWM_CHANNEL_R);
ledcAttachPin(LPWM_PIN, PWM_CHANNEL_L);
#if CURRENT_SENSING_ENABLED
// Configure current sense pins as analog inputs
// GPIO34 and GPIO35 are input-only pins, perfect for ADC
analogSetAttenuation(ADC_11db); // Full range 0-3.3V
pinMode(R_IS_PIN, INPUT);
pinMode(L_IS_PIN, INPUT);
Serial.println("Current sensing enabled");
#endif
// Start stopped
stop();
Serial.println("Motor controller initialized");
}
void MotorController::setSpeed(int speed) {
_speed = constrain(speed, 0, 100);
_stalled = false; // Reset stall state on new command
_stallStartTime = 0;
applyMotorState();
}
void MotorController::setDirection(int dir) {
_direction = constrain(dir, -1, 1);
_stalled = false; // Reset stall state on new command
_stallStartTime = 0;
applyMotorState();
}
void MotorController::stop() {
_speed = 0;
_direction = 0;
_stalled = false;
_stallStartTime = 0;
ledcWrite(PWM_CHANNEL_R, 0);
ledcWrite(PWM_CHANNEL_L, 0);
}
void MotorController::update() {
#if CURRENT_SENSING_ENABLED
// Read current sensors
_currentRight = readCurrentSense(R_IS_PIN);
_currentLeft = readCurrentSense(L_IS_PIN);
// Check for stall condition
checkStall();
#endif
}
int MotorController::getSpeed() {
return _speed;
}
int MotorController::getDirection() {
return _direction;
}
float MotorController::getCurrentRight() {
return _currentRight;
}
float MotorController::getCurrentLeft() {
return _currentLeft;
}
float MotorController::getCurrentActive() {
if (_direction > 0) {
return _currentRight;
} else if (_direction < 0) {
return _currentLeft;
}
return 0.0f;
}
bool MotorController::isStalled() {
return _stalled;
}
void MotorController::setStallCallback(void (*callback)(float current)) {
_stallCallback = callback;
}
void MotorController::applyMotorState() {
// Convert percentage to 8-bit PWM value
int pwmValue = map(_speed, 0, 100, 0, 255);
if (_direction == 0 || _speed == 0) {
// Stop - both PWM outputs off
ledcWrite(PWM_CHANNEL_R, 0);
ledcWrite(PWM_CHANNEL_L, 0);
} else if (_direction > 0) {
// Forward - RPWM active, LPWM off
ledcWrite(PWM_CHANNEL_R, pwmValue);
ledcWrite(PWM_CHANNEL_L, 0);
} else {
// Reverse - LPWM active, RPWM off
ledcWrite(PWM_CHANNEL_R, 0);
ledcWrite(PWM_CHANNEL_L, pwmValue);
}
Serial.printf("Motor: dir=%d, speed=%d%%, pwm=%d\n", _direction, _speed, pwmValue);
}
float MotorController::readCurrentSense(int pin) {
#if CURRENT_SENSING_ENABLED
// Read ADC value (12-bit, 0-4095)
int adcValue = analogRead(pin);
// Convert to voltage (0-3.3V)
float voltage = (adcValue / ADC_MAX) * ADC_VREF;
// Calculate current
// IS pin outputs: I_sense = I_load / CURRENT_SENSE_RATIO
// With sense resistor: V = I_sense * R_sense
// Therefore: I_load = V * CURRENT_SENSE_RATIO / R_sense
float current = (voltage * CURRENT_SENSE_RATIO) / SENSE_RESISTOR;
return current;
#else
return 0.0f;
#endif
}
void MotorController::checkStall() {
#if CURRENT_SENSING_ENABLED
// Only check stall when motor should be running
if (_direction == 0 || _speed == 0) {
_stalled = false;
_stallStartTime = 0;
return;
}
float activeCurrent = getCurrentActive();
// Check if current exceeds stall threshold
if (activeCurrent > STALL_CURRENT_THRESHOLD) {
if (_stallStartTime == 0) {
// Start timing potential stall
_stallStartTime = millis();
} else if ((millis() - _stallStartTime) > STALL_DETECT_TIME_MS) {
// Stall confirmed
if (!_stalled) {
_stalled = true;
Serial.printf("STALL DETECTED! Current: %.2fA\n", activeCurrent);
// Call callback if registered
if (_stallCallback != nullptr) {
_stallCallback(activeCurrent);
}
}
}
} else {
// Current normal, reset stall detection
_stallStartTime = 0;
_stalled = false;
}
#endif
}

215
src/webserver.cpp Normal file
View File

@@ -0,0 +1,215 @@
#include <Arduino.h>
#include <WebServer.h>
#include "motor.h"
#include "config.h"
WebServer server(HTTP_PORT);
// HTML page for motor control
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Motor Control</title>
<style>
* { box-sizing: border-box; }
body {
font-family: Arial, sans-serif;
background: #1a1a2e;
color: #eee;
margin: 0;
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 400px;
margin: 0 auto;
text-align: center;
}
h1 { color: #00d9ff; margin-bottom: 30px; }
.status {
background: #16213e;
padding: 15px;
border-radius: 10px;
margin-bottom: 20px;
}
.status span {
font-size: 24px;
font-weight: bold;
color: #00d9ff;
}
.slider-container {
background: #16213e;
padding: 20px;
border-radius: 10px;
margin-bottom: 20px;
}
.slider {
width: 100%;
height: 25px;
-webkit-appearance: none;
background: #0f3460;
border-radius: 12px;
outline: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 35px;
height: 35px;
background: #00d9ff;
border-radius: 50%;
cursor: pointer;
}
.buttons {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 20px 30px;
font-size: 18px;
border: none;
border-radius: 10px;
cursor: pointer;
transition: transform 0.1s, background 0.2s;
min-width: 100px;
}
.btn:active { transform: scale(0.95); }
.btn-forward { background: #00c853; color: white; }
.btn-reverse { background: #ff5252; color: white; }
.btn-stop { background: #ff9100; color: white; flex-basis: 100%; }
.btn:hover { filter: brightness(1.1); }
.speed-value {
font-size: 48px;
color: #00d9ff;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>Motor Control</h1>
<div class="status">
Direction: <span id="dirStatus">STOPPED</span>
</div>
<div class="slider-container">
<label>Speed</label>
<div class="speed-value"><span id="speedValue">0</span>%</div>
<input type="range" class="slider" id="speedSlider" min="0" max="100" value="0">
</div>
<div class="buttons">
<button class="btn btn-forward" onclick="setDir(1)">FORWARD</button>
<button class="btn btn-reverse" onclick="setDir(-1)">REVERSE</button>
<button class="btn btn-stop" onclick="stopMotor()">STOP</button>
</div>
</div>
<script>
const slider = document.getElementById('speedSlider');
const speedVal = document.getElementById('speedValue');
const dirStatus = document.getElementById('dirStatus');
let currentDir = 0;
slider.oninput = function() {
speedVal.textContent = this.value;
};
slider.onchange = function() {
fetch('/speed?value=' + this.value)
.then(r => r.text())
.then(console.log);
};
function setDir(dir) {
currentDir = dir;
fetch('/direction?value=' + dir)
.then(r => r.text())
.then(() => updateStatus());
}
function stopMotor() {
currentDir = 0;
slider.value = 0;
speedVal.textContent = '0';
fetch('/stop')
.then(r => r.text())
.then(() => updateStatus());
}
function updateStatus() {
if (currentDir > 0) dirStatus.textContent = 'FORWARD';
else if (currentDir < 0) dirStatus.textContent = 'REVERSE';
else dirStatus.textContent = 'STOPPED';
}
// Get initial state
fetch('/status')
.then(r => r.json())
.then(data => {
slider.value = data.speed;
speedVal.textContent = data.speed;
currentDir = data.direction;
updateStatus();
});
</script>
</body>
</html>
)rawliteral";
void handleRoot() {
server.send(200, "text/html", index_html);
}
void handleSpeed() {
if (server.hasArg("value")) {
int speed = server.arg("value").toInt();
motor.setSpeed(speed);
server.send(200, "text/plain", "OK");
} else {
server.send(400, "text/plain", "Missing value");
}
}
void handleDirection() {
if (server.hasArg("value")) {
int dir = server.arg("value").toInt();
motor.setDirection(dir);
server.send(200, "text/plain", "OK");
} else {
server.send(400, "text/plain", "Missing value");
}
}
void handleStop() {
motor.stop();
server.send(200, "text/plain", "OK");
}
void handleStatus() {
String json = "{\"speed\":" + String(motor.getSpeed()) +
",\"direction\":" + String(motor.getDirection()) +
",\"currentR\":" + String(motor.getCurrentRight(), 2) +
",\"currentL\":" + String(motor.getCurrentLeft(), 2) +
",\"stalled\":" + (motor.isStalled() ? "true" : "false") + "}";
server.send(200, "application/json", json);
}
void setupWebServer() {
server.on("/", handleRoot);
server.on("/speed", handleSpeed);
server.on("/direction", handleDirection);
server.on("/stop", handleStop);
server.on("/status", handleStatus);
server.begin();
Serial.println("Web server started on port 80");
}
void handleWebServer() {
server.handleClient();
}