Files
walker_control/src/webserver.cpp
devdesk dffb859826 feat: add simple stall detection with threshold + debounce
- 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.
2026-02-05 21:29:38 +02:00

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();
}