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:
@@ -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
|
||||
Reference in New Issue
Block a user