Einleitung: Wenn Smart Home auf Radfahren trifft

Heute möchte ich euch ein Projekt vorstellen, das ich persönlich sehr interessant finde. Es ist eines dieser klassischen „Problemlöser-Projekte“, das entsteht, wenn man zwei Hobbys miteinander verbindet – in meinem Fall: Smart Home und der Radfahren.

image 5

Jeder, der sein Fahrrad „liebt“ und im Jahr mehr tausend Kilometer im Sattel verbringt, hat sich wahrscheinlich schon einmal mit dem Thema „Ketten wachsen“ beschäftigt. Die Vorteile gegenüber klassischem Kettenöl sind immens: Der Antrieb bleibt dauerhaft sauber, zieht keinen Schmutz oder Sand an und die Verschleißteile halten deutlich länger.

Doch so genial das Ergebnis ist, der Prozess selbst kann sehr Zeitaufwendig sein, nämlich dann wenn man in der Küche oder Werkstatt neben einem Topf mit heißem Wachs steht und auf ein Thermometer starrt.


Fahrradkette wachsen
Fahrradkette wachsen

Der Ablauf ist strikt:

  1. Das Wachs muss langsam auf 90°C erhitzt werden, damit es flüssig genug ist, um in die kleinsten Glieder der Kette zu kriechen.
  2. Dann muss die Kette etwa 10 Minuten im Wachsbad bleiben, wobei die Temperatur konstant gehalten werden muss.
  3. Anschließend muss das Wachs kontrolliert auf ca. 60°C abkühlen. Das ist wichtig, damit das Wachs beim Herausnehmen nicht wie Wasser von der Kette tropft, aber auch noch nicht so fest ist, dass man die Kette nicht mehr herausbekommt.

Dieser ganze Vorgang dauert gut 45 Minuten. Da ich keine Lust hatte, die ganze Zeit tatenlos daneben zu stehen und aufzupassen, dass das Wachs nicht kocht oder zu schnell hart wird, habe ich eine Lösung gesucht.

Tasmota Berry Grill-Thermomether Kette Wachsen
Tasmota Berry Grill-Thermomether Kette Wachsen

Das Ergebnis ist eine ESP32-basierte Steuerung, die den kompletten Prozess überwacht. Sie informiert mich über einen Piezo-Buzzer, wenn ein Schritt erledigt ist, und visualisiert den Temperaturverlauf in einem Live-Diagramm auf einer eigens erstellten Web-Oberfläche. Wer es noch smarter mag, kann das System dank Tasmota natürlich auch mit Telegram-Benachrichtigungen oder einer Alexa-Ansage koppeln – dazu später mehr.

Und ein netter Nebeneffekt: Da der verbaute Sensor technisch gesehen bis zu 600°C messen kann, eignet sich dieses Projekt auch hervorragend als smartes Thermometer für den Grill, etwa wenn man die Garraumtemperatur im Smoker überwachen möchte.

In diesem Beitrag zeige ich euch Schritt für Schritt, wie ihr das nachbaut.



Die Hardware: Das braucht ihr

Das Herzstück ist ein ESP32. Wichtig: Ein ESP8266 D1 Mini reicht hierfür leider nicht aus, da wir die Scriptsprache „Berry“ nutzen, die nur auf dem leistungsstärkeren ESP32 läuft.

Die meisten Komponenten habe ich bequem über Amazon bestellt. Die Kosten für das gesamte Projekt halten sich sehr in Grenzen und liegen – je nachdem was ihr schon in der Bastelkiste habt – bei etwa 20 bis 30 Euro.

Einkaufsliste (Affiliate-Links):

Ein Wort zum Gehäuse

Für den Anfang habe ich ein einfaches, schwarzes Universal-Gehäuse von Amazon verwendet (siehe Link oben). Die Aussparung für das Display habe ich dabei noch ganz klassisch mit einem Messer und einer Feile ausgeschnitten. Das funktioniert und erfüllt seinen Zweck, sieht aber natürlich handwerklich immer etwas „rustikal“ aus.

In der finalen Version, die ihr auch im Video seht, habe ich das Gehäuse mit meinem 3D-Drucker erstellt. Das sieht natürlich deutlich sauberer aus, da das Display perfekt eingefasst ist und alles seinen festen Platz hat. Aber keine Sorge: Ein 3D-Drucker ist kein Muss, meiner Design-Linie „einfache schwarze Kiste“ bin ich auch beim Druck treu geblieben.


Das Software-Problem (und meine Lösung für euch)

Wer sich mit Tasmota auskennt, weiß: Die Standard-Versionen („tasmota32.bin“), die man im Internet findet, sind modular aufgebaut. Meistens muss man sich entscheiden: Will ich Display-Support? Oder IR/KNX/Zigbee Oder Sensor-Support? Alles gleichzeitig ist in den Standard-Builds nicht enthalten, um Speicherplatz zu sparen.

Normalerweise müsstet ihr euch jetzt über Dienste wie Gitpod (inzwischen ONA) und den TasmoCompiler eine eigene Firmware kompilieren. Da Gitpod/ONA mittlerweile hohe Hürden bei der Authentifizierung (Kreditkarten-Zwang) aufgebaut hat, ist das für Einsteiger unnötig kompliziert geworden.

Die gute Nachricht: Ich habe das für euch übernommen.

