kabellose Übertragung von Messwerten

Xiaomi Mi Bluetooth-Thermometer via Raspberry Pi auslesen

In diesem Beitrag zeige ich, wie man die Messdaten des winzigen Thermometers / Hygrometers von Xiaomi Mi regelmäßig via Raspberry Pi ausliest und darauf aufbauend eine schöne grafische Oberfläche mit allen Messdaten erstellt. Ich lasse mir die aktuellen Werte zudem in der Windows-Taskleiste und auf meinem Android-Home-Screen anzeigen.

Bildschirmfoto: Graphen und Informationen bezüglich der Messwerte des Mi-Thermometers als Ansicht in einem Browser

So ein schönes Dashboard kann man sich selbst erstellen bzw. fleißig Messwerte sammeln.

Die aktuelle Temperatur und Luftfeuchtigkeit mit dem Raspberry Pi auszulesen und aufzuzeichnen gehört sicherlich zu den Standards, die man mit dem kleinen „Einplatinencomputer“ zumindest einmal ausprobieren möchte. Das geht auch einfach: Indem man einen entsprechenden Sensor an die GPIOs anstöpselt und die Werte abgreift.

Raspberry Pi in Verpackung, ein Feuerzeug daneben als Größenvergleich
Der Raspberry Pi ist ein Minicomputer, der permanent laufen kann und dabei äußerst wenig Strom verbraucht. Er eignet sich daher sehr gut für das permanente Erfassen von Messdaten.

Dummerweise müsste der Sensor Draußen im Schatten platziert werden. Denn wenn sich dieser in der direkten Sonne befindet, erhält man keine realistischen Werte. Lange Kabel wollte ich auch nicht verlegen, also probierte ich einmal die Sache mittels Bluetooth aus bzw. mit dem kleinen Xiaomi Mi Sensor (Codename LYWSD03MMC):

Xiaomi Mi Bluetooth-Thermometer neben einer Muschel und einem Feuerzeug zum Größenvergleich

Das Xiaomi-Thermometer / -Hygrometer ist winzig klein. Anhand des Symbols im Display sieht man schon, dass ich eine andere Firmware darauf laufen habe. Dazu gleich mehr.

Der Mi-Sensor ist ein sehr günstiges (um 10 €) Thermometer und Hygrometer, welches die Messdaten per Bluetooth bzw. drahtlos ausgibt und entsprechend geeignete Empfänger können diese Daten dann erhalten bzw. speichern. Mein simpler Raspberry Pi 3B+ ist solch ein Empfänger – ohne irgendwelche Zusatz-Hardware. Denn das integrierte Bluetooth-Modul (Bluetooth ab Version 4.0) ist entsprechend kompatibel. Dieses Xiaomi-Thermometer eignet sich übrigens inoffiziell auch für den Außenbetrieb bzw. bei Minus-Temperaturen (bis ca. -5° C habe ich dies – mit Varta-Zelle x – über mehrere Frosttage selbst getestet).

x Die Qualität der Zelle (Batterie) ist hierbei nicht ganz unwichtig.

Wenn man solche Messwerte erst einmal im Raspberry Pi drin hat, ist dies bereits die halbe Miete für ein selbstgebautes, autonomes x „Smart Home“. Man kann dann später simple If-Abfragen erstellen, um beispielsweise Steckdosen-Relais zu schalten oder einen Alarmton ausgeben, wenn es im Gewächshaus zu kalt wird oder so etwas. Beim Einlesen via Smartphone (s. u.) könnte man dann z. B. auch eine SMS versenden lassen.

x Wir sind also nicht mehr von Servern in z. B. US-Amerika oder China abhängig und bei Abschaltung dieser werden wir keinen Elektroschrott besitzen.

Die Sache ist jedoch nicht ganz trivial. Ich versuche in diesem Beitrag alle Schritte nachvollziehbar und für Dummis (wie mich) darzustellen. Hierbei sei jedoch gesagt, dass ich den Artikel auf Basis meiner Aufzeichnungen schreibe und dass ich hier eben kein Experte bin. Außerdem hatte ich mir viel Hilfe von einer KI geholt (insbesondere bei den Codes für die Python-Skripte). Los geht’s:

Funktionsprinzip

Das Mi-Thermometer muss nicht mit dem Raspberry Pi gekoppelt werden, wie man es von anderen Bluetooth-Geräten – etwa von Lautsprechern – kennt. Es handelt sich hier um ein etwas anderes Prinzip: Das Gerät posaunt regelmäßig (etwas alle zehn Sekunden) die aktuellen Messwerte in die Welt und entsprechend vorbereitete Geräte können diese Daten einfach „abfangen“ – wie beim Radioempfang – während sie für einen bestimmten Moment lauschen. Das macht die Sache besonders stromsparend, wenn nichts permanent gekoppelt ist. Dieses Prinzip nennt sich ›BLE‹ (Bluetooth Low Energy).

Die Reichweite ist hier natürlich begrenzt: Auf ca. 10 Meter – bei Wänden dazwischen natürlich kürzer. Ich nutze den LYWSD03MMC auf dem Balkon, dazwischen befindet sich die Hauswand und dahinter mein Raspberry Pi. Die ganze Geschichte verbraucht offenbar so wenig Strom, dass die eingelegte 3V-Knopfzelle für ca. ein Jahr reicht (bei Kälte wird ein geringerer Ladezustand ausgegeben). Aber so lange nutze ich das Xiaomi-Thermometer noch nicht, um dies beurteilen zu können.

Auf dem Raspberry Pi wird dann (z. B. durch die Crontab) in regelmäßigen Zyklen ein Python-Skript gestartet, welches für ca. eine Minute zu lauschen beginnt, bis es aktuelle Daten vom Mi-Sensor erhalten hat. War dies erfolgreich, werden diese Daten (Temperatur, Luftfeuchtigkeit, Zeitpunkt, Batterie-Zustand) in eine kleine Datei (SQLite-Datenbank) geschrieben. Diese füllt sich dann nach und nach.

Parallel dazu erstellen wir ein Skript bzw. einen Mini-Server auf dem Raspberry, welcher eine visuell ansprechende grafische Auswertung dieser Daten bereit stellt, um sie bequem im Browser betrachten zu können. Hierbei wird zusätzlich noch ein „API-Endpunkt“ bereit gestellt, um die aktuellen Daten bei Bedarf auch von anderen Anwendungen (z. B. Android-App) auslesbar- bzw. darstellbar zu machen.

Zunächst folgt als erstes jedoch der schwierigste Schritt:

Die originale Firmware muss durch eine andere ersetzt werden

Dummerweise sendet die hier genutzte Version des Xiaomi Sensors verschlüsselt (bei alten Modellen war dies offenbar anders). So kann man das nicht auslesen. Die sich darauf befindende Firmware muss also durch eine Custom-Firmware (von pvvx) ersetzt werden. Wie das gehen soll? Über einen Browser auf einem Computer (oder auf einem Smartphone) mit Bluetooth-Unterstützung.

