diff --git a/README.md b/README.md index ee94d45..91d8713 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.py b/config.py index e207f8e..cf47b6f 100644 --- a/config.py +++ b/config.py @@ -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. diff --git a/deploy.sh b/deploy.sh index f1d46db..a0f05b0 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 diff --git a/displayloop.py b/displayloop.py index e33556a..f9a8367 100644 --- a/displayloop.py +++ b/displayloop.py @@ -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. diff --git a/main.py b/main.py new file mode 100644 index 0000000..ae035be --- /dev/null +++ b/main.py @@ -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() diff --git a/run_esp32.py b/run_esp32.py index c2415f2..5184fff 100644 --- a/run_esp32.py +++ b/run_esp32.py @@ -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") diff --git a/test_esp32.py b/test_esp32.py index 697c0ed..342027b 100644 --- a/test_esp32.py +++ b/test_esp32.py @@ -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", ) diff --git a/test_main.py b/test_main.py new file mode 100644 index 0000000..7a1b70b --- /dev/null +++ b/test_main.py @@ -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)