Ich stelle euch eine fertig kompilierte Tasmota v15.x Firmware zur Verfügung, die bereits alles enthält, was wir brauchen:

  1. Berry Script Sprache
  2. Universal Display Driver
  3. Dateisystem (LittleFS)
  4. Sensor Support

📥 [Hier klicken zum Download der firmware.bin]


Schritt 1: ESP32 Flashen (Der 2-Schritt-Prozess)

Beim Flashen des ESP32 D1 Mini gibt es eine Stolperfalle, die oft für Frust sorgt. Viele Web-Installer funktionieren mit diesem Board nicht zuverlässig. Ich empfehle daher den Tasmota pyFlasher.

Zudem müssen wir den ESP32 „zweimal“ flashen:

Tasmota pyFlasher
Tasmota pyFlasher
  1. Schritt 1 (Factory Image): Zuerst flashen wir die Datei tasmota32-factory.bin (diese ist oft größer und legt die Grundstruktur an).
  2. WLAN Einrichtung: Nach dem Neustart macht der ESP einen eigenen WLAN-Hotspot auf. Verbindet euch mit dem Handy, gebt eure WLAN-Daten ein, damit der ESP in eurem Heimnetz ist.
  3. Schritt 2 (Upgrade): Ruft jetzt die IP-Adresse des ESP32 in eurem Browser auf. Geht auf „Firmware Upgrade“ -> „Upgrade by file upload“.
    • Wählt jetzt meine spezielle tasmota32.bin (die mit Berry, Sensoren und Display Support) aus.
    • Startet das Upgrade.
image 9

Erst jetzt habt ihr alle Funktionen, die wir benötigen.


Schritt 2: Verdrahtung & Template Konfiguration

Damit die Software weiß, an welchen Pins wir unsere Hardware angelötet haben, nutzen wir ein Template.
Wichtig: Wenn ihr mein Template nutzt, müsst ihr die Hardware auch genau an die GPIOs anschließen, die im Template definiert sind.
Hier ist eine Übersicht der Belegung, wie sie im Template hinterlegt ist:

MAX6675 ESP32 SH1106 Tasmota
MAX6675 ESP32 SH1106 Tasmota
KomponentePin am ModulGPIO am ESP32
MAX6675 SOMISOGPIO 19
MAX6675 CSCSGPIO 5
MAX6675 SCKCLKGPIO 18
Display SDASDAGPIO 21
Display SCLSCLGPIO 22
BuzzerPlusGPIO 26
Display OptionGPIO 27 (Option A3)
image 10

Das Template:
Geht im Tasmota-Hauptmenü auf Configuration -> Configure Other.
Fügt dort folgendes Template ein:

⚠️ Entscheidend: Vergesst auf keinen Fall das Häkchen bei „Activate“ zu setzen! Ohne Aktivierung ignoriert Tasmota das Template komplett.

{"NAME":"Max6675","GPIO":[1,1,1,1,1,2496,1,1,1,1,1,1,1,1,2528,2560,0,640,608,1,0,6210,0,0,0,0,0,0,1,1,1,1,1,0,0,1],"FLAG":0,"BASE":1}

Schritt 3: Treiber & Regeln (Die Konsole)

Jetzt müssen wir Tasmota noch ein paar Feinheiten beibringen. Wechselt dazu in die Console.

1. Sensor-Konfiguration (MAX6675 vs. MAX31855)

Hier gibt es eine Besonderheit: In der GPIO-Auswahl von Tasmota findet man oft nur den MAX31855. Das ist aber kein Problem. Wir wählen diesen Pin-Typ aus (das macht das Template automatisch), müssen Tasmota aber sagen, dass physisch der etwas ältere MAX6675 angeschlossen ist. Das geschieht mit folgendem Befehl:

SetOption94 1

(Hinweis: 0 ist Standard für MAX31855, 1 aktiviert den Treiber für MAX6675).

2. Display initialisieren (Universal Display Driver)

Für das OLED Display nutzen wir den mächtigen „Universal Display Driver“ (UDD) von Tasmota. Dieser benötigt eine Initialisierungs-Zeile, den sogenannten „Descriptor“. Welchen Descriptor ihr genau braucht, hängt von eurem Display-Chip ab (bei mir ein SH1106).

Wer ein anderes Display nutzt, muss sich den passenden Descriptor auf der Tasmota GitHub Seite für UDD heraussuchen. Falls ihr Hilfe dabei braucht, schaut euch gerne mein Video zum Thema Universal Display Driver an, das ich unten verlinkt habe.

Für mein Setup kopiert ihr diesen Block in die Konsole:

rule3 :H,SH1106,128,64,1,I2C,3c,*,*,*
:S,0,2,1,0,30,20
:I
AE
D5,80
A8,3f
D3,00
40
8D,14
20,00
A1
C8
DA,12
81,CF
D9F1
DB,40
A4
A6
AF
:o,AE
:O,AF
:A,00,10,40,00,02
:i,A6,A7 #

⚠️ WICHTIG: Wir legen die Regel zwar an, aber wir aktivieren sie NICHT (also kein Rule3 1 eingeben!).

image 11

3. Schriftart anpassen

Damit das „Grad Celsius“ (°C) Zeichen und die Texte sauber und in der richtigen Größe dargestellt werden, nutzen wir den internen Font 0:

DisplayFont 0

Schritt 4: Das Herzstück – Das Berry Script

