MQTT: Einstellungen empfangen, Zustand veroeffentlichen

Topics (Praefix aus mqtt_config.py, Vorgabe "grossanzeige"):

    <PREFIX>/set/hell_prozent      60      eingehend, schreibt settings.json
    <PREFIX>/set/dunkel_prozent     5
    <PREFIX>/set/schwelle        1800
    <PREFIX>/set/hysterese        150

    <PREFIX>/status/ra        18h36m56s    ausgehend, retained
    <PREFIX>/status/dec      +38°47'01"
    <PREFIX>/status/ldr            2453
    <PREFIX>/status/helligkeit       50
    <PREFIX>/status/link              1
    <PREFIX>/status/online            1    mit Last Will auf 0

Eine empfangene Einstellung wird sofort wirksam: dieselbe Pruefung wie sonst
(settings.update), dann laedt der laufende BrightnessController sie per reload()
nach -- kein Neustart noetig.

Leitgedanke des Moduls: **MQTT darf die Anzeige nie aufhalten.** Die Anzeige ist
der Zweck des Geraets, MQTT ist Beiwerk. Deshalb faengt mqtt.py jeden Fehler
selbst ab und meldet ihn nur:

- Broker nicht erreichbar -> Versuch scheitert, Schleife laeuft weiter.
  Wiederholung mit wachsendem Abstand (5..120 s), sonst kostet ein dauerhaft
  toter Broker in jedem Schleifendurchlauf Zeit.
- Verbindungsabriss -> beim naechsten Senden/Empfangen bemerkt, Verbindung wird
  verworfen und spaeter neu aufgebaut. Danach geht der gesamte Status erneut
  raus, damit der Broker nicht auf veralteten Werten sitzenbleibt.
- Unsinniger Wert von aussen -> verworfen, settings.json bleibt unberuehrt.
- Geraet faellt aus -> Last Will meldet online=0. Ohne das bliebe online=1
  stehen, obwohl niemand mehr da ist.

Der Empfang blockiert nicht (check_msg). Statuswerte gehen nur bei Aenderung
raus -- die Koordinaten aendern sich staendig, LDR und Helligkeit kaum.

mqtt_config.py ist optional und gitignored (Vorlage mqtt_config_example.py);
fehlt sie, laeuft alles wie bisher ohne MQTT. deploy.sh weist nur darauf hin.

test_mqtt.py haengt einen Fake-Broker ein und prueft auch die Faelle, die man
mit einem echten Server schwer herbeifuehrt: Abriss beim Senden, Muell im Topic,
Broker der nicht antwortet. 143 Tests gruen.

Am Geraet bestaetigt ist bisher der Fall OHNE Broker: mqtt.py importiert,
connect_from_config liefert None, ein unerreichbarer Broker wirft keine
Ausnahme. Der Test gegen einen echten Broker steht noch aus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 16:48:41 +02:00
parent 0a893d0684
commit bf39b75ac1
9 changed files with 754 additions and 7 deletions
+3
View File
@@ -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
+81 -3
View File
@@ -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 (`<PREFIX>/set/<name>`, 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 (`<PREFIX>/status/<name>`, 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 <broker> -t 'grossanzeige/#' -v
mosquitto_pub -h <broker> -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) →
+11 -1
View File
@@ -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
+20 -1
View File
@@ -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
+251
View File
@@ -0,0 +1,251 @@
"""MQTT-Anbindung: Einstellungen empfangen, Zustand veroeffentlichen.
<PREFIX>/set/hell_prozent 60 eingehend, schreibt settings.json
<PREFIX>/set/dunkel_prozent 5
<PREFIX>/set/schwelle 1800
<PREFIX>/set/hysterese 150
<PREFIX>/status/ra 18h36m56s ausgehend
<PREFIX>/status/dec +38°47'01"
<PREFIX>/status/ldr 2453
<PREFIX>/status/helligkeit 50
<PREFIX>/status/link 1 Montierung erreichbar
<PREFIX>/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 <PREFIX>/set/<name> 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
+25
View File
@@ -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:
# <PREFIX>/set/<name> eingehend (hell_prozent, dunkel_prozent, schwelle, hysterese)
# <PREFIX>/status/<name> 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"
+25 -1
View File
@@ -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()
+1 -1
View File
@@ -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",
)
+337
View File
@@ -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)