Arduino mit Python auslesen: pySerial, Live-Plot & CSV-Datenlogging

Der serielle Monitor der Arduino IDE kann eines: Zahlen anzeigen. Sobald du sie speichern, auswerten, plotten oder weiterverarbeiten willst, endet er. Der Ausweg ist überraschend kurz – mit Python und pySerial holst du dieselben Daten in vier Zeilen auf deinen Rechner und machst daraus eine CSV-Datei, ein Live-Diagramm oder eine komplette Steuerung. Dieses Tutorial geht den ganzen Weg: vom ersten readline() bis zum bidirektionalen Protokoll, das auch nach acht Stunden Dauerbetrieb noch sauber läuft.

Was zwischen Arduino und Rechner wirklich passiert

Auf einem Nano V3 oder UNO R3 sitzt ein zusätzlicher Chip – meist ein CH340 – der die serielle Schnittstelle des Mikrocontrollers in USB übersetzt. Am Rechner erscheint dadurch ein virtueller COM-Port. Aus Sicht von Python ist das eine ganz normale Datei: öffnen, zeilenweise lesen, schließen.

Beide Seiten müssen sich nur über drei Dinge einig sein:

Einstellung Arduino-Seite Python-Seite Wichtig
Baudrate Serial.begin(115200) baudrate=115200 muss exakt übereinstimmen
Zeilenende Serial.println() readline() println sendet \r\n
Kodierung ASCII decode('utf-8') keine Umlaute senden

⚠️ Die wichtigste Falle überhaupt: der Auto-Reset. Beim Öffnen des Ports zieht der USB-Chip die DTR-Leitung kurz auf LOW – und das löst auf UNO und Nano einen Reset aus. Der Sketch startet also genau in dem Moment neu, in dem Python zu lesen beginnt. Die ersten ein bis zwei Sekunden kommt deshalb Unsinn oder gar nichts. Lösung: nach dem Öffnen time.sleep(2) und danach reset_input_buffer(). Auf einem ESP32 ist das Verhalten ähnlich, dort wird zusätzlich der Bootloader-Text ausgegeben.

Das brauchst du

Nano V3 (ATmega328, USB-C) oder UNO R3 – beide funktionieren identisch
AM2302 (DHT22) – Temperatur und Luftfeuchtigkeit als Datenquelle
BMP280 (I2C) – schneller als der DHT22, ideal für höhere Abtastraten
GY-521 MPU-6050 – sechs Kanäle gleichzeitig, perfekt zum Testen der Bandbreite
Kapazitiver Bodenfeuchte-Sensor – für Langzeitmessungen mit CSV-Log
Relais-Modul KY-019 – damit Python auch etwas schalten kann
3 mm LED-Sortiment – einfachste Rückmeldung für gesendete Befehle

Auf der Software-Seite reicht Python 3.9 oder neuer. Die Pakete installierst du in einem Rutsch:

pip install pyserial matplotlib pandas

1Verkabelung

Bauteil Pin am Modul Nano / UNO Hinweis
DHT22 DATA D2 10 kΩ Pull-Up nach 5V
BMP280 SDA / SCL A4 / A5 I2C, Adresse 0x76
MPU-6050 SDA / SCL A4 / A5 gleicher Bus, Adresse 0x68
Bodenfeuchte AOUT A0 analog, 0–1023
Relais KY-019 SIG D7 VCC auf 5V
Status-LED Anode D8 über 220 Ω nach GND

2Arduino-Seite: sauber formatierte Zeilen senden

Der wichtigste Unterschied zum üblichen Debug-Output: keine erklärenden Texte. „Temperatur: 22.5 Grad“ ist für Menschen schön, für Python nur Arbeit. Sende stattdessen reine Datenzeilen mit festem Trennzeichen:

#include <DHT.h>

#define DHT_PIN     2
#define DHT_TYP     DHT22
#define FEUCHTE_PIN A0
#define RELAIS_PIN  7
#define LED_PIN     8

DHT dht(DHT_PIN, DHT_TYP);

unsigned long letzteMessung = 0;
const unsigned long INTERVALL = 2000;   // DHT22 braucht 2 s Pause
bool relaisAn = false;

void setup() {
  Serial.begin(115200);
  pinMode(RELAIS_PIN, OUTPUT);
  pinMode(LED_PIN, OUTPUT);
  dht.begin();

  // Kopfzeile - Python erkennt sie am fuehrenden Doppelkreuz
  Serial.println(F("#ms,temperatur,feuchte,boden,relais"));
}

