Wer meinen air-Q pro im ausführlichen Review schon kennt, weiß: Das Gerät misst beeindruckend viele Werte – von CO₂ und TVOC über Temperatur und Luftfeuchte bis hin zu Feinstaub, Lärm und Luftdruck. In diesem Beitrag gehen wir den nächsten Schritt: Wir bringen diese Messdaten live auf ein LCD1602-Display – direkt dort, wo du sie brauchst, ohne App und ohne Cloud.
Als Anzeige nutze ich mein universelles MakerDisplay. Das Prinzip ist simpel: Der air-Q liefert die Daten, ein kleines Python-Skript holt sie per REST ab, entschlüsselt sie und schickt sie per HTTP ans Display. So rotieren die 13 Messgrößen plus die beiden virtuellen Werte „Gesundheit“ und „Leistung“ nacheinander über zwei Zeilen – kompakt, gut lesbar und jederzeit im Blick.
Ziel ist eine pragmatische Lösung für Büro, Werkstatt oder Hobbyraum: einschalten, hinschauen, fertig. Im weiteren Verlauf zeige ich dir Schritt für Schritt, wie die Daten abgeholt, sinnvoll benannt und fürs 16×2-Display aufbereitet werden – inklusive Script und Tipps für die Praxis.

Transparenz-Hinweis: Der air-Q pro wurde mir von der Corant GmbH kostenfrei zur Verfügung gestellt. Dieser Beitrag ist redaktionell unabhängig; es gab keine inhaltlichen Vorgaben und keine Bezahlung. Nach Veröffentlichung des letzten Beitrags zu dieser Reihe sende ich das Testgerät an die Corant GmbH zurück.



Inhaltsverzeichnis
- Daten vom air-Q pro laden
- Was bedeutet das konkret?
- Minimalbeispiel (Python): Abrufen & Entschlüsseln
- Messwerte ans MakerDisplay senden
Daten vom air-Q pro laden
Der air-Q pro stellt seine Messwerte lokal im Netzwerk bereit—typisch unterhttp://<IP-Adresse>/data
.
Der Haken: Die Antwort ist nicht direkt lesbar. Du bekommst ein kurzes JSON, dessen Feld content
eine Base64-Zeichenkette enthält. Darin steckt wiederum ein AES-256-CBC-verschlüsseltes JSON mit allen Messwerten.

