Pistenraupe mit dem ESP32 und ESPAsyncWebServer

In diesem Beitrag geht es ausnahmsweise nicht um Modelleisenbahnen. Gemeinsam ist aber die Steuerung von Motoren über einen ESP32 Mikrocontroller und H-Brücken in Form integrierter Motortreiber sowie der ESPAsyncWebServer. Daher passt es hier hoffentlich gut rein. Gebaut wird ein per Web ferngesteuertes Fahrzeug mit Lenkung über den Antrieb, wie bei einer Pistenraupe, einem Panzer oder anderem Kettenfahrzeug oder aber auch bei einem Staubsauger-Roboter üblich.

Ausgangspunkt war, dass eine ferngesteuerte Spielzeug-Pistenraupe einen Defekt in der Elektronik hatte.

Die drei Motoren für beide Ketten und den Räumschild waren direkt an drei Motortreibern angeschlossen. Über eine Web-Suche nach dem Aufdruck ließ sich auch ein Datenblatt finden. Im Prinzip arbeiten diese genauso wie die Motortreiber, die ich in meinem Tutorial in Teil 3: Motorsteuerung mit PWM und H-Brücke vorgestellt habe.

Leider ließ sich die weitere Logik, die auch die Funksignale der Fernsteuerung verarbeitete, nicht genauer nachvollziehen, da der größere Chip unter Spannung sofort sehr heiß wurde.

L298N und ESPAsyncWebServer

Es drängte sich also die Idee auf, die komplette Original-Elektronik durch Komponenten aus meiner Modellbahnsteuerung zu ersetzen. Dazu bräuchte ich neben der Motorsteeurung aus Teil 4: Wir steuern einen Zug mit dem ESP32 noch die Websteuerung aus Teil 5: Mit ESPAsyncWebServer das Handy als Steuerpult, um das ganze Thema Fernbedienung und Empfang der Signale auszuklammern. In einem möglichst einfachen Testaufbau würde ich also wieder ein L298N Modul mit Spannungsregler nehmen und ein ESP32 auf einem D1 Mini Board nutzen, da dieses schon ein passendes Format hätte. Mit den beiden Kanälen des L298N sind dann beide Ketten unabhängig steuerbar.

L298N und ESP32 mit dem darauf laufenden ESPAsyncWebserver im Testaufbau
Erster Testaufbau auf meinem Schreibtisch

Der Sketch der Modelleisenbahn musste für sie Steuerung des Kettenantriebs noch in zwei Punkten angepasst werden:

  1. Duplizierung der Funktion set_common_pwm, um zwei Motoren unabhängig anzusteuern
  2. Umbau des Webinterfaces auf zwei vertikale Schieberegler und den entsprechenden Änderungen an der Datenübertragung

Der Sketch im Überblick

Zunächst ruft die Funktion setup() nach dem üblichen Start der seriellen Ausgabe und der Einrichtung von ledc für die PWM-Steuerung die Funktion WifiSetup auf, wo die WLAN-Verbindung hergestellt wird. Im Anschluss wird der Webserver durch Aufruf der Funktion WebserverSetup() eingerichtet.

void WebserverSetup() {
  // Route for root / web page
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send_P(200, "text/html", index_html, processor);
    Serial.println((String)"Web: index_html");
  });

  // Reply to GET request to <ESP_IP>/slider?value=<inputMessage>
  server.on("/slider", HTTP_GET, [] (AsyncWebServerRequest *request) {
    // GET input1 value on <ESP_IP>/slider?value=<inputMessage>
    if (request->hasParam(PARAM_LEFT)) {
      sliderLeft = request->getParam(PARAM_LEFT)->value();
    }
    if (request->hasParam(PARAM_RIGHT)) {
      sliderRight = request->getParam(PARAM_RIGHT)->value();
    }
    request->send(200, "text/plain", "OK");

    Serial.println((String)sliderLeft + " " + sliderRight);
    setLMotor();
    setRMotor();
    digitalWrite(LED, HIGH);
    delay(10);
    digitalWrite(LED, LOW);
  });

  // Start server
  server.begin();

  Serial.println((String)"Webserver started.");
}

Im ersten Teil wird eine Route zum UI mit den Schiebereglern gesetzt. Die zugehörige Webseite wurde mit PROGMEM in der Variable index_html hinterlegt.

Im zweiten Teil erfolgt die Reaktion auf Einstellungen der Schieberegler für die Ketten der Pistenraupe. Ein AJAX-Aufruf

