MQTT, Discord und Python gehen in eine Bar! - Teil 1

Lerne mit Python MQTT-fähige Geräte auszulesen und zu steuern. Dieser Beitrag dient als Vorbereitung für einen Discord-Bot der die Steuerung deines Smarthomes erlaubt.

Überblick

Ziel dieser Reihe ist das Steuern von MQTT-fähigen Geräten zuhause über Discord-Befehle, sodass keine Cloud-Lösung verwendet werden muss oder irgendwelche Ports nach außen freigegeben werden. ACHTUNG! Was nicht gesteuert werden sollte: Geräte die unbeaufsichtigt Brände verursachen können oder Türen öffnen.
In Teil 1 zeige ich dir zunächst die Anbindung an MQTT in Python.

Dauer: 1-2 Stunden (exkl. Mosquitto-Setup)
Themen: Python, MQTT
Anwendungsgebiet: Steuerung von MQTT-Hardware (z.B. Steckdosen, Motoren, Lichter, Sensoren, ...) aus Python heraus.
Anmerkung: Es schadet nicht ein paar Vorkenntnisse in Python mitzubringen. Wenn das bei dir nicht der Fall ist, empfehle ich den Code wie Prosa zu lesen und sich nicht zu viele Gedanken über die Details zu machen.

Hintergrund für diese Reihe

Wenn ich mein Smarthome vom Internet aus steuern möchte, aber nicht irgendwelche Ports aus meinem privaten Netzwerk nach außen freigeben möchte (und da ist das Problem wechselnder IP-Adressen noch nicht behandelt) und gleichzeitig  auch nicht irgendwelche Cloud-Lösungen nutzen möchte, ist die Antwort oft: Selber bauen. Aus eigener Erfahrung und der verschiedenster Unternehmen ergibt sich aber, dass es nicht empfehlenswert ist, seine eigenen Sicherheitsmechanismen zu bauen, sondern auf etablierte Lösungen zu setzen. Was ist also der sinnvolle Kompromiss zwischen zugänglicher Technologie und Sicherheit? In meinen Augen bieten Discord-Bots eine perfekte Möglichkeit aus dem eigenen Netzwerk heraus eine Schnittstelle nach außen zur Verfügung zu stellen, die die Sicherheit der Discord Regeln und Berechtigungen nutzen und gleichzeitig die Möglichkeit bieten, nur bestimmte Steuerbefehle zu registrieren, um im Falle eines Sicherheitsproblem mit Discord, keine kritischen Systeme verwundbar zu machen. Der Vorteil, Discord kümmert sich um die Sicherheitsupdates. Außerdem läuft der Bot lokal und kann jederzeit (wenn man zuhause ist) abgeschaltet werden.

Voraussetzungen für Mein Beispiel

Hardware

MQTT-Setup mit Raspberry Pi wie in diesem Blogpost

Zur Illustration Sensorik
  • ESP32 devkit: 8,99€ auf Amazon B09LCDJY8Z
  • BMP180 Temperatur- und Drucksensor: 5,49€ für 3 auf Amazon einzeln sind die viel zu teuer. B07FRW7YTK
    Das Setup für diesen Sensor ist in meinem Tasmota Blogpost beschrieben.
Für Steckdosensteuerung (2. oder 3. Post der Reihe)
  • NOUS A1T Tasmota-fertige Steckdose: 9,99€ auf Amazon B09RZCVZK9
  • Ventilator mit Einrastschaltern gegen die Hitze(!): z.B. 24,99€ auf Amazon B096MRHBD3

die abgebildeten Preise sind tagesaktuell am 30. Juni 2022

Als Amazon-Partner verdiene ich an qualifizierten Verkäufen, wenn du die Links verwendest und die Produkte kaufst. Das kostet nicht mehr als wenn du sie einfach so kaufst und hilft mir mehr Zeit für coole Tutorials aufzubringen. Wenn du das nicht möchtest, sind daneben jeweils die Artikelnummern gelistet.

Software

  • Python3 (kann unter Windows aus dem Store installiert werden)
  • Texteditor der Python unterstützt (z.B. Visual Studio Code)
  • empf. Windows Terminal zur Not tut es aber auch Powershell (für nicht Windows Betriebssysteme reicht das Standard-Terminal)