Jetzt kommen wir zu dem Teil, der das Projekt so besonders macht: Das Berry Script, das die komplette Benutzeroberfläche und Logik steuert.

Ich bin ehrlich zu euch: Ich bin kein Programmierer und wusste bis vor einem Jahr nicht einmal, dass Berry existiert. Angefangen hat alles mit Schnipseln aus dem Internet und Fragen auf GitHub. Das erste funktionierende Script entstand durch viel Lesen und Try & Error. Irgendwann wollte ich das Ganze aufwerten und dachte mir, ich frage mal die KI um Hilfe, um zu sehen, was da möglich ist.

Anfang 2025 war das Ganze noch recht neu und nach viel Ärger und so manchem „Streit“ mit der KI hat es irgendwann funktioniert. Das Problem ist, dass Berry nicht so bekannt ist. Was unter Python funktioniert, klappt in Berry nicht immer, und viele Infos hat man damals einfach nicht gefunden. Aber irgendwann hat es dann geklappt.

Als ich das Projekt jetzt für YouTube aufbereiten wollte, dachte ich mir: Schau mal, was die KI von Google so kann – in letzter Zeit wird die ja extrem gehypt. Und siehe da, Anfang 2026 hat sich tatsächlich einiges getan und Gemini hat mein Script extrem aufgewertet. Vor allem der Teil mit dem Diagramm, der vorher auf Biegen und Brechen nicht klappen wollte (die zyklische Aktualisierung), hat auf einmal beim ersten Versuch funktioniert. Ich war begeistert! Dann habe ich Gemini noch ein paar Kommentare für euch Zuschauer und Leser einfügen lassen und fertig war das Skript.

Falls ihr also am Projekt etwas verbessern oder ändern wollt, kann ich euch nur empfehlen: Fragt Gemini um Hilfe. Mit großer Wahrscheinlichkeit kommt da etwas Brauchbares bei rum.

Die Features des Scripts:

  • Custom Web-UI: Schieberegler für Soll-Temperatur, Min-Temperatur und Timer-Dauer direkt auf der Startseite.
  • Live-Diagramm: Ein SVG-Graph, der den Temperaturverlauf der letzten 10 Minuten anzeigt.
  • Ablauf-Steuerung: Logik für Aufheizen -> Timer Start -> Abkühlen.
  • Feedback: Steuert den Piezo-Buzzer und das OLED Display.