void loop() {
  if (millis() - letzteMessung < INTERVALL) return;
  letzteMessung = millis();

  float temp   = dht.readTemperature();
  float feucht = dht.readHumidity();
  int   boden  = analogRead(FEUCHTE_PIN);

  // ungueltige Messungen als leeres Feld senden, nicht als nan
  Serial.print(millis());          Serial.print(',');
  if (isnan(temp))   Serial.print("");   else Serial.print(temp, 2);
  Serial.print(',');
  if (isnan(feucht)) Serial.print("");   else Serial.print(feucht, 2);
  Serial.print(',');
  Serial.print(boden);             Serial.print(',');
  Serial.println(relaisAn ? 1 : 0);
}

💡 Warum CSV und nicht JSON? Auf einem ATmega328 mit 2 KB RAM ist JSON teuer – die Bibliothek allein frisst einen guten Teil des Speichers. CSV kostet nichts, lässt sich in Excel direkt öffnen und ist mit split(',') in einer Zeile geparst. Auf einem ESP32 mit 320 KB RAM sieht die Rechnung anders aus, dort ist JSON die bequemere Wahl.

3Python-Seite: Port finden und lesen

Statt den Port hart einzutragen – unter Windows COM3, unter Linux /dev/ttyUSB0, unter macOS /dev/cu.usbserial-1420 – lässt du ihn suchen. pySerial bringt dafür alles mit:

import time
import serial
import serial.tools.list_ports

BAUDRATE = 115200

# Typische USB-Seriell-Chips auf Arduino-kompatiblen Boards
BEKANNTE_CHIPS = ("CH340", "CH9102", "CP210", "FTDI", "Arduino", "USB-SERIAL", "wchusbserial")


def finde_port():
    """Sucht den ersten Port, der nach einem Arduino aussieht."""
    kandidaten = list(serial.tools.list_ports.comports())

    for p in kandidaten:
        text = f"{p.description} {p.manufacturer} {p.device}"
        if any(chip.lower() in text.lower() for chip in BEKANNTE_CHIPS):
            print(f"Gefunden: {p.device}  ({p.description})")
            return p.device

    if kandidaten:
        print("Kein eindeutiger Treffer. Verfuegbare Ports:")
        for p in kandidaten:
            print(f"   {p.device}  -  {p.description}")
    else:
        print("Keine seriellen Ports gefunden.")
    return None


def oeffne_verbindung(port=None):
    port = port or finde_port()
    if port is None:
        raise RuntimeError("Kein Arduino gefunden")

    ser = serial.Serial(port, BAUDRATE, timeout=1)

    # Auto-Reset abwarten - ohne das kommt nur Muell
    time.sleep(2.0)
    ser.reset_input_buffer()
    print("Verbindung bereit")
    return ser


if __name__ == "__main__":
    ser = oeffne_verbindung()

    try:
        while True:
            roh = ser.readline()
            if not roh:
                continue                       # Timeout, kein Fehler

            zeile = roh.decode("utf-8", errors="ignore").strip()
            if not zeile or zeile.startswith("#"):
                continue                       # Kopfzeile ueberspringen

            print(zeile)

    except KeyboardInterrupt:
        print("\nBeendet")
    finally:
        ser.close()

⚠️ Der serielle Monitor der Arduino IDE muss geschlossen sein. Ein COM-Port kann nur von einem Programm gleichzeitig geöffnet werden. Ist der Monitor offen, bekommst du unter Windows PermissionError: could not open port und unter Linux Device or resource busy. Unter Linux fehlt außerdem oft die Berechtigung – das behebt sudo usermod -a -G dialout $USER und ein Neuanmelden.

4CSV-Logging mit echtem Zeitstempel

Der Arduino kennt keine Uhrzeit, er kennt nur millis() seit dem letzten Start. Die echte Zeit steuert Python bei – damit wird aus dem Datenstrom eine auswertbare Datei:

import csv
import time
from datetime import datetime
from pathlib import Path

SPALTEN = ["zeitstempel", "ms", "temperatur", "feuchte", "boden", "relais"]


def parse_zeile(zeile: str):
    """Wandelt 'ms,temp,feuchte,boden,relais' in ein dict. None bei Fehler."""
    teile = zeile.split(",")
    if len(teile) != 5:
        return None

    def zahl(s, typ=float):
        s = s.strip()
        if s == "":
            return None
        try:
            return typ(s)
        except ValueError:
            return None

    return {
        "ms":          zahl(teile[0], int),
        "temperatur":  zahl(teile[1]),
        "feuchte":     zahl(teile[2]),
        "boden":       zahl(teile[3], int),
        "relais":      zahl(teile[4], int),
    }