Vorbereitung

Installiere alles Benötigte aus dem letzten Abschnitt. Stelle sicher, dass dein MQTT-Broker, z.B. Mosquitto bereits läuft und für den heutigen Post auch ohne SSL auf Port 1883 lauscht. Außerdem sollte irgendwo in deiner Umgebung ein fertig geflashter ESP32 mit Tasmota-Firmware und einem BMP180 Sensor mit diesem Broker verbunden sein. Das Tasmota-Topic (wird in Tasmota konfiguriert und später in Python benötigt) heißt bei mir office/temperature1, das heißt ich kann die aktuelle Temperatur bekommen, indem ich auf das MQTT-Topic stat/office/temperature1/status10 subscribe und dann den Payload 10 an cmnd/office/temperature1/status schicke. Der Nachteil wenn es klappt, erfahre ich, dass hier schon 29,8 °C im Raum sind und es ist noch nicht einmal Mittag.

Python-Umgebung einrichten

Obwohl du alles folgende auch systemweit installieren kannst, empfehle ich die Verwendung von virtuellen Python-Umgebungen, weil du so besser kontrollieren kannst, was für Softwarepakete du für welches Projekt zusätzlich brauchst. Das lässt sich dann auf einem neuen Computer leichter replizieren und es erleichtert das Aufräumen nicht mehr genutzter Pakete.

Der erste Schritt wird sein, im Terminal in den Ordner zu wechseln, in dem du dein Projekt anlegen möchtest. Der Einfachheit halber sind hier ein paar Befehle, die in den "Dokumente"-Ordner in deinem Benutzerkonto wechseln, dort einen Ordner "MQTT-Discord-Bot" anlegen und dort hinein wechseln.

cd ~
mkdir MQTT-Discord-Bot
cd MQTT-Discord-Bot

Damit du ein Python Virtual Environment erstellen und die benötigten Pakete installieren kannst, musst du die folgenden Befehle eingeben:

python3 -m venv env

# Aktivieren in Windows
.\env\Scripts\Activate.ps1

# Aktivieren in Unix
source ./env/bin/activate

# Aktualisiere den Paketmanager und installiere das MQTT-Paket
pip install --upgrade pip
pip install paho-mqtt

# Speichere die installierten Paketversionen für später
pip freeze > requirements.txt

Behalte das Terminal im Hintergrund geöffnet. Jetzt kannst du in Visual Studio Code über das Menü File->Open Folder den MQTT-Discord-Bot Ordner öffnen und siehst dort vorerst den env Ordner und eine requirements.txt Datei. Das war die Vorbereitung der Entwicklungsumgebung, nach einer kurzen Trinkpause kann es also losgehen.

Python-Code: 1. MQTT-Verbindung

Falls du noch gar keine Erfahrung mit Python hast, versuche im Code die Wörter, die du verstehst, wie ein Buch oder ein Rezept zu lesen und dir nicht zu viele Gedanken über Details zu machen.

Wir wollen jetzt MQTT benutzen, indem wir einen Python Client verbinden und eine erste Nachricht senden. Erstelle eine Datei mit dem Namen mqtt.py und folgendem Inhalt:

import paho.mqtt.client as mqtt

MQTT_HOST = "10.0.0.123"  # IP-Adresse oder Hostname des Brokers
MQTT_PORT = 1883
MQTT_USER = "plant"
MQTT_PASSWORD = "programmer"

mqtt_client = mqtt.Client("Mein Python MQTT Client")
mqtt_client.username_pw_set(username=MQTT_USER, password=MQTT_PASSWORD)
mqtt_client.connect(host=MQTT_HOST, port=MQTT_PORT)
mqtt_client.publish("test/topic", "Hallo Welt!")

