diff --git a/README.md b/README.md index 9064966..cc781b4 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ beiden Startprogrammen. | `lx200.py` | LX200-Kommandorahmen. | ✓ | | `wifi.py` | WLAN-Anbindung des ESP32 (verbinden, Verbindung halten). | ✓ | | `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. | ✓ | | `main.py` | Autostart nach jedem Reset, mit Watchdog und Abbruchfenster. | ✓ | | `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 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 Empfangen (`/set/`): @@ -548,9 +555,10 @@ Empfangen (`/set/`): | `grossanzeige/set/schwelle` | `1800` | Umschaltpunkt (LDR-Wert) | | `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/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 -(IP-Adresse oder Hostname). +Alle Nutzlasten außer `mount_host` und `cmd/ota` sind ganze Zahlen; `mount_host` +und `cmd/ota` sind Text (Adresse bzw. URL). Gesendet (`/status/`, 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 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 -t grossanzeige/cmd/ota -m http://192.168.178.50:8000 +``` + +Die Nachricht auf `/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 -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 (`.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 **Grundsatz: MQTT darf die Anzeige nie aufhalten.** Die Anzeige ist der Zweck des diff --git a/config.py b/config.py index 129b705..d3fc4ab 100644 --- a/config.py +++ b/config.py @@ -21,7 +21,7 @@ BYTESIZE = 8 STOPBITS = 1 # 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 # 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. 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**: /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) -------------------------------- # LDR an Pad 1 (D0/A0) = GPIO2, auf dem C3 ein Kanal von ADC1. LDR_PIN = 2 diff --git a/deploy.sh b/deploy.sh index a0f05b0..3b35b31 100755 --- a/deploy.sh +++ b/deploy.sh @@ -46,6 +46,7 @@ brightness.py displayloop.py wifi.py mqtt.py +ota.py run_esp32.py probe.py main.py diff --git a/mqtt.py b/mqtt.py index da5f75a..44b5e9b 100644 --- a/mqtt.py +++ b/mqtt.py @@ -6,6 +6,9 @@ /set/hysterese 150 /set/mount_host 192.168.1.115 Adresse der Montierung; leer = Vorgabe aus config.MOUNT_HOST + /cmd/ota http://192.168.1.50:8000 loest ota.update() aus; leere + Nachricht = Vorgabe aus + config.OTA_URL (siehe ota.py) /status/ra 18h36m56s ausgehend /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 -- 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 @@ -58,7 +65,8 @@ class MqttBridge: def __init__(self, broker=None, port=1883, user="", password="", 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._port = port self._user = user @@ -68,6 +76,8 @@ class MqttBridge: self._client = client self._log = log or (lambda *a: None) 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._retry = RETRY_START @@ -78,6 +88,9 @@ class MqttBridge: def _set_topic(self, name=None): 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): return "%s/status/%s" % (self._prefix, name) @@ -85,10 +98,20 @@ class MqttBridge: def _neuer_client(self): if MQTTClient is None: raise OSError("umqtt.simple nicht verfuegbar") - return MQTTClient(self._client_id, self._broker, port=self._port, - user=self._user or None, - password=self._password or None, - keepalive=60) + kwargs = dict(port=self._port, user=self._user or None, + password=self._password or None, keepalive=60) + if self._ssl: + # 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): """Verbinden, Set-Topics abonnieren, online melden. @@ -106,6 +129,7 @@ class MqttBridge: retain=True) self._client.connect() self._client.subscribe(self._set_topic()) + self._client.subscribe(self._cmd_topic()) self._client.publish(self._status_topic("online"), b"1", retain=True) self.connected = True self._retry = RETRY_START @@ -143,12 +167,17 @@ class MqttBridge: def _on_message(self, topic, payload): """Callback von umqtt. Faengt alles ab -- hier darf nichts durchschlagen.""" try: - name = topic.decode().rsplit("/", 1)[-1] + teile = topic.decode().split("/") + gruppe, name = teile[-2], teile[-1] roh = payload.decode().strip() except Exception: self._log("MQTT: unlesbare Nachricht verworfen") return + if gruppe == "cmd": + self._on_command(name, roh) + return + if name not in SETZBAR: self._log("MQTT: unbekannte Einstellung %r verworfen" % name) return @@ -179,6 +208,23 @@ class MqttBridge: except Exception as e: self._log("MQTT: on_change fehlgeschlagen (%s)" % e) + def _on_command(self, name, roh): + """cmd/ 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): """Anstehende Nachrichten verarbeiten. Blockiert nicht.""" if not self.ensure(): @@ -235,7 +281,7 @@ class MqttBridge: 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. 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"), log=log, on_change=on_change, + on_ota=on_ota, + ssl=getattr(mqtt_config, "SSL", False), ) bridge.connect() # scheitert es, versucht check() es spaeter erneut return bridge diff --git a/mqtt_config_example.py b/mqtt_config_example.py index 048f463..eca25d5 100644 --- a/mqtt_config_example.py +++ b/mqtt_config_example.py @@ -9,7 +9,12 @@ Fehlt die Datei, laeuft die Anzeige einfach ohne MQTT weiter. """ 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. USER = "" diff --git a/ota.py b/ota.py new file mode 100644 index 0000000..27b635b --- /dev/null +++ b/ota.py @@ -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 +(``.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 diff --git a/run_esp32.py b/run_esp32.py index a68fdfe..3bf7f93 100644 --- a/run_esp32.py +++ b/run_esp32.py @@ -156,6 +156,31 @@ def make_on_change(display, client, dimmer=None, log=None): 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, once=False, with_mqtt=True, feed=None, log=print): """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: import mqtt 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, once=once, diff --git a/test_esp32.py b/test_esp32.py index 1f466fe..fd86a9f 100644 --- a/test_esp32.py +++ b/test_esp32.py @@ -460,6 +460,61 @@ class TestWatchdogInDerSchleife(unittest.TestCase): 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): """run_esp32.make_ldr_reader: ADC lesen, auf 12 bit bringen, spiegeln.""" @@ -557,8 +612,8 @@ class TestWifi(unittest.TestCase): DEPLOYED = ( "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", - "main.py", + "displayloop.py", "wifi.py", "mqtt.py", "ota.py", "run_esp32.py", + "probe.py", "main.py", ) diff --git a/test_mqtt.py b/test_mqtt.py index b767b41..8a9c57b 100644 --- a/test_mqtt.py +++ b/test_mqtt.py @@ -111,7 +111,7 @@ class TestVerbinden(MqttTest): def test_connect_abonniert_und_meldet_online(self): self.assertTrue(self.bridge.connect()) 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) def test_last_will_meldet_offline(self): @@ -132,6 +132,46 @@ class TestVerbinden(MqttTest): 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): def setUp(self): @@ -537,5 +577,54 @@ class TestSchleifenanbindung(MqttTest): 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__": unittest.main(verbosity=2) diff --git a/test_ota.py b/test_ota.py new file mode 100644 index 0000000..6998e09 --- /dev/null +++ b/test_ota.py @@ -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()