def logge(dauer_sekunden=3600, datei=None):
    datei = datei or Path(f"messung_{datetime.now():%Y-%m-%d_%H%M}.csv")
    ser   = oeffne_verbindung()
    ende  = time.time() + dauer_sekunden
    n     = 0

    with open(datei, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=SPALTEN, delimiter=";")
        writer.writeheader()

        try:
            while time.time() < ende:
                zeile = ser.readline().decode("utf-8", errors="ignore").strip()
                if not zeile or zeile.startswith("#"):
                    continue

                daten = parse_zeile(zeile)
                if daten is None:
                    print(f"Verworfen: {zeile!r}")
                    continue

                daten["zeitstempel"] = datetime.now().isoformat(timespec="seconds")
                writer.writerow(daten)
                f.flush()                       # sofort auf die Platte

                n += 1
                if n % 10 == 0:
                    print(f"{n} Messwerte  |  {daten['temperatur']} C")

        except KeyboardInterrupt:
            print("\nAbbruch durch Benutzer")
        finally:
            ser.close()

    print(f"Fertig: {n} Zeilen in {datei}")
    return datei


if __name__ == "__main__":
    logge(dauer_sekunden=600)

ℹ️ Semikolon statt Komma als Trennzeichen in der CSV. Deutsche Excel-Versionen erwarten das Semikolon und interpretieren das Komma als Dezimaltrennzeichen. Mit delimiter=";" öffnet sich die Datei per Doppelklick korrekt in Spalten. Die Werte selbst kommen mit Punkt vom Arduino – wer sie direkt in Excel rechnen will, ersetzt sie vorher oder importiert über Daten → Aus Text.

Willst du unabhängig vom Rechner mitschreiben, kannst du die Daten stattdessen auf ein Micro-SD-Modul loggen – der Artikel Arduino Datenlogger mit Micro-SD-Karte zeigt das im Detail.

5Live-Plot mit matplotlib

Der serielle Plotter der Arduino IDE kann eine Kurve zeigen und sonst nichts. Mit matplotlib bekommst du beschriftete Achsen, mehrere Kanäle, Legende – und kannst das Bild speichern:

import collections
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation

MAX_PUNKTE = 120

zeiten   = collections.deque(maxlen=MAX_PUNKTE)
temps    = collections.deque(maxlen=MAX_PUNKTE)
feuchten = collections.deque(maxlen=MAX_PUNKTE)

ser = oeffne_verbindung()

fig, ax1 = plt.subplots(figsize=(10, 5))
ax2 = ax1.twinx()                       # zweite Y-Achse fuer die Feuchte

linie_t, = ax1.plot([], [], color="#3b82f6", label="Temperatur (C)")
linie_f, = ax2.plot([], [], color="#4ade80", label="Feuchte (%)")

ax1.set_xlabel("Sekunden seit Start")
ax1.set_ylabel("Temperatur (C)", color="#3b82f6")
ax2.set_ylabel("Feuchte (%)",    color="#4ade80")
ax1.grid(True, alpha=0.3)
fig.legend(loc="upper left", bbox_to_anchor=(0.1, 0.95))
fig.suptitle("Arduino Live-Messung")


def aktualisiere(_frame):
    # alle wartenden Zeilen abarbeiten, damit der Puffer nicht volllaeuft
    while ser.in_waiting:
        zeile = ser.readline().decode("utf-8", errors="ignore").strip()
        if not zeile or zeile.startswith("#"):
            continue

        d = parse_zeile(zeile)
        if d is None or d["temperatur"] is None:
            continue

        zeiten.append(d["ms"] / 1000.0)
        temps.append(d["temperatur"])
        feuchten.append(d["feuchte"] if d["feuchte"] is not None else 0)

    if zeiten:
        linie_t.set_data(zeiten, temps)
        linie_f.set_data(zeiten, feuchten)
        ax1.relim();  ax1.autoscale_view()
        ax2.relim();  ax2.autoscale_view()

    return linie_t, linie_f


ani = FuncAnimation(fig, aktualisiere, interval=200, cache_frame_data=False)

try:
    plt.show()
finally:
    ser.close()

💡 while ser.in_waiting ist der entscheidende Teil. Liest du pro Frame nur eine Zeile, der Arduino sendet aber schneller als 5 Hz, wächst der Empfangspuffer unaufhörlich – dein Plot läuft dann Minuten hinter der Realität her. Mit der Schleife holst du in jedem Frame alles ab, was da ist.