Allerdings muss man hierzu einige Daten vom Sensor wissen: Device known id, Mi Bind Key sowie Mi Token. Doch woher nehmen? Hierzu gibt es offenbar mehrere Wege. Ich hatte mich für den einfachen entschieden:

  1. Ich habe bereits einen Mi-Account. Wenn nicht, muss man einen anlegen.
  2. Man lädt sich dann die offizielle App von Xiaomi für diese proprietäre SmartHome-Geschichten herunter, installiert sie und loggt sich dort mit seinem Xiaomi-Konto ein. Ich nutzte hierzu ein ausrangiertes Android-Smartphone.

    Diese App kann man später wieder deinstallieren.

  3. Dort in der App verbindet man das Mi-Thermometer und kann dort nun bereits die Daten auslesen / einsehen. Seitens Xiaomi war’s das schon. Wir wollen natürlich mehr.
  4. Ich besorgte mir dann das kleine Tool Xiaomi Cloud Tokens Extractor für Windows. Dieses Tool liest alle mit der Xiaomi-Cloud verbundenen Geräte aus und listet die benötigten Daten auf. In Punkt 3 hatte ich ja meinen Sensor (durch die App) mit der Cloud verbunden. Allerdings muss man seine Zugangsdaten in das Tool eingeben. Ich würde hierfür ein neues Konto bei Xiaomi erstellen, wenn ich ein solches bereits tatsächlich anderweitig nutzen sollte.

Nun hatte ich durch das Tool die Device known id (ID), den Mi Bind Key (BLE Key), den Mi Token (TOKEN), sowie die MAC-Adresse meines Thermometers erhalten. Diese Daten sollten nun notiert werden. Ich deinstallierte die Xiaomi-App auf dem Smartphone wieder und es folgte der nächste Schritt:

Ich ging auf diese Website – auf den Telink Flasher for Mi Thermostat

Man platziere den Mi-Sensor nah am Computer, klickt auf der Website auf Connect und es sollte nun ein solches Fenster erscheinen:

Bildschirmfoto: Koppeln des Mi-Sensors mit dem Computerbrowser

Da ich den Vorgang schon einmal durchführte, ist mein Sensor bereits als „gekoppelt“ aufgeführt. Ansonsten wählt man den richtigen Eintrag (ATC_…) aus und geht auf „Koppeln“. Das andere Gerät gehört meinem Nachbarn.

Ein Koppeln ist hier nur für die Übertragung der neuen Firmware nötig, nicht für das tatsächliche Auslesen via Raspberry Pi.

Der Browser muss dies jedoch unterstützen. Falls hier nichts passiert, muss man bei diesem einige Einstellungen vornehmen (MAC auslesen). Hinweise bzw. Direktlinks dazu bietet praktischerweise gleich die Website, auf der wir uns gerade befinden.

Nun müsste man (soweit ich mich erinnere) die Daten zu seinem Gerät eingeben: Device known id, Mi Bind Key sowie Mi Token. Glücklicherweise hatte ich diese vorher ja mit dem Xiaomi Cloud Tokens Extractor Tool auslesen können.

Wenn dies erfolgreich war, müsste man nun Custom Firmware auswählen- bzw. auf Start Flashing klicken können. Es ist bei mir schon etwas länger her. Meine Erinnerungen hierzu sind etwas verblasst.

Nach erfolgreichem Flashen der individuellen Firmware sollte man eine ziemlich üppige Konfigurationsseite für seinen Sensor erhalten (ggf. nach Neuverbinden):

Bildschirmfoto: Mi-Thermostat Konfigurationsseite

Man kann mittels diesen Einstellungen sogar das Smiley-Symbol auf dem Display ändern.

Ganz oben werden gleich die aktuellen Messdaten angezeigt. Ändern braucht man hier zunächst nichts. Außer: Bei mir ist als Advertising Type BTHome v2 eingestellt. Offenbar ist dies so eine Art Protokolltyp. Damit arbeite ich. Advertising hat hier nichts mit Werbung zu tun: Damit sind die Sendepakete gemeint.

Ich änderte hier später noch das Advertising Intervall (auf 5000.0) sowie RF TX Power (auf -3.03 dBm). Mit diesen Werten kann man den Sensor stromsparender machen. Die Werte hängen natürlich davon ab, wie weit das Bluetooth-Thermometer vom Raspberry Pi entfernt steht und wie lange man später bei einem Empfangsvorgang warten möchte / muss, bis ein neues Signal empfangen wird (Timeout).

Zunächst würde ich für die ersten Tests bei den Standardwerten bleiben, aber eben das „BTHome v2“-Protokoll verwenden.

Das war der kniffligste Teil.

Mit der nun aufgespielten ›pvvx-Firmware‹ sendet der Sensor die Daten unverschlüsselt via s. g. „BLE-Advertisements“ in regelmäßigen Intervallen. Der Raspberry Pi wird gleich hin und wieder passiv mithören bzw. sich diese Daten brav in einer Datenbank notieren.

jetzt geht es am Raspberry Pi weiter:

Mittels Bluetooth & Python die Daten vom Mi-Sensor auslesen

Wir werden gleich etwas Software installieren und danach ein kleines Skript in der Konsole starten, welches die aktuellen Messwerte empfängt bzw. ausgibt.

Bei mir funktioniert dies alles auf meinem Raspberry Pi 3B+ mit dem Raspberry Pi OS Lite 64 Bit (Trixie). Ich gehe natürlich in meiner kleinen Anleitung davon aus, dass gewisse Linux-Basics vorhanden sind. Ich selbst bin hier jedoch auch Laie.

Zunächst muss Bluetooth selbst natürlich auf dem Raspberry laufen. Das Modul sollte via /boot/firmware/config.txt nicht deaktiviert sein – falls man so etwas vielleicht mal getan hatte (wie ich). Man sollte dann auch hciconfig in die Konsole eingeben, um zu prüfen, ob Bluetooth (BT) nicht etwa auf ›DOWN‹ steht. In diesem Fall müsste man es via sudo hciconfig hci0 up aktivieren. Dies sollte genügen. Ansonsten müsste man in die rfkill list schauen und wenn BT hier deaktiviert ist, via sudo rfkill unblock bluetooth aktivieren.

Nun muss Python installiert werden. Wir nutzen dieses später aber innerhalb einer virtuellen Umgebung (venv) bzw. in einer „Sandbox“, weil das Raspberry OS (seit Bookworm) verhindern möchte, dass systemeigene Pakte überschrieben werden. Aber zunächst kommt das obligatorische Paketlisten-Update: sudo apt update.

Nun installieren wir das virtuelle Python (wenn nicht schon vorhanden):

sudo apt install -y python3-venv

Jetzt erstellen wir unseren Projektordner und wechseln dort hinein:

mkdir ~/mi_sensor
cd ~/mi_sensor

Der Projektordner heißt also mi_sensor und er befindet sich gleich im Home-Verzeichnis. Wir werden darin wenige nützliche Python-Skripte speichern und – wichtig – unsere Datenbank mit den ganzen Messdaten vom Xiaomi-Thermometer.

Nun starten wir genau dort in dem Projektordner Python venv bzw. erstellen eine virtuelle Umgebung:

python3 -m venv venv

Die neue Umgebung wird nun durch uns aktiviert:

source venv/bin/activate

Nun wird man feststellen, dass man sich in der Konsole plötzlich innerhalb einer venv-Umgebung befindet.

Darin installieren wir die Bluetooth-Bibliothek ›bleak‹:

pip install bleak

Zum Verständnis: bleak haben wir also innerhalb der virtuellen Sandkasten-Python-Umgebung installiert, die wir vorher gestartet hatten. Systemweit haben wir gar nichts gemacht und das Betriebssystem beschwert sich nicht.

Wir benötigen für die grafische Ansicht im Browser noch einen Mini-Webserver ›flask‹. Wir statten Python also auch noch damit aus (während wir uns noch innerhalb der virtuellen Umgebung befinden):

pip install flask