xhr.open("GET", "/slider?left="+sliderLeft+"&right="+sliderRight, true);

auf das Unterverzeichnis /slider übermittelt jeweils die Werte beider Slider-Positionen. Biede Werte werden in den globalen Strings sliderLeft und sliderRight gepsichert. Dann werden über setLMotor() und setRMotor() beide Motoren auf dem neuen Wert angesteuert.

void setLMotor() {
  int pwmOutput = 0;
  byte pwmDirection = 0;  // +1 for FWD, -1 for BCK
  int sliderPosition = sliderLeft.toInt();

  //this is for compatibility with ESP32 Modellbahn Project, where physical potentiometer is supported
  int potiPosition = potiCenter + sliderPosition * 8;

  // SET MOTOR SPEED AND DIRECTION
  if (potiPosition > potiCenter - 200 && potiPosition < potiCenter + 200) {  
    // Neutral
    pwmOutput = 0;
    digitalWrite(LFwdPin, LOW);
    digitalWrite(LBckPin, LOW);
    //Serial.println("Left 0");
  }
  else {
    if (potiPosition > potiCenter) {
      // Forward
      pwmOutput = map(potiPosition, potiCenter, potiMax, minPwm , 255);
      pwmDirection = 1;
      //Serial.println("Left Forward");
      digitalWrite(LFwdPin, HIGH);
      digitalWrite(LBckPin, LOW);
    }
    else {
      // Back      
      pwmOutput = map(potiCenter - potiPosition, potiMin, potiCenter, minPwm , 255);
      pwmDirection = -1;
      //Serial.println("Left Back");
      digitalWrite(LFwdPin, LOW);
      digitalWrite(LBckPin, HIGH);
    }
  }
  Serial.println(pwmOutput);
  ledcWrite(0, pwmOutput);
}

Die Funktionsweise habe ich möglichst identisch zu der entsprechenden Funktion im Modellbahn-Tutorial belassen. Daher wird die Slider-Position mit Werten zwischen -255 und +255 auf den Rückgabe-Bereich des ADC aus der Messung eines Potentiometers mit 2048 Stufen je Richtung umgerechnet. In einem Bereich um die Mittelstellung stoppt der Motor. Außerhalb der Mittelstellung wird die virtuelle Poti-Position wieder auf einen Bereich mit 256 Stufen von 0 – 255 umgerechnet (map). Die IN-Pins des L298N werden dann entsprechend der Richtung gesetzt und am Ende Erfolgt die Anpassung des PWM-Signals über ledcWrite().

Analog erfolgt das auch für den anderen Motor auf einem anderen ledc-Kanal.

Die ereignisgesteuerte Funktionsweise über den ESPAsyncWebServer benötigt keinen weiteren Code in der loop() Funktion, die folglich leer bleibt.

Der Sketch im Ganzen

//Made for Wemos D1 Mini

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

// WLAN Zugangsdaten
const char* ssid = "mhfra1";
const char* password = "HennricH08";

// PINS
const int LEnPin = 27;
const int LBckPin = 25;
const int LFwdPin = 32;

const int RFwdPin = 12;
const int RBckPin = 4;
const int REnPin = 0;
const int LED = 2;

AsyncWebServer server(80);
//String header;  // Variable to store the HTTP request
//unsigned long currentTime = millis(); // Current time
//unsigned long previousTime = 0;  // Previous time
//const long timeoutTime = 2000;  // Define timeout time in milliseconds (example: 2000ms = 2s)

const int freq = 19531;
const int ledChannel = 0;
const int resolution = 8;

//this is for compatibility with ESP32 Modellbahn Project, where physical potentiometer is supported
const int potiCenter = 2048;
const int potiMax = 4095;
const int potiMin = 0;
const int minPwm = 100;

String sliderLeft = "0";
String sliderRight = "0";
const char* PARAM_LEFT = "left";
const char* PARAM_RIGHT = "right";