6Die Gegenrichtung: Python steuert den Arduino

Bisher redet nur der Arduino. Ein kleines Befehlsprotokoll macht daraus einen Dialog. Auf der Arduino-Seite reichen dafür wenige Zeilen:

String eingabe = "";

void verarbeiteBefehl(const String& b) {
  if (b == "RELAIS:1") {
    relaisAn = true;
    digitalWrite(RELAIS_PIN, HIGH);
    Serial.println(F("#OK RELAIS 1"));
  }
  else if (b == "RELAIS:0") {
    relaisAn = false;
    digitalWrite(RELAIS_PIN, LOW);
    Serial.println(F("#OK RELAIS 0"));
  }
  else if (b.startsWith("LED:")) {
    int wert = b.substring(4).toInt();          // 0-255
    analogWrite(LED_PIN, constrain(wert, 0, 255));
    Serial.print(F("#OK LED "));
    Serial.println(wert);
  }
  else if (b == "PING") {
    Serial.println(F("#PONG"));
  }
  else {
    Serial.print(F("#FEHLER unbekannt: "));
    Serial.println(b);
  }
}

void leseSeriell() {
  while (Serial.available()) {
    char c = Serial.read();
    if (c == '\n') {
      eingabe.trim();
      if (eingabe.length()) verarbeiteBefehl(eingabe);
      eingabe = "";
    } else if (c != '\r') {
      eingabe += c;
      if (eingabe.length() > 40) eingabe = "";   // Schutz gegen Muell
    }
  }
}

// In loop() als erste Zeile aufrufen:
//   leseSeriell();

Und in Python eine kleine Klasse, die Befehle schickt und auf die Bestätigung wartet:

class ArduinoLink:
    def __init__(self, port=None, baudrate=115200):
        self.ser = oeffne_verbindung(port)

    def sende(self, befehl: str, warte_auf_antwort=True, timeout=2.0):
        self.ser.write((befehl + "\n").encode("ascii"))
        self.ser.flush()

        if not warte_auf_antwort:
            return None

        ende = time.time() + timeout
        while time.time() < ende:
            zeile = self.ser.readline().decode("utf-8", errors="ignore").strip()
            if zeile.startswith("#OK") or zeile.startswith("#FEHLER") \
               or zeile.startswith("#PONG"):
                return zeile
        return None                       # keine Antwort im Zeitfenster

    def relais(self, an: bool):
        return self.sende(f"RELAIS:{1 if an else 0}")

    def led(self, helligkeit: int):
        return self.sende(f"LED:{max(0, min(255, helligkeit))}")

    def erreichbar(self) -> bool:
        return self.sende("PING") == "#PONG"

    def close(self):
        self.ser.close()


if __name__ == "__main__":
    link = ArduinoLink()

    print("Verbindung ok:", link.erreichbar())

    # LED langsam aufblenden
    for h in range(0, 256, 15):
        link.led(h)
        time.sleep(0.1)

    # Thermostat-Logik direkt in Python
    link.relais(True)
    time.sleep(2)
    link.relais(False)

    link.close()

ℹ️ Warum Antwortzeilen mit # beginnen. So kann derselbe Parser Messdaten und Steuerantworten unterscheiden, ohne dass zwei Kanäle nötig sind: Alles mit führendem Doppelkreuz ist Meta-Information, der Rest sind Daten. Dieses Muster hält auch dann noch, wenn du später zehn weitere Befehle hinzufügst.

7Auswerten mit pandas

Ist die CSV geschrieben, dauert die Auswertung drei Zeilen. Mittelwerte pro Minute, Extremwerte, ein Diagramm fürs Protokoll:

import pandas as pd

df = pd.read_csv("messung_2026-07-30_0930.csv", sep=";", parse_dates=["zeitstempel"])
df = df.set_index("zeitstempel")

print(df[["temperatur", "feuchte", "boden"]].describe())

# Minutenmittel - glaettet Sensorrauschen ohne Informationsverlust
pro_minute = df[["temperatur", "feuchte"]].resample("1min").mean()
print(pro_minute.head(10))

# Wann war es am waermsten?
print("Maximum:", df["temperatur"].idxmax(), df["temperatur"].max(), "C")

# Diagramm als PNG speichern
achse = pro_minute.plot(figsize=(11, 5), grid=True,
                        title="Messreihe - Minutenmittelwerte")