Flask übernimmt später zwei Aufgaben gleichzeitig: Es fungiert als Webserver und es verarbeitet die Logik (das Auslesen der Datenbank), wofür auf einem richtigen Server sonst PHP zuständig gewesen wäre. Wer lediglich Daten sammeln möchte und keine grafische Ausgabe im Webbrowser benötigt sowie keine Schnittstelle für externe Programmzugriffe auf die Daten, braucht flask natürlich nicht und wird gleich fertig sein.

Skript Nummer 1: Daten lesen, anzeigen, in Datenbank schreiben

Jetzt kann es schon direkt losgehen. Wir erstellen im Projektordner ein kleines py-Skript read_mi.py  (nano ~/mi_sensor/read_mi.py) mit folgendem Inhalt:

Details
import asyncio
import sqlite3
import os
from datetime import datetime
from bleak import BleakScanner

# Konfiguration
# Die MAC-Adresse des Sensors
MAC_ADDR = "A4:C1:38:3C:C8:76"
# Absoluter Pfad zur Datenbank
DB_PATH = "/home/pi/mi_sensor/sensor_data.db"

def save_to_db(temp, humi, batt):
    """Speichert die gemessenen Werte in die SQLite-Datenbank."""
    try:
        conn = sqlite3.connect(DB_PATH)
        c = conn.cursor()
        # Tabelle erstellen, falls sie noch nicht existiert
        # REAL wird fuer Gleitkommazahlen (auch negative) verwendet
        c.execute('''CREATE TABLE IF NOT EXISTS measurements
                     (timestamp TEXT, temperature REAL, humidity REAL, battery INTEGER)''')
        
        # Zeitstempel und Werte einfuegen
        c.execute("INSERT INTO measurements VALUES (?, ?, ?, ?)",
                  (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), temp, humi, batt))
        
        conn.commit()
        conn.close()
        print(f"Erfolg: {temp}°C, {humi}%, {batt}% gespeichert.")
    except Exception as e:
        print(f"Datenbankfehler: {e}")

async def main():
    print(f"Suche nach BLE-Paketen von {MAC_ADDR}...")
    
    def detection_callback(device, advertisement_data):
        if device.address.upper() == MAC_ADDR:
            # Service UUID fuer das ATC-Format (pvvx)
            uuid = "0000fcd2-0000-1000-8000-00805f9b34fb"
            data = advertisement_data.service_data.get(uuid)
            
            if data and len(data) > 5:
                res = {}
                i = 3 
                try:
                    while i < len(data):
                        obj_id = data[i]
                        # Temperatur (0x02), 2 Bytes, signed=True fuer Minuswerte
                        if obj_id == 0x02 and i + 2 < len(data):
                            res['temp'] = int.from_bytes(data[i+1:i+3], 'little', signed=True) / 100
                            i += 3
                        # Luftfeuchtigkeit (0x03), 2 Bytes, unsigned
                        elif obj_id == 0x03 and i + 2 < len(data):
                            res['humi'] = int.from_bytes(data[i+1:i+3], 'little', signed=False) / 100
                            i += 3
                        # Batterie (0x01), 1 Byte
                        elif obj_id == 0x01 and i + 1 < len(data):
                            res['batt'] = data[i+1]
                            i += 2
                        else:
                            i += 1
                    
                    if 'temp' in res and 'humi' in res:
                        temp, humi = res['temp'], res['humi']
                        batt = res.get('batt', 0)
                        
                        save_to_db(temp, humi, batt)
                        # Scan beenden, sobald ein valider Datensatz da ist
                        stop_event.set()
                        
                except Exception:
                    # Fehler beim Parsen einzelner Felder ignorieren
                    pass

    stop_event = asyncio.Event()
    async with BleakScanner(detection_callback):
        try:
            # Timeout nach 60 Sekunden (reicht fuer 5s Advertising-Intervall)
            await asyncio.wait_for(stop_event.wait(), timeout=60.0)
        except asyncio.TimeoutError:
            print("Zeitueberschreitung: Sensor wurde nicht gefunden.")

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\nAbbruch durch Nutzer.")

Im Skript muss die MAC-Adresse des eigenen Gerätes eingetragen- und ggf. der absolute Pfad zur Datenbank angepasst werden. Die Datenbankdatei wird automatisch erstellt. Sie muss noch nicht existieren. Die MAC-Adresse meines Mi-Thermometers konnte ich via dem Tool auslesen, mit dem ich auch die anderen Daten (Bind Key, Mi Token, …) ermittelte; s. o. Sicherlich gibt es hierfür auch noch andere Wege. Der Editor Nano kann nach dem Speichern mit Strg + S mit Strg + X wieder verlassen werden. Das Skript berücksichtigt ein Timeout von 60 Sekunden. Diese Zeit sollte genügen, wenn wir damit gleich die Daten des Xiaomi Mi-Thermometers auslesen- bzw. empfangen werden.

Hierfür starten wir wieder die virtuelle Python-Umgebung in der Konsole (falls nicht noch offen):

cd ~/mi_sensor
source venv/bin/activate

und darin endlich:

~/mi_sensor/venv/bin/python3 ~/mi_sensor/read_mi.py

Etwas warten … Noch etwas warten … – und dann sollte man so eine Ausgabe erhalten:

Bildschirmfoto: Linux-Konsole mit Daten vom Thermometer

Es erscheint also zunächst die Meldung, dass nach Paketen von einem Gerät mit einer bestimmten MAC-Adresse gesucht- bzw. auf diese gewartet wird. Das Mi-Thermometer sendet ja nur z. B. jede 10 Sekunden (wie konfiguriert, s. o.). Die korrekte MAC-Adresse wurde im Skript eingetragen.

➜ Das Skript funktioniert bei mir für den Sensor Typ LYWSD03MMC und für den ›Advertising Type‹ BTHome v2 – wie oben bei der Konfiguration angegeben. Hoffentlich ändert Xiaomi hier nichts bei neueren Bluetooth-Thermometern, die genau so aussehen.

In der Konsole ausgegeben werden die aktuellen Werte für Temperatur, Luftfeuchtigkeit und Batteriestand des Mi-Thermometer. Aber dies dient nur zur Information innerhalb der Konsole, um zu sehen, dass das Auslesen dieser Daten funktioniert. Wichtiger ist das Schreiben in die Datenbank-Datei. Schauen Sie also nach, ob Sie nun im Projektordner eine neue Datei sensor_data.db vorfinden.

Python beinhaltet bereits das Modul „sqlite“. Wir müssen dieses also nicht separat installieren, um die Datenbank generieren- bzw. füllen zu können.

Von nun an wird sich diese Datenbank-Datei immer dann mit weiteren Messdaten füllen, wenn das Skript (read_mi.py) ausgeführt wird – beispielsweise via Cronjob.

Um das Skript also automatisch jede 30 Minuten auszuführen, nutzen wir die klassische Crontab für die Automatisierung:

crontab -e

Dort trägt man dann so etwas ein:

*/30 * * * * /home/pi/mi_sensor/venv/bin/python3 /home/pi/mi_sensor/read_mi.py > /home/pi/mi_sensor/cron.log 2>&1

Hinweis: Damit eine Änderung an der Crontab wirkt, muss der Editor wieder geschlossen werden.

Dies bewirkt ein Ausführen alle 30 Minuten. Wir gönnen uns zudem ein Log. Dies kann man aber auch weglassen, wenn alles funktioniert. Es würde mit den Monaten auch recht groß werden. Wenn der eigene Nutzer nicht „pi“ heißt, muss man natürlich die Pfade anpassen.

