OTA-Update per MQTT

Neues Kommando <PREFIX>/cmd/ota loest ota.update() aus: laedt die
Projektdateien per HTTP, erst als temporaere .ota-Dateien, und benennt
sie nur bei vollem Erfolg um. Schlaegt ein Download fehl, bleiben die
bisherigen Dateien unveraendert -- die Anzeige laeuft mit der alten
Version weiter (Grundsatz wie beim Rest von MQTT: eine fehlgeschlagene
OTA darf das Geraet nicht unbrauchbar machen).

Am Gerät bestätigt, inkl. echtem Reboot: leere/ungueltige Payload,
unbekanntes Kommando und nicht erreichbarer Server werden abgefangen,
die Poll-Schleife laeuft unbeeinflusst weiter. Ein echtes Update mit
allen Projektdateien kam sauber ueber main.py-Autostart wieder hoch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zTSXLK4w32PoCmFgSzp68
This commit is contained in:
2026-09-04 17:34:58 +02:00
parent 72da766af0
commit 0e4bb025ef
9 changed files with 534 additions and 9 deletions
+46 -2
View File
@@ -66,6 +66,7 @@ beiden Startprogrammen.
| `lx200.py` | LX200-Kommandorahmen. | ✓ | | `lx200.py` | LX200-Kommandorahmen. | ✓ |
| `wifi.py` | WLAN-Anbindung des ESP32 (verbinden, Verbindung halten). | ✓ | | `wifi.py` | WLAN-Anbindung des ESP32 (verbinden, Verbindung halten). | ✓ |
| `mqtt.py` | MQTT: Einstellungen empfangen, Zustand veröffentlichen. | ✓ | | `mqtt.py` | MQTT: Einstellungen empfangen, Zustand veröffentlichen. | ✓ |
| `ota.py` | OTA-Update: Dateien per HTTP laden, umbenennen, neu starten. | ✓ |
| `run_esp32.py` | Startprogramm auf dem ESP32: WLAN, Anzeige, Schleife. | ✓ | | `run_esp32.py` | Startprogramm auf dem ESP32: WLAN, Anzeige, Schleife. | ✓ |
| `main.py` | Autostart nach jedem Reset, mit Watchdog und Abbruchfenster. | ✓ | | `main.py` | Autostart nach jedem Reset, mit Watchdog und Abbruchfenster. | ✓ |
| `wifi_config.py` | WLAN-Zugangsdaten, **nicht im Git** (Vorlage `wifi_config_example.py`). | ✓ | | `wifi_config.py` | WLAN-Zugangsdaten, **nicht im Git** (Vorlage `wifi_config_example.py`). | ✓ |
@@ -548,9 +549,10 @@ Empfangen (`<PREFIX>/set/<name>`):
| `grossanzeige/set/schwelle` | `1800` | Umschaltpunkt (LDR-Wert) | | `grossanzeige/set/schwelle` | `1800` | Umschaltpunkt (LDR-Wert) |
| `grossanzeige/set/hysterese` | `150` | Breite um den Umschaltpunkt | | `grossanzeige/set/hysterese` | `150` | Breite um den Umschaltpunkt |
| `grossanzeige/set/mount_host` | `192.168.1.115` | Adresse der Montierung (leer = Vorgabe aus `config.py`) | | `grossanzeige/set/mount_host` | `192.168.1.115` | Adresse der Montierung (leer = Vorgabe aus `config.py`) |
| `grossanzeige/cmd/ota` | `http://192.168.178.50:8000` | löst ein OTA-Update aus (leer = Vorgabe aus `config.OTA_URL`, siehe [OTA-Update per MQTT](#ota-update-per-mqtt)) |
Alle Nutzlasten außer `mount_host` sind ganze Zahlen; `mount_host` ist Text Alle Nutzlasten außer `mount_host` und `cmd/ota` sind ganze Zahlen; `mount_host`
(IP-Adresse oder Hostname). und `cmd/ota` sind Text (Adresse bzw. URL).
Gesendet (`<PREFIX>/status/<name>`, alle *retained*): Gesendet (`<PREFIX>/status/<name>`, alle *retained*):
@@ -618,6 +620,48 @@ Der Wert geht in `socket.getaddrinfo` und kommt von außen, deshalb prüft
`settings.py` ihn eng: nur Buchstaben, Ziffern, Punkt und Bindestrich, höchstens `settings.py` ihn eng: nur Buchstaben, Ziffern, Punkt und Bindestrich, höchstens
63 Zeichen. Alles andere wird verworfen, `settings.json` bleibt unberührt. 63 Zeichen. Alles andere wird verworfen, `settings.json` bleibt unberührt.
### OTA-Update per MQTT
Ein Update lässt sich anstoßen, ohne das Gerät anzustecken:
```bash
cd /pfad/zum/projekt
python3 -m http.server 8000 # einfacher Dateiserver, hier laufen lassen
mosquitto_pub -h <broker> -t grossanzeige/cmd/ota -m http://192.168.178.50:8000
```
Die Nachricht auf `<PREFIX>/cmd/ota` ist die Basis-URL des Dateiservers; eine
leere Nachricht verwendet stattdessen `config.OTA_URL` (dieselbe Regel wie bei
`set/mount_host` mit leerer Nachricht):
```bash
mosquitto_pub -h <broker> -t grossanzeige/cmd/ota -m '' # config.OTA_URL
```
`ota.py` lädt dieselben Dateien, die auch `deploy.sh` überträgt, jede zunächst
unter einem temporären Namen (`<datei>.ota`). **Erst wenn alle Downloads
gelingen**, werden sie auf die endgültigen Namen umbenannt und das Gerät
startet neu (`machine.reset()`) — genau wie ein normaler Reset läuft danach
`main.py` mit Abbruchfenster und Watchdog an. Schlägt ein einzelner Download
fehl (Netz weg, Server nicht erreichbar, Datei fehlt), bleiben die bisherigen
Dateien unverändert liegen, die temporären Reste werden aufgeräumt, und die
Anzeige läuft mit der alten Version unbeeinflusst weiter — dieselbe
Fehlerphilosophie wie beim Rest von MQTT.
Der Watchdog wird während des Updates weiter gefüttert (`run_esp32.make_on_ota`
reicht den `feed` aus `main.py` durch), damit ein Update über viele Dateien
nicht selbst einen Neustart auslöst.
`settings.json`, `wifi_config.py` und `mqtt_config.py` werden **nicht**
angefasst — Laufzeitzustand bzw. Zugangsdaten, dieselbe Ausnahme wie in
`deploy.sh`.
**Sicherheitshinweis:** Der Dateiserver liefert unverändert aus, was unter der
angegebenen URL liegt — wer die MQTT-Nachricht schicken kann, bestimmt damit,
welcher Code auf dem Gerät läuft. Das ist im Rahmen des bestehenden
Vertrauensmodells (MQTT-Zugangsdaten in `mqtt_config.py`) beabsichtigt, aber
kein Ersatz für einen abgesicherten Broker.
### Was dabei schiefgehen kann, und warum es nichts ausmacht ### Was dabei schiefgehen kann, und warum es nichts ausmacht
**Grundsatz: MQTT darf die Anzeige nie aufhalten.** Die Anzeige ist der Zweck des **Grundsatz: MQTT darf die Anzeige nie aufhalten.** Die Anzeige ist der Zweck des
+7
View File
@@ -197,6 +197,13 @@ WIFI_RETRY = 5.0 # Pause vor einem neuen Verbindungsversuch
# Anzeige, solange noch kein WLAN steht. # Anzeige, solange noch kein WLAN steht.
NOWIFI_LINES = ("KEIN WLAN", " ... ") NOWIFI_LINES = ("KEIN WLAN", " ... ")
# -- OTA-Update (nur ESP32-C3) ---------------------------------------------
# Basis-URL eines einfachen HTTP-Fileservers (z.B. "python3 -m http.server"
# im Projektverzeichnis auf dem Mac), von dem ota.update() die Projektdateien
# laedt. Nur die **Vorgabe**: <PREFIX>/cmd/ota kann jederzeit eine andere URL
# mitgeben, leer laesst diese hier gelten (siehe mqtt.py, run_esp32.make_on_ota).
OTA_URL = "" # z.B. "http://192.168.178.50:8000"
# -- Helligkeitssensor (LDR, nur ESP32-C3) -------------------------------- # -- Helligkeitssensor (LDR, nur ESP32-C3) --------------------------------
# LDR an Pad 1 (D0/A0) = GPIO2, auf dem C3 ein Kanal von ADC1. # LDR an Pad 1 (D0/A0) = GPIO2, auf dem C3 ein Kanal von ADC1.
LDR_PIN = 2 LDR_PIN = 2
+1
View File
@@ -46,6 +46,7 @@ brightness.py
displayloop.py displayloop.py
wifi.py wifi.py
mqtt.py mqtt.py
ota.py
run_esp32.py run_esp32.py
probe.py probe.py
main.py main.py
+34 -3
View File
@@ -6,6 +6,9 @@
<PREFIX>/set/hysterese 150 <PREFIX>/set/hysterese 150
<PREFIX>/set/mount_host 192.168.1.115 Adresse der Montierung; leer = <PREFIX>/set/mount_host 192.168.1.115 Adresse der Montierung; leer =
Vorgabe aus config.MOUNT_HOST Vorgabe aus config.MOUNT_HOST
<PREFIX>/cmd/ota http://192.168.1.50:8000 loest ota.update() aus; leere
Nachricht = Vorgabe aus
config.OTA_URL (siehe ota.py)
<PREFIX>/status/ra 18h36m56s ausgehend <PREFIX>/status/ra 18h36m56s ausgehend
<PREFIX>/status/dec +38°47'01" <PREFIX>/status/dec +38°47'01"
@@ -58,7 +61,7 @@ class MqttBridge:
def __init__(self, broker=None, port=1883, user="", password="", def __init__(self, broker=None, port=1883, user="", password="",
prefix="grossanzeige", client_id="grossanzeige", prefix="grossanzeige", client_id="grossanzeige",
client=None, log=None, on_change=None): client=None, log=None, on_change=None, on_ota=None):
self._broker = broker self._broker = broker
self._port = port self._port = port
self._user = user self._user = user
@@ -68,6 +71,7 @@ class MqttBridge:
self._client = client self._client = client
self._log = log or (lambda *a: None) self._log = log or (lambda *a: None)
self._on_change = on_change # wird nach jeder Aenderung gerufen self._on_change = on_change # wird nach jeder Aenderung gerufen
self._on_ota = on_ota # wird bei cmd/ota gerufen (Payload = URL)
self.connected = False self.connected = False
self._retry = RETRY_START self._retry = RETRY_START
@@ -78,6 +82,9 @@ class MqttBridge:
def _set_topic(self, name=None): def _set_topic(self, name=None):
return "%s/set/%s" % (self._prefix, "+" if name is None else name) return "%s/set/%s" % (self._prefix, "+" if name is None else name)
def _cmd_topic(self, name=None):
return "%s/cmd/%s" % (self._prefix, "+" if name is None else name)
def _status_topic(self, name): def _status_topic(self, name):
return "%s/status/%s" % (self._prefix, name) return "%s/status/%s" % (self._prefix, name)
@@ -106,6 +113,7 @@ class MqttBridge:
retain=True) retain=True)
self._client.connect() self._client.connect()
self._client.subscribe(self._set_topic()) self._client.subscribe(self._set_topic())
self._client.subscribe(self._cmd_topic())
self._client.publish(self._status_topic("online"), b"1", retain=True) self._client.publish(self._status_topic("online"), b"1", retain=True)
self.connected = True self.connected = True
self._retry = RETRY_START self._retry = RETRY_START
@@ -143,12 +151,17 @@ class MqttBridge:
def _on_message(self, topic, payload): def _on_message(self, topic, payload):
"""Callback von umqtt. Faengt alles ab -- hier darf nichts durchschlagen.""" """Callback von umqtt. Faengt alles ab -- hier darf nichts durchschlagen."""
try: try:
name = topic.decode().rsplit("/", 1)[-1] teile = topic.decode().split("/")
gruppe, name = teile[-2], teile[-1]
roh = payload.decode().strip() roh = payload.decode().strip()
except Exception: except Exception:
self._log("MQTT: unlesbare Nachricht verworfen") self._log("MQTT: unlesbare Nachricht verworfen")
return return
if gruppe == "cmd":
self._on_command(name, roh)
return
if name not in SETZBAR: if name not in SETZBAR:
self._log("MQTT: unbekannte Einstellung %r verworfen" % name) self._log("MQTT: unbekannte Einstellung %r verworfen" % name)
return return
@@ -179,6 +192,23 @@ class MqttBridge:
except Exception as e: except Exception as e:
self._log("MQTT: on_change fehlgeschlagen (%s)" % e) self._log("MQTT: on_change fehlgeschlagen (%s)" % e)
def _on_command(self, name, roh):
"""cmd/<name> auswerten. Bislang nur "ota" -- Payload ist die URL."""
if name != "ota":
self._log("MQTT: unbekanntes Kommando %r verworfen" % name)
return
if self._on_ota is None:
self._log("MQTT: OTA angefordert, aber kein Handler eingerichtet")
return
self._log("MQTT: OTA angefordert%s" % (" (%s)" % roh if roh else ""))
try:
self._on_ota(roh)
except Exception as e:
# OTA scheitert oft erst mitten im Download (Netz, Server nicht
# erreichbar) -- das darf die Poll-Schleife nicht mitreissen,
# dieselbe Regel wie bei on_change.
self._log("MQTT: OTA fehlgeschlagen (%s)" % e)
def check(self): def check(self):
"""Anstehende Nachrichten verarbeiten. Blockiert nicht.""" """Anstehende Nachrichten verarbeiten. Blockiert nicht."""
if not self.ensure(): if not self.ensure():
@@ -235,7 +265,7 @@ class MqttBridge:
self._client = None self._client = None
def connect_from_config(log=None, on_change=None): def connect_from_config(log=None, on_change=None, on_ota=None):
"""Bruecke aus mqtt_config.py bauen und verbinden. """Bruecke aus mqtt_config.py bauen und verbinden.
Liefert None, wenn keine Konfiguration da ist -- dann laeuft die Anzeige Liefert None, wenn keine Konfiguration da ist -- dann laeuft die Anzeige
@@ -257,6 +287,7 @@ def connect_from_config(log=None, on_change=None):
client_id=getattr(mqtt_config, "CLIENT_ID", "grossanzeige"), client_id=getattr(mqtt_config, "CLIENT_ID", "grossanzeige"),
log=log, log=log,
on_change=on_change, on_change=on_change,
on_ota=on_ota,
) )
bridge.connect() # scheitert es, versucht check() es spaeter erneut bridge.connect() # scheitert es, versucht check() es spaeter erneut
return bridge return bridge
+172
View File
@@ -0,0 +1,172 @@
"""OTA-Update ueber HTTP, ausgeloest per MQTT (siehe mqtt.py, ``cmd/ota``).
Laedt die Projektdateien von einem einfachen HTTP-Server (z.B.
``python3 -m http.server`` im Projektverzeichnis) und startet danach neu.
Bewusst ohne Fremdbibliothek (kein ``urequests``) -- ein minimaler HTTP-GET
ueber ``socket`` reicht und passt zum Rest des Projekts (vgl.
mount_client.py).
**Grundsatz: eine fehlgeschlagene OTA darf das Geraet nicht unbrauchbar
machen.** Jede Datei wird deshalb zuerst unter einem temporaeren Namen
(``<datei>.ota``) geladen; erst wenn *alle* Downloads gelingen, werden sie auf
die endgueltigen Namen umbenannt und das Geraet neu gestartet. Bricht ein
Download ab, bleiben die bisherigen Dateien unveraendert liegen, die
temporaeren Reste werden aufgeraeumt -- die Anzeige laeuft mit der alten
Version weiter.
"""
import socket
# Dieselbe Liste wie deploy.sh FILES -- was auf dem Geraet gebraucht wird.
# settings.json, wifi_config.py und mqtt_config.py gehoeren bewusst nicht
# dazu: Laufzeitzustand bzw. Zugangsdaten, siehe deploy.sh.
FILES = (
"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",
"ota.py",
"main.py",
)
class OtaError(Exception):
"""Download oder Uebernahme fehlgeschlagen."""
def _get(url, timeout=10.0):
"""Minimaler HTTP-GET (nur ``http://``). Liefert den Body als bytes."""
if not url.startswith("http://"):
raise OtaError("nur http:// unterstuetzt: %r" % url)
rest = url[len("http://"):]
if "/" in rest:
host_port, pfad = rest.split("/", 1)
pfad = "/" + pfad
else:
host_port, pfad = rest, "/"
if ":" in host_port:
host, port_s = host_port.split(":", 1)
port = int(port_s)
else:
host, port = host_port, 80
try:
addr = socket.getaddrinfo(host, port)[0][-1]
except (OSError, IndexError) as e:
raise OtaError("Adresse %s:%d nicht aufloesbar: %s" % (host, port, e))
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
try:
try:
sock.connect(addr)
req = ("GET %s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\n\r\n"
% (pfad, host))
sock.send(req.encode())
except OSError as e:
raise OtaError("Verbindung zu %s:%d fehlgeschlagen: %s"
% (host, port, e))
buf = b""
while True:
try:
chunk = sock.recv(512)
except OSError as e:
raise OtaError("Empfang gestoert: %s" % e)
if not chunk:
break
buf += chunk
finally:
sock.close()
kopf, trenner, body = buf.partition(b"\r\n\r\n")
if not trenner:
raise OtaError("unvollstaendige Antwort fuer %s" % pfad)
zeile = kopf.split(b"\r\n", 1)[0]
try:
status = int(zeile.split(b" ")[1])
except (IndexError, ValueError):
raise OtaError("unerwartete Antwort: %r" % zeile)
if status != 200:
raise OtaError("HTTP %d fuer %s" % (status, pfad))
return body
def _aufraeumen(geladen):
import os
for _, tmp in geladen:
try:
os.remove(tmp)
except OSError:
pass
def update(base_url, files=None, log=None, feed=None, getter=_get):
"""Alle Dateien laden, bei vollem Erfolg umbenennen und neu starten.
base_url z.B. "http://192.168.178.50:8000" -- Basis-URL, unter der die
Projektdateien liegen (ein einfacher HTTP-Fileserver im
Projektverzeichnis reicht).
files Vorgabe FILES; zum Testen ueberschreibbar.
log Ausgabefunktion, Vorgabe print.
feed optional: Watchdog-Funktion, vor jeder Datei gerufen -- ein
Update ueber viele Dateien darf den Watchdog nicht ausloesen
(siehe run_esp32.make_watchdog).
getter zum Testen austauschbar; Vorgabe der echte HTTP-GET.
Wirft OtaError, wenn irgendein Download fehlschlaegt -- **vor** dem
Umbenennen, sodass die bisherigen Dateien unangetastet bleiben. Bei Erfolg
kehrt die Funktion unter MicroPython nicht zurueck (machine.reset()); unter
CPython (Tests) tut sie es doch, ohne neu zu starten.
"""
log = log or (lambda *a: None)
if files is None:
files = FILES
base_url = base_url.rstrip("/")
geladen = []
try:
for name in files:
if feed is not None:
feed()
log("OTA: lade %s ..." % name)
body = getter("%s/%s" % (base_url, name))
if not body:
raise OtaError("%s ist leer" % name)
tmp = name + ".ota"
with open(tmp, "wb") as f:
f.write(body)
geladen.append((name, tmp))
except OtaError as e:
log("OTA: fehlgeschlagen (%s) -- bisherige Dateien bleiben unveraendert"
% e)
_aufraeumen(geladen)
raise
except OSError as e:
log("OTA: Dateizugriff gestoert (%s) -- bisherige Dateien bleiben "
"unveraendert" % e)
_aufraeumen(geladen)
raise OtaError("Dateizugriff gestoert: %s" % e)
log("OTA: %d Dateien geladen, uebernehme ..." % len(geladen))
import os
for name, tmp in geladen:
os.rename(tmp, name)
log("OTA: fertig, starte neu ...")
try:
import machine
machine.reset()
except ImportError:
pass # CPython: nur zum Testen, kein echter Neustart
+28 -1
View File
@@ -156,6 +156,31 @@ def make_on_change(display, client, dimmer=None, log=None):
return uebernehmen return uebernehmen
def make_on_ota(feed=None, log=None):
"""Rueckruf fuer mqtt.MqttBridge: OTA-Update anstossen (cmd/ota).
Die Payload ist die Basis-URL des Dateiservers, z.B.
"http://192.168.178.50:8000". Bei leerer Nachricht gilt config.OTA_URL --
so laesst sich per MQTT auch ohne Payload eine feste Vorgabe anstossen,
genau wie set/mount_host mit leerer Nachricht auf config.MOUNT_HOST
zurueckfaellt.
Eigene Funktion statt Verschachtelung in main(), aus demselben Grund wie
make_on_change: Tests pruefen genau diese Logik.
"""
log = log or (lambda *a: None)
def ausloesen(payload):
import ota
url = payload or getattr(config, "OTA_URL", "")
if not url:
log("OTA: keine URL angegeben und config.OTA_URL nicht gesetzt")
return
ota.update(url, feed=feed, log=log)
return ausloesen
def main(host=None, port=None, with_ldr=False, fake_display=False, def main(host=None, port=None, with_ldr=False, fake_display=False,
once=False, with_mqtt=True, feed=None, log=print): once=False, with_mqtt=True, feed=None, log=print):
"""WLAN aufbauen, Anzeige oeffnen und die Poll-Schleife fahren. """WLAN aufbauen, Anzeige oeffnen und die Poll-Schleife fahren.
@@ -205,7 +230,9 @@ def main(host=None, port=None, with_ldr=False, fake_display=False,
if with_mqtt: if with_mqtt:
import mqtt import mqtt
bridge = mqtt.connect_from_config( bridge = mqtt.connect_from_config(
log=log, on_change=make_on_change(d, client, dimmer, log=log)) log=log,
on_change=make_on_change(d, client, dimmer, log=log),
on_ota=make_on_ota(feed=feed, log=log))
displayloop.poll_loop(d, client, displayloop.poll_loop(d, client,
once=once, once=once,
+57 -2
View File
@@ -460,6 +460,61 @@ class TestWatchdogInDerSchleife(unittest.TestCase):
self.assertEqual(len(self._lauf(Client())), 1) self.assertEqual(len(self._lauf(Client())), 1)
class TestOnOta(unittest.TestCase):
"""run_esp32.make_on_ota: baut den MQTT-Rueckruf fuer cmd/ota.
ota.update() selbst wird hier durch eine Attrappe ersetzt -- getestet
wird nur, ob make_on_ota die richtige URL und den Watchdog-Feed
durchreicht, nicht der Download (siehe test_ota.py dafuer).
"""
def setUp(self):
import ota
import run_esp32
self.ota = ota
self.run_esp32 = run_esp32
self.aufrufe = []
def fake_update(url, files=None, log=None, feed=None, getter=None):
self.aufrufe.append((url, feed))
self._alt_update = ota.update
ota.update = fake_update
self.addCleanup(lambda: setattr(ota, "update", self._alt_update))
import config
self._alt_ota_url = getattr(config, "OTA_URL", "")
self.addCleanup(lambda: setattr(config, "OTA_URL", self._alt_ota_url))
self.config = config
def test_payload_wird_als_url_verwendet(self):
ausloesen = self.run_esp32.make_on_ota()
ausloesen("http://192.168.1.50:8000")
self.assertEqual(self.aufrufe, [("http://192.168.1.50:8000", None)])
def test_leere_payload_faellt_auf_config_ota_url_zurueck(self):
"""Genau wie set/mount_host: leer heisst "die Vorgabe aus config.py"."""
self.config.OTA_URL = "http://vorgabe:8000"
ausloesen = self.run_esp32.make_on_ota()
ausloesen("")
self.assertEqual(self.aufrufe, [("http://vorgabe:8000", None)])
def test_ohne_jede_url_wird_nichts_ausgeloest(self):
self.config.OTA_URL = ""
meldungen = []
ausloesen = self.run_esp32.make_on_ota(log=meldungen.append)
ausloesen("")
self.assertEqual(self.aufrufe, [])
self.assertTrue(any("keine URL" in m for m in meldungen))
def test_feed_wird_durchgereicht(self):
"""Ein Update ueber viele Dateien darf den Watchdog nicht ausloesen."""
feed = lambda: None
ausloesen = self.run_esp32.make_on_ota(feed=feed)
ausloesen("http://x")
self.assertIs(self.aufrufe[0][1], feed)
class TestLdrLeser(unittest.TestCase): class TestLdrLeser(unittest.TestCase):
"""run_esp32.make_ldr_reader: ADC lesen, auf 12 bit bringen, spiegeln.""" """run_esp32.make_ldr_reader: ADC lesen, auf 12 bit bringen, spiegeln."""
@@ -557,8 +612,8 @@ class TestWifi(unittest.TestCase):
DEPLOYED = ( DEPLOYED = (
"ticks.py", "migra.py", "config.py", "settings.py", "transport.py", "ticks.py", "migra.py", "config.py", "settings.py", "transport.py",
"display.py", "coords.py", "lx200.py", "mount_client.py", "brightness.py", "display.py", "coords.py", "lx200.py", "mount_client.py", "brightness.py",
"displayloop.py", "wifi.py", "mqtt.py", "run_esp32.py", "probe.py", "displayloop.py", "wifi.py", "mqtt.py", "ota.py", "run_esp32.py",
"main.py", "probe.py", "main.py",
) )
+50 -1
View File
@@ -111,7 +111,7 @@ class TestVerbinden(MqttTest):
def test_connect_abonniert_und_meldet_online(self): def test_connect_abonniert_und_meldet_online(self):
self.assertTrue(self.bridge.connect()) self.assertTrue(self.bridge.connect())
self.assertTrue(self.bridge.connected) self.assertTrue(self.bridge.connected)
self.assertEqual(self.client.subscribed, ["ga/set/+"]) self.assertEqual(self.client.subscribed, ["ga/set/+", "ga/cmd/+"])
self.assertIn(("ga/status/online", b"1", True), self.client.published) self.assertIn(("ga/status/online", b"1", True), self.client.published)
def test_last_will_meldet_offline(self): def test_last_will_meldet_offline(self):
@@ -537,5 +537,54 @@ class TestSchleifenanbindung(MqttTest):
self.assertEqual(gezeigt[0], "RA 18h36.9m") self.assertEqual(gezeigt[0], "RA 18h36.9m")
class TestOta(MqttTest):
"""cmd/ota loest den injizierten on_ota-Rueckruf aus (siehe ota.py,
run_esp32.make_on_ota). Die eigentliche OTA-Logik wird hier nicht
getestet -- nur, dass mqtt.py sie zuverlaessig anstoesst, ohne dabei die
Anzeige mitzureissen."""
def setUp(self):
super().setUp()
self.ota_aufrufe = []
self.bridge._on_ota = self.ota_aufrufe.append
self.bridge.connect()
def test_cmd_topic_wird_abonniert(self):
self.assertIn("ga/cmd/+", self.client.subscribed)
def test_ota_wird_mit_payload_ausgeloest(self):
self.sende("ga/cmd/ota", "http://192.168.1.50:8000")
self.assertEqual(self.ota_aufrufe, ["http://192.168.1.50:8000"])
def test_ota_ohne_payload_wird_trotzdem_ausgeloest(self):
"""Leere Nachricht heisst: config.OTA_URL gilt (make_on_ota)."""
self.sende("ga/cmd/ota", "")
self.assertEqual(self.ota_aufrufe, [""])
def test_unbekanntes_kommando_wird_verworfen(self):
self.sende("ga/cmd/reboot", "")
self.assertEqual(self.ota_aufrufe, [])
self.assertTrue(any("unbekanntes Kommando" in m for m in self.meldungen))
def test_ohne_handler_wird_nur_protokolliert(self):
self.bridge._on_ota = None
self.sende("ga/cmd/ota", "http://x")
self.assertTrue(any("kein Handler" in m for m in self.meldungen))
def test_kaputter_on_ota_bricht_nichts_ab(self):
"""Ein Fehler beim OTA-Anstossen darf den Empfang nicht toeten."""
def kaputt(_):
raise RuntimeError("absichtlich")
self.bridge._on_ota = kaputt
self.sende("ga/cmd/ota", "http://x") # darf nicht werfen
self.assertTrue(any("OTA fehlgeschlagen" in m for m in self.meldungen))
def test_set_topics_bleiben_unberuehrt(self):
"""Das gruppenbasierte Routing (set/cmd) darf set/ nicht veraendern."""
self.sende("ga/set/hell_prozent", "60")
self.assertEqual(settings.load(self.pfad)["hell_prozent"], 60)
self.assertEqual(self.ota_aufrufe, [])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Tests fuer das OTA-Update (ota.py).
python3 -m unittest -v test_ota
Ein echter Download wird nicht gebraucht: update() nimmt einen Getter
entgegen (Vorgabe ota._get), der sich hier durch eine Attrappe ersetzen
laesst -- genau wie mqtt.MqttBridge einen Client injiziert bekommt.
Schwerpunkt ist derselbe Grundsatz wie bei MQTT: **Eine fehlgeschlagene OTA
darf das Geraet nicht unbrauchbar machen.** Ein abgebrochener Download darf
die bisherigen Dateien nicht antasten.
"""
import os
import unittest
import ota
class FakeGetter:
"""Ersatz fuer ota._get: liefert vordefinierte Inhalte oder wirft."""
def __init__(self, inhalte):
self.inhalte = dict(inhalte) # url -> bytes (oder Exception-Instanz)
self.angefragt = []
def __call__(self, url):
self.angefragt.append(url)
wert = self.inhalte.get(url)
if wert is None:
raise ota.OtaError("nicht gefunden: %s" % url)
if isinstance(wert, Exception):
raise wert
return wert
class OtaTest(unittest.TestCase):
"""Arbeitet in einem eigenen Verzeichnis, um echte Projektdateien nicht
anzufassen."""
def setUp(self):
self._cwd = os.getcwd()
self.tmp = "test_ota_tmp"
os.makedirs(self.tmp, exist_ok=True)
os.chdir(self.tmp)
self.meldungen = []
def tearDown(self):
os.chdir(self._cwd)
import shutil
shutil.rmtree(self.tmp, ignore_errors=True)
def _bestehende_datei(self, name, inhalt):
with open(name, "w") as f:
f.write(inhalt)
class TestErfolg(OtaTest):
def test_alle_dateien_werden_geschrieben(self):
files = ("a.py", "b.py")
getter = FakeGetter({
"http://srv/a.py": b"INHALT_A",
"http://srv/b.py": b"INHALT_B",
})
ota.update("http://srv", files=files, log=self.meldungen.append,
getter=getter)
with open("a.py", "rb") as f:
self.assertEqual(f.read(), b"INHALT_A")
with open("b.py", "rb") as f:
self.assertEqual(f.read(), b"INHALT_B")
# Keine .ota-Reste nach erfolgreicher Uebernahme.
self.assertFalse(os.path.exists("a.py.ota"))
self.assertFalse(os.path.exists("b.py.ota"))
def test_bestehende_datei_wird_ersetzt(self):
self._bestehende_datei("a.py", "ALT")
getter = FakeGetter({"http://srv/a.py": b"NEU"})
ota.update("http://srv", files=("a.py",), getter=getter)
with open("a.py") as f:
self.assertEqual(f.read(), "NEU")
def test_url_mit_abschliessendem_slash(self):
getter = FakeGetter({"http://srv/a.py": b"X"})
ota.update("http://srv/", files=("a.py",), getter=getter)
self.assertEqual(getter.angefragt, ["http://srv/a.py"])
def test_feed_wird_vor_jeder_datei_gerufen(self):
gefuettert = []
getter = FakeGetter({
"http://srv/a.py": b"A", "http://srv/b.py": b"B",
})
ota.update("http://srv", files=("a.py", "b.py"), getter=getter,
feed=lambda: gefuettert.append(1))
self.assertEqual(len(gefuettert), 2)
class TestFehlschlag(OtaTest):
def test_abgebrochener_download_laesst_bestehende_datei_unangetastet(self):
self._bestehende_datei("a.py", "ALT")
self._bestehende_datei("b.py", "ALT_B")
# a.py laedt erfolgreich, b.py schlaegt fehl -- danach darf a.py nicht
# trotzdem auf dem neuen Stand stehen, sonst liefe ein halb
# aktualisiertes Geraet weiter.
getter = FakeGetter({"http://srv/a.py": b"NEU"})
with self.assertRaises(ota.OtaError):
ota.update("http://srv", files=("a.py", "b.py"), getter=getter,
log=self.meldungen.append)
with open("a.py") as f:
self.assertEqual(f.read(), "ALT")
with open("b.py") as f:
self.assertEqual(f.read(), "ALT_B")
self.assertFalse(os.path.exists("a.py.ota"))
self.assertTrue(any("fehlgeschlagen" in m for m in self.meldungen))
def test_leere_antwort_gilt_als_fehler(self):
getter = FakeGetter({"http://srv/a.py": b""})
with self.assertRaises(ota.OtaError):
ota.update("http://srv", files=("a.py",), getter=getter)
self.assertFalse(os.path.exists("a.py"))
def test_http_fehler_bricht_ab(self):
getter = FakeGetter({"http://srv/a.py": ota.OtaError("HTTP 404 fuer /a.py")})
with self.assertRaises(ota.OtaError):
ota.update("http://srv", files=("a.py",), getter=getter)
class TestHttpGet(unittest.TestCase):
"""_get: nur der Teil, der ohne echten Netzwerkzugriff prüfbar ist."""
def test_nur_http_wird_unterstuetzt(self):
with self.assertRaises(ota.OtaError):
ota._get("https://srv/a.py")
if __name__ == "__main__":
unittest.main()