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:
@@ -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
|
||||
|
||||
@@ -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) →
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user