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
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
Reference in New Issue
Block a user