Installation des Scripts

  1. Vorbereitung am PC: Öffnet einen Texteditor (ich empfehle [Notepad++], da man dort die Sprache auf „Python“ stellen kann, was den Berry-Code schön lesbar macht.
  2. Kopiert den Berry-Code (siehe unten) in den Editor.
  3. Speichert die Datei unter dem Namen autoexec.be auf eurem Desktop ab oder verwendet die Datei die ich euch hier zur Verfügung stelle. (Achtung Windows-Nutzer: Überprüft, ob ihr Dateiendungen eingeblendet habt, damit die Datei nicht versehentlich autoexec.be.txt heißt!)
  4. Upload: Geht im Tasmota Menü auf Consoles -> Manage File System.
  5. Klickt auf „Datei auswählen“, sucht eure autoexec.be und ladet sie hoch.

📥 [Hier klicken zum Download Datei]

Der Berry Code (autoexec.be)

import webserver
import gpio

# ==============================================================================
# KLASSE: SollwertSteuerung
# BESCHREIBUNG: Steuert eine Wachs-Station mit Aufheizphase, Timer und Abkühlphase.
#               Beinhaltet Web-UI Integration, OLED Display und ein Temperatur-Diagramm.
# ==============================================================================
class SollwertSteuerung : Driver

    # --- Haupt-Variablen ---
    var sollwert                  # Zieltemperatur für das Aufheizen (in °C)
    var temperatur                # Aktueller Messwert vom Sensor (in °C)
    var initial_temp              # Starttemperatur vor dem Prozess (in °C)
    var min_temperatur            # Zieltemperatur für die Abkühlphase (in °C)
    
    # --- Status-Flags (Zustandsmerker) ---
    var max_erreicht              # True, wenn Sollwert einmal erreicht wurde
    var fertig_phase_erreicht     # True, wenn der gesamte Prozess beendet ist
    
    # --- Timer-Variablen ---
    var timer_started             # True, wenn der Timer (Wachs-Zeit) läuft
    var timer_end_time            # Zeitstempel (Millis), wann der Timer endet
    var timer_duration            # Dauer des Timers in Sekunden (z.B. 600 = 10 Min)
    
    # --- Hilfs-Merker für Logik-Steuerung ---
    var betriebsbereit_merker     # 0 = Initialzustand, 1 = Aufheizen gestartet
    var temp_erreicht_merker      # 1 = In Abkühlphase
    var timer_expired             # True, wenn die Timer-Zeit abgelaufen ist
    
    # --- Merker für einmalige Aktionen (Verhindert Wiederholungen) ---
    var timer_expired_blinked     # Hat es beim Ablauf des Timers schon gepiept?
    var timer_start_blinked       # Hat es beim Start des Timers schon gepiept?
    var betriebsbereit_printed    # Wurde "Betriebsbereit" schon ins Log geschrieben?
    var aufheizphase_printed      # Wurde "Aufheizphase" schon ins Log geschrieben?
    var sollwert_erreicht_printed # Wurde "Sollwert erreicht" schon ins Log geschrieben?
    var abkuehlphase_printed      # Wurde "Abkühlphase" schon ins Log geschrieben?
    
    # --- Diagramm Variablen ---
    var history_temps   # Liste für Temperaturen (Nur Zahlen)
    var history_times   # Liste für Uhrzeiten (Strings, aktuell nur intern genutzt)
    var last_history_update
    
    # --- Konfiguration der Slider im Webinterface (Grenzen & Schritte) ---
    var sollwert_min, sollwert_max, sollwert_step       # Einstellungen für Sollwert-Slider
    var min_temp_min, min_temp_max, min_temp_step       # Einstellungen für Min-Temp-Slider
    var timer_min, timer_max, timer_step                # Einstellungen für Timer-Slider

    # --------------------------------------------------------------------------
    # INIT: Wird einmalig beim Laden des Treibers ausgeführt
    # --------------------------------------------------------------------------
    def init()
        # Slider-Grenzen definieren
        self.sollwert_min = 10; self.sollwert_max = 100; self.sollwert_step = 10
        self.min_temp_min = 10; self.min_temp_max = 100; self.min_temp_step = 10
        self.timer_min = 1;     self.timer_max = 60;     self.timer_step = 1
        
        # Standardwerte für den ersten Start setzen
        self.sollwert = 90         # Standard: 90°C Wachstemperatur
        self.min_temperatur = 60    # Standard: 60°C Entnahme
        self.timer_duration = 600   # Standard: 10 Minuten (600 Sekunden)
        
        # Initialisierung der Diagramm-Listen
        self.history_temps = []
        self.history_times = []
        self.last_history_update = 0
        
        # Hardware-Setup
        gpio.pin_mode(26, gpio.OUTPUT)      # GPIO 26 als Ausgang für Summer
        gpio.digital_write(26, gpio.LOW)    # Summer ausschalten
        
        # Sensor-Verknüpfung: Ruft update_temperatur auf, wenn Sensor Daten liefert
        tasmota.add_rule('MAX6675#Temperature', def (value, trigger, data)
            self.update_temperatur(value)
        end)
        
        # Web-Interface Button beschriften
        tasmota.cmd("webbutton1 Display An/Aus")
        
        # Führt den initialen Reset aller Prozess-Variablen durch
        self.reset_process()
        print("Treiber geladen. Initialer Sollwert:", self.sollwert, "°C")
    end

    # --------------------------------------------------------------------------
    # RESET: Setzt alle Variablen auf Anfang zurück (Neustart des Wachs-Vorgangs)
    # --------------------------------------------------------------------------
    def reset_process()
        self.temperatur = 0.0
        self.initial_temp = nil             # Wird bei der nächsten Messung neu gesetzt
        
        # Logik-Status zurücksetzen
        self.max_erreicht = false
        self.fertig_phase_erreicht = false
        self.timer_started = false
        self.timer_end_time = 0
        
        # Hilfsvariablen nullen
        self.betriebsbereit_merker = 0
        self.temp_erreicht_merker = 0
        self.timer_expired = false
        
        # Einmal-Merker zurücksetzen (damit es wieder piept und loggt)
        self.timer_expired_blinked = false
        self.timer_start_blinked = false
        self.betriebsbereit_printed = false
        self.aufheizphase_printed = false
        self.sollwert_erreicht_printed = false
        self.abkuehlphase_printed = false
        
        # Diagramm beim Reset auch leeren
        self.history_temps = []
        self.history_times = []
        self.last_history_update = 0
        
        print("--- Prozess wurde manuell neu gestartet ---")
        self.update_display()               # Display sofort aktualisieren
    end

    # --------------------------------------------------------------------------
    # WEB-UI: Erstellt die Slider und Buttons auf der Hauptseite
    # --------------------------------------------------------------------------
    def web_add_main_button()
        webserver.content_send("<p></p>")
        
        # --- Slider 1: Sollwert (Heiztemperatur) ---
        webserver.content_send("<div style='text-align:center;'>")
        webserver.content_send("<label for='sollwert_slider'>Sollwert:</label>")
        webserver.content_send("<input type='range' id='sollwert_slider' min='" + str(self.sollwert_min) + "' max='" + str(self.sollwert_max) + "' step='" + str(self.sollwert_step) + "' value='" + str(self.sollwert) + "' oninput='document.getElementById(\"sollwert_value\").innerText = this.value;' onchange='la(\"&sollwert=\" + this.value);' style='width:80%;'>")
        webserver.content_send("<span id='sollwert_value'>" + str(self.sollwert) + "</span> °C")
        webserver.content_send("</div>")
        
        # --- Slider 2: Mindesttemperatur (Entnahme) ---
        webserver.content_send("<div style='text-align:center;'>")
        webserver.content_send("<label for='min_slider'>Mindesttemperatur:</label>")
        webserver.content_send("<input type='range' id='min_slider' min='" + str(self.min_temp_min) + "' max='" + str(self.min_temp_max) + "' step='" + str(self.min_temp_step) + "' value='" + str(self.min_temperatur) + "' oninput='document.getElementById(\"min_value\").innerText = this.value;' onchange='la(\"&min_temp=\" + this.value);' style='width:80%;'>")
        webserver.content_send("<span id='min_value'>" + str(self.min_temperatur) + "</span> °C")
        webserver.content_send("</div>")
        
        # --- Slider 3: Timer Dauer ---
        webserver.content_send("<div style='text-align:center;'>")
        webserver.content_send("<label for='timer_slider'>Timer (Minuten):</label>")
        webserver.content_send("<input type='range' id='timer_slider' min='" + str(self.timer_min) + "' max='" + str(self.timer_max) + "' step='" + str(self.timer_step) + "' value='" + str(int(self.timer_duration / 60)) + "' oninput='document.getElementById(\"timer_value\").innerText = this.value;' onchange='la(\"&timer_duration=\" + this.value);' style='width:80%;'>")
        webserver.content_send("<span id='timer_value'>" + str(int(self.timer_duration / 60)) + "</span> Minuten")
        webserver.content_send("</div>")

        # --- Button: Prozess Neustart (Reset) ---
        webserver.content_send("<div style='text-align:center; margin-top: 15px;'>")
        webserver.content_send("<button onclick='la(\"&reset_proc=1\");' style='background-color:#d43f3a; color:white; padding:10px 20px; border:none; border-radius:5px; font-size:16px; cursor:pointer;'>Prozess Neustart</button>")
        webserver.content_send("</div>")
    end

    # --------------------------------------------------------------------------
    # WEB SENSOR: Verarbeitet Eingaben (AJAX) und aktualisiert Statusanzeige
    # --------------------------------------------------------------------------
    def web_sensor()
        # -- Eingabe Sollwert --
        if webserver.has_arg("sollwert") == true
            var neuer_sollwert = int(webserver.arg("sollwert"))
            if neuer_sollwert >= self.sollwert_min && neuer_sollwert <= self.sollwert_max
                self.sollwert = neuer_sollwert
                self.check_temperature()
                tasmota.cmd("WebSend") # Seite neu laden
            end
        end

        # -- Eingabe Mindesttemperatur --
        if webserver.has_arg("min_temp") == true
            var neue_min_temp = int(webserver.arg("min_temp"))
            if neue_min_temp >= self.min_temp_min && neue_min_temp <= self.min_temp_max
                self.min_temperatur = neue_min_temp
                self.check_temperature()
                tasmota.cmd("WebSend")
            end
        end

        # -- Eingabe Timer --
        if webserver.has_arg("timer_duration") == true
            var neue_timer_dauer = int(webserver.arg("timer_duration"))
            if neue_timer_dauer >= self.timer_min && neue_timer_dauer <= self.timer_max
                self.timer_duration = neue_timer_dauer * 60 # Umrechnung Min -> Sek
                self.update_display()
                tasmota.cmd("WebSend")
            end
        end

        # -- Eingabe Reset Button --
        if webserver.has_arg("reset_proc")
            self.reset_process()
            tasmota.cmd("WebSend") 
        end

        # -- HTML Status Ausgabe erstellen --
        var status_text = self.get_web_phase_text()
        webserver.content_send("<div class='status-box' style='text-align:center;margin:24px 0;'>" + status_text + "</div>")
        webserver.content_send(format("<div style='text-align:center;font-size:22px;margin:10px 0;'>Aktuelle Temperatur: %d°C</div>", int(self.temperatur)))
        
        # --- DIAGRAMM EINFÜGEN ---
        # Fügt das Diagramm ein. Da Tasmota diesen Bereich automatisch refreshed, 
        # wird das Diagramm live aktualisiert.
        webserver.content_send(self.get_svg_chart())
        
        webserver.content_send(self.get_web_timer_text())
        webserver.content_send(format("<div style='text-align:center;font-size:12px;margin:10px 0;'>Sollwert: %d°C | Mindesttemperatur: %d°C</div>", self.sollwert, self.min_temperatur))
        
        if self.initial_temp != nil
            webserver.content_send(format("<div style='text-align:center;font-size:12px;margin:10px 0;'>Initialtemperatur: %d°C</div>", int(self.initial_temp)))
        end
    end

    # --------------------------------------------------------------------------
    # DIAGRAMM LOGIK: Erzeugt SVG mit fester Zeitachse (10 Min)
    # --------------------------------------------------------------------------
    def get_svg_chart()
        var list_size = size(self.history_temps)
        
        # Konsistenzcheck der beiden Listen (Sicherheitshalber)
        if list_size != size(self.history_times)
            self.history_temps = []
            self.history_times = []
            return "<div>Datenfehler bereinigt. Warte auf neue Daten...</div>"
        end

        if list_size < 2
            return "<div style='text-align:center; color:grey; padding:20px;'>Diagramm: Sammle Daten... (Warte 5s)</div>"
        end

        var width = 300
        var full_height = 140
        var graph_height = 110
        var padding = 5
        var max_slots = 120 # Entspricht 120 * 5s = 600s = 10 Minuten
        
        # Min/Max berechnen für automatische Y-Achsen Skalierung
        var min_val = 200.0
        var max_val = -50.0
        
        for val : self.history_temps
            if val < min_val min_val = val end
            if val > max_val max_val = val end
        end
        
        # Puffer, damit Linie nicht am Rand klebt
        if min_val == max_val 
            max_val = max_val + 1 
            min_val = min_val - 1
        end
        
        var temp_range = max_val - min_val
        var svg = format("<div style='text-align:center;'><svg width='%d' height='%d' style='background-color:#222; border-radius:5px;'>", width, full_height)
        
        # --- Polylinie berechnen ---
        # Logik: Wir haben eine feste Breite für 10 Min (max_slots).
        # Neue Werte sind immer RECHTS (bei 0 min). Alte Werte wandern nach LINKS.
        var points = ""
        for i : 0 .. (list_size - 1)
            var val = self.history_temps[i]
            
            # X-Position:
            # i=0 (ältester in Liste) -> muss weiter links stehen
            # i=list_size-1 (neuester) -> muss ganz rechts bei width stehen
            # Formel: Breite - ((Anzahl_in_Liste - 1 - aktueller_Index) * Pixel_pro_Slot)
            var slot_pos_from_right = list_size - 1 - i
            var pixel_per_slot = width / (max_slots * 1.0)
            
            var x = width - (slot_pos_from_right * pixel_per_slot)
            
            # Y-Position (invertiert, da 0 oben)
            var y = graph_height - ((val - min_val) * (graph_height - (2 * padding)) / temp_range) - padding
            
            points = points + format("%d,%d ", int(x), int(y))
        end
        
        # Grüne Linie zeichnen
        svg = svg + format("<polyline points='%s' style='fill:none;stroke:#00FF00;stroke-width:2' />", points)
        
        # Text: Min/Max Temperatur (Links im Graphen)
        svg = svg + format("<text x='5' y='15' fill='white' font-size='10'>Max: %d°C</text>", int(max_val))
        svg = svg + format("<text x='5' y='%d' fill='white' font-size='10'>Min: %d°C</text>", graph_height - 5, int(min_val))
        
        # Text: Feste Zeitachse (Unten)
        # Positionen: 0%, 20%, 40%, 60%, 80%, 100%
        # Labels: 10, 8, 6, 4, 2, 0
        var y_text = full_height - 5
        svg = svg + format("<text x='0' y='%d' fill='#AAA' font-size='10' text-anchor='start'>10</text>", y_text)
        svg = svg + format("<text x='60' y='%d' fill='#AAA' font-size='10' text-anchor='middle'>8</text>", y_text)
        svg = svg + format("<text x='120' y='%d' fill='#AAA' font-size='10' text-anchor='middle'>6</text>", y_text)
        svg = svg + format("<text x='180' y='%d' fill='#AAA' font-size='10' text-anchor='middle'>4</text>", y_text)
        svg = svg + format("<text x='240' y='%d' fill='#AAA' font-size='10' text-anchor='middle'>2</text>", y_text)
        svg = svg + format("<text x='300' y='%d' fill='#AAA' font-size='10' text-anchor='end'>0 min</text>", y_text)
        
        # Hilfslinien (optional, ganz dezent)
        svg = svg + format("<line x1='0' y1='%d' x2='300' y2='%d' style='stroke:#444;stroke-width:1' />", full_height-20, full_height-20)

        svg = svg + "</svg></div>"
        return svg
    end

    # --------------------------------------------------------------------------
    # HISTORIE AUFZEICHNEN: Speichert alle 5s einen Wert in die Listen
    # --------------------------------------------------------------------------
    def record_history()
        var now = tasmota.millis()
        
        # Intervall 5000ms (5 Sekunden)
        if (now - self.last_history_update) >= 5000 || self.last_history_update == 0
            
            # Zeitstring sicher abrufen (mit Fehlerbehandlung für map types)
            var time_str = "--:--:--"
            try
                var rtc_map = tasmota.rtc()
                if rtc_map.contains('local')
                    time_str = tasmota.strftime("%H:%M:%S", rtc_map['local'])
                end
            except ..
                time_str = "Error"
            end

            # In zwei getrennte Listen speichern
            self.history_temps.push(self.temperatur)
            self.history_times.push(time_str)
            
            # Begrenzung auf 121 Werte (damit 10 Min voll abgedeckt sind)
            if size(self.history_temps) > 121
                self.history_temps.pop(0) 
                self.history_times.pop(0)
            end
            
            self.last_history_update = now
        end
    end

    # --------------------------------------------------------------------------
    # TEXT GENERATOR: Erzeugt Text für das OLED Display
    # --------------------------------------------------------------------------
    def get_display_phase_text()
        var phase_text = ""
        var timer_running = self.timer_started && !self.timer_expired
        
        # Logik-Abfolge für Displaytext
        if self.fertig_phase_erreicht
            phase_text = "Kette fertig"
        elif self.max_erreicht && timer_running
            phase_text = "Temperatur erreich"
            # Einmalige Log-Ausgabe
            if !self.sollwert_erreicht_printed
                print("Sollwerttemperatur erreicht")
                self.sollwert_erreicht_printed = true
            end
        elif self.max_erreicht && self.timer_expired
            phase_text = "Abkuehlphase"
            self.temp_erreicht_merker = 1
            if !self.abkuehlphase_printed
                print("Abkühlphase hat begonnen")
                self.abkuehlphase_printed = true
            end
        elif self.temp_erreicht_merker == 1
            phase_text = "Abkuehlphase"
        elif self.temperatur < self.sollwert && !self.max_erreicht
            if self.initial_temp != nil && self.temperatur >= (self.initial_temp + 1)
                phase_text = "Aufheizphase"
                self.betriebsbereit_merker = 1
                if !self.aufheizphase_printed
                    print("Aufheizphase hat begonnen")
                    self.aufheizphase_printed = true
                end
            elif self.betriebsbereit_merker == 0
                phase_text = "Betriebsbereit"
                if !self.betriebsbereit_printed
                    print("Betriebsbereit")
                    self.betriebsbereit_printed = true
                end
            else
                phase_text = "Aufheizphase"
            end
        else
            # Fallback
            if self.betriebsbereit_merker == 0
                phase_text = "Betriebsbereit"
                if !self.betriebsbereit_printed
                    print("Betriebsbereit")
                    self.betriebsbereit_printed = true
                end
            else
                phase_text = "Aufheizphase"
            end
        end
        return phase_text
    end

    # --------------------------------------------------------------------------
    # TIMER DISPLAY: Formatiert die verbleibende Zeit MM:SS
    # --------------------------------------------------------------------------
    def get_display_timer_text()
        var timer_text = ""
        if self.timer_started
            var remaining_time = (self.timer_end_time - tasmota.millis()) / 1000
            if remaining_time > 0
                var remaining_minutes = int(remaining_time / 60)
                var remaining_seconds = int(remaining_time % 60)
                timer_text = format("%02d:%02d", remaining_minutes, remaining_seconds)
            else
                timer_text = "00:00"
            end
        else
            # Zeigt eingestellte Dauer wenn Timer noch nicht läuft
            var initial_minutes = int(self.timer_duration / 60)
            var initial_seconds = int(self.timer_duration % 60)
            timer_text = format("%02d:%02d", initial_minutes, initial_seconds)
        end
        return timer_text
    end

    # --------------------------------------------------------------------------
    # WEB TEXT: Erzeugt farbigen HTML-Text für die Statusbox
    # --------------------------------------------------------------------------
    def get_web_phase_text()
        var phase_text = ""
        var timer_running = self.timer_started && !self.timer_expired
        
        if self.fertig_phase_erreicht
            phase_text = "<span style='font-size:24px;color:blue;font-weight:bold;'>Fertig Kette entnehmen</span>"
        elif self.max_erreicht && timer_running
            phase_text = "<span style='font-size:24px;color:green;font-weight:bold;'>Sollwerttemperatur erreicht</span>"
        elif self.max_erreicht && self.timer_expired
            phase_text = "<span style='font-size:24px;color:yellow;'>Abkühlphase</span>"
        elif self.temp_erreicht_merker == 1
            phase_text = "<span style='font-size:24px;color:yellow;'>Abkühlphase</span>"
        elif self.temperatur < self.sollwert && !self.max_erreicht
            if self.initial_temp != nil && self.temperatur >= (self.initial_temp + 1)
                phase_text = "<span style='font-size:24px;color:orange;'>Aufheizphase</span>"
            elif self.betriebsbereit_merker == 0
                phase_text = "<span style='font-size:24px;color:red;'>Betriebsbereit</span>"
            else
                phase_text = "<span style='font-size:24px;color:orange;'>Aufheizphase</span>"
            end
        else
            if self.betriebsbereit_merker == 0
                phase_text = "<span style='font-size:24px;color:red;'>Betriebsbereit</span>"
            else
                phase_text = "<span style='font-size:24px;color:orange;'>Aufheizphase</span>"
            end
        end
        return phase_text
    end

    # --------------------------------------------------------------------------
    # WEB TIMER: HTML für Timer-Anzeige
    # --------------------------------------------------------------------------
    def get_web_timer_text()
        var timer_text = ""
        if self.timer_started
            var remaining_time = (self.timer_end_time - tasmota.millis()) / 1000
            if remaining_time > 0
                var remaining_minutes = int(remaining_time / 60)
                var remaining_seconds = int(remaining_time % 60)
                timer_text = format("<div style='text-align:center;font-size:20px;margin:10px 0;'>Timer: %02d:%02d</div>", remaining_minutes, remaining_seconds)
            else
                timer_text = "<div style='text-align:center;font-size:20px;margin:10px 0;'>Timer abgelaufen</div>"
            end
        else
            var initial_minutes = int(self.timer_duration / 60)
            var initial_seconds = int(self.timer_duration % 60)
            timer_text = format("<div style='text-align:center;font-size:20px;margin:10px 0;'>Timer: %02d:%02d (bereit)</div>", initial_minutes, initial_seconds)
        end
        return timer_text
    end

    # --------------------------------------------------------------------------
    # UPDATE TEMP: Wird bei jedem Sensor-Update aufgerufen
    # --------------------------------------------------------------------------
    def update_temperatur(value)
        if self.initial_temp == nil
            self.initial_temp = value
            print("Initialtemperatur:", self.initial_temp, "°C")
        end
        self.temperatur = value
        
        # Historie aktualisieren
        self.record_history()
        
        self.check_temperature()    # Hauptlogik prüfen
    end

    # --------------------------------------------------------------------------
    # BLINK GPIO26: Summer Ansteuerung (Non-Blocking)
    # Erzeugt 3 Pieptöne nacheinander, ohne Tasmota anzuhalten.
    # --------------------------------------------------------------------------
    def blink_gpio26()
        # Interne Hilfsfunktion für EINEN Piepton
        def do_beep()
            gpio.digital_write(26, gpio.HIGH)
            # Schaltet Summer nach 150ms wieder aus
            tasmota.set_timer(150, def() gpio.digital_write(26, gpio.LOW) end)
        end

        # Timer setzen für 3 Töne (Zeitversetzt: 0ms, 1200ms, 2400ms)
        tasmota.set_timer(0, do_beep)
        tasmota.set_timer(1200, do_beep)
        tasmota.set_timer(2400, do_beep)
    end

    # --------------------------------------------------------------------------
    # HAUPTLOGIK: State Machine (Prüft Status bei jedem Temperatur-Update)
    # --------------------------------------------------------------------------
    def check_temperature()
        # 1. Wenn fertig, nichts mehr tun (nur Display aktualisieren)
        if self.fertig_phase_erreicht
            self.update_display()
            return
        end

        # 2. Timer Logic prüfen (wenn er läuft)
        if self.timer_started && !self.timer_expired
            var remaining_time = (self.timer_end_time - tasmota.millis()) / 1000
            if remaining_time <= 0
                self.timer_expired = true
                print("Timer abgelaufen - Abkühlphase hat begonnen")
                # Einmalig Piepen bei Ablauf
                if !self.timer_expired_blinked
                    self.blink_gpio26()
                    self.timer_expired_blinked = true
                end
            end
        end

        # 3. Aufheizphase: Prüfen ob Sollwert erreicht
        if self.temperatur >= self.sollwert
            self.max_erreicht = true
            # Timer starten, wenn noch nicht geschehen
            if !self.timer_started
                self.timer_started = true
                self.timer_end_time = tasmota.millis() + (self.timer_duration * 1000)
                print("Timer gestartet (" + str(int(self.timer_duration / 60)) + " Minuten und " + str(self.timer_duration % 60) + " Sekunden)")
                # Einmalig Piepen bei Start
                if !self.timer_start_blinked
                    self.blink_gpio26()
                    self.timer_start_blinked = true
                end
            end
        end

        # 4. Abschluss: Wenn Timer abgelaufen UND Temperatur tief genug
        if self.max_erreicht && self.timer_expired && self.temperatur <= self.min_temperatur
            self.fertig_phase_erreicht = true
            print("Minimum Temperatur erreicht, Prozess abgeschlossen. Kette entfernen!")
            self.blink_gpio26() # Abschluss-Piepen
            
            # Flags zurücksetzen (Vorbereitung, falls Reset gedrückt wird)
            self.betriebsbereit_merker = 0
            self.temp_erreicht_merker = 0
            self.timer_expired = false
            self.timer_expired_blinked = false
            self.timer_start_blinked = false
        end

        # Display immer aktualisieren
        self.update_display()
    end

    # --------------------------------------------------------------------------
    # DISPLAY UPDATE: Sendet Befehle an den Display Treiber
    # Nutzt Tasmota Commands [sXlYcX] für Positionierung
    # --------------------------------------------------------------------------
    def update_display()
        var phase_text = self.get_display_phase_text()
        var timer_text = self.get_display_timer_text()

        tasmota.cmd("DisplayText [s1l1c1]" + phase_text + "       ")
        tasmota.cmd("DisplayText [s1l3c1]Temperatur")
        tasmota.cmd("DisplayText [s1y37x1]Timer")
        # Temperatur groß anzeigen (~F8C ist Schriftart)
        tasmota.cmd("DisplayText [s2y13x72]" + str(int(self.temperatur)) + "~F8C")
        tasmota.cmd("DisplayText [s2y35x60]" + timer_text)
        # Sollwert und Min-Wert klein unten
        tasmota.cmd("DisplayText [s1l8c1]Max: " + str(self.sollwert) + "~F8C" + " Min: " + str(self.min_temperatur) + "~F8C")
    end
end

# Treiber registrieren (Löschen -> Hinzufügen für Reload ohne Reboot)
steuerung = SollwertSteuerung()
tasmota.remove_driver(steuerung)
tasmota.add_driver(steuerung)
Vollständige Code anzeigen

Fazit

image 12

Nach dem Hochladen der autoexec.be müsst ihr den ESP32 nur noch einmal neu starten (Hauptmenü -> Restart). Danach solltet ihr auf der Startseite nicht mehr das Standard-Menü, sondern meine Regler und das Diagramm sehen.

Dieses Projekt zeigt wunderbar, wie mächtig Tasmota in Kombination mit Berry ist. Wir haben ein Gerät gebaut, das komplett autark läuft, ohne dass zwingend ein Smarthome-Server laufen muss. Es verbindet Hardware (Display, Buzzer, Sensor) intelligent miteinander und bietet eine grafische Oberfläche, die es so standardmäßig gar nicht gibt.

Ich hoffe, das Tutorial und die Dateien helfen euch beim Nachbauen. Falls ihr Fragen habt oder eure eigenen Anpassungen am Script vorgenommen habt (vielleicht mit Hilfe von KI?), schreibt es mir gerne in die Kommentare unter dem YouTube Video.

Viel Spaß beim Wachsen (oder Grillen)!

Euer Eddy


Relevante Videos & Links


⚠️ Hinweis zu Affiliate-Links:
Alle Links zu Produkten auf dieser Website sind Affiliate-Links. Durch das Anklicken dieser Links unterstützt du meine Arbeit und hilfst mir, diese Website zu finanzieren.
Für dich entstehen dabei keine zusätzlichen Kosten – der Preis bleibt gleich.
Ich erhalte lediglich eine kleine Provision vom Händler oder Amazon. Vielen Dank für deine Unterstützung! 🙏