const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE HTML>
<html>
<head>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ESP32 Pistenraupe</title>
  <style>
    html {font-family: Arial; display: inline-block; text-align: center; color: lightgrey;}
    body {max-width: 400px; margin:0px auto; padding-bottom: 25px; background-color: #222222;}
    section {display: block; width: 95%%; margin-top: 30px; padding: 15px; background: #2A2A2A}
    h2 {font-size: 2.3rem; color: lightgrey;}
    p {font-size: 1.9rem; color: lightgrey;}

    .slider { -webkit-appearance: none; margin: 14px; width: 25px; height: 150px; background: #555555; outline: none; -webkit-transition: .2s; transition: opacity .2s; -webkit-appearance: slider-vertical;}
    .slider::-webkit-slider-thumb {-webkit-appearance: none; appearance: none; width: 35px; height: 35px; background: #003249; cursor: pointer;}
    .slider::-moz-range-thumb { width: 35px; height: 35px; background: #003249; cursor: pointer; }
    .button  {background-color: #555555; border: none; color: white; padding: 16px 40px; text-decoration: none; font-size: 30px; margin: 2px; cursor: pointer;}
    div.table {display: table; border-collapse:collapse;}
    div.tr {display:table-row;}
    div.td {display:table-cell; border:none; padding:5px;}
  </style>
</head>
<body>
  <h2>ESP32 Pistenraupe</h2>
  <section>
    <div class="table" style="margin: auto; width: 90%%">
      <div class="tr">
        <div class="td"><span id="textSliderLeft">%SLIDERLEFT%</span></div>
        <div class="td"><span id="textSliderRight">%SLIDERRIGHT%</span></div>
      </div>
      <div class="tr">
        <div class="td"><input type="range" orient="vertical" oninput="updateSliderPWM(this)" id="pwmSliderLeft" min="-255" max="255" step="5" value="0" class="slider"></div>
        <div class="td"><input type="range" orient="vertical" oninput="updateSliderPWM(this)" id="pwmSliderRight" min="-255" max="255" step="5" value="0" class="slider"></div>
      </div>
  </section>
  <section>
    <div class="td"><div style="text-align:center;"><input type="range" orient="vertical" oninput="updateSliderBoth(this)" id="pwmSliderBoth" min="-255" max="255" step="5" value="0" class="slider"></div></div>
  </section>
  <section>
    <p><button onclick="zeroSlider(this)" class="button buttonRed">STOP</button></p>
  </section>

<script>
function updateSliderBoth(element) {
  var sliderBoth = document.getElementById("pwmSliderBoth").value;
  document.getElementById("pwmSliderLeft").value = sliderBoth;
  document.getElementById("pwmSliderRight").value = sliderBoth;
  updateSliderPWM(this);
}

function updateSliderPWM(element) {
  var sliderLeft = document.getElementById("pwmSliderLeft").value;
  var sliderRight = document.getElementById("pwmSliderRight").value;
  document.getElementById("textSliderLeft").innerHTML = sliderLeft;
  document.getElementById("textSliderRight").innerHTML = sliderRight;
  console.log(sliderLeft);
  console.log(sliderLeft);
  var xhr = new XMLHttpRequest();
  xhr.open("GET", "/slider?left="+sliderLeft+"&right="+sliderRight, true);
  xhr.send();
}
 
function zeroSlider(element) {
  document.getElementById("pwmSliderLeft").value = 0;
  document.getElementById("pwmSliderRight").value = 0;
  document.getElementById("pwmSliderBoth").value = 0;
  updateSliderPWM(element);
}
</script>
</body>
</html>
)rawliteral";


void WifiSetup() {
  // Connect to Wi-Fi network with SSID and password
  Serial.print("Connecting Wifi to ");
  Serial.print(ssid);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println(""); 

  // Print local IP address
  Serial.println("WiFi connected.");
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());
  
  digitalWrite(LED, HIGH);
  delay(300);
  digitalWrite(LED, LOW);
  delay(300);
  digitalWrite(LED, HIGH);
  delay(300);
  digitalWrite(LED, LOW);
}

void WebserverSetup() {
  // Route for root / web page
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send_P(200, "text/html", index_html, processor);
    Serial.println((String)"Web: index_html");
  });

  // Reply to GET request to <ESP_IP>/slider?value=<inputMessage>
  server.on("/slider", HTTP_GET, [] (AsyncWebServerRequest *request) {
    // GET input1 value on <ESP_IP>/slider?value=<inputMessage>
    if (request->hasParam(PARAM_LEFT)) {
      sliderLeft = request->getParam(PARAM_LEFT)->value();
    }
    if (request->hasParam(PARAM_RIGHT)) {
      sliderRight = request->getParam(PARAM_RIGHT)->value();
    }
    request->send(200, "text/plain", "OK");

    Serial.println((String)sliderLeft + " " + sliderRight);
    setLMotor();
    setRMotor();
    digitalWrite(LED, HIGH);
    delay(10);
    digitalWrite(LED, LOW);
  });

  // Start server
  server.begin();

  Serial.println((String)"Webserver started.");
}

String processor(const String& var){
  // Replaces placeholder with button section in your web page
  //Serial.println(var);
  if (var == "SLIDERLEFT"){
    return sliderLeft;
  }
  if (var == "SLIDERRIGHT"){
    return sliderRight;
  }
  return String();
}

void setLMotor() {
  int pwmOutput = 0;
  byte pwmDirection = 0;  // +1 for FWD, -1 for BCK
  int sliderPosition = sliderLeft.toInt();

  //this is for compatibility with ESP32 Modellbahn Project, where physical potentiometer is supported
  int potiPosition = potiCenter + sliderPosition * 8;

  // SET MOTOR SPEED AND DIRECTION
  if (potiPosition > potiCenter - 200 && potiPosition < potiCenter + 200) {  
    // Neutral
    pwmOutput = 0;
    digitalWrite(LFwdPin, LOW);
    digitalWrite(LBckPin, LOW);
    //Serial.println("Left 0");
  }
  else {
    if (potiPosition > potiCenter) {
      // Forward
      pwmOutput = map(potiPosition, potiCenter, potiMax, minPwm , 255);
      pwmDirection = 1;
      //Serial.println("Left Forward");
      digitalWrite(LFwdPin, HIGH);
      digitalWrite(LBckPin, LOW);
    }
    else {
      // Back      
      pwmOutput = map(potiCenter - potiPosition, potiMin, potiCenter, minPwm , 255);
      pwmDirection = -1;
      //Serial.println("Left Back");
      digitalWrite(LFwdPin, LOW);
      digitalWrite(LBckPin, HIGH);
    }
  }
  Serial.println(pwmOutput);
  ledcWrite(0, pwmOutput);
}

void setRMotor() {
  int pwmOutput = 0;
  byte pwmDirection = 0;  // +1 for FWD, -1 for BCK
  int sliderPosition = sliderRight.toInt();
  
  //this is for compatibility with ESP32 Modellbahn Project, where physical potentiometer is supported
  int potiPosition = potiCenter + sliderPosition * 8;

  // SET MOTOR SPEED AND DIRECTION
  if (potiPosition > potiCenter - 200 && potiPosition < potiCenter + 200) {  
    // Neutral
    pwmOutput = minPwm;
    digitalWrite(RFwdPin, LOW);
    digitalWrite(RBckPin, LOW);
    //Serial.println("Right 0");
  }
  else {
    if (potiPosition > potiCenter) {
      // Forward
      pwmOutput = map(potiPosition, potiCenter, potiMax, minPwm , 255);
      pwmDirection = 1;
      //Serial.println("Right Forward");
      digitalWrite(RFwdPin, HIGH);
      digitalWrite(RBckPin, LOW);
    }
    else {
      // Back      
      pwmOutput = map(potiCenter - potiPosition, potiMin, potiCenter, minPwm , 255);
      pwmDirection = -1;
      //Serial.println("Right Back");
      digitalWrite(RFwdPin, LOW);
      digitalWrite(RBckPin, HIGH);
    }
  }
  ledcWrite(1, pwmOutput);
}

void setup() {
  // Start Serial
  Serial.begin(115200);
  delay(100);
  Serial.println("++++Neustart++++");

  pinMode(LED, OUTPUT);
  pinMode(LFwdPin, OUTPUT);
  pinMode(LBckPin, OUTPUT);
  pinMode(RFwdPin, OUTPUT);
  pinMode(RBckPin, OUTPUT);

  ledcSetup(0, freq, resolution);
  ledcAttachPin(LEnPin, 0);
  ledcSetup(1, freq, resolution);
  ledcAttachPin(REnPin, 1);

  WifiSetup();
  WebserverSetup();
}

void loop() {
  //empty
}

Für einen kompakteren Aufbau und bessere Leistung durch geringeren Spannungsabfall habe ich schließlich den L298N durch einen TB6612FNG ersetzt. Die konstante 5V Versorgungspannung wird durch einen LM317T Spannungsregler erzeugt, da der auf dem L298N Modul eingebaute Spannungsregler nicht mehr genutzt werden kann.

Offen ist derzeit noch die Ansteuerung des Räumschildes, wozu ich einen einfachen L293D geplant und unter dem ESP32 D1 mini versteckt habe. Dazu dann mehr in einem anderen Beitrag 😉

WordPress Cookie Plugin von Real Cookie Banner