0e4bb025ef
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
173 lines
5.6 KiB
Python
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
|