achse.set_xlabel("Uhrzeit")
achse.get_figure().savefig("messreihe.png", dpi=150, bbox_inches="tight")

Wenn du die Werte lieber direkt auf dem Mikrocontroller glätten willst, statt sie nachträglich zu mitteln, findest du die passenden Verfahren im Artikel Sensorwerte glätten mit Arduino.

Troubleshooting

⚠️ readline() liefert nur leere Bytes: Meist stimmt die Baudrate nicht überein, oder der Arduino sendet mit Serial.print() ohne ln – dann findet Python nie ein Zeilenende und wartet bis zum Timeout.

⚠️ Die ersten Zeilen sind kryptische Zeichen: Auto-Reset, siehe oben. time.sleep(2) plus reset_input_buffer() nach dem Öffnen. Beim ESP32 kommt zusätzlich der Bootloader-Text bei 74880 Baud – der sieht bei 115200 zwangsläufig kaputt aus und ist harmlos.

⚠️ PermissionError beim Öffnen: Ein anderes Programm hält den Port. Serieller Monitor, serieller Plotter oder ein noch laufendes Python-Skript. Unter Linux zusätzlich die dialout-Gruppe prüfen.

⚠️ Das Skript wird mit der Zeit immer langsamer: Klassischer Pufferüberlauf. Du liest langsamer, als der Arduino sendet. Entweder das Sendeintervall erhöhen oder pro Durchlauf mit while ser.in_waiting alles abholen.

⚠️ Werte springen unrealistisch, einzelne Zeilen sind halbiert: Bei hohen Datenraten kann eine Zeile mitten im Senden abgeschnitten werden. Genau dafür prüft parse_zeile() die Feldanzahl und verwirft unvollständige Zeilen, statt sie halb zu verarbeiten.

⚠️ Der Port verschwindet nach dem Abziehen und Wiedereinstecken: Unter Windows bekommt das Board manchmal eine neue COM-Nummer. Genau deshalb sucht finde_port() jedes Mal neu, statt eine feste Nummer zu verwenden.

⚠️ UnicodeDecodeError mitten im Betrieb: Ein einzelnes gestörtes Byte reicht dafür. errors="ignore" beim Dekodieren lässt das Skript weiterlaufen, statt an einer einzigen Zeile zu sterben.

⚠️ Befehle kommen beim Arduino nicht an: Das abschließende \n nicht vergessen – ohne Zeilenende wartet die Arduino-Seite ewig. Und ser.flush() aufrufen, damit der Puffer tatsächlich rausgeht.

Praxistipps

  • 115200 Baud als Standard. 9600 sind bei mehreren Kanälen schnell zu wenig – rund 960 Zeichen pro Sekunde. Bei 115200 hast du gut das Zwölffache und praktisch keine Nachteile.
  • Timeout immer setzen. Ohne timeout=1 blockiert readline() unendlich, wenn das Board abstürzt – und dein Skript hängt ohne jede Meldung.
  • Keine Umlaute über die serielle Schnittstelle. Der Arduino sendet einzelne Bytes, Python erwartet UTF-8. Ein „ä“ aus einer Arduino-Quelldatei kommt oft als kaputtes Zeichen an.
  • Rohdaten immer mitschreiben. Speichere neben der geparsten CSV auch eine Datei mit den unveränderten Zeilen. Wenn die Auswertung später komisch aussieht, kannst du nachsehen, ob es am Parser oder am Sensor lag.
  • Der ESP32 kann beides. Willst du später kabellos loggen, schickt derselbe Sensoraufbau seine Werte per MQTT – der Parser auf der Python-Seite bleibt fast unverändert.

🚀 Bauteile für deine Python-Messstation

Alles aus diesem Tutorial:

Fazit

Python verwandelt den Arduino von einem Gerät, das Zahlen in ein Terminalfenster schreibt, in eine echte Messstation. Der Weg dorthin ist kurz – pySerial, ein sauber definiertes Zeilenformat, und der Rest ist normales Python.

Drei Punkte entscheiden darüber, ob das Ergebnis stabil läuft: zwei Sekunden warten nach dem Öffnen des Ports wegen des Auto-Resets, jede Zeile validieren statt blind zu vertrauen, und den Empfangspuffer leerlesen, statt pro Durchlauf nur eine Zeile zu holen. Wer diese drei beherzigt, hat ein Skript, das auch nach acht Stunden Dauermessung noch dieselben sauberen Daten liefert wie in der ersten Minute.

Zurück zum Blog