Zeile 1 importiert das Softwarepaket, das dir erlaubt mit einem MQTT-Broker zu interagieren
Zeilen 3-6 definieren die Konfigurationsparameter die zu einer Verbindung gehören als Variablen (Das mache ich so, weil ich dann weiß, dass ich nur oben Werte ändern muss auch wenn das Skript länger wird und damit das Aufräumen in eine Konfigurationsdatei später einfacher ist).
Die letzten 4 Zeilen erstellen erstmal einen MQTT-Client (8), konfigurieren Zugangsdaten (9; wenn du beim Mosquitto-Setup keine Berechtigungen eingeschränkt hast, kannst du diese Zeile weglassen), verbinden zu Host:Port (10) und schicken direkt die erste MQTT-Nachricht (11) an den Broker. Wenn du das direkt beobachten möchtest, nutze MQTT-Explorer oder die Kommandozeilen-Tools aus dem ersten Teil der Mosquitto-Anleitung. Zum Starten kannst du im Terminal python3 mqtt.py eingeben. (Anm.: Es gibt auch eine Möglichkeit den Code direkt in Visual Studio Code auszuführen, das sprengt aber hier den Rahmen)

Python-Code: 2. MQTT-Nachrichten empfangen

Am Anfang jedes Kapitels stehen erstmal Aufräumarbeiten. Aus den Zeilen 8-11 oben machst du eine Funktion, die du aufrufst. Das sieht so aus:


def create_client_and_connect():
    client = mqtt.Client("Mein Python MQTT Client")
    client.username_pw_set(username=MQTT_USER, password=MQTT_PASSWORD)
    client.connect(host=MQTT_HOST, port=MQTT_PORT)
    return client


mqtt_client = create_client_and_connect()
mqtt_client.publish("test/topic", "Hallo Welt!")
Wofür ist das Aufräumen gut, es macht doch das Gleiche?!? Das ist vollkommen richtig. Die Code-Struktur wird sich für den finalen Bot noch mehrfach ändern müssen. Ich halte es trotzdem für sinnvoll dir den Weg von einfach zu schwer zu zeigen. Das Bündeln von Code-Zeilen, die irgendwie zusammengehören und eine Aufgabe haben, in eine Funktion ist der erste Schritt hin zu einer Klassenstruktur, die wir am Ende benötigen. Zeitlich ist es immer eine gute Gelegenheit aufzuräumen, wenn gerade eine Sache funktioniert, wie in diesem Beispiel das Verbinden und Nachricht schicken.

Um nun Nachrichten zu empfangen, musst du insgesamt drei Dinge tun.

  1. Eine Funktion bereitstellen, die eingehende Nachrichten verarbeitet
  2. Dem Client mitteilen auf welches Topic er reagieren soll und wie die Funktion aus 1. heißt
  3. Dem Client regelmäßig mitteilen, dass er schauen soll, ob neue Nachrichten da sind, statt das Programm zu beenden.

Für Schritt 1 brauchen wir eine Funktion die drei Übergabeparameter akzeptiert (für technische Details siehe Paho Documentation). Füge also nach der connect Funktion eine neue ein, die vorerst ankommende Nachrichten auf der Konsole ausgibt

def handle_message(client, userdata, message):
    print(message.topic, message.payload.decode("utf-8"))

Diese Funktion macht nicht viel mehr als die topic und payload Eigenschaften der empfangenen messageauszugeben. Der payload wird in diesem MQTT-Paket standardmäßig als bytes übergeben, deshalb wird das mit der decode Funktion vor der Ausgabe noch zu einem string konvertiert.

In Schritt 2 wird diese Funktion am Client registriert und die Subscription für das Test-Topic beim Broker angemeldet. Zwischen dem Aufruf der connect-Funktion und dem publish, füge diese Zeilen ein

mqtt_client.on_message = handle_message
mqtt_client.subscribe("test/topic")

Besonders ist hier, dass die Funktion in der ersten Zeile als Name übergeben wird und noch nicht aufgerufen wird (es stehen keine runden Klammern hinter handle_message). Denn der Aufruf passiert intern in dem Paket, das wir nutzen. Subscriptions sind denkbar einfach, denn der client hat eine Funktion dafür, die das Topic übergeben bekommt.
Wie sage ich Python denn, dass es bei Topic A handle_message und Topic B eine andere Funktion aufruft?
Gar nicht. Alle eingehenden Nachrichten werden über die gleiche Funktion verwaltet. Das heißt, wenn du eine Unterscheidung machen möchtest, verwendest du den Wert von message.topic innerhalb der handle_message Funktion dafür.

Für Schritt 3 füge ganz am Ende diese Zeilen ein:

while True:
    mqtt_client.loop()