Was bedeutet das konkret?
- HTTP-GET an
/data
→ JSON mitcontent
. - Base64 dekodieren → bekommst Bytes:
IV || CIPHERTEXT
. - AES-256-CBC entschlüsseln
- IV = die ersten 16 Bytes.
- Key = aus deinem air-Q-Passwort erzeugt (UTF-8, auf 32 Bytes aufgefüllt/abgeschnitten).
- PKCS#7-Padding entfernen, Ergebnis als UTF-8 interpretieren.
- JSON parsen → jetzt hast du alle Felder (13 Messgrößen + 2 virtuelle Werte).
Warum verschlüsselt?
Der Hersteller schützt so die Daten selbst im lokalen Netz. Offiziell ist das nicht dokumentiert; naheliegend sind Datenschutz/Manipulationsschutz. Praktisch heißt das für uns: erst entschlüsseln, dann nutzen.
Schnelltest: Encrypted-Antwort ansehen
In einer PowerShell Konsole oder unter einem Linux Terminalfenster kann man sich mit curl recht einfach die Daten wie folgt laden: curl http://192.168.178.108/data
PowerShell 7.5.2
PS C:\Users\stefa> curl http://192.168.178.108/data
{"content":"mOcmtRiI43ER8vG1bytSba0UcqWTRJ1BdamyRrrXnfPquIjVXWtIb0f3yYagFGlx7Jx80jAZ...kBgM"}
PS C:\Users\stefa>
Minimalbeispiel (Python): Abrufen & Entschlüsseln
Voraussetzung: PyCryptodome
ist installiert (pip install pycryptodome
).
Der air-Q liefert die Daten unter http://<IP>/data
. Das Feld content
enthält Base64 + AES-256-CBC-verschlüsseltes JSON.
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Minimalbeispiel: air-Q pro Daten laden und entschlüsseln. Ergebnis: Vollständiges Messwerte-JSON auf der Konsole. """ import base64 import json import http.client from Crypto.Cipher import AES # Paket: pycryptodome AIRQ_IP = "192.168.178.108" # <- anpassen AIRQ_PASSWORT = "dein-passwort" # <- anpassen AES_SCHLUESSEL_LAENGE = 32 AES_BLOCKGROESSE = 16 # CBC-IV-Länge in Bytes def _erzeuge_aes_schluessel(passwort: str) -> bytes: """Passwort als UTF-8 auf 32 Byte bringen (auffüllen/abschneiden).""" key = passwort.encode("utf-8") return key + b"0" * (AES_SCHLUESSEL_LAENGE - len(key)) if len(key) < AES_SCHLUESSEL_LAENGE else key[:AES_SCHLUESSEL_LAENGE] def _entferne_pkcs7_padding(daten: bytes) -> bytes: """PKCS#7-Padding entfernen (letztes Byte = Anzahl der Füllbytes).""" return daten[:-daten[-1]] def hole_rohantwort(ip_adresse: str) -> dict: """Holt das äußere JSON vom air-Q (mit dem Feld 'content').""" conn = http.client.HTTPConnection(ip_adresse, timeout=5) try: conn.request("GET", "/data") resp = conn.getresponse() return json.loads(resp.read()) finally: conn.close() def entschluessle_content(content_b64: str, passwort: str) -> dict: """Base64 → AES-256-CBC entschlüsseln → JSON als dict zurückgeben.""" blob = base64.b64decode(content_b64) iv, ciphertext = blob[:AES_BLOCKGROESSE], blob[AES_BLOCKGROESSE:] cipher = AES.new(_erzeuge_aes_schluessel(passwort), AES.MODE_CBC, iv) plaintext = cipher.decrypt(ciphertext) plaintext = _entferne_pkcs7_padding(plaintext) return json.loads(plaintext.decode("utf-8")) if __name__ == "__main__": roh = hole_rohantwort(AIRQ_IP) daten = entschluessle_content(roh["content"], AIRQ_PASSWORT) print(json.dumps(daten, ensure_ascii=False, indent=2))
Auf der Konsole werden nun die entschlüsselten Messdaten im JSON-Format angezeigt.

Messwerte ans MakerDisplay senden
Nachdem wir die Daten erfolgreich empfangen und entschlüsselt haben, können wir sie entweder auf dem LCD1602 (MakerDisplay) anzeigen oder parallel als CSV lokal mitschreiben. Beides hat Vorteile:
- Display: Live-Überblick, ohne App/Cloud.
- CSV: Verlauf auswerten (z. B. in Excel).
Darum steuern wir das Verhalten per Parametern – und behalten ein einziges, sauberes Skript.
Hinweis: Du musst nur IP-Adresse deines air-Q und dein Geräte-Kennwort (Standard:
airqsetup
) eintragen. Für die Anzeige trägst du die URL deines MakerDisplays ein (z. B.http://192.168.178.96/anzeige
).

