Schüttelst du noch oder mischst du schon? Teil 2: Controller Programmieren, Webdesign

Arduino IDE für den Nagellackmixer

Computer sind immer nur so intelligent, wie der, der davor sitzt!

Im letzten Teil der Reihe haben wir für den Nagellackmixer das Grundkonzept umrissen und die elektronischen Teile zusammengebastelt. Mikrocontroller haben leider die Angewohnheit nicht zu wissen was man von ihnen will bis man es ihnen sagt, also steigen wir heute in die abenteuerliche Welt der Mikrocontroller-Programmierung ein!

Leistungsbeschreibung der Software

Folgende Aufgaben soll die Software auf dem Mikrocontroller erledigen

  • Verbinden zu einem voreingestellten WLAN
    • die Zugangsinformationen sollen nicht in der Hauptdatei stehen, um versehentliches Leaken in Screenshots zu verhindern (ich kenne mich…)
  • Bereitstellen eines Webservers inklusive Weboberfläche und REST Schnittstelle zum Verwalten des Mischers
  • Aktivieren und Steuern des Stepper Motors über den angeschlossenen Steppertreiber
  • Wenn der Mixer nicht läuft soll der Strom zum Steppermotor unterbrochen werden
  • Der Betrieb des Webservers darf das Timing des Motors nicht beeinträchtigen

Insbesondere die WLAN Anforderungen bedingen, dass ein Arduino Uno hier nicht in Frage kommt.

Der ESP32 – ein Arduino auf Crack

Der in diesem Projekt verwendete ESP32 (genauer: ESP32-WROOM-32) ist technisch ziemlich beeindruckend. Für eine kleine Hand voll Euro (weniger als bei einem offiziellen Arduino) bekommt man

  • Eingebautes WLAN (Arduino: Nö)
  • Eingebautes Bluetooth (Arduino: Nope)
  • Eine CPU mit 2 Kernen, 32-Bit und mindestens 160MHz (Arduino Uno: 1 Kern 8-Bit 8MHz)
  • 320kB RAM (Arduino Uno: 2kB)
  • ~4MB Flash Memory (Arduino Uno: 32kB)
  • Deutlich kleinerer Formfaktor
ESP32 und Arduino Uno haben nicht ganz die gleiche Größe…

Dann mal los!

Langsam, langsam! Man kann den ESP problemlos mit der relativ komfortablen und integrierten ArduinoIDE programmieren, allerdings erst, nachdem man das Board über dieses Tutorial hinzugefügt hat. Anschließend das Board wählen was am ehesten dem gekauften entspricht und ab dafür!

Der ESP32 – der zweiköpfige Oger

Da der Controller zwei Prozessorkerne hat (Core 0 und Core 1), Arduinocode aber standardmäßig nur auf Core 1 läuft kümmern wir uns zunächst mal darum, dass wir beide Cores nutzen können. Für Core 1 nutzen wir hier einfach die Arduino loop() Funktion, für Core0 legen wir eine eigene Funktion “runOtherThread” an.

// Handle for controlling the task on Core0.
TaskHandle_t task1;

/** 
 *  This method gets executed on core0.
 */
void runOtherThread(void * parameter) {
  for (;;) {
    handleWebServer();
    delay(20); // give the core time to report back to the watchdog
  }
}

// always runs on core 1
void loop() {
...
}

void setup() {
  (...)
  xTaskCreatePinnedToCore(
    runOtherThread, /* Function to implement the task */
    "otherThread", /* Name of the task */
    50000,  /* Stack size in words */
    NULL,  /* Task input parameter */
    0,  /* Priority of the task */
    &task1,  /* Task handle. */
    0); /* Core where the task should run */
}

Hinweis: Der ESP32 hat einen Watchdog, der den Controller neustartet wenn ein Core nicht regelmäßig Pause macht. Plane daher immer delays in länger laufenden Schleifen ein! (Hier in runOtherThread 20ms pro Durchlauf). loop() hat diese Funktion automatisch zwischen Aufrufen eingebaut, hier muss man nur drauf achten, dass ein Durchlauf von loop() nicht zu lange braucht (optimalerweise <1 s, delay() calls nicht eingerechnet).

Motorsteuerung

