- Add STALL_THRESHOLD (8A) and STALL_CONFIRM_SAMPLES (3) config - Add 500ms stabilization delay after direction change to prevent false positives - Add stall warning banner in web UI - Stop motor and pingpong when stall detected Algorithm: If active current > 8A for 3 consecutive samples (300ms), and motor has been running for at least 500ms, trigger stall callback.
466 lines
16 KiB
C++
466 lines
16 KiB
C++
#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; }
|
|
h2 { color: #00d9ff; margin: 20px 0 15px 0; font-size: 20px; }
|
|
.status {
|
|
background: #16213e;
|
|
padding: 15px;
|
|
border-radius: 10px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.status span {
|
|
font-size: 24px;
|
|
font-weight: bold;
|
|
color: #00d9ff;
|
|
}
|
|
.current-display {
|
|
background: #16213e;
|
|
padding: 15px;
|
|
border-radius: 10px;
|
|
margin-bottom: 20px;
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 10px;
|
|
}
|
|
.current-value {
|
|
font-size: 28px;
|
|
font-weight: bold;
|
|
color: #00d9ff;
|
|
}
|
|
.current-label {
|
|
font-size: 12px;
|
|
color: #888;
|
|
}
|
|
.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;
|
|
}
|
|
.section-divider {
|
|
border-top: 2px solid #0f3460;
|
|
margin: 30px 0;
|
|
}
|
|
.stall-warning {
|
|
background: #ff5252;
|
|
color: white;
|
|
padding: 15px;
|
|
border-radius: 10px;
|
|
margin-bottom: 20px;
|
|
font-weight: bold;
|
|
font-size: 18px;
|
|
display: none;
|
|
}
|
|
.stall-warning.active {
|
|
display: block;
|
|
}
|
|
.pingpong-section {
|
|
background: #16213e;
|
|
padding: 20px;
|
|
border-radius: 10px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.pingpong-status {
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
margin-bottom: 15px;
|
|
font-weight: bold;
|
|
}
|
|
.pingpong-status.active {
|
|
background: #00c853;
|
|
color: white;
|
|
}
|
|
.pingpong-status.inactive {
|
|
background: #444;
|
|
color: #aaa;
|
|
}
|
|
.setting-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
}
|
|
.setting-row label {
|
|
text-align: left;
|
|
flex: 1;
|
|
}
|
|
.setting-row .value {
|
|
color: #00d9ff;
|
|
font-weight: bold;
|
|
min-width: 60px;
|
|
}
|
|
.setting-slider {
|
|
width: 100%;
|
|
height: 20px;
|
|
-webkit-appearance: none;
|
|
background: #0f3460;
|
|
border-radius: 10px;
|
|
outline: none;
|
|
margin-top: 5px;
|
|
}
|
|
.setting-slider::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
width: 25px;
|
|
height: 25px;
|
|
background: #00d9ff;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
}
|
|
.btn-pingpong-start { background: #9c27b0; color: white; }
|
|
.btn-pingpong-stop { background: #607d8b; color: white; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>Motor Control</h1>
|
|
|
|
<div class="stall-warning" id="stallWarning">⚠️ STALL DETECTED!</div>
|
|
|
|
<div class="current-display">
|
|
<div>
|
|
<div class="current-label">CURRENT R</div>
|
|
<div class="current-value"><span id="currentR">0.00</span>A</div>
|
|
</div>
|
|
<div>
|
|
<div class="current-label">CURRENT L</div>
|
|
<div class="current-value"><span id="currentL">0.00</span>A</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="status">
|
|
Direction: <span id="dirStatus">STOPPED</span>
|
|
</div>
|
|
|
|
<div class="slider-container">
|
|
<label>Speed</label>
|
|
<div class="speed-value"><span id="speedValue">20</span>%</div>
|
|
<input type="range" class="slider" id="speedSlider" min="20" max="100" value="20">
|
|
</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 class="section-divider"></div>
|
|
|
|
<h2>🏓 Pingpong Mode</h2>
|
|
|
|
<div class="pingpong-section">
|
|
<div class="pingpong-status inactive" id="pingpongStatus">INACTIVE</div>
|
|
|
|
<div class="setting-row">
|
|
<label>Speed</label>
|
|
<span class="value"><span id="ppSpeedVal">50</span>%</span>
|
|
</div>
|
|
<input type="range" class="setting-slider" id="ppSpeed" min="20" max="100" value="50">
|
|
|
|
<div class="setting-row" style="margin-top:15px;">
|
|
<label>Time before return</label>
|
|
<span class="value"><span id="ppTimeVal">2.0</span>s</span>
|
|
</div>
|
|
<input type="range" class="setting-slider" id="ppTime" min="500" max="10000" value="2000" step="100">
|
|
|
|
<div class="setting-row" style="margin-top:15px;">
|
|
<label>Speed randomness</label>
|
|
<span class="value"><span id="ppSpeedRandVal">0</span>%</span>
|
|
</div>
|
|
<input type="range" class="setting-slider" id="ppSpeedRand" min="0" max="50" value="0">
|
|
|
|
<div class="setting-row" style="margin-top:15px;">
|
|
<label>Time randomness</label>
|
|
<span class="value"><span id="ppTimeRandVal">0</span>%</span>
|
|
</div>
|
|
<input type="range" class="setting-slider" id="ppTimeRand" min="0" max="50" value="0">
|
|
</div>
|
|
|
|
<div class="buttons">
|
|
<button class="btn btn-pingpong-start" onclick="startPingpong()">START PINGPONG</button>
|
|
<button class="btn btn-pingpong-stop" onclick="stopPingpong()">STOP PINGPONG</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const slider = document.getElementById('speedSlider');
|
|
const speedVal = document.getElementById('speedValue');
|
|
const dirStatus = document.getElementById('dirStatus');
|
|
let currentDir = 0;
|
|
|
|
// Pingpong controls
|
|
const ppSpeed = document.getElementById('ppSpeed');
|
|
const ppTime = document.getElementById('ppTime');
|
|
const ppSpeedRand = document.getElementById('ppSpeedRand');
|
|
const ppTimeRand = document.getElementById('ppTimeRand');
|
|
const ppSpeedVal = document.getElementById('ppSpeedVal');
|
|
const ppTimeVal = document.getElementById('ppTimeVal');
|
|
const ppSpeedRandVal = document.getElementById('ppSpeedRandVal');
|
|
const ppTimeRandVal = document.getElementById('ppTimeRandVal');
|
|
const ppStatus = document.getElementById('pingpongStatus');
|
|
|
|
slider.oninput = function() {
|
|
speedVal.textContent = this.value;
|
|
};
|
|
|
|
slider.onchange = function() {
|
|
fetch('/speed?value=' + this.value)
|
|
.then(r => r.text())
|
|
.then(console.log);
|
|
};
|
|
|
|
// Pingpong slider handlers
|
|
ppSpeed.oninput = function() { ppSpeedVal.textContent = this.value; };
|
|
ppTime.oninput = function() { ppTimeVal.textContent = (this.value / 1000).toFixed(1); };
|
|
ppSpeedRand.oninput = function() { ppSpeedRandVal.textContent = this.value; };
|
|
ppTimeRand.oninput = function() { ppTimeRandVal.textContent = this.value; };
|
|
|
|
function setDir(dir) {
|
|
currentDir = dir;
|
|
fetch('/direction?value=' + dir)
|
|
.then(r => r.text())
|
|
.then(() => updateStatus());
|
|
}
|
|
|
|
function stopMotor() {
|
|
currentDir = 0;
|
|
// Don't reset slider - keep last speed
|
|
fetch('/stop')
|
|
.then(r => r.text())
|
|
.then(() => updateStatus());
|
|
}
|
|
|
|
function startPingpong() {
|
|
const params = new URLSearchParams({
|
|
speed: ppSpeed.value,
|
|
time: ppTime.value,
|
|
speedRand: ppSpeedRand.value,
|
|
timeRand: ppTimeRand.value
|
|
});
|
|
fetch('/pingpong/start?' + params)
|
|
.then(r => r.text())
|
|
.then(console.log);
|
|
}
|
|
|
|
function stopPingpong() {
|
|
fetch('/pingpong/stop')
|
|
.then(r => r.text())
|
|
.then(console.log);
|
|
}
|
|
|
|
function updateStatus() {
|
|
if (currentDir > 0) dirStatus.textContent = 'FORWARD';
|
|
else if (currentDir < 0) dirStatus.textContent = 'REVERSE';
|
|
else dirStatus.textContent = 'STOPPED';
|
|
}
|
|
|
|
function pollStatus() {
|
|
fetch('/status')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
// Only update speed display if motor is running (direction != 0)
|
|
// When stopped, keep the last speed setting
|
|
if (data.direction != 0) {
|
|
slider.value = data.speed;
|
|
speedVal.textContent = data.speed;
|
|
}
|
|
currentDir = data.direction;
|
|
updateStatus();
|
|
|
|
// Update current display
|
|
document.getElementById('currentR').textContent = data.currentR.toFixed(2);
|
|
document.getElementById('currentL').textContent = data.currentL.toFixed(2);
|
|
|
|
// Update stall warning
|
|
const stallWarning = document.getElementById('stallWarning');
|
|
if (data.stalled) {
|
|
stallWarning.classList.add('active');
|
|
} else {
|
|
stallWarning.classList.remove('active');
|
|
}
|
|
|
|
// Update pingpong status
|
|
if (data.pingpong) {
|
|
ppStatus.textContent = 'ACTIVE';
|
|
ppStatus.classList.remove('inactive');
|
|
ppStatus.classList.add('active');
|
|
// Update sliders to match current settings
|
|
ppSpeed.value = data.ppSpeed;
|
|
ppSpeedVal.textContent = data.ppSpeed;
|
|
ppTime.value = data.ppTime;
|
|
ppTimeVal.textContent = (data.ppTime / 1000).toFixed(1);
|
|
ppSpeedRand.value = data.ppSpeedRand;
|
|
ppSpeedRandVal.textContent = data.ppSpeedRand;
|
|
ppTimeRand.value = data.ppTimeRand;
|
|
ppTimeRandVal.textContent = data.ppTimeRand;
|
|
} else {
|
|
ppStatus.textContent = 'INACTIVE';
|
|
ppStatus.classList.remove('active');
|
|
ppStatus.classList.add('inactive');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Poll status every 500ms
|
|
pollStatus();
|
|
setInterval(pollStatus, 500);
|
|
</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") +
|
|
",\"pingpong\":" + (motor.isPingpongActive() ? "true" : "false") +
|
|
",\"ppSpeed\":" + String(motor.getPingpongSpeed()) +
|
|
",\"ppTime\":" + String(motor.getPingpongTime()) +
|
|
",\"ppSpeedRand\":" + String(motor.getPingpongSpeedRandom()) +
|
|
",\"ppTimeRand\":" + String(motor.getPingpongTimeRandom()) + "}";
|
|
server.send(200, "application/json", json);
|
|
}
|
|
|
|
void handlePingpongStart() {
|
|
int speed = 50;
|
|
int time = 2000;
|
|
int speedRand = 0;
|
|
int timeRand = 0;
|
|
|
|
if (server.hasArg("speed")) {
|
|
speed = server.arg("speed").toInt();
|
|
}
|
|
if (server.hasArg("time")) {
|
|
time = server.arg("time").toInt();
|
|
}
|
|
if (server.hasArg("speedRand")) {
|
|
speedRand = server.arg("speedRand").toInt();
|
|
}
|
|
if (server.hasArg("timeRand")) {
|
|
timeRand = server.arg("timeRand").toInt();
|
|
}
|
|
|
|
motor.startPingpong(speed, time, speedRand, timeRand);
|
|
server.send(200, "text/plain", "OK");
|
|
}
|
|
|
|
void handlePingpongStop() {
|
|
motor.stopPingpong();
|
|
server.send(200, "text/plain", "OK");
|
|
}
|
|
|
|
void setupWebServer() {
|
|
server.on("/", handleRoot);
|
|
server.on("/speed", handleSpeed);
|
|
server.on("/direction", handleDirection);
|
|
server.on("/stop", handleStop);
|
|
server.on("/status", handleStatus);
|
|
server.on("/pingpong/start", handlePingpongStart);
|
|
server.on("/pingpong/stop", handlePingpongStop);
|
|
|
|
server.begin();
|
|
Serial.println("Web server started on port 80");
|
|
}
|
|
|
|
void handleWebServer() {
|
|
server.handleClient();
|
|
}
|