Quellcode
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Titel: Luftqualität live: air-Q pro Daten via REST abrufen, entschlüsseln und weiterverarbeiten Autor: Stefan Draeger Blogartikel: https://draeger-it.blog/luftqualitaet-live-air-q-pro-daten-via-rest-abrufen-und-anzeigen/ Beschreibung: Dieses Skript lädt Sensordaten vom air-Q pro, entschlüsselt sie (AES-256-CBC) und liefert ein klar strukturiertes Objekt (AirQMessung) zurück. Die Anzeige/Weiterverarbeitung (CSV, LCD) ist bewusst entkoppelt, damit du das Objekt flexibel verwenden kannst. """ from __future__ import annotations import argparse import base64 import csv import http.client from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, Optional import urllib.request from typing import Tuple from Crypto.Cipher import AES # pycryptodome # -------------------------------------------------------------------- # Konfiguration (Defaults für CLI; produktiv per .env/Umgebungsvariablen) # -------------------------------------------------------------------- STANDARD_AIRQ_IP = "192.168.178.108" STANDARD_AIRQ_PASSWORT = "draeger1980" AIRQ_HTTP_PORT = 80 AIRQ_HTTP_PFAD = "/data" AIRQ_HTTP_TIMEOUT_S = 8 AIRQ_HTTP_RETRIES = 3 AES_SCHLUESSEL_LAENGE = 32 AES_BLOCKGROESSE = 16 # -------------------------------------------------------------------- # Domänenmodell # -------------------------------------------------------------------- @dataclass(frozen=True) class AirQMessung: """ Repräsentiert eine Messung des air-Q. - zeitpunkt_local / zeitpunkt_utc: Zeitpunkt (aus 'timestamp' in ms) - device_id, uptime_s: Gerätemetadaten - rohwerte: Original-JSON (entschlüsselt) - werte: deutsch beschriftete, einheitenklare Basiswerte (für CSV/LCD) - status: Warmup-/Hinweistexte je Sensor """ zeitpunkt_local: datetime zeitpunkt_utc: datetime device_id: Optional[str] uptime_s: Optional[int] rohwerte: Dict[str, Any] werte: Dict[str, Any] status: Dict[str, str] # -------------------------------------------------------------------- # Datenabruf & Entschlüsselung # -------------------------------------------------------------------- def _entferne_pkcs7_padding(daten: bytes) -> bytes: """Entfernt PKCS#7-Padding sicher im Byte-Kontext.""" return daten[:-daten[-1]] def _erzeuge_aes_schluessel(passwort: str) -> bytes: """Erzeugt einen 32-Byte-AES-Schlüssel aus dem Passwort (auffüllen/abschneiden).""" key = passwort.encode("utf-8") return key + b"0" * (AES_SCHLUESSEL_LAENGE - len(key)) if len(key) < AES_SCHLUESSEL_LAENGE else key[:AES_SCHLUESSEL_LAENGE] def _entschluessle_content(base64_text: str, passwort: str) -> Dict[str, Any]: """Entschlüsselt den base64-kodierten 'content' des air-Q und liefert das JSON als Dict.""" verschluesselt = base64.b64decode(base64_text) iv, payload = verschluesselt[:AES_BLOCKGROESSE], verschluesselt[AES_BLOCKGROESSE:] cipher = AES.new(key=_erzeuge_aes_schluessel(passwort), mode=AES.MODE_CBC, IV=iv) entschluesselt = cipher.decrypt(payload) json_text = _entferne_pkcs7_padding(entschluesselt).decode("utf-8") return json.loads(json_text) def _hole_rohdaten(ip_adresse: str) -> Dict[str, Any]: """ Holt das äußere JSON (mit 'content') fest über http://<ip>/data (Port 80). Baut Retries und aussagekräftige Fehler ein. """ last_err: Optional[Exception] = None for attempt in range(1, AIRQ_HTTP_RETRIES + 1): conn = http.client.HTTPConnection(ip_adresse, port=AIRQ_HTTP_PORT, timeout=AIRQ_HTTP_TIMEOUT_S) try: conn.request("GET", AIRQ_HTTP_PFAD) resp = conn.getresponse() body = resp.read() if resp.status == 200: return json.loads(body) raise RuntimeError(f"HTTP {resp.status} {resp.reason} auf {AIRQ_HTTP_PFAD}") except (socket.timeout, TimeoutError, OSError, RuntimeError, json.JSONDecodeError) as e: last_err = e # kleiner Backoff zwischen den Versuchen time.sleep(0.5 * attempt) finally: try: conn.close() except Exception: pass raise ConnectionError( f"air-Q unter http://{ip_adresse}{AIRQ_HTTP_PFAD} nicht erreichbar (Port 80). " f"Letzter Fehler: {last_err}" ) def hole_airq_objekt(ip_adresse: str, passwort: str) -> Dict[str, Any]: """ EINSTIEGSPUNKT für Datenzugriff: - Holt die Rohantwort - Entschlüsselt 'content' - Gibt das entschlüsselte JSON (als Dict) zurück """ rohdaten = _hole_rohdaten(ip_adresse) return _entschluessle_content(rohdaten["content"], passwort) # -------------------------------------------------------------------- # Normierung & Beschriftung # -------------------------------------------------------------------- # Mapping: JSON-Key -> (deutscher Name, Einheit) MAPPING: Dict[str, Tuple[str, Optional[str]]] = { "pm1": ("PM1.0", "µg/m³"), "pm2_5": ("PM2.5", "µg/m³"), "pm10": ("PM10", "µg/m³"), "TypPS": ("Ø Partikelgröße", "µm"), "dewpt": ("Taupunkt", "°C"), "temperature": ("Temperatur", "°C"), "humidity": ("Relative Luftfeuchte", "%"), "humidity_abs":("Absolute Luftfeuchte", "g/m³"), "pressure": ("Luftdruck", "hPa"), "sound": ("Lärmpegel", "dB(A)"), "sound_max": ("Lärmpegel max (2 min)", "dB(A)"), "tvoc": ("TVOC (flüchtige organische Verbindungen)", "ppb"), "co": ("CO (Kohlenmonoxid)", "ppm"), # korrigiert "co2": ("CO₂ (Kohlendioxid)", "ppm"), # korrigiert "health": ("Gesundheitsindex", None), "performance": ("Leistungsindex", None), "dHdt": ("Δ absolute LF", "mg/m³/s"), "dCO2dt": ("Δ CO₂", "ppb/s"), "uptime": ("Betriebszeit", "s"), "measuretime": ("Messdauer letzter Durchlauf", "ms"), "DeviceID": ("Geräte-ID", None), "timestamp": ("Zeitstempel (ms)", "ms"), } def _wert_aus_array_oder_skalar(v: Any) -> Tuple[Any, Optional[Any]]: """ air-Q liefert häufig [wert, unsicherheit]. Gibt (basiswert, unsicherheit) zurück. """ if isinstance(v, (list, tuple)) and len(v) >= 1: basis = v[0] unsicherheit = v[1] if len(v) > 1 else None return basis, unsicherheit return v, None def _normalisiere_status(raw) -> Dict[str, str]: """ Wandelt das 'Status'-Feld in ein Dict[str, str] um. - Dict: Schlüssel/Werte als Strings - String/Number/sonstiges: {"status": "<wert>"} - Liste/Tuple: {"status_0": "...", "status_1": "...", ...} - None: {} """ if raw is None: return {} if isinstance(raw, dict): return {str(k): str(v) for k, v in raw.items()} if isinstance(raw, (list, tuple)): return {f"status_{i}": str(v) for i, v in enumerate(raw)} # alles andere (z. B. "OK") return {"status": str(raw)} def normiere_messung(entschluesselt: Dict[str, Any]) -> AirQMessung: """ Wandelt das entschlüsselte Dict in eine AirQMessung: - Zeit aus 'timestamp' (ms) → UTC + lokale Zeit - deutsch beschriftete, einheitenklare Basiswerte - Statusmeldungen (Warmup etc.) unter 'Status' """ # Zeitstempel (ms) → UTC/local ts_ms = entschluesselt.get("timestamp") if isinstance(ts_ms, (int, float)): ts_utc = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc) else: ts_utc = datetime.now(tz=timezone.utc) ts_local = ts_utc.astimezone() # Status-Hinweise status_norm = _normalisiere_status(entschluesselt.get("Status")) # Beschriftete Werte werte: Dict[str, Any] = {} for key, (name_de, einheit) in MAPPING.items(): if key in entschluesselt: basiswert, _unsicherheit = _wert_aus_array_oder_skalar(entschluesselt[key]) label = f"{name_de} ({einheit})" if einheit else name_de werte[label] = basiswert return AirQMessung( zeitpunkt_local=ts_local, zeitpunkt_utc=ts_utc, device_id=str(entschluesselt.get("DeviceID")) if entschluesselt.get("DeviceID") else None, uptime_s=int(entschluesselt.get("uptime")) if entschluesselt.get("uptime") is not None else None, rohwerte=entschluesselt, werte=werte, status=status_norm, ) # -------------------------------------------------------------------- # Weiterverarbeitung (CSV / LCD-Stub) – optional # -------------------------------------------------------------------- def schreibe_csv_zeile(datei: Path, messung: AirQMessung, erstelle_header: bool = True) -> None: """ Schreibt eine Zeile in eine CSV-Datei (Delimiter ';'). - Erstellt optional beim ersten Mal einen Header. - 'zeitpunkt' wird als ISO-String vorangestellt. """ datei.parent.mkdir(parents=True, exist_ok=True) spalten = ["zeitpunkt"] + sorted(messung.werte.keys()) schreibe_header = (not datei.exists()) and erstelle_header with datei.open("a", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=spalten, delimiter=";") if schreibe_header: writer.writeheader() zeile = {"zeitpunkt": messung.zeitpunkt_local.isoformat(timespec="seconds")} zeile.update({k: messung.werte.get(k, "") for k in spalten if k != "zeitpunkt"}) writer.writerow(zeile) # ------------------------------------------------------------ # 1) Anzeigezeilen aus Messung erzeugen # ------------------------------------------------------------ def formatiere_lcd_zeilen(messung: AirQMessung) -> Tuple[str, str]: """ Baut zwei Textzeilen für ein 16x2 LCD. Da dein ESP 'scroll' unterstützt, dürfen die Zeilen länger sein; wir halten sie trotzdem kompakt & gut lesbar. """ # Werte robust lesen (falls mal etwas fehlt) co2 = messung.werte.get("CO₂ (Kohlendioxid) (ppm)") tvoc = messung.werte.get("TVOC (flüchtige organische Verbindungen) (ppb)") temp = messung.werte.get("Temperatur (°C)") rh = messung.werte.get("Relative Luftfeuchte (%)") # Schonend runden/formatieren def fmt(v, nd=0): if v is None or v == "-": return "-" try: return f"{round(float(v), nd)}" except Exception: return str(v) co2_s = fmt(co2, 0) tvoc_s = fmt(tvoc, 0) temp_s = fmt(temp, 1) rh_s = fmt(rh, 1) # Zeile 1 & 2 kompakt – darf länger sein (scroll) zeile1 = f"CO2:{co2_s}ppm TVOC:{tvoc_s}ppb" zeile2 = f"T:{temp_s}C rH:{rh_s}%" return zeile1, zeile2 # ------------------------------------------------------------ # 2) HTTP-POST an den ESP-Endpoint senden # ------------------------------------------------------------ def sende_an_lcd_http(lcd_url: str, zeile1: str, zeile2: str, modus: str = "scroll", anzeigedauer: int = 15, timeout_s: int = 5) -> None: """ Sendet die gewünschten LCD-Zeilen an den ESP (JSON-POST). Erwartet einen Endpoint wie: http://192.168.178.96/anzeige """ payload = { "zeile1": zeile1, "zeile2": zeile2, "modus": modus, # z.B. "scroll", "static", ... "anzeigedauer": anzeigedauer # Sekunden } daten = json.dumps(payload).encode("utf-8") req = urllib.request.Request( url=lcd_url, data=daten, headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=timeout_s) as resp: # Antwort bei Bedarf auswerten: _ = resp.read() except HTTPError as e: # Hier gerne auf logging umstellen print(f"HTTP-Fehler beim Senden an LCD ({e.code}): {e.reason}") except URLError as e: print(f"Verbindungsfehler zum LCD: {e.reason}") except Exception as e: print(f"Unerwarteter Fehler beim LCD-POST: {e}") # ------------------------------------------------------------ # 3) Öffentliche Anzeige-Funktion (ersetzt den Stub) # ------------------------------------------------------------ def zeige_auf_lcd_http(messung: AirQMessung, lcd_url: str, modus: str = "scroll", anzeigedauer: int = 15) -> None: """ Erzeugt zwei Anzeigezeilen aus der Messung und sendet sie per HTTP an den ESP-LCD-Service. """ zeile1, zeile2 = formatiere_lcd_zeilen(messung) print(f"[LCD→HTTP] {zeile1} || {zeile2}") # kleine Laufzeit-Info sende_an_lcd_http( lcd_url=lcd_url, zeile1=zeile1, zeile2=zeile2, modus=modus, anzeigedauer=anzeigedauer ) # --- IMPORTS (ergänzen) --- import time import json import urllib.request from urllib.error import URLError, HTTPError from typing import Tuple, List # Falls noch nicht vorhanden, aus deinem Script: # from Crypto.Cipher import AES # class AirQMessung: ... # hole_airq_objekt(...), normiere_messung(...) # ------------------------------------------------------------ # Hilfen für LCD-Text # ------------------------------------------------------------ def _wert_aus_array_oder_skalar(v): """Basiswert (und Unsicherheit) aus [wert, unsicherheit] oder Skalar extrahieren.""" if isinstance(v, (list, tuple)) and v: basis = v[0] unsicherheit = v[1] if len(v) > 1 else None return basis, unsicherheit return v, None def _fmt_wert(v, nd=0) -> str: """Zahl sauber formatieren; fällt robust auf str(v) zurück.""" try: return f"{round(float(v), nd)}" except Exception: return str(v) def _kuerze16(text: str) -> str: """Max 16 Zeichen fürs 16x2-LCD.""" return text if len(text) <= 16 else text[:16] def _baue_zeile(tag_kurz: str, wert, einheit_kurz: str = "", nd: int = 0) -> str: """ Erzeugt eine LCD-Zeile wie 'CO2 1164ppm' bzw. 'T 23.4C'. Hält strikt 16 Zeichen ein. """ val = _fmt_wert(wert, nd) unit = einheit_kurz or "" zeile = f"{tag_kurz} {val}{unit}" if len(zeile) > 16: zeile = f"{tag_kurz}:{val}{unit}" return _kuerze16(zeile) # ------------------------------------------------------------ # HTTP-POST an den ESP-Endpoint # ------------------------------------------------------------ def _sende_an_lcd_http(lcd_url: str, zeile1: str, zeile2: str, modus: str = "static", anzeigedauer: int = 3, timeout_s: int = 5) -> None: """ Sendet zwei Zeilen an dein LCD via HTTP-JSON. 'modus' hier auf 'static' (keine Scroll-Animation). """ payload = { "zeile1": zeile1, "zeile2": zeile2, "modus": modus, "anzeigedauer": anzeigedauer } daten = json.dumps(payload).encode("utf-8") req = urllib.request.Request( url=lcd_url, data=daten, headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=timeout_s) as resp: _ = resp.read() except HTTPError as e: print(f"HTTP-Fehler LCD ({e.code}): {e.reason}") except URLError as e: print(f"Verbindungsfehler LCD: {e.reason}") except Exception as e: print(f"Unerwarteter LCD-Fehler: {e}") # ------------------------------------------------------------ # 1) Eine Messung -> Seiten (2 Zeilen) nacheinander anzeigen # ------------------------------------------------------------ def zeige_auf_lcd_http_sequenziell(messung: AirQMessung, lcd_url: str, warte_sek: int = 3) -> None: """ Zeigt aus *einer* Messung nacheinander alle relevanten Werte an. Jede Seite: 2 Zeilen à max. 16 Zeichen, danach 'warte_sek' warten. """ roh = messung.rohwerte # Reihenfolge & Formatregeln (key, tag, unit, decimals) anzeigen_reihenfolge = [ ("co2", "CO2", "ppm", 0), ("tvoc", "TVOC", "ppb", 0), ("temperature","T", "C", 1), ("humidity", "rH", "%", 1), ("pressure", "p", "hPa", 1), ("pm1", "PM1", "ug/m3", 0), ("pm2_5", "PM2.5", "ug/m3", 0), ("pm10", "PM10", "ug/m3", 0), ("co", "CO", "ppm", 2), ("sound", "dB", "", 1), ("sound_max", "dBmax", "", 1), ("dewpt", "Td", "C", 1), ("humidity_abs","AbsLF","g/m3",1), ("dCO2dt", "dCO2", "ppb/s", 0), ("dHdt", "dH", "mg/m3/s", 1), ("health", "Health","", 0), ("performance","Perf", "", 0), ] # Alle vorhandenen Messzeilen generieren zeilen: List[str] = [] for key, tag, unit, nd in anzeigen_reihenfolge: if key in roh: basis, _ = _wert_aus_array_oder_skalar(roh[key]) zeilen.append(_baue_zeile(tag, basis, unit, nd)) if not zeilen: # Fallback _sende_an_lcd_http(lcd_url, "Keine Daten", "", anzeigedauer=warte_sek) time.sleep(warte_sek) return # In 2er-Pärchen (Seiten) aufteilen for i in range(0, len(zeilen), 2): z1 = zeilen[i] z2 = zeilen[i+1] if i + 1 < len(zeilen) else "" # Senden & warten _sende_an_lcd_http(lcd_url, z1, z2, modus="static", anzeigedauer=warte_sek) time.sleep(warte_sek) # ------------------------------------------------------------ # 2) Endlosschleife: Daten holen -> sequenziell anzeigen -> wiederholen # ------------------------------------------------------------ def starte_lcd_endlosschleife(ip: str, passwort: str, lcd_url: str, warte_sek: int = 3, pause_nach_zyklus: int = 0) -> None: """ Holt in einer Endlosschleife neue Messungen und zeigt diese sequenziell an. - 'warte_sek': Wartezeit zwischen den Seiten - 'pause_nach_zyklus': zusätzliche Pause nach *allen* Seiten (optional) """ while True: try: entschluesselt = hole_airq_objekt(ip, passwort) messung = normiere_messung(entschluesselt) zeige_auf_lcd_http_sequenziell(messung, lcd_url, warte_sek=warte_sek) except Exception as e: print(f"Fehler im Anzeigezyklus: {e}") # kurze Atempause, damit bei Dauerfehlern nicht gespammt wird time.sleep(max(5, warte_sek)) if pause_nach_zyklus > 0: time.sleep(pause_nach_zyklus) # -------------------------------------------------------------------- # CLI / main # -------------------------------------------------------------------- def main() -> None: """ Nutzung: # nur einmal laden & anzeigen python main.py # CSV schreiben python main.py --csv daten/airq.csv # LCD-Stub anzeigen python main.py --lcd # beides (CSV + LCD) python main.py --csv daten/airq.csv --lcd """ parser = argparse.ArgumentParser(description="air-Q Daten abrufen, entschlüsseln und verarbeiten.") parser.add_argument("--ip", default=STANDARD_AIRQ_IP, help="IP-Adresse des air-Q (Default: %(default)s)") parser.add_argument("--passwort", default=STANDARD_AIRQ_PASSWORT, help="Passwort des air-Q") parser.add_argument("--csv", type=Path, help="Pfad zur CSV-Datei (schreibt eine Zeile)") parser.add_argument("--lcd-url", help="HTTP-Endpoint des ESP-LCD (z. B. http://192.168.178.96/anzeige)") parser.add_argument("--lcd-warte", type=int, default=3, help="Wartezeit je Seite in Sekunden (Default: %(default)s)") parser.add_argument("--lcd-loop", action="store_true", help="Endlosschleife: Messung holen, anzeigen, wiederholen") parser.add_argument("--loop-pause", type=int, default=0, help="Zusatzpause nach jedem kompletten Anzeigenzyklus (s)") args = parser.parse_args() # 1) Rohobjekt laden (entschlüsselte JSON als Dict) entschluesselt = hole_airq_objekt(args.ip, args.passwort) # 2) Normieren in Domänenobjekt messung = normiere_messung(entschluesselt) # 3) Ohne Flags: kurze Übersicht if not args.csv and not args.lcd_url: print(f"[{messung.zeitpunkt_local:%Y-%m-%d %H:%M:%S}] air-Q Messwerte") for k in sorted(messung.werte.keys()): print(f" - {k}: {messung.werte[k]}") if messung.status: print("\nStatus:") for sensor, text in messung.status.items(): print(f" - {sensor}: {text}") return # 4) CSV if args.csv: schreibe_csv_zeile(args.csv, messung) print(f"CSV aktualisiert: {args.csv}") # 5) LCD if args.lcd_url and args.lcd_loop: starte_lcd_endlosschleife(args.ip, args.passwort, args.lcd_url, warte_sek=args.lcd_warte, pause_nach_zyklus=args.loop_pause) return if args.lcd_url: # Nur *eine* Messung holen und sequenziell anzeigen entschluesselt = hole_airq_objekt(args.ip, args.passwort) messung = normiere_messung(entschluesselt) zeige_auf_lcd_http_sequenziell(messung, args.lcd_url, warte_sek=args.lcd_warte) return if __name__ == "__main__": main()