Achtung, ab jetzt musst du das Programm aktiv mit Strg+C abbrechen, weil es sonst durch das while True endlos lange läuft. In dieser Endlosschleife wird dem MQTT-Client immer wieder mitgeteilt, er möge nochmal nach Nachrichten schauen. Wenn das klappt, heißt es nochmal ein bisschen aufräumen/umstrukturieren.

Das Ziel ist es am Ende keine Funktion vom mqtt_client mehr direkt aufzurufen, weil es dann leichter fällt, das in eine eigene Klasse in einer externen Datei auszulagern.

Dementsprechend kannst du unterhalb der handle_message Funktion noch eine neue definieren, die z.B. send_message heißt. So z.B.

def send_message(client, topic, payload):
    client.publish(topic, payload)

Hier kannst du also den Client übergeben und dann innerhalb der Funktion nutzen. Für den Moment wirkt das vielleicht umständlicher. Es wird den Schritt zur Klasse am Ende deutlich vereinfachen. Den Aufruf mqtt_client.publish("test.....) kannst du dann durch send_message(mqtt_client, "test.....) ersetzen.

Als letztes kannst du noch die Subscription aufräumen, indem du eine Funktion setup_subscriptions erstellst (Anmerkung: der Plural im Namen ist eigentlich schlecht, weil wir aktuell nur eine Subscription haben, aber das soll sich ja bald ändern). Versuch das mal selbst, bevor du weiterliest.

Wenn alles geklappt hat, müsste dein gesamter Code jetzt ungefähr so aussehen und mit diesem Stand geht es nach einer Trinkpause in den Endspurt:

import paho.mqtt.client as mqtt

MQTT_HOST = "10.0.0.123"  # IP-Adresse oder Hostname des Brokers
MQTT_PORT = 1883
MQTT_USER = "plant"
MQTT_PASSWORD = "programmer"


def create_configured_client():
    client = mqtt.Client("Mein Python MQTT Client")
    client.username_pw_set(username=MQTT_USER, password=MQTT_PASSWORD)
    client.connect(host=MQTT_HOST, port=MQTT_PORT)
    return client


def handle_message(client, userdata, message):
    print(message.topic, message.payload.decode("utf-8"))


def send_message(client, topic, payload):
    client.publish(topic, payload)


def setup_subscriptions(client, topic):
    client.on_message = handle_message
    client.subscribe(topic)


mqtt_client = create_configured_client()
setup_subscriptions(mqtt_client, "test/topic")
send_message(mqtt_client, "test/topic", "Hallo Welt!")

while True:
    mqtt_client.loop()

Python-Code: 3. Sensor nach Daten fragen und Temperatur ausgeben

Am Ende der Serie sollst du unter anderem die Temperatur deines Sensors über Discord abfragen können. Wir simulieren das jetzt erst einmal mit einem Befehl der automatisch 1x pro Sekunde gesendet wird und dem entsprechenden Code der das Ergebnis auswertet und die Temperatur auf der Kommandozeile ausgibt.

Bei Tasmota ist es so, dass es ein cmnd-Präfix für die Topics gibt, mit dem du Kommandos setzen kannst und ein stat-Präfix, unter dem du Daten abrufen kannst. Diese Topics kannst du ähnlich wie die MQTT Benutzerdaten im Kopf definieren:

BMP180_DATA_TOPIC = "stat/office/temperature1/STATUS10"
BMP180_POLL_TOPIC = "cmnd/office/temperature1/status"
BMP180_POLL_PAYLOAD = "10"

Wichtig, Tasmota baut die Topics dynamisch zusammen und das konfigurierte Topic im Web-Interface vom ESP lautet nur office/temperature1. Daher kommt auch die Großschreibung von STATUS10, die unbedingt eingehalten werden muss.

Um jetzt z.B. 1x pro Sekunde die Daten abzufragen, kannst du in dem While-Loop den client-Loop laufen lassen, 1 Sekunde warten, und dann die nächste Abfrage senden.

import time
# .....
while True:
    mqtt_client.loop()
    time.sleep(1)
    send_message(mqtt_client, BMP180_POLL_TOPIC, BMP180_POLL_PAYLOAD)

Wenn du dem Code bis hierhin folgen konntest, ist dieser Code-Schnipsel selbst erklärend. Falls nicht, lies nochmal den vorherigen Abschnitt in Ruhe durch. Wenn diese Poll-Nachricht gesendet wird, antwortet Tasmota mit einer JSON-Nachricht, deren Struktur so aussieht:

{
  "StatusSNS": {
    "Time": "1990-01-01T00:00:00",
    "BMP180": {
      "Temperature": 28.4,
      "Pressure": 1005.3
    },
    "ESP32": {
      "Temperature": 72.2
    },
    "PressureUnit": "hPa",
    "TempUnit": "C"
  }
}

JSON besteht im Wesentlichen aus zwei Substrukturen. Das JSON-Objekt (oder Key-Value-Paar) erkennbar durch geschweifte Klammern drumherum und der Gegenüberstellung "Schlüssel": Wert kann über den Schlüssel abgefragt werden. Der Wert kann selber wieder ein Objekt sein oder ein Einzelwert. Außerdem gibt es die JSON-Liste, die durch eckige Klammern gekennzeichnet ist(und in diesem Payload nicht vorkommt). Bei Listen stehen einfach nur Werte und keine Schlüssel. In diesem Fall willst du die Temperatur verwenden, diese befindet sich hinter den Schlüsseln "StatusSNS"->"BMP180"->"Temperature"

Damit du auf die Nachricht reagieren kannst, ändere als erstes das Topic im Aufruf der setup_subscriptions Funktion in BMP180_DATA_TOPIC. Die folgenden Änderungen in der handle_message Funktion erlauben dir dann den Temperaturwert auszulesen und auf der Kommandozeile auszugeben. Wird ein anderes Topic verwendet (und der Client ist subscribed) dann wird sowohl Topic als auch Payload wieder ausgegeben.

def handle_message(client, userdata, message):
    if message.topic == BMP180_DATA_TOPIC:
        json_tree = json.loads(message.payload.decode("utf-8"))
        temperature = json_tree["StatusSNS"]["BMP180"]["Temperature"]
        print(f"Es ist {temperature}°C")
    else:
        print("[UNHANDLED]", message.topic, message.payload.decode("utf-8"))

Zeile 23 überprüft ob die eingehende Nachricht zum Sensor Topic gehört und interpretiert (Zeile 24) in dem Fall die Nachricht als JSON Objekt (In Python steht das jetzt als Dictionary zur Verfügung). In Zeile 25 greifst du dann schrittweise auf die oben abgelesenen Schlüssel zu, sodass am Ende der Wert 28.4 (aus dem Beispiel JSON oben) herauskommt und in temperature abgespeichert wird.
Schließlich wird (Zeile 26) der Wert mit etwas Begleittext auf der Kommandozeile ausgegeben.

Geschafft. Jetzt kannst du über Python auf MQTT Topics lauschen und wenn nötig Aufrufe an den Sensor senden, Daten zu schicken (Polling). Ein letztes kleines Aufräumen steht noch an. In der handle_message Funktion ist Code dupliziert, der nicht unbedingt dupliziert sein muss. Findest du ihn?

Genau, das Dekodieren des Payloads wird in beiden Verzweigungen der if-Bedingung benutzt und kann in einer Variable zusammengefasst werden.

def handle_message(client, userdata, message):
    payload = message.payload.decode("utf-8")
    if message.topic == BMP180_DATA_TOPIC:
        json_tree = json.loads(payload)
        temperature = json_tree["StatusSNS"]["BMP180"]["Temperature"]
        print(f"Es ist {temperature}°C")
    else:
        print("[UNHANDLED]", message.topic, payload)

Weiter geht es in 2-3 Wochen mit Teil 2. In Teil 2 wirst du einen Discord-Bot schreiben, der sich auf einem Server einloggt, auf Befehle wartet und Antworten in den Chat schreibt. In Teil 3 werden die beiden Blöcke dann zusammengeführt.

Wenn es dir bis hierhin Spaß gemacht hat, teile den Post gerne mit Freunden und Bekannten, die sich auch für das Thema interessieren könnten. Wenn nicht, teile es mit deinen ärgsten Feinden :D Um diese kostenlosen Anleitungen zu unterstützen, kauf deine Hardware gerne über meine Affiliate-Links oder gib mir einen Kaffee auf ko-fi aus (Link im Menü). Bei Fragen schreib mir eine E-Mail an info@ oder auf Twitter.