diff --git a/.gitignore b/.gitignore index 122ff48..f3a3578 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ Hardware/ # WLAN-Zugangsdaten des ESP32 (Vorlage: wifi_config_example.py) wifi_config.py +# MQTT-Zugangsdaten (Vorlage: mqtt_config_example.py) +mqtt_config.py + # Laufzeit-Einstellungen (Helligkeiten, Schwelle -- spaeter per MQTT verstellt). # Gehoert dem Geraet, nicht dem Repo; die Vorgaben stehen in settings.DEFAULTS. settings.json diff --git a/README.md b/README.md index 20f5739..7cb8b4e 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,10 @@ beiden Startprogrammen. | `mount_client.py` | TCP-Client zur GM4000 (LX200). | ✓ | | `lx200.py` | LX200-Kommandorahmen. | ✓ | | `wifi.py` | WLAN-Anbindung des ESP32 (verbinden, Verbindung halten). | ✓ | +| `mqtt.py` | MQTT: Einstellungen empfangen, Zustand veröffentlichen. | ✓ | | `run_esp32.py` | Startprogramm auf dem ESP32: WLAN, Anzeige, Schleife. | ✓ | | `wifi_config.py` | WLAN-Zugangsdaten, **nicht im Git** (Vorlage `wifi_config_example.py`). | ✓ | +| `mqtt_config.py` | MQTT-Zugangsdaten, **nicht im Git** (Vorlage `mqtt_config_example.py`). | ✓ | | `run_display.py` | Startprogramm auf dem Mac: dieselbe Schleife mit Kommandozeile. | | | `show_coords.py` | Testprogramm (Koordinaten von Hand). | | | `mount_mock.py` | Simulator der Montierung (LX200) fürs Testen ohne Teleskop. | | @@ -161,6 +163,7 @@ mpremote repl >>> import run_esp32 >>> run_esp32.main() # ohne Helligkeitsregelung >>> run_esp32.main(with_ldr=True) # mit LDR über den ADC +>>> run_esp32.main(with_mqtt=False) # ohne MQTT (sonst an, sofern konfiguriert) ``` Bewusst **kein** `main.py`: MicroPython startet eine Datei dieses Namens nach @@ -253,9 +256,10 @@ Mac: Schreibtisch. Vor Ort bei Tag und Nacht die Rohwerte ablesen und die Schwelle in `settings.json` bestätigen. Bis dahin läuft die Anzeige mit fester Helligkeit (`run_esp32.main()` ohne `with_ldr`). -- **MQTT-Anbindung.** Die Einstellungen liegen bereits zur Laufzeit änderbar in - `settings.json` (`settings.update(...)`, geprüft auf Typ und Bereich); der - MQTT-Client, der sie von außen setzt, fehlt noch. +- **MQTT gegen einen echten Broker prüfen.** Der Client ist fertig und gegen einen + Fake-Broker getestet (`test_mqtt.py`), am Gerät ist bisher nur der Fall *ohne* + Broker bestätigt: kein Absturz, Anzeige läuft weiter. Sobald ein Broker steht, + `mqtt_config.py` anlegen und gegenprüfen. - **Netzwerk zur echten Montierung.** Die GM4000 steht in `192.168.1.115`, das Heimnetz des ESP32 ist `192.168.178.x` — der Mac erreicht sie per VPN, der ESP32 so nicht. Muss geklärt werden, bevor es an die echte Montierung geht. @@ -438,6 +442,80 @@ Die Schwellen sind am Aufbau gemessen, aber **am Schreibtisch, nicht in der Sternwarte**. Vor dem Echteinsatz dort bei Tag und Nacht gegenprüfen — deshalb bleibt `with_ldr` in `run_esp32.main` vorerst auf `False`. +## MQTT + +Der ESP32 nimmt Einstellungen über MQTT entgegen und veröffentlicht seinen +Zustand. Zugangsdaten kommen aus `mqtt_config.py` (gitignored, Vorlage +`mqtt_config_example.py`): + +```bash +cp mqtt_config_example.py mqtt_config.py # Broker, Port, ggf. Benutzer/Passwort +./deploy.sh +``` + +**Fehlt die Datei, läuft die Anzeige einfach ohne MQTT** — `deploy.sh` weist nur +darauf hin und kopiert sie nicht. + +### Topics + +Empfangen (`/set/`, Nutzlast als Zahl): + +| Topic | Beispiel | Wirkung | +|---|---|---| +| `grossanzeige/set/hell_prozent` | `60` | Helligkeit über der Schwelle | +| `grossanzeige/set/dunkel_prozent` | `5` | Helligkeit unter der Schwelle | +| `grossanzeige/set/schwelle` | `1800` | Umschaltpunkt (LDR-Wert) | +| `grossanzeige/set/hysterese` | `150` | Breite um den Umschaltpunkt | + +Gesendet (`/status/`, alle *retained*): + +| Topic | Beispiel | +|---|---| +| `grossanzeige/status/ra` | `18h36m56s` | +| `grossanzeige/status/dec` | `+38°47'01"` | +| `grossanzeige/status/ldr` | `2453` | +| `grossanzeige/status/helligkeit` | `50` | +| `grossanzeige/status/link` | `1` (Montierung erreichbar) | +| `grossanzeige/status/online` | `1` — **Last Will** setzt es auf `0` | + +Zum Ausprobieren: + +```bash +mosquitto_sub -h -t 'grossanzeige/#' -v +mosquitto_pub -h -t grossanzeige/set/hell_prozent -m 60 +``` + +Eine empfangene Einstellung wird sofort wirksam: Sie geht durch dieselbe Prüfung +wie jede andere (`settings.update`), landet in `settings.json` und der laufende +`BrightnessController` lädt sie über `reload()` nach — ein Neustart ist nicht nötig. + +### Was dabei schiefgehen kann, und warum es nichts ausmacht + +**Grundsatz: MQTT darf die Anzeige nie aufhalten.** Die Anzeige ist der Zweck des +Geräts, MQTT ist Beiwerk. Deshalb fängt `mqtt.py` seine Fehler selbst ab: + +- **Broker nicht erreichbar** → Der Verbindungsversuch schlägt fehl, wird notiert, + und die Schleife läuft weiter. Wiederholt wird mit **wachsendem Abstand** + (5 s bis 120 s) — sonst würde ein dauerhaft toter Broker in *jedem* + Schleifendurchlauf Zeit kosten. +- **Verbindung bricht mitten im Betrieb ab** → Wird beim nächsten Senden oder + Empfangen bemerkt, die Brücke verwirft die Verbindung und baut sie später neu + auf. Nach dem Reconnect wird der gesamte Status erneut gesendet, damit der + Broker nicht auf veralteten Werten sitzenbleibt. +- **Unsinniger Wert von außen** (`hell_prozent = 500`, `schwelle = -1`, Text statt + Zahl, unbekanntes Topic) → wird verworfen und protokolliert; `settings.json` + bleibt unberührt. Genau dafür prüft `settings.py` jeden Wert auf Typ und Bereich. +- **Gerät fällt aus** → Der **Last Will** sorgt dafür, dass der Broker von sich aus + `online = 0` meldet. Ohne das bliebe `online = 1` stehen, obwohl niemand mehr da ist. + +`test_mqtt.py` prüft all diese Fälle gegen einen eingehängten Fake-Broker — auch +die, die sich mit einem echten Server nur schwer herbeiführen lassen. Der wichtigste +Test ist `test_toter_broker_haelt_die_anzeige_nicht_auf`. + +Der Empfang blockiert nicht: `check_msg()` kehrt sofort zurück, wenn nichts anliegt. +Statuswerte werden nur bei Änderung gesendet — die Koordinaten ändern sich ständig, +LDR und Helligkeit kaum. + ## Antwortleitung (RX) Der Rückkanal ist verdrahtet und verifiziert (2026-07-16): Anzeige Pin 3 (TxD) → diff --git a/deploy.sh b/deploy.sh index 8e97e6a..f1d46db 100755 --- a/deploy.sh +++ b/deploy.sh @@ -41,6 +41,7 @@ mount_client.py brightness.py displayloop.py wifi.py +mqtt.py run_esp32.py probe.py " @@ -51,7 +52,16 @@ if [ ! -f wifi_config.py ]; then exit 1 fi -for f in $FILES wifi_config.py; do +# mqtt_config.py ist optional: Fehlt sie, laeuft die Anzeige ohne MQTT weiter. +EXTRA="wifi_config.py" +if [ -f mqtt_config.py ]; then + EXTRA="$EXTRA mqtt_config.py" +else + echo "Hinweis: mqtt_config.py fehlt -- laeuft ohne MQTT." + echo " cp mqtt_config_example.py mqtt_config.py und ausfuellen" +fi + +for f in $FILES $EXTRA; do echo "-> $f" mpremote connect "$PORT" fs cp "$f" ":$f" done diff --git a/displayloop.py b/displayloop.py index 451dda6..4b254c4 100644 --- a/displayloop.py +++ b/displayloop.py @@ -26,7 +26,8 @@ def format_coordinates(ra_raw, dec_raw): def poll_loop(display, client, interval=None, once=False, - read_ldr=None, dimmer=None, ensure_link=None, log=None): + read_ldr=None, dimmer=None, ensure_link=None, log=None, + bridge=None): """Zyklisch die Montierung abfragen und die Anzeige nachfuehren. display offenes Display-Objekt @@ -40,6 +41,9 @@ def poll_loop(display, client, interval=None, once=False, sicherstellt (auf dem ESP32 das WLAN) log optional: Ausgabefunktion, Vorgabe print; None-freies Logging erlaubt es, auf dem ESP32 still zu laufen (log=lambda *a: None) + bridge optional: mqtt.MqttBridge -- empfaengt Einstellungen und + veroeffentlicht den Zustand. Faellt der Broker aus, laeuft die + Anzeige unveraendert weiter (mqtt.py faengt seine Fehler selbst). Laeuft bis KeyboardInterrupt oder -- bei once=True -- genau einmal. Fehler von Anzeige und Montierung werden abgefangen: die Schleife meldet sie und @@ -53,6 +57,11 @@ def poll_loop(display, client, interval=None, once=False, last_shown = None while True: try: + # Zuerst MQTT abholen: So wirkt eine gerade gesetzte Helligkeit noch + # in diesem Durchlauf, statt eine Runde hinterherzuhinken. + if bridge is not None: + bridge.check() + if ensure_link is not None: ensure_link() if not client.connected: @@ -66,6 +75,9 @@ def poll_loop(display, client, interval=None, once=False, display.set_brightness(percent) log("LDR %d -> %s = %d %%" % (raw, "HELL" if level else "DUNKEL", percent)) + if bridge is not None: + bridge.publish("ldr", raw) + bridge.publish("helligkeit", percent) ra_raw, dec_raw = client.get_coordinates() ra_line, dec_line = format_coordinates(ra_raw, dec_raw) @@ -81,6 +93,11 @@ def poll_loop(display, client, interval=None, once=False, last_shown = (ra_line, dec_line) log("RA %-11s DEC %-12s -> %s | %s" % (ra_raw, dec_raw, ra_line, dec_line)) + if bridge is not None: + bridge.publish("ra", ra_line) + bridge.publish("dec", dec_line) + if bridge is not None: + bridge.publish("link", 1) except LinkError as e: # Kein Netz (WLAN): die Montierung gar nicht erst versuchen. log("Netz: %s" % e) @@ -95,6 +112,8 @@ def poll_loop(display, client, interval=None, once=False, log("Montierung: %s" % e) client.close() last_shown = _show_notice(display, config.NOLINK_LINES, last_shown, log) + if bridge is not None: + bridge.publish("link", 0) if once: return last_shown diff --git a/mqtt.py b/mqtt.py new file mode 100644 index 0000000..d3fa139 --- /dev/null +++ b/mqtt.py @@ -0,0 +1,251 @@ +"""MQTT-Anbindung: Einstellungen empfangen, Zustand veroeffentlichen. + + /set/hell_prozent 60 eingehend, schreibt settings.json + /set/dunkel_prozent 5 + /set/schwelle 1800 + /set/hysterese 150 + + /status/ra 18h36m56s ausgehend + /status/dec +38°47'01" + /status/ldr 2453 + /status/helligkeit 50 + /status/link 1 Montierung erreichbar + /status/online 1 retained, mit Last Will auf 0 + +**Grundsatz: MQTT darf die Anzeige nie aufhalten.** Die Anzeige ist der Zweck des +Geraets, MQTT ist Beiwerk. Deshalb faengt jede Methode hier ihre Fehler selbst ab +und meldet sie nur zurueck; ein toter Broker, ein abgezogenes Netzwerkkabel oder +ein unsinniger Wert duerfen die Poll-Schleife nicht unterbrechen. Aus demselben +Grund wird ein Verbindungsverlust nicht sofort und nicht endlos neu versucht, +sondern mit wachsendem Abstand (RETRY_START..RETRY_MAX) -- sonst haengt die +Schleife bei jedem Durchlauf im Verbindungsaufbau. + +Der Empfang laeuft ueber check_msg(), das nicht blockiert: Liegt nichts an, +kehrt es sofort zurueck. + +Ohne mqtt_config.py (oder ohne umqtt) gibt connect_from_config() None zurueck -- +die Anzeige laeuft dann ohne MQTT weiter. +""" + +import settings +from ticks import deadline as _deadline, expired as _expired + +try: # MicroPython + from umqtt.simple import MQTTClient +except ImportError: # CPython: nur zum Testen, Client wird injiziert + MQTTClient = None + + +# Wartezeiten fuer den Wiederverbindungsversuch (Sekunden). +RETRY_START = 5.0 +RETRY_MAX = 120.0 + +# Diese Einstellungen lassen sich ueber /set/ aendern. Bewusst +# aus settings.DEFAULTS abgeleitet: Was dort nicht steht, ist auch per MQTT +# nicht setzbar, und settings prueft jeden Wert zusaetzlich auf seinen Bereich. +SETZBAR = tuple(sorted(settings.DEFAULTS)) + + +class MqttBridge: + """Haelt die Broker-Verbindung und uebersetzt in beide Richtungen. + + Wird der Client von aussen gegeben (Tests), unterbleibt der Aufbau eines + echten MQTTClient. So laesst sich die ganze Logik ohne Broker pruefen. + """ + + def __init__(self, broker=None, port=1883, user="", password="", + prefix="grossanzeige", client_id="grossanzeige", + client=None, log=None, on_change=None): + self._broker = broker + self._port = port + self._user = user + self._password = password + self._prefix = prefix + self._client_id = client_id + self._client = client + self._log = log or (lambda *a: None) + self._on_change = on_change # wird nach jeder Aenderung gerufen + + self.connected = False + self._retry = RETRY_START + self._naechster_versuch = None + self._zuletzt = {} # Topic -> zuletzt gesendeter Wert + + # -- Topics ------------------------------------------------------------ + def _set_topic(self, name=None): + return "%s/set/%s" % (self._prefix, "+" if name is None else name) + + def _status_topic(self, name): + return "%s/status/%s" % (self._prefix, name) + + # -- Verbindung -------------------------------------------------------- + def _neuer_client(self): + if MQTTClient is None: + raise OSError("umqtt.simple nicht verfuegbar") + return MQTTClient(self._client_id, self._broker, port=self._port, + user=self._user or None, + password=self._password or None, + keepalive=60) + + def connect(self): + """Verbinden, Set-Topics abonnieren, online melden. + + Liefert True bei Erfolg. Schlaegt es fehl, wird das nur protokolliert -- + der naechste check() versucht es nach der Wartezeit erneut. + """ + try: + if self._client is None: + self._client = self._neuer_client() + self._client.set_callback(self._on_message) + # Last Will: Bricht die Verbindung weg, meldet der Broker selbst + # offline. Ohne das bliebe online=1 stehen, obwohl niemand da ist. + self._client.set_last_will(self._status_topic("online"), b"0", + retain=True) + self._client.connect() + self._client.subscribe(self._set_topic()) + self._client.publish(self._status_topic("online"), b"1", retain=True) + self.connected = True + self._retry = RETRY_START + self._zuletzt = {} # nach Reconnect alles neu senden + self._log("MQTT: verbunden mit %s:%d" % (self._broker, self._port)) + return True + except Exception as e: # OSError, MQTTException, ... + self._log("MQTT: Verbindung fehlgeschlagen (%s)" % e) + self._abwerfen() + return False + + def _abwerfen(self): + """Verbindung als tot markieren und den naechsten Versuch terminieren.""" + self.connected = False + try: + if self._client is not None: + self._client.disconnect() + except Exception: + pass # beim Aufraeumen ist alles egal + self._client = None + self._naechster_versuch = _deadline(self._retry) + # Beim naechsten Mal laenger warten, damit ein dauerhaft toter Broker + # nicht bei jedem Schleifendurchlauf Zeit kostet. + self._retry = min(self._retry * 2, RETRY_MAX) + + def ensure(self): + """Verbindung herstellen, sofern die Wartezeit abgelaufen ist.""" + if self.connected: + return True + if self._naechster_versuch is not None and not _expired(self._naechster_versuch): + return False + return self.connect() + + # -- Empfang ----------------------------------------------------------- + def _on_message(self, topic, payload): + """Callback von umqtt. Faengt alles ab -- hier darf nichts durchschlagen.""" + try: + name = topic.decode().rsplit("/", 1)[-1] + roh = payload.decode().strip() + except Exception: + self._log("MQTT: unlesbare Nachricht verworfen") + return + + if name not in SETZBAR: + self._log("MQTT: unbekannte Einstellung %r verworfen" % name) + return + try: + wert = int(roh) + except ValueError: + self._log("MQTT: %s=%r ist keine ganze Zahl" % (name, roh)) + return + + try: + neu = settings.update({name: wert}) + except settings.SettingsError as e: + self._log("MQTT: %s" % e) + return + + self._log("MQTT: %s = %d uebernommen" % (name, wert)) + if self._on_change: + try: + self._on_change(neu) + except Exception as e: + self._log("MQTT: on_change fehlgeschlagen (%s)" % e) + + def check(self): + """Anstehende Nachrichten verarbeiten. Blockiert nicht.""" + if not self.ensure(): + return False + try: + self._client.check_msg() + return True + except Exception as e: + self._log("MQTT: Empfang gestoert (%s)" % e) + self._abwerfen() + return False + + # -- Senden ------------------------------------------------------------ + def publish(self, name, wert, retain=True, nur_bei_aenderung=True): + """Einen Statuswert senden. + + nur_bei_aenderung spart Funkverkehr: Die Koordinaten aendern sich zwar + staendig, LDR und Helligkeit aber kaum. Mit retain=True holt sich ein + neu verbundener Client den letzten Stand von selbst ab. + """ + if not self.ensure(): + return False + topic = self._status_topic(name) + text = str(wert) + if nur_bei_aenderung and self._zuletzt.get(topic) == text: + return True + try: + self._client.publish(topic, text.encode(), retain=retain) + self._zuletzt[topic] = text + return True + except Exception as e: + self._log("MQTT: Senden von %s gestoert (%s)" % (name, e)) + self._abwerfen() + return False + + def publish_many(self, werte, retain=True): + """Mehrere Statuswerte senden. Liefert True, wenn alle durchgingen.""" + ok = True + for name, wert in werte.items(): + if not self.publish(name, wert, retain=retain): + ok = False + return ok + + def close(self): + """Sauber abmelden -- online=0 bleibt als retained Nachricht stehen.""" + try: + if self.connected and self._client is not None: + self._client.publish(self._status_topic("online"), b"0", + retain=True) + self._client.disconnect() + except Exception: + pass + self.connected = False + self._client = None + + +def connect_from_config(log=None, on_change=None): + """Bruecke aus mqtt_config.py bauen und verbinden. + + Liefert None, wenn keine Konfiguration da ist -- dann laeuft die Anzeige + ohne MQTT weiter, was ausdruecklich in Ordnung ist. + """ + log = log or (lambda *a: None) + try: + import mqtt_config + except ImportError: + log("MQTT: mqtt_config.py fehlt, laeuft ohne MQTT") + return None + + bridge = MqttBridge( + broker=getattr(mqtt_config, "BROKER", None), + port=getattr(mqtt_config, "PORT", 1883), + user=getattr(mqtt_config, "USER", ""), + password=getattr(mqtt_config, "PASSWORD", ""), + prefix=getattr(mqtt_config, "PREFIX", "grossanzeige"), + client_id=getattr(mqtt_config, "CLIENT_ID", "grossanzeige"), + log=log, + on_change=on_change, + ) + bridge.connect() # scheitert es, versucht check() es spaeter erneut + return bridge diff --git a/mqtt_config_example.py b/mqtt_config_example.py new file mode 100644 index 0000000..c24aa89 --- /dev/null +++ b/mqtt_config_example.py @@ -0,0 +1,25 @@ +"""Vorlage fuer die MQTT-Zugangsdaten. + + cp mqtt_config_example.py mqtt_config.py + +und dort die eigenen Werte eintragen. mqtt_config.py steht in .gitignore -- +Zugangsdaten gehoeren nicht ins Repository. + +Fehlt die Datei, laeuft die Anzeige einfach ohne MQTT weiter. +""" + +BROKER = "192.168.178.30" # IP oder Hostname des Brokers +PORT = 1883 # 1883 = unverschluesselt, 8883 = TLS (nicht unterstuetzt) + +# Leer lassen, wenn der Broker keine Anmeldung verlangt. +USER = "" +PASSWORD = "" + +# Wurzel aller Topics. Darunter entstehen: +# /set/ eingehend (hell_prozent, dunkel_prozent, schwelle, hysterese) +# /status/ ausgehend (ra, dec, ldr, helligkeit, link, online) +PREFIX = "grossanzeige" + +# Name des Geraets am Broker. Muss eindeutig sein, sonst werfen sich zwei +# Clients gegenseitig aus der Verbindung. +CLIENT_ID = "grossanzeige" diff --git a/run_esp32.py b/run_esp32.py index b1638eb..ed311a1 100644 --- a/run_esp32.py +++ b/run_esp32.py @@ -96,7 +96,7 @@ def selftest(host=None, port=None, log=print): def main(host=None, port=None, with_ldr=False, fake_display=False, - once=False, log=print): + once=False, with_mqtt=True, log=print): """WLAN aufbauen, Anzeige oeffnen und die Poll-Schleife fahren. host/port Montierung; Vorgabe aus config.py. Zum Testen gegen den Mock @@ -108,6 +108,9 @@ def main(host=None, port=None, with_ldr=False, fake_display=False, fake_display Anzeige nur als Hex in die REPL schreiben, statt ueber den UART zu senden -- fuer den Test ohne Pegelwandler und Anzeige. once nur ein Durchlauf, dann zurueck. + with_mqtt MQTT-Anbindung versuchen. Ohne mqtt_config.py passiert nichts, + deshalb ist die Vorgabe an: fehlt die Datei, laeuft alles wie + bisher weiter. """ d = open_display(fake=fake_display) client = MountClient(config.MOUNT_HOST if host is None else host, @@ -122,6 +125,7 @@ def main(host=None, port=None, with_ldr=False, fake_display=False, else: dimmer = BrightnessController() + bridge = None try: # Einmal vorab verbinden, damit ein Fehler gleich sichtbar wird; danach # haelt ensure_link die Verbindung in der Schleife. @@ -130,14 +134,34 @@ def main(host=None, port=None, with_ldr=False, fake_display=False, except wifi.WifiError as e: log("WLAN: %s" % e) + if with_mqtt: + import mqtt + # Kommt per MQTT eine neue Helligkeit, muss der Dimmer sie sofort + # sehen -- sonst wirkte sie erst nach einem Neustart. + def uebernehmen(werte): + if dimmer is not None: + dimmer.reload() + # Ohne LDR gibt es keinen Stufenwechsel, der die Helligkeit + # nachzieht: dann direkt die helle Stufe setzen. + if dimmer is None: + try: + d.set_brightness(werte["hell_prozent"]) + except Exception as e: + log("Anzeige: %s" % e) + + bridge = mqtt.connect_from_config(log=log, on_change=uebernehmen) + displayloop.poll_loop(d, client, once=once, read_ldr=read_ldr, dimmer=dimmer, ensure_link=lambda: wifi.ensure(log=log), + bridge=bridge, log=log) except KeyboardInterrupt: log("\nbeendet") finally: + if bridge is not None: + bridge.close() client.close() d.close() diff --git a/test_esp32.py b/test_esp32.py index 4e053f9..7d614a8 100644 --- a/test_esp32.py +++ b/test_esp32.py @@ -440,7 +440,7 @@ class TestWifi(unittest.TestCase): DEPLOYED = ( "ticks.py", "migra.py", "config.py", "settings.py", "transport.py", "display.py", "coords.py", "lx200.py", "mount_client.py", "brightness.py", - "displayloop.py", "wifi.py", "run_esp32.py", "probe.py", + "displayloop.py", "wifi.py", "mqtt.py", "run_esp32.py", "probe.py", ) diff --git a/test_mqtt.py b/test_mqtt.py new file mode 100644 index 0000000..399b249 --- /dev/null +++ b/test_mqtt.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +"""Tests fuer die MQTT-Anbindung. + + python3 -m unittest -v test_mqtt + +Statt eines echten Brokers wird ein FakeClient eingehaengt, der dieselbe +Schnittstelle wie umqtt.simple.MQTTClient bietet. So laesst sich alles pruefen, +was im Betrieb schiefgehen kann -- auch die Faelle, die man mit einem echten +Broker nur schwer herbeifuehrt: Verbindungsabbruch beim Senden, Muell im Topic, +ein Broker, der gar nicht antwortet. + +Schwerpunkt ist der Grundsatz aus mqtt.py: **MQTT darf die Anzeige nie +aufhalten.** Kein Test hier darf eine Ausnahme nach aussen durchlassen. +""" + +import os +import unittest + +import mqtt +import settings + + +class FakeClient: + """Ersatz fuer umqtt.simple.MQTTClient.""" + + def __init__(self, **kw): + self.published = [] # (topic, payload, retain) + self.subscribed = [] + self.last_will = None + self.callback = None + self.connected = False + self.disconnected = 0 + self.eingang = [] # (topic, payload) -- werden bei check_msg zugestellt + self.fail_connect = False + self.fail_publish = False + self.fail_check = False + + def set_callback(self, f): + self.callback = f + + def set_last_will(self, topic, msg, retain=False): + self.last_will = (topic, msg, retain) + + def connect(self): + if self.fail_connect: + raise OSError("Broker nicht erreichbar") + self.connected = True + + def subscribe(self, topic): + self.subscribed.append(topic) + + def publish(self, topic, msg, retain=False): + if self.fail_publish: + raise OSError("Verbindung weg") + self.published.append((topic, msg, retain)) + + def check_msg(self): + if self.fail_check: + raise OSError("Verbindung weg") + while self.eingang: + topic, payload = self.eingang.pop(0) + if self.callback: + self.callback(topic, payload) + + def disconnect(self): + self.disconnected += 1 + self.connected = False + + +class MqttTest(unittest.TestCase): + """Basis: eine Bruecke mit FakeClient und eigener settings-Datei.""" + + def setUp(self): + self.pfad = "test_mqtt_settings_tmp.json" + self._weg() + # settings.PFAD umbiegen, damit die Tests nicht die echte Datei anfassen. + self._alt = settings.PFAD + settings.PFAD = self.pfad + settings.save(settings.DEFAULTS, self.pfad) + + self.meldungen = [] + self.geaendert = [] + self.client = FakeClient() + self.bridge = mqtt.MqttBridge( + broker="test", prefix="ga", client=self.client, + log=self.meldungen.append, + on_change=self.geaendert.append) + + def tearDown(self): + settings.PFAD = self._alt + self._weg() + + def _weg(self): + try: + os.remove(self.pfad) + except OSError: + pass + + def sende(self, topic, payload): + """Eine Nachricht vom Broker zustellen lassen.""" + self.client.eingang.append((topic.encode(), payload.encode())) + self.bridge.check() + + def topics(self): + return [t.decode() if isinstance(t, bytes) else t + for t, _, _ in self.client.published] + + +class TestVerbinden(MqttTest): + + def test_connect_abonniert_und_meldet_online(self): + self.assertTrue(self.bridge.connect()) + self.assertTrue(self.bridge.connected) + self.assertEqual(self.client.subscribed, ["ga/set/+"]) + self.assertIn(("ga/status/online", b"1", True), self.client.published) + + def test_last_will_meldet_offline(self): + """Bricht die Verbindung weg, muss der Broker selbst offline melden.""" + self.bridge.connect() + self.assertEqual(self.client.last_will, ("ga/status/online", b"0", True)) + + def test_fehlgeschlagene_verbindung_wirft_nicht(self): + self.client.fail_connect = True + self.assertFalse(self.bridge.connect()) + self.assertFalse(self.bridge.connected) + self.assertTrue(any("fehlgeschlagen" in m for m in self.meldungen)) + + def test_close_meldet_offline_ab(self): + self.bridge.connect() + self.bridge.close() + self.assertIn(("ga/status/online", b"0", True), self.client.published) + self.assertFalse(self.bridge.connected) + + +class TestEmpfang(MqttTest): + + def setUp(self): + super().setUp() + self.bridge.connect() + + def test_gueltiger_wert_wird_uebernommen(self): + self.sende("ga/set/hell_prozent", "60") + self.assertEqual(settings.load(self.pfad)["hell_prozent"], 60) + self.assertEqual(self.geaendert[-1]["hell_prozent"], 60) + + def test_alle_setzbaren_werte(self): + for name in mqtt.SETZBAR: + self.assertIn(name, settings.DEFAULTS) + self.sende("ga/set/schwelle", "2000") + self.sende("ga/set/dunkel_prozent", "3") + self.sende("ga/set/hysterese", "100") + w = settings.load(self.pfad) + self.assertEqual((w["schwelle"], w["dunkel_prozent"], w["hysterese"]), + (2000, 3, 100)) + + def test_wert_ausserhalb_des_bereichs_wird_abgelehnt(self): + """Der Kern der Pruefung: von aussen darf nichts Unsinniges durch.""" + self.sende("ga/set/hell_prozent", "500") + self.assertEqual(settings.load(self.pfad)["hell_prozent"], + settings.DEFAULTS["hell_prozent"]) + self.assertEqual(self.geaendert, []) + self.assertTrue(any("500" in m for m in self.meldungen)) + + def test_keine_zahl_wird_abgelehnt(self): + self.sende("ga/set/hell_prozent", "hell bitte") + self.assertEqual(settings.load(self.pfad)["hell_prozent"], + settings.DEFAULTS["hell_prozent"]) + self.assertTrue(any("ganze Zahl" in m for m in self.meldungen)) + + def test_unbekanntes_topic_wird_verworfen(self): + self.sende("ga/set/gibtsnicht", "1") + self.assertEqual(self.geaendert, []) + self.assertTrue(any("unbekannt" in m for m in self.meldungen)) + + def test_leere_nachricht_wirft_nicht(self): + self.sende("ga/set/hell_prozent", "") + self.assertEqual(self.geaendert, []) + + def test_kaputtes_on_change_bricht_nichts_ab(self): + """Ein Fehler im Rueckruf darf den Empfang nicht toeten.""" + def kaputt(_): + raise RuntimeError("absichtlich") + self.bridge._on_change = kaputt + self.sende("ga/set/hell_prozent", "60") # darf nicht werfen + self.assertEqual(settings.load(self.pfad)["hell_prozent"], 60) + self.assertTrue(any("on_change" in m for m in self.meldungen)) + + +class TestSenden(MqttTest): + + def setUp(self): + super().setUp() + self.bridge.connect() + self.client.published.clear() + + def test_status_wird_veroeffentlicht(self): + self.bridge.publish("ra", "18h36m56s") + self.assertIn(("ga/status/ra", b"18h36m56s", True), self.client.published) + + def test_gleicher_wert_wird_nicht_erneut_gesendet(self): + """Spart Funkverkehr: LDR und Helligkeit aendern sich kaum.""" + self.bridge.publish("ldr", 2453) + self.bridge.publish("ldr", 2453) + self.assertEqual(self.topics().count("ga/status/ldr"), 1) + + def test_geaenderter_wert_wird_gesendet(self): + self.bridge.publish("ldr", 2453) + self.bridge.publish("ldr", 2600) + self.assertEqual(self.topics().count("ga/status/ldr"), 2) + + def test_publish_many(self): + self.bridge.publish_many({"ra": "1", "dec": "2"}) + self.assertIn("ga/status/ra", self.topics()) + self.assertIn("ga/status/dec", self.topics()) + + def test_nach_reconnect_wird_alles_neu_gesendet(self): + """Sonst fehlte dem Broker nach einem Abriss der aktuelle Stand.""" + self.bridge.publish("ldr", 2453) + self.bridge._abwerfen() + self.bridge._client = self.client + self.bridge._naechster_versuch = None + self.bridge.connect() + self.client.published.clear() + self.bridge.publish("ldr", 2453) + self.assertIn("ga/status/ldr", self.topics()) + + +class TestStoerungen(MqttTest): + """Der eigentliche Zweck: die Anzeige darf nie stehenbleiben.""" + + def test_senden_bei_toter_verbindung_wirft_nicht(self): + self.bridge.connect() + self.client.fail_publish = True + self.assertFalse(self.bridge.publish("ra", "x")) + self.assertFalse(self.bridge.connected) + + def test_empfang_bei_toter_verbindung_wirft_nicht(self): + self.bridge.connect() + self.client.fail_check = True + self.assertFalse(self.bridge.check()) + self.assertFalse(self.bridge.connected) + + def test_ohne_verbindung_wird_nicht_bei_jedem_aufruf_versucht(self): + """Sonst kostet ein toter Broker in jedem Schleifendurchlauf Zeit.""" + b = mqtt.MqttBridge(broker="test", prefix="ga", client=FakeClient(), + log=self.meldungen.append) + b._client.fail_connect = True + self.assertFalse(b.connect()) + vorher = len(self.meldungen) + for _ in range(5): + self.assertFalse(b.check()) + # Kein einziger neuer Versuch, solange die Wartezeit laeuft. + self.assertEqual(len(self.meldungen), vorher) + + def test_wartezeit_waechst_und_ist_begrenzt(self): + b = mqtt.MqttBridge(broker="test", prefix="ga", client=FakeClient()) + b._client.fail_connect = True + werte = [] + for _ in range(12): + b._client = FakeClient() + b._client.fail_connect = True + b._naechster_versuch = None + b.connect() + werte.append(b._retry) + self.assertGreater(werte[1], werte[0]) # waechst + self.assertLessEqual(max(werte), mqtt.RETRY_MAX) # aber begrenzt + + def test_publish_ohne_verbindung_meldet_nur_false(self): + b = mqtt.MqttBridge(broker="test", prefix="ga", client=FakeClient()) + b._client.fail_connect = True + b.connect() + self.assertFalse(b.publish("ra", "x")) # wirft nicht + + +class TestOhneKonfiguration(unittest.TestCase): + + def test_fehlende_mqtt_config_liefert_none(self): + """Ohne mqtt_config.py laeuft die Anzeige ohne MQTT weiter.""" + import sys + gemerkt = sys.modules.pop("mqtt_config", None) + pfad = sys.path[:] + try: + # Sicherstellen, dass mqtt_config wirklich nicht importierbar ist. + sys.path = [p for p in sys.path if p not in ("", ".", os.getcwd())] + meldungen = [] + self.assertIsNone(mqtt.connect_from_config(log=meldungen.append)) + self.assertTrue(any("mqtt_config" in m for m in meldungen)) + finally: + sys.path = pfad + if gemerkt is not None: + sys.modules["mqtt_config"] = gemerkt + + +class TestSchleifenanbindung(MqttTest): + """poll_loop mit Bruecke -- die Anzeige laeuft, auch wenn MQTT streikt.""" + + def _loop_einmal(self, bridge): + import displayloop + from display import Display + from transport import FakeTransport + + class Client: + connected = True + + def connect(self): + pass + + def close(self): + pass + + def get_coordinates(self): + return "18:36:56", "+38*47:01" + + d = Display(FakeTransport(echo=False), response=True) + return displayloop.poll_loop(d, Client(), once=True, bridge=bridge, + log=lambda *a: None) + + def test_koordinaten_gehen_raus(self): + self.bridge.connect() + self.client.published.clear() + self._loop_einmal(self.bridge) + self.assertIn("ga/status/ra", self.topics()) + self.assertIn("ga/status/dec", self.topics()) + self.assertIn("ga/status/link", self.topics()) + + def test_toter_broker_haelt_die_anzeige_nicht_auf(self): + """Der wichtigste Test des Moduls.""" + self.bridge.connect() + self.client.fail_publish = True + self.client.fail_check = True + gezeigt = self._loop_einmal(self.bridge) + self.assertIsNotNone(gezeigt) # Anzeige wurde trotzdem bedient + self.assertEqual(gezeigt[0], "18h36m56s") + + +if __name__ == "__main__": + unittest.main(verbosity=2)