Bildschirmfoto: Cronicle = Weboberfläche automatisches Einschalten nach Zeiten
Ich nutze übrigens anstelle der Crontab das Programm Cronicle. Damit lassen sich Aufgaben grafisch bequem via Browser konfigurieren, organisieren bzw. auch manuell starten.

Damit füllt sich die Datenbank im Home-Ordner nach und nach ganz automatisch. Man kann sich diese SQLite-Datei mit einem Tool wie DB Browser for SQLite auch einmal ansehen. Sie besteht lediglich aus einer Tabelle mit vier Spalten: Abrufzeit, Temperatur, Luftfeuchtigkeit und Batterie-Status.

Die sensor_data.db wird sich über die nächsten Monate und Jahre immer weiter füllen und immer interessanter werden. Man sollte bereits jetzt an eine Backup-Lösung denken.

Ich kopiere diese Datei regelmäßig einfach via Cronjob / Cronicle auf einen USB-Stick, welcher am Raspberry Pi angeschlossen ist.

Aber richtig hübsch und übersichtlich können die Daten erst mittels Webinterface via Browser dargestellt werden:

Skript Nummer 2: Grafische Darstellung im Browser

Ich hatte ein Bildschirmfoto davon bereits gezeigt:

Bildschirmfoto: Graphen und Informationen bezüglich der Messwerte des Mi-Thermometers als Ansicht in einem Browser

Oben werden die Daten des letzten Messergebnisses ausgegeben (letzter Eintrag in der Datenbank) und es wird zudem eine vage Prognose gewagt. Der Graph ändert bisweilen seine Farbe – je nach Temperatur.

Für das Bereitstellen so einer lokalen Website benötigt man natürlich einen Server auf dem Raspberry Pi. Daher hatte ich ja bereits ›flask‹ installiert (s. o.). Wir benötigen aber noch eine ganz bestimmte Javascript-Datei, welche den schönen Graphen bereit stellt – Chart.js. Dies ist eine sehr verbreitete Open-Source-Bibliothek, mit der man Daten in Form von recht ansprechenden Diagrammen darstellen kann.

Also laden wir uns diese Datei in den Projektordner. Der Übersichtlichkeit halber erstellen wir dort aber zunächst einen Unterordner und wechseln dort hinein:

mkdir -p ~/mi_sensor/static
cd ~/mi_sensor/static

Nun ziehen wir uns die JS-Datei aus dem Netz und speichern sie dort:

wget https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js

Es kann sein, dass sich mittlerweile die URL / Versionsnummer geändert hat und es wird dann eine Fehlermeldung in der Konsole erscheinen. Dann bitte nach einer aktuellen Version der JS-Datei suchen. Man könnte diese JS-Datei im Skript auch direkt verknüpfen. Aber dann müsste man sie sich ja bei jedem Seitenaufruf von einem fremden Server holen.

Damit alles hübsch ausschaut, benötigen wir noch eine Stylesheet-Datei. Diese legen wir (ebenfalls im static-Unterordner) selber an:

nano ~/mi_sensor/static/style.css

Der Inhalt dieser style.css lautet:

Details
body {
    font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
    background-color: #f8f9fa;
    padding: 20px;
    color: #333;
    line-height: 1.6;
}

.container {
    max-width: 1400px;
    margin: 0 auto;
}

.header {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    margin-bottom: 25px;
    gap: 15px;
    align-items: stretch;
}

.card {
    background: #ffffff;
    padding: 20px;
    border-radius: 6px;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
    text-align: center;
    flex: 1;
    min-width: 220px;
    display: flex;
    flex-direction: column;
    justify-content: flex-start;
}

.card h3 {
    margin: 0 0 15px 0;
    font-size: 0.85em;
    color: #6c757d;
    text-transform: uppercase;
    letter-spacing: 1px;
    min-height: 1.2em;
}

.value {
    font-size: 2.2em;
    font-weight: bold;
    color: #333;
    margin: 5px 0;
    min-height: 1.2em;
    display: block; 
}

/* Spezifische Formatierung für die Prognose-Schriftgröße */
.value-forecast {
    font-size: 1.5em;
    padding-top: 10px;
}

.unit {
    font-size: 0.5em;
    color: #6c757d;
    margin-left: 4px;
    display: inline-block;
    vertical-align: middle;
}

.prog-icon {
    font-size: 1.1em;
    display: inline-block;
    margin-right: 8px;
    vertical-align: middle;
}

/* Info-Kachel Styling */
.info-content {
    text-align: left;
    display: inline-block;
    margin: 0 auto;
    font-size: 0.9em;
    color: #444;
    width: 100%;
    max-width: 200px;
    padding-top: 5px;
}

.info-line {
    margin: 4px 0;
    display: flex;
    justify-content: space-between;
    gap: 10px;
}

.info-label {
    color: #6c757d;
    font-weight: normal;
}

.info-val {
    font-weight: bold;
}

/* Chart Sektion */
.chart-section {
    background: #ffffff;
    padding: 25px;
    border-radius: 6px;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
}

.chart-container {
    position: relative;
    height: 550px; /* Erhöhte Höhe auf Wunsch */
    width: 100%;
}

.nav-tabs {
    display: flex;
    justify-content: flex-end;
    margin-bottom: 20px;
    gap: 10px;
}

.nav-tabs button {
    padding: 6px 15px;
    border: 1px solid #dee2e6;
    background: #fff;
    cursor: pointer;
    border-radius: 4px;
    transition: all 0.2s;
}

.nav-tabs button.active {
    background: #333;
    color: #fff;
    border-color: #333;
}

Damit lässt sich dann das Design der lokalen Website steuern. Zunächst kann man erste einmal diese CSS-Werte übernehmen.

Jetzt fehlt nur noch das (etwas größere) Haupt-Skript web_sensor.py. Wir legen es mittels nano direkt im Projektordner an (nicht im Unterordner):

nano ~/mi_sensor/web_sensor.py

Der Inhalt dieser Datei lautet:

Details
from flask import Flask, jsonify, render_template_string, request
import sqlite3
import os

app = Flask(__name__)
app.static_folder = 'static'
# Absoluter Pfad fuer stabilen Betrieb als Systemd-Service
DB_PATH = "/home/pi/mi_sensor/sensor_data.db"

HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Klima Dashboard</title>
    <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌡️</text></svg>">
    <link rel="stylesheet" href="/static/style.css">
    <script src="/static/chart.umd.min.js"></script>
