Compare commits

...

4 Commits

Author SHA1 Message Date
admin 8124cf5d42 Merge branch 'feature/ota-mqtt'
OTA-Update per MQTT (cmd/ota) und TLS-Unterstuetzung fuer MQTT, am
ESP32-C3 bestaetigt (inkl. echtem Reboot per OTA und Verbindung zum
neuen Broker mqtt.fuerst-stuttgart.de).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zTSXLK4w32PoCmFgSzp68
2026-09-04 17:35:57 +02:00
admin 9119bc0213 TLS-Unterstuetzung fuer MQTT
MqttBridge(ssl=True) / mqtt_config.SSL: umqtt.simple.MQTTClient wird mit
ssl_params={"server_hostname": ..., "cert_reqs": CERT_NONE} aufgebaut --
SNI fuer virtuelle Hosts hinter einem Reverse-Proxy, aber ohne
Zertifikatspruefung (kein CA-Bundle auf dem Geraet). Verschluesselt die
Zugangsdaten auf der Leitung, schuetzt aber nicht vor einem aktiven
Man-in-the-Middle.

Anlass: Der Broker wechselte auf einen Host, der nur noch TLS anbot --
Klartext gegen den TLS-Port endete in ECONNRESET.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zTSXLK4w32PoCmFgSzp68
2026-09-04 17:35:50 +02:00
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
admin 72da766af0 USB-Seriell-Port des Mac-Wandlers aktualisiert
Adapter meldet sich jetzt als /dev/cu.usbmodem1101 statt
/dev/cu.usbserial-120.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zTSXLK4w32PoCmFgSzp68
2026-09-04 17:33:47 +02:00
10 changed files with 608 additions and 15 deletions
+52 -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`). | ✓ |
@@ -537,6 +538,12 @@ cp mqtt_config_example.py mqtt_config.py # Broker, Port, ggf. Benutzer/Passw
**Fehlt die Datei, läuft die Anzeige einfach ohne MQTT** — `deploy.sh` weist nur **Fehlt die Datei, läuft die Anzeige einfach ohne MQTT** — `deploy.sh` weist nur
darauf hin und kopiert sie nicht. darauf hin und kopiert sie nicht.
**TLS:** `mqtt_config.SSL = True` (üblicherweise mit `PORT = 8883`) verschlüsselt
die Verbindung, prüft aber **nicht** das Serverzertifikat — auf dem Gerät liegt
kein CA-Bundle. Schützt die Zugangsdaten auf der Leitung, nicht vor einem
aktiven Man-in-the-Middle. Details in `mqtt_config_example.py` und
`mqtt.py` (`_neuer_client`).
### Topics ### Topics
Empfangen (`<PREFIX>/set/<name>`): Empfangen (`<PREFIX>/set/<name>`):
@@ -548,9 +555,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 +626,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
+8 -1
View File
@@ -21,7 +21,7 @@ BYTESIZE = 8
STOPBITS = 1 STOPBITS = 1
# CPython: Geraetedatei des USB-RS232-Wandlers. # CPython: Geraetedatei des USB-RS232-Wandlers.
PORT = "/dev/cu.usbserial-120" PORT = "/dev/cu.usbmodem1101"
# ESP32-C3: UART1 mit frei zugeordneten Pins. Die Nummern stammen aus dem # ESP32-C3: UART1 mit frei zugeordneten Pins. Die Nummern stammen aus dem
# Schaltplan (Hardware/KiCad/Grossanzeige) -- am XIAO ist Pad 7 = D6 = GPIO21 # Schaltplan (Hardware/KiCad/Grossanzeige) -- am XIAO ist Pad 7 = D6 = GPIO21
@@ -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
+55 -7
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"
@@ -28,6 +31,10 @@ kehrt es sofort zurueck.
Ohne mqtt_config.py (oder ohne umqtt) gibt connect_from_config() None zurueck -- Ohne mqtt_config.py (oder ohne umqtt) gibt connect_from_config() None zurueck --
die Anzeige laeuft dann ohne MQTT weiter. die Anzeige laeuft dann ohne MQTT weiter.
TLS (mqtt_config.SSL = True, ueblicherweise mit PORT = 8883): Serverzertifikat
wird NICHT geprueft (kein CA-Bundle auf dem Geraet) -- schuetzt die Zugangsdaten
auf der Leitung, nicht vor einem aktiven Man-in-the-Middle. Siehe _neuer_client.
""" """
import settings import settings
@@ -58,7 +65,8 @@ 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,
ssl=False):
self._broker = broker self._broker = broker
self._port = port self._port = port
self._user = user self._user = user
@@ -68,6 +76,8 @@ 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._ssl = ssl
self.connected = False self.connected = False
self._retry = RETRY_START self._retry = RETRY_START
@@ -78,6 +88,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)
@@ -85,10 +98,20 @@ class MqttBridge:
def _neuer_client(self): def _neuer_client(self):
if MQTTClient is None: if MQTTClient is None:
raise OSError("umqtt.simple nicht verfuegbar") raise OSError("umqtt.simple nicht verfuegbar")
return MQTTClient(self._client_id, self._broker, port=self._port, kwargs = dict(port=self._port, user=self._user or None,
user=self._user or None, password=self._password or None, keepalive=60)
password=self._password or None, if self._ssl:
keepalive=60) # server_hostname fuers SNI -- ohne das weisen manche TLS-Server
# (virtuelle Hosts hinter einem Reverse-Proxy) die Verbindung ab.
# CERT_NONE, weil auf dem Geraet kein CA-Bundle liegt: die Leitung
# ist verschluesselt, aber ein aktiver Man-in-the-Middle waere
# nicht nachweisbar. Reicht fuer den hiesigen Zweck (Zugangsdaten
# nicht im Klartext), ist aber keine volle Zertifikatspruefung.
import ssl as _ssl
kwargs["ssl"] = True
kwargs["ssl_params"] = {"server_hostname": self._broker,
"cert_reqs": _ssl.CERT_NONE}
return MQTTClient(self._client_id, self._broker, **kwargs)
def connect(self): def connect(self):
"""Verbinden, Set-Topics abonnieren, online melden. """Verbinden, Set-Topics abonnieren, online melden.
@@ -106,6 +129,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 +167,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 +208,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 +281,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 +303,8 @@ 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,
ssl=getattr(mqtt_config, "SSL", False),
) )
bridge.connect() # scheitert es, versucht check() es spaeter erneut bridge.connect() # scheitert es, versucht check() es spaeter erneut
return bridge return bridge
+6 -1
View File
@@ -9,7 +9,12 @@ Fehlt die Datei, laeuft die Anzeige einfach ohne MQTT weiter.
""" """
BROKER = "192.168.178.30" # IP oder Hostname des Brokers BROKER = "192.168.178.30" # IP oder Hostname des Brokers
PORT = 1883 # 1883 = unverschluesselt, 8883 = TLS (nicht unterstuetzt) PORT = 1883 # 1883 = unverschluesselt, 8883 = ueblicher TLS-Port
# True fuer TLS (dann meist PORT = 8883). Das Serverzertifikat wird dabei NICHT
# geprueft (kein CA-Bundle auf dem Geraet) -- schuetzt die Zugangsdaten auf der
# Leitung, aber nicht vor einem aktiven Man-in-the-Middle. Siehe mqtt.py.
SSL = False
# Leer lassen, wenn der Broker keine Anmeldung verlangt. # Leer lassen, wenn der Broker keine Anmeldung verlangt.
USER = "" USER = ""
+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",
) )
+90 -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):
@@ -132,6 +132,46 @@ class TestVerbinden(MqttTest):
self.assertFalse(self.bridge.connected) self.assertFalse(self.bridge.connected)
class FakeMQTTClient:
"""Ersatz fuer umqtt.simple.MQTTClient selbst (nicht fuer eine Instanz) --
zeichnet nur auf, mit welchen Argumenten mqtt._neuer_client() ihn baut."""
aufrufe = []
def __init__(self, client_id, server, **kwargs):
FakeMQTTClient.aufrufe.append((client_id, server, kwargs))
class TestNeuerClient(unittest.TestCase):
"""mqtt.MqttBridge._neuer_client(): baut den echten MQTTClient auf (kein
FakeClient injiziert). Schwerpunkt TLS -- siehe mqtt_config_example.py."""
def setUp(self):
FakeMQTTClient.aufrufe = []
self._alt = mqtt.MQTTClient
mqtt.MQTTClient = FakeMQTTClient
self.addCleanup(lambda: setattr(mqtt, "MQTTClient", self._alt))
def test_ohne_ssl_werden_keine_ssl_argumente_gesetzt(self):
bridge = mqtt.MqttBridge(broker="b", port=1883)
bridge._neuer_client()
_, _, kwargs = FakeMQTTClient.aufrufe[0]
self.assertNotIn("ssl", kwargs)
self.assertNotIn("ssl_params", kwargs)
def test_ssl_setzt_server_hostname_und_cert_none(self):
import ssl as echtes_ssl
bridge = mqtt.MqttBridge(broker="mqtt.example.org", port=8883, ssl=True)
bridge._neuer_client()
client_id, server, kwargs = FakeMQTTClient.aufrufe[0]
self.assertEqual(server, "mqtt.example.org")
self.assertTrue(kwargs["ssl"])
self.assertEqual(kwargs["ssl_params"]["server_hostname"],
"mqtt.example.org")
self.assertEqual(kwargs["ssl_params"]["cert_reqs"],
echtes_ssl.CERT_NONE)
class TestEmpfang(MqttTest): class TestEmpfang(MqttTest):
def setUp(self): def setUp(self):
@@ -537,5 +577,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()