Files
admin 0e4bb025ef 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
2026-09-04 17:34:58 +02:00

173 lines
5.6 KiB
Python

"""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