Die Motorsteuerung hat eine sehr einfache Logik: Solange ein eingestellter Countdown nicht auf 0 ist, rotiere 1 Step pro Millisekunde (2.3°). Die Drehrichtung ändert sich dabei alle 10.000 Ticks. Da wir pro Tick die Step-Leitung einmal Low->High und anschließend wieder High->Low setzen bedeutet das kontraintuitiverweise 20.000 Steps pro Richtungswechsel, da der Steppertreiber für beide Spannungswechsel einmal “steppt”.
Während eines Richtungswechsels wartet der Sketch 10ms um dem Motor etwas Zeit zu geben anzuhalten. Das reduziert den Stromfluss massiv und sorgt für weniger Krach beim Richtungswechsel.

Ist der Countdown erreicht drehen wir noch für 1200ms deutlich langsamer (200 Steps/ Sekunde ggü. 2000 Steps/Sekunde). Mit dieser langsameren Drehgeschwindigkeit ist der Motor deutlich lauter, so dass wir das als akustisches Signal dafür verwenden können, dass der Mixer fertig ist.

/** This method handles all the Stepper management.
    It decides which direction to turn and switches rotation direction every ~20 seconds.
*/
void handleStepper() {
  if (countdown > 0) {
    countdown--;
    // This is called every cycle but doesn't really hurt that much. Enables Power to the motors.
    digitalWrite(PIN_ENABLE, LOW);
    if (countUp) {
      flipCounter++;
    } else {
      flipCounter--;
    }
    if (flipCounter > 10000) {
      delay(10);
      countUp = false;
      // reverse direction, start counting down.
      digitalWrite(PIN_DIR, LOW);
    } else if (flipCounter < 0) {
      delay(10);
      countUp = true;
      digitalWrite(PIN_DIR, HIGH); 
    }
    digitalWrite(PIN_STEP, HIGH);
    delay(1);
    digitalWrite(PIN_STEP, LOW);
    delay(1);
  } else {
    delay(2);
    if (once) {
      once = false;
      signalEnd();
    }
    //cut power to the motors - saves power, cools the motor and allows for free-hand rotation.
    digitalWrite(PIN_ENABLE, HIGH);
  }
}
void signalEnd() {
  for (int i = 0; i < 60; i++) {
    digitalWrite(PIN_STEP, HIGH);
    digitalWrite(PIN_DIR, HIGH);
    delay(10);
    digitalWrite(PIN_STEP, LOW);
    digitalWrite(PIN_DIR, LOW);
    delay(10);
  }
}

Hinweis: Wann immer möglich sollte PIN_ENABLE auf High gesetzt werden. Damit wird der Strom zum Motor unterbrochen, was den Motor, den Steppertreiber und die Stromrechnung schont, weniger Hitze erzeugt und erlaubt, den Motor von Hand zu drehen!

Internet-of-Shit: Make it shittier!

Ich bin Informatiker. Als solcher weiß ich, dass IoT Geräte in der Regel furchtbar sind und gerne für Hackangriffe genutzt werden. Warum mach ich dann selber eins? Haha, ganz einfach: Es ist lustig. Außerdem ist mein IoT Gerät dermaßen dumm, dass man als Hacker nicht viel mit ihm anfangen kann (außer Nagellack zu mischen 🤷️) Insbesondere da ich Updates nicht OTA (over-the-air) einspiele und damit auch die Firmware nicht angreifbar ist sollten wir hier sicher sein.

Die Weboberfläche hat folgende Anforderungen:

  • Muss auf dem µC laufen
  • Muss eine Eingabe der Laufzeit in Minuten ermöglichen
  • Muss den Motor außerplanmäßig stoppen können
  • Muss die verbleibende Laufzeit anzeigen
Das Frontend könnte einfacher nicht sein

Zwar kann man den ESP32 als Webserver benutzen, allerdings könnte es kaum lowleveliger sein – wir parsen den eingehenden Datenstrom und implementieren einen kleinen Teil des HTTP Protokolls von Hand 🤯️
Auch halten wir die Antwort (incl. CSS und JavaScript) als char[] Array vor und interpretieren alles innerhalb einer Methode – für mich als großen Freund von “Separation of Concerns” Ansätzen tatsächlich schmerzhaft (aber effizient).

Effektiv funktioniert der “Webserver” wie folgt: Lausche auf eingehende Requests, nachdem zwei Newlines gekommen sind (Ende der Header) suchen wir nach dem GET Header und lesen die URL aus. Basierend auf der URL wird dann entschieden, wie mit dem Request umgegangen wird:

  • Zurücksenden der Webseite
  • Zurücksenden des Textes für die verbleibende Zeit

