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:
2026-07-27 18:41:33 +02:00
parent 433c53aabd
commit 142a621066
8 changed files with 489 additions and 7 deletions
+41 -5
View File
@@ -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
+22
View File
@@ -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.
+5
View File
@@ -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
View File
@@ -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.
+87
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)