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.

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.

Der Ablauf ist strikt:
- Das Wachs muss langsam auf 90°C erhitzt werden, damit es flüssig genug ist, um in die kleinsten Glieder der Kette zu kriechen.
- Dann muss die Kette etwa 10 Minuten im Wachsbad bleiben, wobei die Temperatur konstant gehalten werden muss.
- 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.

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):
- Der Controller: [ESP32 D1 Mini]
- Der Sensor: [MAX6675 Thermoelement]
- Die Anzeige: [OLED Display SH1106 (1.3 Zoll)]
- Der Alarm: [Aktiver Piezo-Buzzer]
- Lochstreifenplatine: [Platine]
- Draht: [Draht]
- Gehäuse: [Universal Kunststoff-Gehäuse schwarz]
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:
- Berry Script Sprache
- Universal Display Driver
- Dateisystem (LittleFS)
- 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:

- Schritt 1 (Factory Image): Zuerst flashen wir die Datei
tasmota32-factory.bin(diese ist oft größer und legt die Grundstruktur an).- Lade den [Tasmota pyFlasher hier herunter].
- Wähle den COM-Port und die
tasmota32-factory.bin. - Klicke auf „Flash NodeMCU“.
- 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.
- 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.
- Wählt jetzt meine spezielle

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:

| Komponente | Pin am Modul | GPIO am ESP32 |
| MAX6675 SO | MISO | GPIO 19 |
| MAX6675 CS | CS | GPIO 5 |
| MAX6675 SCK | CLK | GPIO 18 |
| Display SDA | SDA | GPIO 21 |
| Display SCL | SCL | GPIO 22 |
| Buzzer | Plus | GPIO 26 |
| Display Option | – | GPIO 27 (Option A3) |

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!).

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 0Schritt 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
- 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.
- Kopiert den Berry-Code (siehe unten) in den Editor.
- Speichert die Datei unter dem Namen
autoexec.beauf 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 versehentlichautoexec.be.txtheißt!) - Upload: Geht im Tasmota Menü auf Consoles -> Manage File System.
- Klickt auf „Datei auswählen“, sucht eure
autoexec.beund 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)Fazit

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
- YouTube: Mein Video zu diesem Projekt
- YouTube: Tasmota Display Konfiguration erklärt (UDD)
- YouTube: Tasmota mit Telegram
- Tasmota pyFlasher GitHub
- Nodepad++
- tasmota32.bin Firmware
⚠️ 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! 🙏
