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

In Teil 3 zeige ich dir, wie die zuvor separat programmierten Python-Programme zusammengeführt werden und so die Schnittstelle zwischen Discord und deiner Sensorik oder Beleuchtung komplettiert wird. Teil 1 findest du hier und Teil 2 hier.
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.

Dauer: 2-4 Stunden
Themen: MQTT, Discord.py, Python Hintergrundprozesse (async), Klassen und Konfiguration mit Dateien.
Anwendungsgebiet: In diesem Post wird konkret das Abfragen eines Temperatur- und Luftdrucksensors und die Steuerung eines LED-Streifens besprochen. Der fertige Bot kann aber ohne Programmierung schon deutlich mehr nur durch Konfiguration der Befehlsliste.
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.

Voraussetzungen

Du brauchst das Python-Projekt aus Teil 1 und Teil 2. Darüberhinaus benötigst du zum Ausprobieren der exakt hier beschriebenen Beispiele einen ESP32 mit Tasmota und angeschlossenem BMP180 Sensor (Anleitung: https://plantprogrammer.de/mqtt-ohne-programmieren-tasmota/), einen Mosquitto-Broker (https://plantprogrammer.de/mqtt-auf-dem-raspberry-pi-mosquitto/) und eine IoT-Steckdose mit Tasmota zum Beispiel mit Ventilator angeschlossen. Als Bonus gehe ich noch auf die Steuerung von einem LED-Streifen (https://plantprogrammer.de/mqtt-auf-dem-raspberry-pi-mosquitto-part-iii/) ein.

Hardware-Voraussetzungen

s. Teil 1

Übersicht

Da dieser Post etwas länger wird, hier eine kurze Aufstellung, was wo passiert:

  1. MQTT-Skript aus dem ersten Teil in Klassenstruktur umbauen und Konfiguration auch in config.py auslagern.
  2. Definition von Kommandos in der Konfiguration für BMP180 (Temperatur)
  3. Umbau des Discord Bots, damit der MQTT-Client parallel gestartet wird.
  4. Anbindung von Steckdosen und LEDs

Die Änderungen in diesem Post greifen alle ineinander, deshalb gibt es dieses Mal bei den Zwischenschritten leider auch nichts zu sehen. Erst zwischen 3. und 4., wenn alle Komponenten fertig sind, funktioniert es auf magische Weise.

MQTT-Upgrade

Ausgehend von dem was du in Teil 1 und 2 gelernt hast, schreiben wir die MQTT-Anbindung jetzt nochmal etwas anders neu. Du wirst sehen, dass der Code dadurch kürzer und übersichtlicher wird und gleichzeitig etwas besser verständlich. Was in diesem Code komplett neu ist, sind Python-Klassen, deshalb hier ein minimal Beispiel, um zu illustrieren, wie diese funktionieren.

class Person:
    def __init__(self, vorname):
        self.name = vorname
    
    def introduce(self):
        print("Hallo mein Name ist " + self.name)
        

p1 = Person("Plant")
p1.introduce()

Kurz zusammengefasst, eine Klasse wird durch das class Schlüsselwort gekennzeichnet. Innerhalb einer Klasse (eingerückt) können Funktionen definiert werden, die alle als erstes Argument self haben. Danach kommen erst die eigentlichen Übergabewerte. Die Idee hinter Klassen ist, dass sie als Vorlage agieren und du beliebig viele Objekte nach dieser Vorlage erstellen kannst. Diese heißen in den meisten Sprachen Instanzen. Die Funktion __init__() ist insofern besonders, dass sie beim Erstellen der Instanz wie hier in Zeile 9 automatisch ausgeführt wird. An self kann man beliebig viele Variablen anhängen wie hier bei self.name die dann innerhalb der Instanz verfügbar sind. Zeile 9 erstellt eine solche Instanz und übergibt den Namen Plant, der dann in der Instanzvariable name abgespeichert wird. Die Instanz selber wird in der Variable p1 abgelegt, weshalb in Zeile 10 auch die Funktion introduce aufgerufen werden kann und als Name "Plant" verwendet.

Lege eine Datei mit dem Namen mqtt_connection.py an. Ziel ist es, die MQTT-Verbindung in eine Klassenstruktur zu bringen. Versuche den folgenden Code zu lesen und zu verstehen und lies dann weiter unten bei Bedarf die Erklärungen nach.

import paho.mqtt.client as mqtt
import config


class MQTTConnection:
    def __init__(self):
        self.client = mqtt.Client("Discord Bot")
        if config.MQTT_USER != "":
            self.client.username_pw_set(config.MQTT_USER, config.MQTT_PASSWORD)
        self.client.connect(config.MQTT_HOST, config.MQTT_PORT)

        self.client.on_message = self.on_message

    def on_message(self, client, userdata, message):
        payload = message.payload.decode("utf-8")
        topic = message.topic
        
        response, function = config.MQTT_SUBSCRIBERS.get(topic)
        processed_data = function(topic, payload)
        
        discord_message = response.format(value=processed_data)
        self.discord_callback(discord_message)

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

    def add_subscriber(self, topic):
        self.client.subscribe(topic)
        
    def set_discord_callback(self, function):
        self.discord_callback = function

Wie oben erklärt nutzt du hier die __init__() Methode um die Verbindung einzurichten. Der erste Unterschied zum alten Code ist, dass du auch hier, wie in Teil 2 für Discord, die Konfigurationswerte nicht mehr im Code ablegst sondern aus der Config Datei importierst. Wenn du möchtest kannst du dort auch noch MQTT_CLIENT_ID ergänzen und importieren statt es fest auf "Discord Bot" zu setzen.
Zeilen 7-10 stellen die Verbindung zu MQTT mit Passwort (falls in der Config gesetzt, d.h. kein leerer String) her.
Zeile 12 definiert, dass on_message aufgerufen wird, wenn eine Nachricht ankommt.
Die on_message Funktion sieht ähnlich aus wie die handle_message Funktion aus Teil 1. Auf die Details gehe ich später nochmal ein. Im Grunde konvertiert sie den Payload in einen String und statt einfach eine Ausgabe auf der Kommandozeile zu machen, wird eine Funktion zur Verarbeitung in der Config gesucht (damit auch komplexere Payloads, wie JSON auf Einzelwerte heruntergebrochen werden können und eine Rückgabenachricht generiert, die sodann an den discord_callback geschickt wird. Diese Funktion musst du gleich auf der Discord-Seite bereitstellen, damit MQTT Nachrichten dahin senden kann.
send_message ist vermutlich selbsterklärend, hier wird ein übergebener Payload zu einem übergebenen Topic an den MQTT-Broker verschickt.
Genauso abonniert add_subscriber ein Topic beim Broker.
Ganz am Schluss gibt es noch eine Funktion, die es dir erlaubt von außen die Funktion zu setzen, die verwendet werden soll, um Discord-Nachrichten zu senden.

Konfiguration

Neben den MQTT-Zugangsdaten kannst du jetzt in der Config auch schon dein erstes Topic definieren, auf das gehorcht werden soll. Testen kannst du es leider erst, wenn wir die Discord-Anbindung noch angepasst haben.

Anmerkung: Du kannst die subscriber-Funktionen auch in einer separaten Datei definieren und in der Config importieren. Damit der Umfang an zu bearbeitenden Dateien hier nicht ausartet, definiere ich die Funktionen aber direkt in der Config

config.py

# ...
MQTT_PORT=1883

def bmp180_temperature(topic, payload):
    import json
    json_tree = json.loads(payload)
    temperature = json_tree["StatusSNS"]["BMP180"]["Temperature"]
    return temperature

MQTT_SUBSCRIBERS = {
    "stat/office/temperature1/STATUS10": ("Es ist {value}°C", bmp180_temperature),
}

# ...

Hier siehst du das Topic aus Teil 1 wieder, unter dem der Temperatursensor seine Werte als JSON zurückgibt. Die definierte Funktion (Zeilen 7-11) sucht in diesem JSON den Temperaturwert und gibt ihn zurück. Der Kern der Konfiguration (damit der Bot nachher nicht mehr programmiert sondern nur noch konfiguriert werden muss) ist Zeile 14. Hier legst du zu einem bestimmten Topic zwei Informationen ab. Erstens, welcher Text an Discord zurückgegeben werden soll; in diesem Fall "Es ist ...°C". Zweitens eine Funktion, die den Wert zurückgibt, der den Platzhalter "{value}" ersetzen soll. Hier ist es die Temperatur. Wenn du jetzt nochmal in den MQTT-Code oben in Zeile 18 ff. schaust, siehst du, dass dort genau diese beiden Informationen, ein Vorlagetext(response) und eine Funktion(function), verwendet werden.

Falls du einfach nur den Payload zurückgeben möchtest, kannst du mit dem Wissen eine Funktion direct anlegen, die Topic und Payload als Parameter akzeptiert und Payload zurückgibt.

def direct(topic, payload):
    return payload

Umbau des Discord-Bots

Da wir jetzt zwei Programme haben, einen Discord Bot und einen MQTT-Client, die regelmäßig nach Nachrichten schauen müssen, können wir den Code-Fluss nicht mehr einfach mit der While-Schleife abarbeiten. Beide Programme müssen parallel laufen. Wie genau parallele Programmierung funktioniert, darauf werde ich hier nicht eingehen, wir nutzen einfach Teile der Standard-Bibliothek. Wenn du mehr über parallele Ausführung lernen möchtest, schau mal bei tutsplus vorbei. In deiner bot.py aus Teil 2 kannst du jetzt den Kopf wie folgt anpassen

import asyncio as asyncio
from threading import Thread

from mqtt_connection import MQTTConnection
import discord
import config

client = discord.Client()
mqtt_connection = MQTTConnection()

Zusätzlich zu den bestehenden Importen kommen also jetzt noch zwei für die parallele Ausführung dazu und die Klasse, die du eben erstellt hast wird importiert. Dann werden Discord-Client und MQTT-Verbindung angelegt.

@client.event
async def on_ready():
    print(f"{client.user} has connected to {client.guilds[0].name}")
    mqtt_connection.client.publish(config.DISCORD_BOT_TOPIC, "online")


@client.event
async def on_message(message):
    if not message.content.startswith("!"):
        return
    await run_command(message)

Die on_ready-Funktion ist im Prinzip gleich mit dem Zusatz, dass der Discord-Bot sich beim MQTT-Broker online meldet. Das ist absolut optional, ich wollte das aber anderweitig noch als Event nutzen. Die on_message-Funktion ist eingedampft auf das Wesentliche, schauen ob eine Nachricht mit "!" beginnt und dann an die Funktion run_command (die wir noch schreiben) weiterleiten und ansonsten nichts tun.

async def send_message(message_string, channel=None):
    if channel is None:
        channel = await client.fetch_channel(config.DISCORD_BOT_CHANNEL_ID)
    await channel.send(message_string)


async def run_command(message):
    for command, (topic, payload, feedback) in config.COMMANDS.items():
        if command in message.content:
            if topic:
                mqtt_connection.client.publish(topic, payload)
            if feedback:
                await send_message(feedback)
            return

Da der Discord-Bot sowohl auf Kommandos antworten als von MQTT verwendet werden soll, brauchen wir eine Funktion send_message, die eine Nachricht als Übergabeparameter akzeptiert (und optional einen bestimmten Discord-Channel) und diese Nachricht dann sendet. Wenn kein bestimmter Channel gewählt wird, wird der in der Config definierte verwendet.
Die zweite Funktion run_command, die von on_message aufgerufen wird, geht alle definierten Kommandos aus der Config durch und wenn es ein passendes findet, nimmt es sich die zugehörigen Informationen topic, payload und feedback. Wenn topic gesetzt ist, wird eine MQTT-Nachricht mit dem zugehörigen Payload gesendet und wenn ein Feedback angegeben ist, dieses sogleich als Antwort im Discord verschickt.
Die Config passen wir gleich im Anschluss an.

def send_mqtt_to_discord(message_string, channel=None):
    asyncio.run_coroutine_threadsafe(send_message(message_string, channel), client.loop)


def setup_subscribers_and_callback():
    for topic in config.MQTT_SUBSCRIBERS.keys():
        mqtt_connection.add_subscriber(topic)
    mqtt_connection.set_discord_callback(send_mqtt_to_discord)


def start_both_async():
    mqtt_thread = Thread(mqtt_connection.client.loop_start())
    mqtt_thread.start()
    discord_thread = Thread(client.run(config.DISCORD_TOKEN))
    discord_thread.run()


Und weil der meiste Code parallel zueinander läuft müssen wir einen kleinen Umweg machen um MQTT Nachrichten als Discord-Nachricht zu senden. Die einzeilige Hilfsfunktion in Zeile 41 sagt dem Discord bot im Prinzip, dass er send_message (die Funktion von oben) ausführen soll, aber dass diese nicht aus dem gleichen parallelen Thread gestartet wird, sondern von außen gestartet im client.loop (das ist die Kontrollschleife vom Discord-Bot) laufen soll. Diese Hilfsfunktion gibst du im nächsten Block dann deiner MQTT-Verbindung mit (Zeile 48). Der Rest von setup_subscribers_and_callback ist im Prinzip genauso wie in Teil 1, nur dass hier direkt alle angegebenen Topics abonniert werden und nicht nur eins.
Zu guter Letzt brauchst du noch eine Funktion, die sowohl Discord-Bot als auch MQTT-Client parallel startet und das ist, was die Zeilen 51-55 tun. Mit mqtt_thread.start() wird die Ausführung im Hintergrund gestartet und gleichzeitig hier die nächste Zeile ausgeführt (ab hier laufen also zwei "Programme" parallel). Genauso bei discord_thread.start(). Im Prinzip laufen ab hier 3 "Programme" parallel, das aktuelle hat aber nach dieser Anweisung nichts mehr zu tun(s. nächster und letzter Code-Block) und wartet einfach ab, bis die anderen fertig sind.

if __name__ == "__main__":
    setup_subscribers_and_callback()
    start_both_async()

Ganz zum Schluss werden noch die beiden angezeigten Funktionen ausgeführt, sobald dieses Pythonskript gestartet wird. Es bleibt also nur noch, die Config anzupassen und den Bot auszuprobieren.

config.py

# MQTT_SUBSCRIBERS .....

COMMANDS = {
    "t_arbeitszimmer": (
    "cmnd/office/temperature1/status",  # topic
    "10",                               # payload
    "Temperatur wird abgefragt!"),      # direktes Feedback
}

In der Config kannst du jetzt dieses Dictionary anlegen, das zu jedem Kommando (ACHTUNG: ohne Ausrufezeichen!) bereitstellt, an welches Topic, welcher Payload verschickt werden soll und ob es eine direkte Antwort gibt (Die Antwort mit dem Temperaturwert ist ja bereits in den SUBSCRIBERS definiert).
Möchtest du keine MQTT Nachricht wegschicken sondern nur antworten, kannst du die ersten beiden Werte leer lassen "direct_command": ("", "", "Hier deine Antwort!"). Umgekehrt, falls du keine direkte Antwort schicken möchtest, kannst du das Feedback Feld leer lassen.

Wenn du den Bot jetzt startest, solltest du im besten Fall so einen Output sehen können (einmal mit, einmal ohne direktes Feedback)

Hier mache ich beim Schreiben eine Pause und es ist auch für dich eine gute Gelegenheit einmal im Kreis zu laufen (vor Freude weil es klappt natürlich) und etwas zu trinken zu holen. Der Rest des Posts beschäftigt sich hauptsächlich mit Konfiguration um auch noch die anderen Geräte anzubinden.

Steckdosen und Badezimmer-LEDs per Discord steuern

Fangen wir mit den Steckdosen an. Im Wesentlichen besteht die Steuerung aus dem Absetzen von MQTT-Nachrichten. Dementsprechend kannst du einfach deine COMMANDS in der config erweitern.

COMMANDS = COMMANDS = {
    "t_arbeitszimmer": (...),
    "steckdose_ventilator_an": ("cmnd/office/fansocket/POWER", "on", ""),
    "steckdose_ventilator_aus": ("cmnd/office/fansocket/POWER", "off", ""),
    "steckdose_ventilator_schalten": ("cmnd/office/fansocket/POWER", "toggle", ""),
    
}

Da Tasmota Steckdosen genauso funktionieren wie Tasmota-Sensoren, ist die Steuerung denkbar einfach. Auf das Kommando Topic (in meinem Fall "cmnd/office/fansocket/POWER") kannst du on für an off für aus oder toggle zum Schalten senden. Wenn du direktes Feedback haben möchtest kannst du das entsprechend eintragen. Wenn du stattdessen lieber eine konkrete Rückmeldung über den Zustand haben möchtest, zum Beispiel auch, wenn jemand die Steckdose per Hand betätigt, kannst du einen entsprechenden Eintrag in MQTT_SUBSCRIBERS machen, z.B. "stat/office/fansocket/POWER": ("Die Ventilator Steckdose ist {value}", direct), direct ist die oben definierte Funktion, die einfach nur den Payload zurückgibt. Alternativ könntest du hier natürlich noch eine Funktion bereitstellen, die zwischen On und AN und so übersetzt.

Für die LED-Leiste gilt grundsätzlich das Gleiche. Da sie aber McLighting nutzt und nicht Tasmota, sind die Befehle etwas anders. Für den kompletten Befehlssatz verweise ich auf das McLighting-Wiki, dort stehen die Payloads beschrieben die du verwenden kannst, das Topic ist immer "name/in" für eingehende Befehle und "name/out" für die Bestätigungen.

Außerdem bietet es sich für McLighting an mehrere Kommandos nacheinander zu schicken. Daher empfehle ich die Funktion run_command, wie folgt umzuschreiben:

async def run_command(message):
    for command, mqtt_commands in config.COMMANDS.items():
        if command in message.content:
            for topic, payload, feedback in mqtt_commands:
                if topic:
                mqtt_connection.client.publish(topic, payload)
                    if feedback:
                        await send_message(feedback)
            return

So kann eine Liste von MQTT-Kommandos angegeben werden und es werden alle abgearbeitet, wenn das Kommando konfiguriert ist. In der Konfiguration musst du dann leider um alle (auch die mit einem einzelnen MQTT-Kommando) Einträge eckige Klammern machen, damit sie als Liste verstanden werden

Die COMMANDS sähen danach inklusive LED-Steuerung zum so aus:

COMMANDS = {
    "t_arbeitszimmer": [("cmnd/office/temperature1/status", "10", "")],
    "steckdose_ventilator_an": [("cmnd/office/fansocket/POWER", "on", "")],
    "steckdose_ventilator_aus": [("cmnd/office/fansocket/POWER", "off", "")],
    "steckdose_ventilator_schalten": [("cmnd/office/fansocket/POWER", "toggle", "")],
    "led_lagerfeuer": [("OneNightLamp/in", "?47", ""),       # geschwindigkeit
                       ("OneNightLamp/in", "%18", ""),       # helligkeit
                       ("OneNightLamp/in", "#00FF4400", ""), # primärfarbe
                       ("OneNightLamp/in", "/48", "")
                       ],      # animation Lagerfeuer
    "led_breath_red": [("OneNightLamp/in", "?92", ""),
                       ("OneNightLamp/in", "%25", ""),
                       ("OneNightLamp/in", "#00FF1000", ""),   # tiefes Rot
                       ("OneNightLamp/in", "##00000000", ""),  # schwarz
                       ("OneNightLamp/in", "/2", "")
                       ],
}

Bevor du fragst, ja, meine LED-Leiste heißt inzwischen OneNightLamp nicht mehr guestbathroom :D
Die wichtigsten Payloads in Übersicht findest du auch noch mal in meinem alten Blogpost zu McLighting.

Das ist auf Technologie-Seite auch schon alles für diesen Blogpost. Den gesamten Code der Reihe (inklusive einer etwas erweiterten Version) findest du unter https://github.com/sisch/mqttDiscordBot/ Dieser Code bietet auch schon alles, damit du zum Beispiel deinem DM vom Pen&Paper per Discord Zugriff auf dein Licht geben kannst, sodass die Stimmung noch immersiver wird. Andere Ideen sind in der Readme Datei auf Github gelistet. In der aktuellen Fassung ist über die Themen dieses Blogposts hinaus auch die Möglichkeit gegeben, den Bot an bestimmte User, Gruppen oder Kanäle zu binden. Wenn ich die Zeit finde wird es also noch einen Teil 3¾ geben.

Zusammenfassung

Mit der Verknüpfung zwischen Discord und MQTT entsteht eine Möglichkeit Sensoren zuhause auch von unterwegs abzufragen und Geräte zu steuern. Das ganze ohne Ports nach außen freizugeben oder vorhandene Cloud-Lösungen zu verwenden. Der initiale Programmieraufwand ist moderat, die Konfiguration zusätzlicher Geräte sehr einfach. Wenn der Bot allerdings abstürzt und du keinen Zugriff von außen hast, verlierst du auch den Zugriff auf MQTT, deshalb bitte, bitte keine kritische Infrastruktur (Türen, elektrische Großgeräte, Bewässerungsanlagen) damit betreiben, solange diese nicht über eigene Sicherheitsmechanismen (z.B. Abschaltautomatik bei Bewässerung) verfügen. Im Github-Projekt liegt ein Systemd-Service bei, der den Bot (neu-)startet bei Fehler oder Systemstart, das gibt allerdings auch keine ultimative Garantie.

Patreon

In eigener Sache ein bisschen Werbung. Ich schreibe diese Anleitungen wirklich gerne. In eine Anleitung, wie diese hier (nur Teil 3) fließen zwischen 10 und 30 Stunden zusätzliche Arbeit. Zusätzlich deshalb, weil ich nur Anleitungen zu Dingen schreibe, die ich selber haben / basteln möchte. Da ich zwischendurch auch meinen Lebensunterhalt verdienen muss, fällt das Blogschreiben leider manchmal hinten über. Deshalb habe ich einen Patreon-Account angelegt. Ich möchte keine Inhalte als Benefit hinter einer Paywall anbieten, die für das besprochene Thema wichtig sind, deshalb gibt es aktuell nur die Möglichkeit mich mit €2 pro Monat zu unterstützen. Sobald mir etwas einfällt, was einen nicht-notwendigen dafür schönen, angenehmen oder witzigen Mehrwert bietet, werde ich das eventuell erweitern. Bis dahin freue ich mich über alle Personen, die durch ihren monatlichen Beitrag die Arbeit, die in diesen Blog fließt, wertschätzen und das Unterfangen möglichst vielen Menschen Zugang zu Technologie zu ermöglichen ideell unterstützen. Vielen Dank.

Wie gehabt stehe ich für Fragen, Anregungen, Themenwünsche, Lob und Kritik zu diesem Blogpost unter info@ oder auf Twitter gerne zur Verfügung. Und wenn diesen Bot tatsächlich mal jemand für Pen&Paper oder LARP über Discord nutzt, würde ich mich sehr über die Einsendung eines Bildes, Videos oder Streamlinks freuen :D

Frohes Nachrichten schicken!

Unterstütze mich gerne monatlich auf Patreon
oder einmalig auf ko-fi