</head>
<body>
    <div class="container">
        <div class="header">
            <div class="card">
                <h3>Temperatur</h3>
                <div class="value" id="temp">--</div>
            </div>
            <div class="card">
                <h3>Luftfeuchtigkeit</h3>
                <div class="value" id="humi">--</div>
            </div>
            <div class="card">
                <h3>Prognose</h3>
                <div class="value value-forecast" id="forecast">--</div>
            </div>
            <div class="card">
                <h3>Info</h3>
                <div id="info_box" class="info-content">
                    <div class="info-line"><span class="info-label">Batterie:</span> <span id="batt" class="info-val">--</span></div>
                    <div class="info-line"><span class="info-label">Abruf:</span> <span id="time_val" class="info-val">--</span></div>
                </div>
            </div>
        </div>

        <div class="chart-section">
            <div class="nav-tabs">
                <button onclick="changePeriod('24', this)" class="active">24 Stunden</button>
                <button onclick="changePeriod('168', this)">7 Tage</button>
                <button onclick="changePeriod('all', this)">Trend</button>
            </div>
            <div class="chart-container">
                <canvas id="mainChart"></canvas>
            </div>
        </div>
    </div>

    <script>
        let myChart;
        let currentPeriod = '24';

        function getTempColor(temp) {
            if (temp <= 0) return '#3b82f6';
            if (temp <= 12) return '#60a5fa';
            if (temp <= 22) return '#10b981';
            if (temp <= 30) return '#f59e0b';
            return '#ef4444';
        }

        function calculateForecast(current, allData) {
            if (!allData || allData.length < 12) return { text: "Beständig", icon: "✨" };
            const lookbackIndex = Math.max(0, allData.length - 36);
            const past = allData[lookbackIndex];
            const tempDiff = current.temperature - past.temperature;
            const humiDiff = current.humidity - past.humidity;

            const a = 17.27, b = 237.7;
            const gamma = (a * current.temperature) / (b + current.temperature) + Math.log(current.humidity / 100);
            const dewPoint = (b * gamma) / (a - gamma);
            const spread = current.temperature - dewPoint;

            if (dewPoint > 18 && current.temperature > 24) return { text: "Schwül/Gewittrig", icon: "⛈️" };
            if (current.humidity > 85 && (humiDiff > 3 || spread < 2)) {
                if (current.temperature < 2) return { text: "Schneefall", icon: "❄️" };
                return { text: "Regenrisiko", icon: "🌧️" };
            }
            if (current.humidity > 94 && spread < 1.2) return { text: "Nebel", icon: "🌫️" };
            if (current.temperature > 32) return { text: "Extr. Hitze", icon: "🔥" };
            if (current.temperature < -5) return { text: "Str. Frost", icon: "🧊" };
            if (tempDiff > 1.2 && humiDiff < -2) return { text: "Aufklarend", icon: "☀️" };
            if (tempDiff < -1.5 && humiDiff > 3) return { text: "Umschwung", icon: "☁️" };
            if (tempDiff < -1.0) return { text: "Abkühlung", icon: "🌬️" };
            if (tempDiff > 0.8) return { text: "Milder", icon: "🌤️" };

            return { text: "Beständig", icon: "✨" };
        }

        function changePeriod(p, btn) {
            currentPeriod = p;
            document.querySelectorAll('.nav-tabs button').forEach(b => b.classList.remove('active'));
            btn.classList.add('active');
            loadData();
        }

        async function loadData() {
            try {
                const response = await fetch(`/api/data?period=${currentPeriod}`);
                const data = await response.json();

                if (data && data.length > 0) {
                    const last = data[data.length - 1];
                    const parts = last.timestamp.split(' ');
                    const dateParts = parts[0].split('-');
                    const timeParts = (parts[1] || "00:00").split(':');
                    document.getElementById('time_val').innerText = `${dateParts[2]}.${dateParts[1]}. ${timeParts[0]}:${timeParts[1]}`;
                    document.getElementById('batt').innerText = last.battery + "%";
                    
                    const tempElement = document.getElementById('temp');
                    tempElement.innerHTML = last.temperature.toFixed(1) + '<span class="unit">°C</span>';
                    tempElement.style.color = getTempColor(last.temperature);
                    document.getElementById('humi').innerHTML = last.humidity.toFixed(1) + '<span class="unit">%</span>';
                    
                    const fc = calculateForecast(last, data);
                    document.getElementById('forecast').innerHTML = `<span class="prog-icon">${fc.icon}</span>${fc.text}`;

                    const ctx = document.getElementById('mainChart').getContext('2d');
                    if (myChart) { myChart.destroy(); }
                    
                    myChart = new Chart(ctx, {
                        type: 'line',
                        data: {
                            labels: data.map(d => d.timestamp.length > 10 ? d.timestamp.substring(5, 16) : d.timestamp), 
                            datasets: [
                                {
                                    label: 'Temperatur (°C)',
                                    data: data.map(d => d.temperature),
                                    fill: false,
                                    tension: 0.4,
                                    yAxisID: 'y',
                                    segment: {
                                        borderColor: ctx => ctx.p1.parsed ? getTempColor(ctx.p1.parsed.y) : '#3b82f6'
                                    }
                                },
                                {
                                    label: 'Feuchtigkeit (%)',
                                    data: data.map(d => d.humidity),
                                    borderColor: '#94a3b8',
                                    fill: false,
                                    yAxisID: 'y1',
                                    tension: 0.4,
                                    borderDash: [5, 5],
                                    borderWidth: 2
                                }
                            ]
                        },
                        options: {
                            responsive: true,
                            maintainAspectRatio: false,
                            interaction: { mode: 'index', intersect: false },
                            plugins: { legend: { display: false } },
                            scales: {
                                y: { 
                                    type: 'linear', 
                                    position: 'left', 
                                    grid: { color: context => context.tick.value === 0 ? '#333' : '#f0f0f0', lineWidth: context => context.tick.value === 0 ? 2 : 1 }
                                },
                                y1: { type: 'linear', position: 'right', grid: {drawOnChartArea: false} }
                            }
                        }
                    });
                }
            } catch (err) {
                console.error("Fehler beim Laden der Daten:", err);
            }
        }
        window.onload = loadData;
    </script>