Da die Response Header vollständig statisch sind bekommt man von diesem Webserver grundsätzlich HTTP 200/Ok als Antwort zurück, egal was für einen Unfug man schickt. Für unseren Usecase ist das aber ok.

const char index_html[] PROGMEM = R"rawliteral(
...)rawliteral";
void handleWebServer() {
  WiFiClient client = server.available();   // Listen for incoming clients

  if (client) {                             // If a new client connects,
    currentTime = millis();
    previousTime = currentTime;
    String currentLine = "";                // make a String to hold incoming data from the client
    while (client.connected() && currentTime - previousTime <= TIMEOUT_MILLIS) {  // loop while the client's connected
      currentTime = millis();
      if (client.available()) {             // if there's bytes to read from the client,
        char c = client.read();             // read a byte, then
        header += c;
        if (c == '\n') {                    // if the byte is a newline character
          // if the current line is blank, you got two newline characters in a row.
          // that's the end of the client HTTP request, so send a response:
          if (currentLine.length() == 0) {
            // HTTP headers always start with a response code (e.g. HTTP/1.1 200 OK)
            // and a content-type so the client knows what's coming, then a blank line:
            client.println("HTTP/1.1 200 OK");
            client.println("Content-type:text/html");
            client.println("Connection:close");
            client.println();
            String value;
            // turns the GPIOs on and off
            if (header.indexOf("GET /set") >= 0) {
              // API Call intercepted
              int startPos = header.indexOf("duration=");
              if (startPos >= 0) {
                // received an update
                value = header.substring(startPos + 9, header.indexOf(" ", startPos));
                if (serialEnabled) Serial.println("Setting rotation for " + value + " minutes!");
                setCountdown(value.toInt() * 60);
                if (serialEnabled) Serial.println("After setting Countdown Value");
              } else {
                // Just do nothing, if we are currently working we are currently working.
              }
              client.println(index_html);
            } else if (header.indexOf("GET /remaining") >= 0) {
              // We are asked for remaining time
              if (countdown > 0) {
                int remainingSeconds = countdown / 500;
                int remainingMinutes = remainingSeconds / 60;
                remainingSeconds = remainingSeconds - (remainingMinutes * 60);
                client.print("Verbleibende Zeit: ");
                if (remainingMinutes < 10) {
                  client.print("0");
                }
                client.print(remainingMinutes);
                client.print(":");
                if (remainingSeconds < 10) {
                  client.print("0");
                }
                client.print(remainingSeconds);
              } else {
                client.println("Mixer aktuell nicht in Betrieb.");
              }
            } else {
              client.println(index_html);
            }

            // The HTTP response ends with another blank line
            client.println();
            // Break out of the while loop
            break;
          } else { // if you got a newline, then clear currentLine
            currentLine = "";
          }
        } else if (c != '\r') {  // if you got anything else but a carriage return character,
          currentLine += c;      // add it to the end of the currentLine
        }
      }
    }
    header = "";
    client.stop();
  }
}

Bonus: WLAN Zugangsdaten per Header einbinden

Ich habe in der Vergangenheit bereits Software-Entwicklung gestreamt und dabei versehentlich Passwörter und ähnliches veröffentlicht, die eigentlich geheimgehalten werden sollten. Um dem vorzubeugen habe ich dieses Mal von anfang an die schützenswerten Daten in eine separate Datei geschrieben. Hierfür kann man ganz einfach im Projektverzeichnis (wo die .ino Datei liegt) eine Datei mit dem Namen (z.B.) “Credentials.h” anlegen und diese anschließend über Sketch -> Add File… hinzufügen.

Extra Datei, damit die geheimen Daten geheim bleiben.
Bitte nicht hingucken!

Anschließend können diese Daten in der Hauptdatei verwendet werden ohne dass man sie sehen kann

#include "Credentials.h"
#include <WiFi.h>

void setup() {
(...)
  String hostname = MY_HOSTNAME;
  WiFi.setHostname(hostname.c_str());
  WiFi.begin(MY_SSID, MY_PASSWORD);
(...)
}

Schlusswort

In diesem Teil haben wir die “Intelligenz” des Lackmischers entworfen. Grundsätzlich kann jetzt schon Lack gemischt werden, ohne Gehäuse muss aber der Motor von Hand gehalten werden. Das können wir besser. im dritten und letzten Teil bauen wir deshalb noch ein schickes Gehäuse!