Dauerbetrieb: Autostart (main.py) und Watchdog
MicroPython fuehrt main.py nach jedem Reset selbst aus -- nach Stromausfall oder Watchdog-Neustart laeuft die Anzeige damit ohne Notebook wieder an. Bisher gab es die Datei bewusst nicht, weil am Aufbau gearbeitet wurde. **Das Abbruchfenster ist kein Komfort, sondern die Notbremse.** Ohne es waere ein Geraet, dessen Programm beim Start haengt, nur noch durch Neuflashen erreichbar: Die Endlosschleife belegt die REPL sofort nach dem Reset. main.py wartet deshalb erst AUTOSTART_DELAY (3 s), in denen Ctrl-C zurueck an die REPL fuehrt -- dann laeuft auch kein Watchdog. Watchdog (WDT_TIMEOUT = 120 s): startet das Geraet neu, wenn die Poll-Schleife stehenbleibt. Gefuettert wird zu Beginn *jedes* Durchlaufs, auch nach einem Fehler -- "KEIN LINK" ist kein Stillstand, die Schleife laeuft ja weiter. Der Wert muss ueber allem liegen, was regulaer blockieren darf (WLAN 20 s, Montierung 3 s, dazu ein MQTT-Versuch); knapper waere er eine Neustart-Schleife. Ein gestarteter Watchdog laesst sich nicht mehr abschalten. Deshalb legt ihn nur main.py an -- im Handbetrieb ueber die REPL startete das Geraet sonst neu, waehrend man nachdenkt. Weiteres: - Nach einem Watchdog-Neustart meldet main.py das beim Start (machine.reset_cause()), sonst saehe er aus wie ein Stromausfall. - Eine Ausnahme faengt main.py ab und startet nach RESTART_DELAY (10 s) neu, statt in die REPL zu fallen und die Anzeige einzufrieren. Die Pause verhindert eine Neustart-Schleife im Sekundentakt. - Autostart faehrt mit Helligkeitsregelung (with_ldr=True, Nutzerentscheidung). Am Geraet bestaetigt: Autostart nach Reset laeuft durch (Watchdog aktiv, WLAN, MQTT), ein absichtlich herbeigefuehrter Stillstand loest den Watchdog aus und das Geraet kommt von allein wieder hoch -- mit der Meldung "Vorheriger Lauf wurde vom Watchdog beendet". Ctrl-C im Startfenster gibt die REPL frei. Beim Geraetetest aufgefallen: make_watchdog wurde ohne log aufgerufen, im Startprotokoll stand also nichts vom Watchdog. Behoben und durch einen Test festgehalten. 178 Tests gruen (neu: test_main.py). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,7 @@ beiden Startprogrammen.
|
||||
| `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. | ✓ |
|
||||
| `main.py` | Autostart nach jedem Reset, mit Watchdog und Abbruchfenster. | ✓ |
|
||||
| `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. | |
|
||||
@@ -168,10 +169,46 @@ mpremote repl
|
||||
>>> run_esp32.main(with_mqtt=False) # ohne MQTT (sonst an, sofern konfiguriert)
|
||||
```
|
||||
|
||||
Bewusst **kein** `main.py`: MicroPython startet eine Datei dieses Namens nach
|
||||
jedem Reset von selbst, und solange am Aufbau gearbeitet wird, ist der manuelle
|
||||
Start angenehmer. Für den Dauerbetrieb in der Sternwarte reicht später eine
|
||||
`main.py` mit den zwei Zeilen von oben.
|
||||
### Dauerbetrieb: Autostart und Watchdog
|
||||
|
||||
`main.py` startet die Anzeige nach jedem Reset von selbst — nach Stromausfall
|
||||
oder Watchdog-Neustart läuft sie also ohne Notebook wieder an.
|
||||
|
||||
**Das Abbruchfenster ist die Notbremse.** Ohne es wäre ein Gerät, dessen Programm
|
||||
beim Start hängt, nur noch durch Neuflashen erreichbar: Die Endlosschleife belegt
|
||||
die REPL sofort nach dem Reset. Deshalb wartet `main.py` erst
|
||||
`config.AUTOSTART_DELAY` Sekunden:
|
||||
|
||||
```
|
||||
Grossanzeige startet in 3 s -- Ctrl-C bricht ab.
|
||||
Watchdog: aktiv, 120 s
|
||||
WLAN: verbinde mit Mizar ...
|
||||
```
|
||||
|
||||
In diesen drei Sekunden führt Ctrl-C zurück an die REPL (`Autostart abgebrochen,
|
||||
REPL ist frei`) — dann läuft auch kein Watchdog. Wer das Gerät ganz ohne
|
||||
Autostart will: `mpremote rm :main.py`.
|
||||
|
||||
Der **Watchdog** (`config.WDT_TIMEOUT`, 120 s) startet das Gerät neu, wenn die
|
||||
Poll-Schleife stehenbleibt. Gefüttert wird zu Beginn jedes Durchlaufs — auch nach
|
||||
einem Fehler, denn „KEIN LINK" ist kein Stillstand: Die Schleife läuft ja weiter.
|
||||
Der Watchdog greift nur, wenn gar nichts mehr geht.
|
||||
|
||||
Die 120 s müssen über allem liegen, was regulär blockieren darf: WLAN-Aufbau bis
|
||||
20 s, Montierung 3 s, dazu ein MQTT-Verbindungsversuch. Ein knapper Wert wäre
|
||||
eine Neustart-Schleife. **Ein gestarteter Watchdog lässt sich nicht mehr
|
||||
abschalten** — deshalb legt ihn nur `main.py` an, nicht der Handbetrieb über die
|
||||
REPL; sonst startete das Gerät neu, während man an der REPL nachdenkt.
|
||||
|
||||
Nach einem Watchdog-Neustart meldet `main.py` das beim Start
|
||||
(`Vorheriger Lauf wurde vom Watchdog beendet`, über `machine.reset_cause()`) —
|
||||
sonst sähe der Neustart aus wie ein gewöhnlicher Stromausfall. Eine **Ausnahme**
|
||||
fängt `main.py` ab und startet nach `config.RESTART_DELAY` neu, statt in die REPL
|
||||
zu fallen und die Anzeige einzufrieren.
|
||||
|
||||
Am Gerät bestätigt (2026-07-27): Autostart nach Reset, Watchdog löst bei
|
||||
absichtlichem Stillstand aus und das Gerät kommt von allein wieder hoch, Ctrl-C
|
||||
im Startfenster gibt die REPL frei.
|
||||
|
||||
### Was auf beiden Seiten gleich läuft
|
||||
|
||||
@@ -264,7 +301,6 @@ Mac:
|
||||
- **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.
|
||||
- **Dauerbetrieb:** Autostart (`main.py`) und ein Watchdog fehlen noch bewusst.
|
||||
|
||||
## Gradzeichen
|
||||
|
||||
|
||||
@@ -156,6 +156,28 @@ LABEL_DEC = "DE "
|
||||
# "MONTIERUNG" stand hier zuerst und erschien am Geraet als "MONTIERUN".
|
||||
NOLINK_LINES = ("KEIN LINK", "TELESKOP")
|
||||
|
||||
# -- Dauerbetrieb (nur ESP32-C3) ------------------------------------------
|
||||
# Watchdog: Kommt die Poll-Schleife so lange nicht mehr durch, startet die
|
||||
# Hardware das Geraet neu. 120 s sind reichlich bemessen -- im Normalbetrieb
|
||||
# wird jede Sekunde gefuettert. Der Wert muss ueber allem liegen, was regulaer
|
||||
# blockieren darf: WLAN-Aufbau bis WIFI_TIMEOUT (20 s), Montierung
|
||||
# MOUNT_TIMEOUT (3 s), dazu ein MQTT-Verbindungsversuch. Sonst startet das
|
||||
# Geraet mitten im normalen Betrieb neu.
|
||||
#
|
||||
# Achtung: Ein einmal gestarteter Watchdog laesst sich nicht mehr abschalten.
|
||||
# Deshalb legt ihn nur main.py an, nicht der Handbetrieb ueber die REPL.
|
||||
WDT_TIMEOUT = 120.0 # Sekunden
|
||||
|
||||
# Wartezeit beim Autostart, bevor main.py die Schleife startet. In diesem
|
||||
# Fenster bricht Ctrl-C ab und man kommt an die REPL -- ohne das waere ein
|
||||
# Geraet mit fehlerhaftem main.py nur noch ueber ein Neuflashen erreichbar.
|
||||
AUTOSTART_DELAY = 3.0 # Sekunden
|
||||
|
||||
# Nach einem Absturz in main.py: so lange warten, dann neu starten. Die Pause
|
||||
# verhindert eine Neustart-Schleife im Sekundentakt, wenn etwas dauerhaft
|
||||
# kaputt ist (und gibt Zeit, mit Ctrl-C einzugreifen).
|
||||
RESTART_DELAY = 10.0 # Sekunden
|
||||
|
||||
# -- WLAN (nur ESP32-C3) --------------------------------------------------
|
||||
# Zugangsdaten stehen in wifi_config.py (nicht im Git, siehe
|
||||
# wifi_config_example.py). Hier nur das Zeitverhalten.
|
||||
|
||||
@@ -28,6 +28,10 @@ PORT="${1:-auto}"
|
||||
# dem Geraet (Helligkeiten, Schwelle -- spaeter per MQTT verstellt). Wuerde
|
||||
# deploy.sh sie mitkopieren, waeren diese Einstellungen nach jedem Deployen weg.
|
||||
# Fehlt die Datei, gelten die Vorgaben aus settings.DEFAULTS.
|
||||
#
|
||||
# main.py startet die Anzeige nach jedem Reset von selbst. Wer am Geraet
|
||||
# arbeiten will, drueckt im Startfenster Ctrl-C (siehe main.py) -- oder loescht
|
||||
# sie mit: mpremote rm :main.py
|
||||
FILES="
|
||||
ticks.py
|
||||
migra.py
|
||||
@@ -44,6 +48,7 @@ wifi.py
|
||||
mqtt.py
|
||||
run_esp32.py
|
||||
probe.py
|
||||
main.py
|
||||
"
|
||||
|
||||
if [ ! -f wifi_config.py ]; then
|
||||
|
||||
+10
-1
@@ -48,7 +48,7 @@ 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,
|
||||
bridge=None):
|
||||
bridge=None, feed=None):
|
||||
"""Zyklisch die Montierung abfragen und die Anzeige nachfuehren.
|
||||
|
||||
display offenes Display-Objekt
|
||||
@@ -65,6 +65,11 @@ def poll_loop(display, client, interval=None, once=False,
|
||||
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).
|
||||
feed optional: Funktion ohne Argumente, die den Watchdog fuettert.
|
||||
Wird zu Beginn **jedes** Durchlaufs gerufen -- auch nach einem
|
||||
Fehler, denn eine gemeldete Stoerung ist kein Stillstand: Die
|
||||
Schleife laeuft ja weiter und zeigt "KEIN LINK". Der Watchdog
|
||||
soll nur greifen, wenn gar nichts mehr geht.
|
||||
|
||||
Laeuft bis KeyboardInterrupt oder -- bei once=True -- genau einmal. Fehler
|
||||
von Anzeige und Montierung werden abgefangen: die Schleife meldet sie und
|
||||
@@ -77,6 +82,10 @@ def poll_loop(display, client, interval=None, once=False,
|
||||
|
||||
last_shown = None
|
||||
while True:
|
||||
# Ganz am Anfang und ausserhalb des try: Der Watchdog misst, ob die
|
||||
# Schleife noch laeuft -- nicht, ob sie fehlerfrei laeuft.
|
||||
if feed is not None:
|
||||
feed()
|
||||
try:
|
||||
# Zuerst MQTT abholen: So wirkt eine gerade gesetzte Helligkeit noch
|
||||
# in diesem Durchlauf, statt eine Runde hinterherzuhinken.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Autostart auf dem ESP32-C3.
|
||||
|
||||
MicroPython fuehrt diese Datei nach boot.py bei jedem Reset selbst aus. Damit
|
||||
laeuft die Anzeige nach Stromausfall oder Watchdog-Neustart von allein wieder an,
|
||||
ohne dass jemand ein Notebook anschliessen muss.
|
||||
|
||||
**Das Abbruchfenster ist kein Komfort, sondern die Notbremse.** Ohne es waere ein
|
||||
Geraet, dessen Programm beim Start haengt oder abstuerzt, nur noch durch
|
||||
Neuflashen erreichbar -- die Endlosschleife belegt die REPL sofort nach dem
|
||||
Reset. Deshalb wartet main.py erst config.AUTOSTART_DELAY Sekunden, in denen
|
||||
Ctrl-C zurueck an die REPL fuehrt:
|
||||
|
||||
mpremote repl dann Ctrl-C druecken, solange "startet in ..." steht
|
||||
|
||||
Danach:
|
||||
- Der **Watchdog** startet das Geraet neu, wenn die Poll-Schleife stehenbleibt
|
||||
(siehe run_esp32.make_watchdog). Das faengt Haenger, die keine Ausnahme werfen.
|
||||
- Eine **Ausnahme** faengt der Block unten ab und startet nach
|
||||
config.RESTART_DELAY neu, statt in die REPL zu fallen und die Anzeige
|
||||
einzufrieren.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
import config
|
||||
|
||||
|
||||
def _startbar():
|
||||
"""Wartet das Abbruchfenster ab. False, wenn abgebrochen wurde."""
|
||||
print("Grossanzeige startet in %.0f s -- Ctrl-C bricht ab."
|
||||
% config.AUTOSTART_DELAY)
|
||||
try:
|
||||
time.sleep(config.AUTOSTART_DELAY)
|
||||
except KeyboardInterrupt:
|
||||
print("Autostart abgebrochen, REPL ist frei.")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _neustart(grund):
|
||||
"""Nach einem Absturz neu starten -- mit Pause und Abbruchmoeglichkeit."""
|
||||
print("%s -- Neustart in %.0f s (Ctrl-C bricht ab)"
|
||||
% (grund, config.RESTART_DELAY))
|
||||
try:
|
||||
time.sleep(config.RESTART_DELAY)
|
||||
except KeyboardInterrupt:
|
||||
print("Neustart abgebrochen, REPL ist frei.")
|
||||
return
|
||||
try:
|
||||
import machine
|
||||
machine.reset()
|
||||
except ImportError:
|
||||
sys.exit()
|
||||
|
||||
|
||||
def start():
|
||||
if not _startbar():
|
||||
return
|
||||
|
||||
import run_esp32
|
||||
|
||||
# Nach einem Watchdog-Neustart ist das der einzige Hinweis darauf, dass
|
||||
# ueberhaupt etwas gehangen hat -- ohne ihn sieht ein Neustart aus wie ein
|
||||
# gewoehnlicher Stromausfall.
|
||||
try:
|
||||
import machine
|
||||
if machine.reset_cause() == machine.WDT_RESET:
|
||||
print("Vorheriger Lauf wurde vom Watchdog beendet.")
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
# log=print, damit im Startprotokoll steht, dass der Watchdog laeuft --
|
||||
# sonst bliebe unsichtbar, ob er ueberhaupt angelegt wurde.
|
||||
feed = run_esp32.make_watchdog(log=print)
|
||||
try:
|
||||
run_esp32.main(with_ldr=True, feed=feed)
|
||||
except KeyboardInterrupt:
|
||||
# Von Hand beendet: nicht neu starten, sondern die REPL freigeben.
|
||||
# Der Watchdog laeuft allerdings weiter und startet das Geraet in
|
||||
# config.WDT_TIMEOUT Sekunden neu -- er laesst sich nicht abschalten.
|
||||
print("\nbeendet (Watchdog startet in %.0f s neu)" % config.WDT_TIMEOUT)
|
||||
except Exception as e:
|
||||
_neustart("Absturz: %s" % e)
|
||||
|
||||
|
||||
start()
|
||||
+26
-1
@@ -95,8 +95,28 @@ def selftest(host=None, port=None, log=print):
|
||||
log("Selbsttest bestanden.")
|
||||
|
||||
|
||||
def make_watchdog(timeout=None, log=None):
|
||||
"""Watchdog starten und seine feed-Funktion liefern.
|
||||
|
||||
Liefert None, wenn es keinen gibt (CPython). **Ein gestarteter Watchdog
|
||||
laesst sich nicht mehr abschalten** -- deshalb ruft nur main.py das hier
|
||||
auf, nicht der Handbetrieb ueber die REPL: Sonst startet das Geraet mitten
|
||||
im Entwickeln neu, sobald man an der REPL nachdenkt.
|
||||
"""
|
||||
log = log or (lambda *a: None)
|
||||
try:
|
||||
from machine import WDT
|
||||
except ImportError:
|
||||
log("Watchdog: machine.WDT nicht verfuegbar")
|
||||
return None
|
||||
sekunden = config.WDT_TIMEOUT if timeout is None else timeout
|
||||
wdt = WDT(timeout=int(sekunden * 1000))
|
||||
log("Watchdog: aktiv, %d s" % sekunden)
|
||||
return wdt.feed
|
||||
|
||||
|
||||
def main(host=None, port=None, with_ldr=False, fake_display=False,
|
||||
once=False, with_mqtt=True, log=print):
|
||||
once=False, with_mqtt=True, feed=None, log=print):
|
||||
"""WLAN aufbauen, Anzeige oeffnen und die Poll-Schleife fahren.
|
||||
|
||||
host/port Montierung; Vorgabe aus config.py. Zum Testen gegen den Mock
|
||||
@@ -111,6 +131,10 @@ def main(host=None, port=None, with_ldr=False, fake_display=False,
|
||||
with_mqtt MQTT-Anbindung versuchen. Ohne mqtt_config.py passiert nichts,
|
||||
deshalb ist die Vorgabe an: fehlt die Datei, laeuft alles wie
|
||||
bisher weiter.
|
||||
feed Watchdog-Funktion aus make_watchdog(). Vorgabe None: Von Hand
|
||||
gestartet laeuft die Schleife ohne Watchdog, sonst wuerde das
|
||||
Geraet beim Nachdenken an der REPL neu starten. main.py legt
|
||||
ihn an.
|
||||
"""
|
||||
d = open_display(fake=fake_display)
|
||||
client = MountClient(config.MOUNT_HOST if host is None else host,
|
||||
@@ -165,6 +189,7 @@ def main(host=None, port=None, with_ldr=False, fake_display=False,
|
||||
dimmer=dimmer,
|
||||
ensure_link=lambda: wifi.ensure(log=log),
|
||||
bridge=bridge,
|
||||
feed=feed,
|
||||
log=log)
|
||||
except KeyboardInterrupt:
|
||||
log("\nbeendet")
|
||||
|
||||
+118
@@ -80,6 +80,20 @@ class FakeADC:
|
||||
return self.value
|
||||
|
||||
|
||||
class FakeWDT:
|
||||
"""machine.WDT-Attrappe: merkt sich Timeout und feed-Aufrufe."""
|
||||
|
||||
last = None
|
||||
|
||||
def __init__(self, timeout=None):
|
||||
self.timeout = timeout
|
||||
self.feeds = 0
|
||||
FakeWDT.last = self
|
||||
|
||||
def feed(self):
|
||||
self.feeds += 1
|
||||
|
||||
|
||||
class FakeWLAN:
|
||||
"""network.WLAN-Attrappe. connect() gelingt, wenn succeed gesetzt ist."""
|
||||
|
||||
@@ -120,6 +134,9 @@ def _install_machine():
|
||||
m.UART = FakeUART
|
||||
m.Pin = FakePin
|
||||
m.ADC = FakeADC
|
||||
m.WDT = FakeWDT
|
||||
m.reset_cause = lambda: 1
|
||||
m.PWRON_RESET, m.WDT_RESET, m.SOFT_RESET, m.DEEPSLEEP_RESET = 1, 3, 5, 4
|
||||
sys.modules["machine"] = m
|
||||
return m
|
||||
|
||||
@@ -343,6 +360,106 @@ class TestLdrKennlinie(unittest.TestCase):
|
||||
self.assertEqual(c.update(dunkel)[0], brightness.DUNKEL)
|
||||
|
||||
|
||||
class TestWatchdog(unittest.TestCase):
|
||||
"""Dauerbetrieb: Der Watchdog holt das Geraet aus einem Stillstand zurueck."""
|
||||
|
||||
def setUp(self):
|
||||
_install_machine()
|
||||
import transport
|
||||
importlib.reload(transport)
|
||||
_remove("run_esp32")
|
||||
import run_esp32
|
||||
self.run_esp32 = run_esp32
|
||||
|
||||
def tearDown(self):
|
||||
_remove("machine", "run_esp32")
|
||||
import transport
|
||||
importlib.reload(transport)
|
||||
|
||||
def test_timeout_kommt_aus_config_und_ist_in_millisekunden(self):
|
||||
import config
|
||||
self.run_esp32.make_watchdog()
|
||||
self.assertEqual(FakeWDT.last.timeout, int(config.WDT_TIMEOUT * 1000))
|
||||
|
||||
def test_gelieferte_funktion_fuettert(self):
|
||||
feed = self.run_esp32.make_watchdog()
|
||||
feed()
|
||||
feed()
|
||||
self.assertEqual(FakeWDT.last.feeds, 2)
|
||||
|
||||
def test_timeout_liegt_ueber_den_regulaeren_wartezeiten(self):
|
||||
"""Sonst startet das Geraet mitten im normalen Betrieb neu.
|
||||
|
||||
Der WLAN-Aufbau darf allein WIFI_TIMEOUT Sekunden dauern; kommt die
|
||||
Montierung dazu, waere ein knapper Watchdog eine Neustart-Schleife.
|
||||
"""
|
||||
import config
|
||||
self.assertGreater(config.WDT_TIMEOUT,
|
||||
config.WIFI_TIMEOUT + config.MOUNT_TIMEOUT + 10)
|
||||
|
||||
def test_ohne_machine_gibt_es_keinen_watchdog(self):
|
||||
"""Auf dem Mac laeuft dieselbe Schleife -- ohne Watchdog, ohne Fehler."""
|
||||
_remove("machine", "run_esp32")
|
||||
import run_esp32
|
||||
meldungen = []
|
||||
self.assertIsNone(run_esp32.make_watchdog(log=meldungen.append))
|
||||
self.assertTrue(any("WDT" in m for m in meldungen))
|
||||
|
||||
|
||||
class TestWatchdogInDerSchleife(unittest.TestCase):
|
||||
"""poll_loop muss den Watchdog jede Runde fuettern -- auch bei Stoerung."""
|
||||
|
||||
def _lauf(self, client, once=True):
|
||||
import displayloop
|
||||
from display import Display
|
||||
from transport import FakeTransport
|
||||
|
||||
gefuettert = []
|
||||
d = Display(FakeTransport(echo=False), response=True)
|
||||
displayloop.poll_loop(d, client, once=once,
|
||||
feed=lambda: gefuettert.append(1),
|
||||
log=lambda *a: None)
|
||||
return gefuettert
|
||||
|
||||
def test_normaler_durchlauf_fuettert(self):
|
||||
class Client:
|
||||
connected = True
|
||||
|
||||
def connect(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def get_coordinates(self):
|
||||
return "18:36:56", "+38*47:01"
|
||||
|
||||
self.assertEqual(len(self._lauf(Client())), 1)
|
||||
|
||||
def test_auch_bei_ausgefallener_montierung_wird_gefuettert(self):
|
||||
"""Der wichtige Fall: "KEIN LINK" ist kein Stillstand.
|
||||
|
||||
Die Schleife laeuft weiter und zeigt den Hinweis -- der Watchdog darf
|
||||
deshalb nicht zuschlagen, sonst startet das Geraet neu, solange die
|
||||
Montierung aus ist.
|
||||
"""
|
||||
from mount_client import MountError
|
||||
|
||||
class Client:
|
||||
connected = False
|
||||
|
||||
def connect(self):
|
||||
raise MountError("keine Verbindung")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def get_coordinates(self):
|
||||
raise MountError("keine Verbindung")
|
||||
|
||||
self.assertEqual(len(self._lauf(Client())), 1)
|
||||
|
||||
|
||||
class TestLdrLeser(unittest.TestCase):
|
||||
"""run_esp32.make_ldr_reader: ADC lesen, auf 12 bit bringen, spiegeln."""
|
||||
|
||||
@@ -441,6 +558,7 @@ 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", "mqtt.py", "run_esp32.py", "probe.py",
|
||||
"main.py",
|
||||
)
|
||||
|
||||
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests fuer den Autostart (main.py).
|
||||
|
||||
python3 -m unittest -v test_main
|
||||
|
||||
main.py startet beim Import die Schleife -- deshalb wird es hier nicht importiert,
|
||||
sondern die Quelle geladen und in einer eigenen Umgebung ausgefuehrt, in der
|
||||
time.sleep, run_esp32 und machine durch Attrappen ersetzt sind.
|
||||
|
||||
Der wichtigste Test ist das Abbruchfenster: Ohne es waere ein Geraet mit
|
||||
fehlerhaftem main.py nur noch durch Neuflashen erreichbar.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import config
|
||||
|
||||
|
||||
class FakeRunEsp32:
|
||||
def __init__(self, wirft=None):
|
||||
self.aufrufe = []
|
||||
self.watchdogs = 0
|
||||
self.log_beim_watchdog = None
|
||||
self._wirft = wirft
|
||||
|
||||
def make_watchdog(self, timeout=None, log=None):
|
||||
self.watchdogs += 1
|
||||
self.log_beim_watchdog = log
|
||||
return lambda: None
|
||||
|
||||
def main(self, **kwargs):
|
||||
self.aufrufe.append(kwargs)
|
||||
if self._wirft is not None:
|
||||
raise self._wirft
|
||||
|
||||
|
||||
class FakeMachine:
|
||||
WDT_RESET = 3
|
||||
PWRON_RESET = 1
|
||||
|
||||
def __init__(self, ursache=1):
|
||||
self.resets = 0
|
||||
self._ursache = ursache
|
||||
|
||||
def reset(self):
|
||||
self.resets += 1
|
||||
|
||||
def reset_cause(self):
|
||||
return self._ursache
|
||||
|
||||
|
||||
def lade_main(run_esp32, machine=None, sleep_wirft=None):
|
||||
"""main.py in einer kontrollierten Umgebung ausfuehren.
|
||||
|
||||
sleep_wirft: Ausnahme, die time.sleep beim n-ten Aufruf wirft -- damit
|
||||
laesst sich Ctrl-C im Abbruchfenster nachstellen.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
schlaefe = []
|
||||
|
||||
class FakeTime:
|
||||
@staticmethod
|
||||
def sleep(s):
|
||||
schlaefe.append(s)
|
||||
if sleep_wirft is not None and len(schlaefe) == 1:
|
||||
raise sleep_wirft
|
||||
|
||||
ausgaben = []
|
||||
umgebung = {
|
||||
"__name__": "main",
|
||||
"print": lambda *a: ausgaben.append(" ".join(str(x) for x in a)),
|
||||
}
|
||||
|
||||
gemerkt = {name: sys.modules.get(name)
|
||||
for name in ("time", "run_esp32", "machine")}
|
||||
try:
|
||||
sys.modules["time"] = FakeTime
|
||||
sys.modules["run_esp32"] = run_esp32
|
||||
if machine is not None:
|
||||
sys.modules["machine"] = machine
|
||||
else:
|
||||
sys.modules.pop("machine", None)
|
||||
with open("main.py", encoding="utf-8") as f:
|
||||
quelle = f.read()
|
||||
exec(compile(quelle, "main.py", "exec"), umgebung)
|
||||
finally:
|
||||
for name, modul in gemerkt.items():
|
||||
if modul is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = modul
|
||||
return ausgaben, schlaefe
|
||||
|
||||
|
||||
class TestAbbruchfenster(unittest.TestCase):
|
||||
"""Die Notbremse: Ctrl-C im Startfenster fuehrt zurueck an die REPL."""
|
||||
|
||||
def test_ohne_abbruch_startet_die_schleife(self):
|
||||
r = FakeRunEsp32()
|
||||
ausgaben, schlaefe = lade_main(r)
|
||||
self.assertEqual(len(r.aufrufe), 1)
|
||||
self.assertEqual(schlaefe[0], config.AUTOSTART_DELAY)
|
||||
|
||||
def test_ctrl_c_im_startfenster_bricht_ab(self):
|
||||
"""Ohne das waere ein Geraet mit kaputtem main.py nur noch flashbar."""
|
||||
r = FakeRunEsp32()
|
||||
ausgaben, _ = lade_main(r, sleep_wirft=KeyboardInterrupt())
|
||||
self.assertEqual(r.aufrufe, [])
|
||||
self.assertEqual(r.watchdogs, 0, "Watchdog darf dann nicht laufen")
|
||||
self.assertTrue(any("abgebrochen" in a for a in ausgaben))
|
||||
|
||||
def test_wartezeit_ist_lang_genug_zum_eingreifen(self):
|
||||
self.assertGreaterEqual(config.AUTOSTART_DELAY, 2)
|
||||
|
||||
|
||||
class TestStart(unittest.TestCase):
|
||||
|
||||
def test_watchdog_wird_angelegt_und_durchgereicht(self):
|
||||
r = FakeRunEsp32()
|
||||
lade_main(r)
|
||||
self.assertEqual(r.watchdogs, 1)
|
||||
self.assertIn("feed", r.aufrufe[0])
|
||||
self.assertIsNotNone(r.aufrufe[0]["feed"])
|
||||
|
||||
def test_watchdog_meldet_sich_im_startprotokoll(self):
|
||||
"""Sonst bliebe unsichtbar, ob er ueberhaupt laeuft (am Geraet aufgefallen)."""
|
||||
r = FakeRunEsp32()
|
||||
lade_main(r)
|
||||
self.assertIsNotNone(r.log_beim_watchdog,
|
||||
"make_watchdog wurde ohne log aufgerufen")
|
||||
|
||||
def test_helligkeitsregelung_ist_an(self):
|
||||
"""Nutzerentscheidung 2026-07-27: Autostart faehrt mit LDR."""
|
||||
r = FakeRunEsp32()
|
||||
lade_main(r)
|
||||
self.assertTrue(r.aufrufe[0]["with_ldr"])
|
||||
|
||||
def test_watchdog_neustart_wird_gemeldet(self):
|
||||
"""Sonst sieht ein Watchdog-Neustart aus wie ein Stromausfall."""
|
||||
r = FakeRunEsp32()
|
||||
m = FakeMachine(ursache=FakeMachine.WDT_RESET)
|
||||
ausgaben, _ = lade_main(r, machine=m)
|
||||
self.assertTrue(any("Watchdog" in a for a in ausgaben))
|
||||
|
||||
def test_gewoehnlicher_start_meldet_keinen_watchdog(self):
|
||||
r = FakeRunEsp32()
|
||||
m = FakeMachine(ursache=FakeMachine.PWRON_RESET)
|
||||
ausgaben, _ = lade_main(r, machine=m)
|
||||
self.assertFalse(any("Watchdog beendet" in a for a in ausgaben))
|
||||
|
||||
|
||||
class TestAbsturz(unittest.TestCase):
|
||||
"""Eine Ausnahme darf nicht in der REPL enden -- die Anzeige stuende still."""
|
||||
|
||||
def test_absturz_loest_neustart_aus(self):
|
||||
r = FakeRunEsp32(wirft=RuntimeError("kaputt"))
|
||||
m = FakeMachine()
|
||||
ausgaben, schlaefe = lade_main(r, machine=m)
|
||||
self.assertEqual(m.resets, 1)
|
||||
self.assertIn(config.RESTART_DELAY, schlaefe)
|
||||
self.assertTrue(any("Absturz" in a for a in ausgaben))
|
||||
|
||||
def test_neustartpause_verhindert_dauerschleife(self):
|
||||
"""Ist etwas dauerhaft kaputt, soll nicht im Sekundentakt neu gestartet
|
||||
werden -- die Pause gibt auch Zeit, mit Ctrl-C einzugreifen."""
|
||||
self.assertGreaterEqual(config.RESTART_DELAY, 5)
|
||||
|
||||
def test_ctrl_c_im_betrieb_startet_nicht_neu(self):
|
||||
"""Von Hand beendet heisst: die REPL soll frei werden."""
|
||||
r = FakeRunEsp32(wirft=KeyboardInterrupt())
|
||||
m = FakeMachine()
|
||||
ausgaben, _ = lade_main(r, machine=m)
|
||||
self.assertEqual(m.resets, 0)
|
||||
self.assertTrue(any("beendet" in a for a in ausgaben))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user