</body>
</html>
"""

# NEUER API-ENDPUNKT FÜR ANDROID/TASKER
@app.route('/api/current')
def get_current_data():
    """Gibt den allerletzten Datensatz als reines JSON zurueck."""
    try:
        conn = sqlite3.connect(DB_PATH)
        conn.row_factory = sqlite3.Row
        c = conn.cursor()
        # Hole nur den letzten Eintrag
        c.execute("SELECT * FROM measurements ORDER BY timestamp DESC LIMIT 1")
        row = c.fetchone()
        conn.close()
        
        if row:
            # dict(row) konvertiert das SQLite-Objekt in ein Standard-JSON-Format
            return jsonify(dict(row))
        else:
            return jsonify({"error": "Keine Daten vorhanden"}), 404
    except Exception as e:
        return jsonify({"error": str(e)}), 500

@app.route('/api/data')
def get_data():
    period = request.args.get('period', default="24")
    try:
        conn = sqlite3.connect(DB_PATH)
        conn.row_factory = sqlite3.Row
        c = conn.cursor()
        if period == "all":
            c.execute("""SELECT date(timestamp) as timestamp, AVG(temperature) as temperature, 
                                AVG(humidity) as humidity, MAX(battery) as battery 
                         FROM measurements GROUP BY date(timestamp) ORDER BY timestamp ASC""")
        else:
            hours = int(period)
            c.execute("""SELECT * FROM measurements 
                         WHERE timestamp > datetime((SELECT max(timestamp) FROM measurements), ?)
                         ORDER BY timestamp ASC""", (f'-{hours} hours',))
        rows = c.fetchall()
        conn.close()
        return jsonify([dict(row) for row in rows])
    except Exception as e:
        return jsonify({"error": str(e)}), 500

@app.route('/')
def index():
    return render_template_string(HTML_TEMPLATE)

if __name__ == '__main__':
    # host='0.0.0.0' macht den Server im lokalen Netzwerk erreichbar
    app.run(host='0.0.0.0', port=5000)

Hinweis: Recht weit oben im Script muss man den absoluten Pfad zur SQLite-Datei definieren bzw. diesen anpassen, wenn man hier eine andere Struktur verwendet.

Nachdem man diese Datei innerhalb von nano gespeichert- und den Editor verlassen hat ( Strg + S & Strg + X ) kann man sie manuell ausführen. Dies startet den kleinen Webserver bzw. die einzige Website darauf – die grafische Darstellung der Datenbankeinträge:

/home/pi/mi_sensor/venv/bin/python3 /home/pi/mi_sensor/web_sensor.py

Wir starten hierbei also das isolierte Sandkasten-Python direkt, welches sich im Projektordner befindet und führen damit das Skript aus.

Bildschirmfoto: Graphen und Informationen bezüglich der Messwerte des Mi-Thermometers als Ansicht in einem Browser➜ unter http://IP_des_Pi:5000/ sollte nun die Website im Browser erscheinen.

Ganz unten im Skript kann man den Port (5000) definieren. Falls dieser bei Ihnen bereits durch eine andere Anwendung bespielt wird, muss man einen anderen wählen und die Skriptausführung neustarten.

Selbstverständlich muss Ihr Router (z. B. FritzBox) so eingestellt sein, dass man innerhalb des Heimnetzwerkes von Computer A auf Computer B (Raspberry Pi) zugreifen kann. Dies sollte jedoch der Standard sein.

Nach einem Neustart des Raspberry Pi müsste man das Skript jedoch jedes Mal manuell starten. Die Datenbank selbst jedoch wird ja bereits unabhängig davon per Cronjob regelmäßig befüllt. Der kleine Server soll auch automatisch starten:

Webinterface via Dienst starten

Daher erstellen und registrieren wir nun einfach einen Dienst:

sudo nano /etc/systemd/system/sensor-web.service

In die noch leere Datei tragen wir folgendes ein:

[Unit]
Description=Sensor Web Dashboard
After=network.target

[Service]
User=pi
WorkingDirectory=/home/pi/mi_sensor
ExecStart=/home/pi/mi_sensor/venv/bin/python3 /home/pi/mi_sensor/web_sensor.py
Restart=always

[Install]
WantedBy=multi-user.target

Auch hier sollten wieder die Pfade beachtet werden und der User (falls dies auf Ihrem System etwas anders sein sollte). Danach speichern und nano wieder verlassen.

Nun aktivieren und starten wir den Dienst (zwei Schritte):

sudo systemctl enable sensor-web.service
sudo systemctl start sensor-web.service

Mit sudo systemctl status sensor-web.service prüfen wir, ob der Dienst korrekt läuft: Es sollte alles im grünen Bereich sein.

Nach einem Neustart des Raspberry Pi sollte die Website mit den Messdaten vom Xiaomi-Thermometer aufrufbar sein, ohne dass man das Skript manuell ausführen muss.

Nach jeder Änderung an der web_sensor.py muss der Dienst (bzw. der Server) neu gestartet werden: sudo systemctl restart sensor-web.service. Danach sollte eine Änderung (z. B. am Design) im Browser sichtbar sein.

Damit wäre die Sache gemeistert!

Aber ich bin noch nicht ganz fertig. Man kann die Sensordaten vom Mi Thermometer / Hygrometer auch noch von anderen Programmen einlesen lassen. Hierzu ließ ich mir von meinem Programmierer (der KI) noch etwas ins Skript einbauen:

API-Endpunkt-Bereitstellung für externe Programme

Rufen Sie die Seite http://IP_des_Pi:5000/api/current auf. Sie werden dann (hoffentlich) so etwas ausgegeben bekommen:

{"battery":67,"humidity":69.13,"temperature":-2.93,"timestamp":"2026-02-02 15:00:04"}

Wie man sieht, erfolgt hier eine JSON-Ausgabe der letzten Messwerte (also die aktuellsten in der Datenbank), welche ja durch das erste Skript (read_mi.py) via Cronjob ausgelesen- und in die Datenbank geschrieben wurden.

Damit externe Software nicht die ganze SQLite-Datenbankdatei öffnen muss (sofern es überhaupt Zugriff darauf gibt), kann sie sich also einfach an diesem knackigen API-Endpunkt über http laben. Und dies tue ich einmal mit einer App auf meinem Android-Tablet und einer kleinen, selbstgeschriebenen Windows-Anwendung:

Temperaturanzeige in der Windows-Taskleiste

Ich habe mir ein kleines Programm erstellt, welches eine simple Temperatur-Ausgabe innerhalb der Windows-Taskleiste im „Tray“ erstellt – und Sie können das auch:

Bildschirmfoto: Windows Taskleiste mit Icons, u. a. Temperaturanzeige und Tooltip mit weiteren Daten

Das Icon zeigt die aktuelle Temperatur (gerundet), wie sie vom Xiaomi Mi Thermometer an den Raspberry Pi übermittelt wurde. Dies ist genau der Wert, welcher als letztes in der Datenbank gespeichert wurde. Zugegriffen wird einfach regelmäßig via http auf die API-Ausgabe (JSON) – wie eben näher beschrieben. Per Tooltip sieht man auch die anderen Einträge. Hier ist leider der Platz sehr begrenzt. Daher gibt es an dieser Stelle kein „°C“ sondern nur den aktuellen Wert.

➜ Selbstverständlich muss der Computer via http Zugriff auf den Raspberry Pi haben. Er muss sich also im einfachsten Fall im selben Netzwerk befinden. Oder aber man ist via VPN mit dem Raspberry Pi verbunden. Oder man hat den Raspberry via offenem Port und DynDNS für das Internet freigegeben.

Dieses kleine Windows-Programm erstellen wir einfach selbst. Es besteht aus einer Exe-Datei und einer simplen config-Datei. Das Erstellen ist recht einfach.

Das funktioniert so:

  1. Wir installieren zunächst Python für Windows.

    Später – wenn wir die Exe-Datei erstellt haben – können wir Python wieder deinstallieren.

  2. Wir erstellen ein weiteres py-Skript (und eine config-Datei) und testen beides zunächst.
  3. Wir kompilieren dies bzw. erstellen eine simple, ausführbare Exe-Datei.

Fertig. Und nun die detaillierte Anleitung:

Python für Windows installiert man zunächst ganz normal. Es ist bei der Installation jedoch darauf zu achten, dass die Option Add Python to PATH angehakt ist. Ansonsten müssten wir später recht lange Befehle in der Windows-Konsole verfassen:

Nun öffnen wir die Eingabeaufforderung. Man findet dieses Konsolenprogramm, indem man im Startmenü die Suche benutzt (für die, die es nie nutzten). Innerhalb dieser Konsole installieren wir zunächst noch einige weitere Python-Komponenten, welche wir für das Anfertigen des kleinen Temperatur-Tray-Icon-Programms benötigen.

Mehrere Schritte:

pip install pillow
pip install pystray
pip install requests
pip install pyinstaller

Dass wir hier nicht den ganzen Pfad zu Python eingeben müssen, hat etwas mit dem Häkchen bei der Installation zu tun.

Die Eingabeaufforderung können wir mit dem Befehl exit nun wieder schließen. Als nächstes erstellen wir wieder irgendwo (z. B. auf dem Windows-Desktop) einen Projektordner. Wir können ihn z. B. „Mein Temperatur-Monitor“ nennen.

Darin erstellen wir eine neue Textdatei tray_monitor.py. Diese Datei muss tatsächlich auf „.py“ enden und nicht auf „.py.txt“. Windows blendet normalerweise bekannte Dateiendungen aus (siehe dieser Hinweis).

Diese Datei sollte diesen Inhalt besitzen (mit einem reinen Texteditor öffnen, nicht mit Word oder so etwas und danach abspeichern und schließen):

Details
import os
import time
import requests
import threading
import configparser
from datetime import datetime
from PIL import Image, ImageDraw, ImageFont
import pystray
from pystray import MenuItem as item

# Globale Variablen
stop_flag = False

def get_config():
    """Liest die Konfiguration aus der config.ini."""
    config = configparser.ConfigParser()
    ini_file = 'config.ini'
    
    if not os.path.exists(ini_file):
        return None

    try:
        with open(ini_file, 'r', encoding='utf-8-sig') as f:
            config.read_file(f)
        
        if 'Settings' in config:
            return config['Settings']
    except Exception:
        pass
    return None

def get_latest_data(api_url):
    """Ruft die aktuellen Daten vom Flask-API-Endpunkt ab."""
    if not api_url:
        return None
        
    try:
        response = requests.get(api_url, timeout=5)
        response.raise_for_status()
        data = response.json()
        
        if data and "temperature" in data:
            temp_raw = float(data["temperature"])
            humi_raw = float(data["humidity"])
            timestamp = str(data["timestamp"])
            battery = data.get("battery", "N/A")
            
            # Kaufmännische Rundung für das Icon
            icon_val = str(int(round(temp_raw + 0.0001)))
            
            return {
                "icon_temp": icon_val,
                "exact_temp": f"{temp_raw:.1f}",
                "exact_humi": f"{humi_raw:.1f}",
                "timestamp": timestamp,
                "battery": battery
            }
    except Exception:
        pass
    return None

def create_image(text_to_draw, is_error=False):
    """Erzeugt ein Icon mit der Temperatur oder einem Warnsymbol."""
    size = 64
    image = Image.new('RGBA', (size, size), (255, 255, 255, 0))
    dc = ImageDraw.Draw(image)
    
    display_text = str(text_to_draw) if (text_to_draw is not None and text_to_draw != "") else "-"
    
    try:
        if is_error:
            font_size = 56
            color = (255, 0, 0, 255) 
        else:
            font_size = 50 if len(display_text) <= 2 else 38
            color = (255, 255, 255, 255)
            
        font = ImageFont.truetype("arialbd.ttf", font_size)
    except:
        font = ImageFont.load_default()

    bbox = dc.textbbox((0, 0), display_text, font=font)
    w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
    
    x = (size - w) // 2
    y = (size - h) // 2 - 6 
    
    dc.text((x + 2, y + 2), display_text, fill=(0, 0, 0, 255), font=font)
    dc.text((x, y), display_text, fill=color, font=font)
    
    return image

def update_loop(icon):
    global stop_flag
    
    # Kurze Pause, damit Windows das Tray-Icon initialisieren kann
    time.sleep(2)
    
    while not stop_flag:
        settings = get_config()
        
        if settings:
            api_url = settings.get('api_url')
            interval = int(settings.get('interval', 300))
            
            data = get_latest_data(api_url)
            
            if data:
                # WICHTIG: Das Bild wird hier explizit neu erzeugt und zugewiesen
                new_icon_img = create_image(data["icon_temp"])
                icon.icon = new_icon_img
                
                try:
                    dt = datetime.strptime(data["timestamp"], "%Y-%m-%d %H:%M:%S")
                    time_str = dt.strftime("%H:%M")
                except:
                    time_str = data["timestamp"]
                
                icon.title = (
                    f"Temperatur: {data['exact_temp']}°C\n"
                    f"Feuchtigkeit: {data['exact_humi']}%\n"
                    f"Batterie: {data['battery']}%\n"
                    f"Update: {time_str}"
                )
                
                # Refresh erzwingen
                if hasattr(icon, 'update_menu'):
                    icon.update_menu()
            else:
                icon.icon = create_image("!", is_error=True)
                icon.title = "Verbindung zum API-Server fehlgeschlagen"
        else:
            icon.icon = create_image("?", is_error=True)
            icon.title = "Konfiguration fehlt"
            interval = 10 

        for _ in range(interval):
            if stop_flag: break
            time.sleep(1)

def on_exit(icon, item):
    global stop_flag
    stop_flag = True
    icon.stop()

def setup():
    # Initialer Startwert
    icon = pystray.Icon("SensorMonitor", create_image("..."), title="Lade Daten...")
    icon.menu = pystray.Menu(item('Beenden', on_exit))
    
    thread = threading.Thread(target=update_loop, args=(icon,))
    thread.daemon = True
    thread.start()
    
    icon.run()

if __name__ == "__main__":
    setup()

Diese Datei enthält die gesamte Funktionalität, um die JSON-Daten vom Raspberry-Server auszulesen und diese als Tray-Icon (eigentlich eine Grafik) in der Windows-Taskleiste einblenden zu können. Aber sie greift auch auf eine config.ini zu. Diese müssen wir ebenfalls im selben Verzeichnis erstellen:

[Settings]
api_url = http://192.168.178.2:5000/api/current
interval = 900
unit = °C

Zunächst muss man hier die korrekte URL (inkl. Port) zur API-Schnittstelle (Raspberry-Server) angeben, als nächstes das Abfrageintervall in Sekunden. Ich frage nur alle 15 Minuten ab, denn mein read_mi.py Skript auf dem Raspberry Pi fragt den Xiaomi-Sensor ja eh nur halbstündlich ab. Als letztes kann man noch die Temperatur-Einheit angeben.

Erst einmal testen

Wenn diese beiden Dateien erstellt worden sind, können wir das Skript sofort testen (zunächst ohne Kompilierung). Denn wir haben ja Python mit den nötigen Bibliotheken auf dem (Windows-) System. Hierzu machen wir innerhalb des Projektordners einen Rechtsklick auf eine frei Stelle und wählen im Kontextmenü ›In Terminal öffnen‹.

Dort geben wir einfach ein:

python tray_monitor.py

➜ Daraufhin müsste das Tray-Icon in der Windows-Taskleiste erscheinen und es sollte den zuletzt in der SQLite-Datei auf dem Raspberry Pi hinterlegten Temperaturwert anzeigen. Ggf. ist das Icon noch hinter dem „Aufklapper“ in der Taskleiste versteckt. Per Mouseover sollten die anderen Werte (Luftfeuchtigkeit, Ladezustand, …) im Tooltip dargestellt werden.

Wenn das funktioniert, schließen wir die Anwendung via Rechtsklick auf das Tray-Icon („Beenden„). Wenn ggf. Änderungen an der config.ini nötig sind, muss man danach das Programm schließen und erneut via Konsole starten.

Kompilieren als Exe-Datei

Jetzt nehmen wir die ganzen Python-Abhängigkeiten und unser tray_monitor.py-Skript in die Hand, kneten das ganze durch und formen daraus sozusagen einen festen Schneeball – eine Exe-Datei: Wir kompilieren.

Wir befinden uns dazu weiterhin im Projektordner mit der Eingabeaufforderung und geben dort nun ein:

pyinstaller --noconsole --onefile tray_monitor.py

Daraufhin laufen erst einmal einige Ausgaben durch. Nach Abschluss dieses „Builds“ befindet sich die Exe-Datei in einem neuen Unterordner des Projektordners ›\dist‹. Wir kopieren diese Exe-Datei und die vorher angefertigte config.ini an den gewünschten Ort (z. B. C:\Meine portablen Programme\Temperatur Sensor) und führen die tray_monitor.exe via Doppelklick aus. Nun sollte sich in der Windows-Taskleiste erneut ein Icon mit der aktuellen Temperatur vom Mi-Sensor befinden. Vermutlich müsste man dieses dort permanent sichtbar machen.

Damit unser kleines Programm bei jedem Neustart von Windows automatisch mit startet, müsste es zudem zu den Autostart-Programmen hinzugefügt werden.

Da wir die Exe-Datei nun erfolgreich erstellt / kompiliert haben, kann das gesamte Python-Programm unter Windows wieder deinstalliert werden – außer wir möchten das py-Skript später noch einmal ändern bzw. neu kompilieren. Wenn nicht, kann auch der gesamte Projektordner mit den Rohdaten gelöscht werden.

So eine Taskleisten-Anzeige der tatsächlichen (Außen-) Temperatur ist doch eine schöne Sache und gar nicht so schwer umsetzbar.

Die ganze Geschichte läuft nie über fremde Server. Man hat alles selber in der Hand.

Xiaomi Mi Bluetooth-Thermometer außen in einer Schutzbox

Tipp: Wenn man das kleine Xiaomi Mi Thermometer im Außenbereich nutzt, empfiehlt es sich natürlich, dass man dieses geschützt vor Regen und Schnee installiert. So eine offene Plastebox auf dem Balkon oder unter einem Vordach bietet bereits einen guten Schutz. Ferner ist es natürlich wichtig, dass sich der Sensor stets im Schatten befindet. Ansonsten erhält man bei direkter Sonneneinstrahlung viel zu hohe Temperaturwerte.

Und nun wollen wir auch noch auf einem Android-Gerät (Smartphone, Tablet) die aktuellen Messwerte einblenden:

Raspberry-Pi-Messwerte auf dem Smartphone bzw. Tablet ausgeben

Zur Erinnerung: Das Xiaomi-Thermometer befindet sich z. B. auf dem Balkon und sendet in regelmäßigen Intervallen seine Messdaten. Auf dem Raspberry Pi wird regelmäßig (via Crontab) das kleine Skript read_mi.py mobilisiert, welches diese Daten brav in eine SQLite-Datenbankdatei im Home-Ordner schreibt.

Gleichzeitig läuft auf dem Raspberry der Dienst für die web_sensor.py, der nicht nur eine schöne grafische Übersicht im Browser gestattet, sondern auch eine JSON-Ausgabe der letzten Messergebnisse per http im Heimnetz bereit stellt. Darauf kann natürlich auch ein Android-Gerät (Smartphone / Tablet) zugreifen – solange es sich im selben Netzwerk befindet (oder VPN, DynDNS, …).

Vermutlich gibt es für das Abgreifen solcher JSON-Daten spezielle Apps. Ich greife hierfür einfach zum guten, alten ›Tasker‹ – da ich diese App eh anderweitig nutze:

Smartphone vor hellem Hintergrund mit der App Tasker auf dem Bildschirm und einem Tasker-Symbol
Tasker ist das Schweizer Taschenmesser für Android-Geräte: Die App kann in alle möglichen Prozesse eingreifen und diese automatisieren. Tasker ersetzt somit viele Apps. Ich hatte zu diesem Programm für Beginner eine verständliche Anleitung geschrieben.

Und das Ganze funktioniert dann so:

  1. Man legt sich unter ›Tasker‹ einen neuen „Task“ an und darin die Aktion HTTP Request. Als URL trägt man dort dann den API-Endpunkt des Raspberry Pi ein (z. B. http://192.168.178.2:5000/api/current). Als Methode wählt man hier ›GET‹. Das Häkchen bei „Ausgabe strukturieren (JSON, etc.)“ muss gesetzt sein.
  2. Damit holt sich Tasker die aktuellen Daten und speichert diese als Werte für die internen Variablen: %http_data.temperature %http_data.humidity %http_data.battery %http_data.timestamp
  3. Und diese Variablen (bzw. deren aktuelle Werte) können wir natürlich innerhalb anderer Tasks oder Profilen abfragen und beliebig damit arbeiten.
  4. Getriggert wird der Task über ein simples Zeit-Profil: Z. B. Ausführen alle 30 Minuten.

Tasker könnte dann also parallel dazu z. B. eine SMS mit einem bestimmten Text an meine Oma schreiben, wenn der Wert der Variable %http_data.temperature irgendwann kleiner als 5 sein sollte ist. So funktioniert diese App.

Tasker kann auf Basis von Variablenwerte aber auch einfach nur ein Widget auf dem Android-Homescreen erstellen bzw. aktualisieren. Dies wäre in unserem Fall wohl sinnvoller (obwohl die Sache mit der SMS eine ziemlich clevere Smart-Home-Geschichte ist):

Android-Tablet mit Temperaturanzeige auf dem Homescreen (Detailausschnitt)

Zusätzlich zum allgemeinen Wetterdienst-App-Widget lasse ich mir die tatsächlichen aktuellen Temperatur- bzw. Luftfeuchtigkeitswerte von meinem Balkon auf dem Android-Homescreen anzeigen.

Unter Tasker hat man die Aktion ›Widget v2‹ zur Verfügung, welche man in einem Task integrieren kann und die recht gut konfigurierbar ist. Für die Textausgabe gibt man dabei einfach die derzeitigen Werte der gewünschten Variablen an. Tippt man auf Tasker-Widget, öffnet sich z. B. der Browser mit der lokalen Website mit allen Werten bzw. mit dem Graphen.

Wenn man die Werte der internen System-Variablen (%http_data.temperature, …) zuvor innerhalb dieses Tasks in eigene Variablen überträgt („Variable setzen“ › „%meine_balkontemperatur“), kann man diese Werte übrigens recht leicht mathematisch runden, damit man bei der Ausgabe im Widget keine Kommawerte erhält – siehe Abbildung.

Wenn ich mich mit meinem Tablet nicht mehr im heimischen WLAN befinde – sondern in einem fremden – bemerkt dies Tasker übrigens und schaltet automatisch den WireGuard-VPN-Kanal ein. Somit erhalte ich weiterhin frische Daten von meinem heimischen Raspberry Pi.

Eine schöne Sache und man ist nicht mehr abhängig von proprietären Apps bzw. von Fremdanbietern.

Raspberry Pi an Wand montiert mit via Kabeln angeschlossenem Infrarot-Empfänger
Was ich sonst noch so alles Hübsches mit meinem Raspberry Pi anstelle, lesen Sie in dem Artikel → Meine Anwendungsfälle für den Raspberry Pi

Fazit

Ich bin richtig zufrieden mit der hier dargestellten Lösung. Der Xiaomi Mi Sensor kostet lediglich um die 10 Euro und er war für mich zunächst ziemlich uninteressant. Aber wenn man diesem erst einmal eine Custom Firmware verpasst hat (s. o.), ist er ein hübsches, kleines Werkzeug, welches sich ideal mit meinem Raspberry Pi verbinden lässt. Ich protokolliere damit tatsächliche Daten und keine von einer entfernten Wetterstation. Gut: Letztere sind auch in Ordnung. Aber selber machen macht doch etwas mehr Freude.

Man könnte die Skripte natürlich noch dahingehend erweitern, dass mehrere Mi-Sensoren abgefragt werden können. Dann müsste man hier nach unterschiedlichen MAC-Adressen sortieren. Da ich so etwas nicht benötige, habe ich dies ausgelassen.

Zudem habe ich – mit der Hilfe von KI – recht viel gelernt. Ich hatte vorher noch nie ein kleines Windows-Programm kompiliert. Vielleicht ist meine Anleitung auch für Sie ein hübsches, kleines Projekt für einen verregneten Sonntagnachmittag.

Kommentar schreiben

Hier gibt es die Möglichkeit für Resonanz. Pflichtfelder sind mit * markiert.

Kommentare erscheinen nicht sofort bzw. werden manuell freigegeben. Mit dem Absenden des Formulars stimmen Sie der Datenschutzerklärung zu bzw., dass Ihre eingegebenen Daten gespeichert werden. IP-Adressen werden dabei grundsätzlich nicht gespeichert.