diff --git a/README.md b/README.md index 91d8713..9064966 100644 --- a/README.md +++ b/README.md @@ -167,8 +167,12 @@ mpremote repl >>> run_esp32.main() # ohne Helligkeitsregelung >>> run_esp32.main(with_ldr=True) # mit LDR über den ADC >>> run_esp32.main(with_mqtt=False) # ohne MQTT (sonst an, sofern konfiguriert) +>>> run_esp32.main(host="192.168.1.115") # feste Adresse, statt der eingestellten ``` +Ohne `host` gilt die per MQTT gesetzte Adresse aus `settings.json`, sonst +`config.MOUNT_HOST` (siehe *Zwischen Mock und Montierung umschalten*). + ### Dauerbetrieb: Autostart und Watchdog `main.py` startet die Anzeige nach jedem Reset von selbst — nach Stromausfall @@ -301,6 +305,8 @@ Mac: - **Netzwerk zur echten Montierung.** Die GM4000 steht in `192.168.1.115`, das Heimnetz des ESP32 ist `192.168.178.x` — der Mac erreicht sie per VPN, der ESP32 so nicht. Muss geklärt werden, bevor es an die echte Montierung geht. + Das *Umschalten* zwischen Mock und Montierung ist dagegen erledigt: Es geht + über MQTT (`set/mount_host`, siehe dort), ohne Deployen und ohne Neustart. ## Gradzeichen @@ -442,21 +448,25 @@ dazwischen fälschlich dunkel und wachte erst später auf. ### Die Werte stehen in `settings.json`, nicht im Code -`config.py` ist Programmcode und ändert sich nur beim Deployen. Die Helligkeiten -und die Schwelle sollen dagegen im Betrieb verstellbar sein — **später über MQTT**. -Sie liegen deshalb als JSON auf dem Gerät und werden über `settings.py` gelesen und -geschrieben: +`config.py` ist Programmcode und ändert sich nur beim Deployen. Die Helligkeiten, +die Schwelle und die Adresse der Montierung sollen dagegen im Betrieb verstellbar +sein — **über MQTT**. Sie liegen deshalb als JSON auf dem Gerät und werden über +`settings.py` gelesen und geschrieben: ```python import settings settings.load() # mit Vorgaben aufgefüllt settings.update({"hell_prozent": 60}) # ändern und speichern +settings.update({"mount_host": "192.168.1.115"}) # Montierung umstellen ``` Ein laufender `BrightnessController` übernimmt Änderungen mit `reload()`. Jeder Wert wird beim Laden **und** beim Schreiben geprüft (Typ und Bereich: -Prozente 0–100, Schwelle 0–4095). Das ist der Grund, warum die Prüfung überhaupt +Prozente 0–100, Schwelle 0–4095, `mount_host` nur Zeichen aus IP-Adressen und +Hostnamen). Ob eine Einstellung Zahl oder Text ist, entscheidet der Typ ihrer +Vorgabe in `settings.DEFAULTS` — so gibt es nur eine Stelle, an der eine neue +Einstellung einzutragen ist. Das ist der Grund, warum die Prüfung überhaupt existiert: Sobald MQTT dazukommt, kommen die Werte von außen, und ein Helligkeitswert von 500 % oder eine Schwelle von −1 würde die Anzeige sonst unbrauchbar machen. Fehlt die Datei oder ist sie beschädigt, gelten die Vorgaben @@ -529,7 +539,7 @@ darauf hin und kopiert sie nicht. ### Topics -Empfangen (`/set/`, Nutzlast als Zahl): +Empfangen (`/set/`): | Topic | Beispiel | Wirkung | |---|---|---| @@ -537,6 +547,10 @@ Empfangen (`/set/`, Nutzlast als Zahl): | `grossanzeige/set/dunkel_prozent` | `5` | Helligkeit unter der Schwelle | | `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`) | + +Alle Nutzlasten außer `mount_host` sind ganze Zahlen; `mount_host` ist Text +(IP-Adresse oder Hostname). Gesendet (`/status/`, alle *retained*): @@ -546,6 +560,7 @@ Gesendet (`/status/`, alle *retained*): | `grossanzeige/status/dec` | `+38°47'01"` | | `grossanzeige/status/ldr` | `2453` | | `grossanzeige/status/helligkeit` | `50` | +| `grossanzeige/status/mount_host` | `192.168.1.115` (Adresse, die gerade abgefragt wird) | | `grossanzeige/status/link` | `1` (Montierung erreichbar) | | `grossanzeige/status/online` | `1` — **Last Will** setzt es auf `0` | @@ -566,6 +581,43 @@ raus — eine per MQTT gesetzte Helligkeit bliebe unsichtbar, solange die Stufe dieselbe bleibt. Das ist am Gerät aufgefallen (2026-07-27) und wird von `test_mqtt.py` (`TestHelligkeitWirdSofortSichtbar`) festgehalten. +### Zwischen Mock und Montierung umschalten (`mount_host`) + +Welche Adresse abgefragt wird, ist im Betrieb umstellbar — ohne Deployen, ohne +Neustart: + +```bash +mosquitto_pub -h -t grossanzeige/set/mount_host -m 192.168.1.115 # echte GM4000 +mosquitto_pub -h -t grossanzeige/set/mount_host -m 192.168.178.26 # Mock auf dem Mac +mosquitto_pub -h -t grossanzeige/set/mount_host -m '' # zurück zu config.py +``` + +Die Adresse landet in `settings.json` und **übersteht damit einen Stromausfall**: +Nach dem Neustart fragt das Gerät dieselbe Adresse wieder ab. Welche gerade gilt, +klärt `mount_client.resolve_host()` in dieser Reihenfolge: + +1. eine beim Aufruf ausdrücklich genannte Adresse (`run_esp32.main(host=...)` an + der REPL, `run_display.py --host` auf dem Mac), +2. `mount_host` aus `settings.json` (das per MQTT Gesetzte), +3. `config.MOUNT_HOST` — was beim Deployen im Code stand. + +**Eine leere Nachricht ist der Weg zurück.** Ohne sie würde eine einmal gesetzte +Adresse die Vorgabe aus `config.py` für immer verdecken, auch nach einem Deployen +mit geändertem Code. Deshalb heißt „leer" hier nicht „keine Adresse", sondern +„nimm die aus dem Code". + +Wirksam wird die Umstellung sofort: Der Rückruf trägt die neue Adresse in den +`MountClient` ein und **trennt die bestehende Verbindung** — neu verbunden wird im +nächsten Durchlauf der Poll-Schleife, mit der gewohnten Fehlerbehandlung. Antwortet +unter der neuen Adresse niemand, steht „KEIN LINK" auf der Anzeige, und eine weitere +Nachricht holt das Gerät wieder zurück. Was gerade abgefragt wird, meldet +`grossanzeige/status/mount_host` — die Rückmeldung kommt auch dann, wenn dort +niemand antwortet. + +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. + ### 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 f65a738..129b705 100644 --- a/config.py +++ b/config.py @@ -123,6 +123,12 @@ DEGREE_FALLBACK = "d" # Ersatz, falls DEGREE_CHARSET = None # -- Montierung (Phase 2) ------------------------------------------------- # 10Micron GM4000 HPS II: LX200-Protokoll ueber TCP. Die IP der Montierung # eintragen, sobald sie erreichbar ist; Port 3490 ist die Vorgabe von 10Micron. +# +# MOUNT_HOST ist nur die **Vorgabe**: Im Betrieb laesst sich die Adresse ueber +# MQTT umstellen (/set/mount_host), sie liegt dann in settings.json und +# ueberlebt einen Stromausfall. Eine leere Nachricht an dieses Topic holt die +# Adresse von hier zurueck. Wer wissen will, was gerade gilt, liest +# /status/mount_host oder ruft mount_client.resolve_host(). # Vorübergehend auf den Mock auf dem Mac gestellt (2026-07-27), weil die echte # Montierung vom ESP32 aus nicht erreichbar ist: Sie steht in 192.168.1.115, der # ESP32 im Heimnetz 192.168.178.x. Zum Umstellen die beiden Zeilen tauschen. diff --git a/displayloop.py b/displayloop.py index f9a8367..033fb13 100644 --- a/displayloop.py +++ b/displayloop.py @@ -52,7 +52,9 @@ def poll_loop(display, client, interval=None, once=False, """Zyklisch die Montierung abfragen und die Anzeige nachfuehren. display offenes Display-Objekt - client MountClient (wird bei Bedarf selbst verbunden) + client MountClient (wird bei Bedarf selbst verbunden). Seine Adresse + (client.host) darf sich zwischen zwei Durchlaeufen aendern -- + die Schleife verbindet dann einfach neu. interval Sekunden zwischen zwei Abfragen (Vorgabe config.POLL_INTERVAL) once nur ein Durchlauf, dann zurueck read_ldr optional: Funktion ohne Argumente, liefert einen LDR-Rohwert @@ -65,6 +67,9 @@ def poll_loop(display, client, interval=None, once=False, bridge optional: mqtt.MqttBridge -- empfaengt Einstellungen und veroeffentlicht den Zustand. Faellt der Broker aus, laeuft die Anzeige unveraendert weiter (mqtt.py faengt seine Fehler selbst). + Setzt eine Nachricht die Adresse der Montierung um, trennt der + Rueckruf die Verbindung (siehe run_esp32.main) -- noch in + diesem Durchlauf wird dann die neue Adresse angefragt. feed optional: Funktion ohne Argumente, die den Watchdog fuettert. Wird zu Beginn **jedes** Durchlaufs gerufen -- auch nach einem Fehler, denn eine gemeldete Stoerung ist kein Stillstand: Die @@ -91,6 +96,11 @@ def poll_loop(display, client, interval=None, once=False, # in diesem Durchlauf, statt eine Runde hinterherzuhinken. if bridge is not None: bridge.check() + # Gleich nach dem Empfang: Ein "set/mount_host" wirkt schon in + # diesem Durchlauf, und die Rueckmeldung zeigt, welche Adresse + # angekommen ist -- auch dann, wenn die Montierung dort nicht + # antwortet und der Rest der Runde in "KEIN LINK" endet. + bridge.publish("mount_host", client.host) if ensure_link is not None: ensure_link() diff --git a/mount_client.py b/mount_client.py index 63d9a89..de997aa 100644 --- a/mount_client.py +++ b/mount_client.py @@ -2,17 +2,42 @@ Die Socket-API (socket.socket/connect/send/recv) ist unter CPython und MicroPython gleich, laeuft also spaeter auch auf dem ESP32-C3. + +Welche Adresse abgefragt wird, klaert resolve_host(): per MQTT umstellbar, +mit config.MOUNT_HOST als Vorgabe. """ import socket +import config import lx200 +import settings class MountError(Exception): """Verbindungs- oder Kommunikationsfehler mit der Montierung.""" +def resolve_host(vorgabe=None, log=None): + """Die Adresse liefern, die abgefragt werden soll. + + Reihenfolge, absteigend: + + 1. ``vorgabe`` -- von Hand mitgegeben (REPL, Kommandozeile, Tests). Wer die + Adresse ausdruecklich nennt, meint sie auch. + 2. ``mount_host`` aus settings.json -- per MQTT gesetzt und ueber einen + Stromausfall hinweg gespeichert. + 3. ``config.MOUNT_HOST`` -- was beim Deployen im Code stand. + + So laesst sich zwischen Mock und echter Montierung umschalten, ohne etwas + neu aufzuspielen, und ein leerer mount_host holt die Vorgabe aus dem Code + zurueck. + """ + if vorgabe: + return vorgabe + return settings.load(log=log).get("mount_host") or config.MOUNT_HOST + + class MountClient: """Fragt Koordinaten von der Montierung ab. diff --git a/mqtt.py b/mqtt.py index d3fa139..da5f75a 100644 --- a/mqtt.py +++ b/mqtt.py @@ -4,11 +4,14 @@ /set/dunkel_prozent 5 /set/schwelle 1800 /set/hysterese 150 + /set/mount_host 192.168.1.115 Adresse der Montierung; leer = + Vorgabe aus config.MOUNT_HOST /status/ra 18h36m56s ausgehend /status/dec +38°47'01" /status/ldr 2453 /status/helligkeit 50 + /status/mount_host 192.168.1.115 Adresse, die gerade abgefragt wird /status/link 1 Montierung erreichbar /status/online 1 retained, mit Last Will auf 0 @@ -149,11 +152,19 @@ class MqttBridge: if name not in SETZBAR: self._log("MQTT: unbekannte Einstellung %r verworfen" % name) return - try: - wert = int(roh) - except ValueError: - self._log("MQTT: %s=%r ist keine ganze Zahl" % (name, roh)) - return + + # Zahl oder Text? Entscheidet der Typ der Vorgabe -- dieselbe Regel wie + # in settings._pruefe. Eine leere Nachricht ist deshalb nur bei + # Textwerten sinnvoll: mount_host faellt damit auf config.MOUNT_HOST + # zurueck, waehrend int("") wie gehabt als Unsinn abgelehnt wird. + if isinstance(settings.DEFAULTS[name], str): + wert = roh + else: + try: + wert = int(roh) + except ValueError: + self._log("MQTT: %s=%r ist keine ganze Zahl" % (name, roh)) + return try: neu = settings.update({name: wert}) @@ -161,7 +172,7 @@ class MqttBridge: self._log("MQTT: %s" % e) return - self._log("MQTT: %s = %d uebernommen" % (name, wert)) + self._log("MQTT: %s = %r uebernommen" % (name, wert)) if self._on_change: try: self._on_change(neu) diff --git a/mqtt_config_example.py b/mqtt_config_example.py index c24aa89..048f463 100644 --- a/mqtt_config_example.py +++ b/mqtt_config_example.py @@ -16,8 +16,10 @@ USER = "" PASSWORD = "" # Wurzel aller Topics. Darunter entstehen: -# /set/ eingehend (hell_prozent, dunkel_prozent, schwelle, hysterese) -# /status/ ausgehend (ra, dec, ldr, helligkeit, link, online) +# /set/ eingehend (hell_prozent, dunkel_prozent, schwelle, +# hysterese, mount_host) +# /status/ ausgehend (ra, dec, ldr, helligkeit, mount_host, +# link, online) PREFIX = "grossanzeige" # Name des Geraets am Broker. Muss eindeutig sein, sonst werfen sich zwei diff --git a/run_esp32.py b/run_esp32.py index 5184fff..a68fdfe 100644 --- a/run_esp32.py +++ b/run_esp32.py @@ -28,7 +28,7 @@ import displayloop import wifi from brightness import BrightnessController, raw_to_ldr from display import open_display -from mount_client import MountClient +from mount_client import MountClient, resolve_host try: # MicroPython from machine import ADC, Pin @@ -75,7 +75,7 @@ def selftest(host=None, port=None, log=print): cfg = wifi.connect(log=log) log(" IP %s, Gateway %s" % (cfg[0], cfg[2])) - host = config.MOUNT_HOST if host is None else host + host = resolve_host(host, log=log) port = config.MOUNT_PORT if port is None else port log("2) Montierung %s:%d ..." % (host, port)) client = MountClient(host, port, timeout=config.MOUNT_TIMEOUT) @@ -115,12 +115,55 @@ def make_watchdog(timeout=None, log=None): return wdt.feed +def make_on_change(display, client, dimmer=None, log=None): + """Rueckruf fuer mqtt.MqttBridge: geaenderte Einstellungen sofort wirksam + machen. settings.json ist zu diesem Zeitpunkt schon geschrieben. + + **Helligkeit:** Der Dimmer muss die neuen Werte nicht nur lesen -- die + Helligkeit muss auch neu an die Anzeige. Sonst bliebe eine gesetzte + Helligkeit unsichtbar, solange die Stufe dieselbe bleibt: ein Telegramm geht + sonst nur beim *Wechsel* raus. (Am Geraet aufgefallen, 2026-07-27.) + + **Adresse der Montierung:** Die wirkt erst, wenn die bestehende Verbindung + faellt. Hier wird darum nur getrennt -- neu verbindet die Poll-Schleife im + naechsten Durchlauf von selbst, mit derselben Fehlerbehandlung wie sonst + auch: Antwortet unter der neuen Adresse niemand, steht "KEIN LINK", und ein + weiteres set/mount_host holt das Geraet wieder zurueck. + + Eigene Funktion und nicht als Verschachtelung in main(), damit die Tests + genau diese Logik pruefen koennen statt einer Abschrift davon. + """ + log = log or (lambda *a: None) + + def uebernehmen(werte): + try: + if dimmer is not None: + dimmer.reload() + if dimmer.level is not None: + display.set_brightness(dimmer.percent(dimmer.level)) + else: + # Ohne LDR gibt es keine Stufen -- dann gilt "hell". + display.set_brightness(werte["hell_prozent"]) + except Exception as e: + log("Anzeige: %s" % e) + + neuer_host = resolve_host(werte.get("mount_host")) + if neuer_host != client.host: + log("Montierung: Adresse %s -> %s" % (client.host, neuer_host)) + client.host = neuer_host + client.close() + + return uebernehmen + + 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. - host/port Montierung; Vorgabe aus config.py. Zum Testen gegen den Mock - auf dem Mac dessen LAN-Adresse angeben. + host/port Montierung. Ohne host gilt die per MQTT gesetzte Adresse aus + settings.json, sonst config.MOUNT_HOST (siehe + mount_client.resolve_host). Zum Testen gegen den Mock auf dem + Mac dessen LAN-Adresse angeben. with_ldr Helligkeitsregelung ueber den ADC dazuschalten. Die Schwellen sind am realen LDR gemessen (2026-07-27, siehe brightness.py), aber am Schreibtisch, nicht in der Sternwarte -- deshalb bleibt @@ -137,9 +180,10 @@ def main(host=None, port=None, with_ldr=False, fake_display=False, ihn an. """ d = open_display(fake=fake_display) - client = MountClient(config.MOUNT_HOST if host is None else host, + client = MountClient(resolve_host(host, log=log), config.MOUNT_PORT if port is None else port, timeout=config.MOUNT_TIMEOUT) + log("Montierung: %s:%d" % (client.host, client.port)) dimmer = read_ldr = None if with_ldr: @@ -160,28 +204,8 @@ def main(host=None, port=None, with_ldr=False, fake_display=False, if with_mqtt: import mqtt - - def uebernehmen(werte): - """Per MQTT geaenderte Einstellungen sofort sichtbar machen. - - Der Dimmer muss die neuen Werte nicht nur lesen -- die Helligkeit - muss auch neu an die Anzeige. Sonst bliebe eine gesetzte - Helligkeit unsichtbar, solange die Stufe dieselbe bleibt: ein - Telegramm geht sonst nur beim *Wechsel* raus. (Am Geraet - aufgefallen, 2026-07-27.) - """ - try: - if dimmer is not None: - dimmer.reload() - if dimmer.level is not None: - d.set_brightness(dimmer.percent(dimmer.level)) - else: - # Ohne LDR gibt es keine Stufen -- dann gilt "hell". - d.set_brightness(werte["hell_prozent"]) - except Exception as e: - log("Anzeige: %s" % e) - - bridge = mqtt.connect_from_config(log=log, on_change=uebernehmen) + bridge = mqtt.connect_from_config( + log=log, on_change=make_on_change(d, client, dimmer, log=log)) displayloop.poll_loop(d, client, once=once, diff --git a/settings.py b/settings.py index a54890a..66323d3 100644 --- a/settings.py +++ b/settings.py @@ -19,6 +19,10 @@ Alle Werte werden beim Laden UND beim Schreiben geprueft (siehe _pruefe). Das is wichtig, sobald MQTT dazukommt: Von dort kommen fremde Eingaben, und ein Helligkeitswert von 500 % oder eine Schwelle von -1 wuerde die Anzeige sonst in einen unbrauchbaren Zustand bringen. + +Es gibt zwei Sorten von Werten: ganze Zahlen mit einem erlaubten Bereich +(_GRENZEN) und Zeichenketten mit erlaubtem Zeichenvorrat (mount_host). Welche +Sorte gilt, steht am Typ der Vorgabe in DEFAULTS. """ import json @@ -35,10 +39,17 @@ DEFAULTS = { "hell_prozent": 50, # Helligkeit oberhalb der Schwelle "schwelle": 1800, # LDR-Wert (nach raw_to_ldr) fuer den Umschaltpunkt "hysterese": 150, # Breite um die Schwelle, gegen Flattern + + # Adresse der Montierung (IP oder Hostname). **Leer heisst: config.MOUNT_HOST + # gilt.** Damit bleibt die im Code eingetragene Adresse die Vorgabe -- eine + # einmal per MQTT gesetzte Adresse wuerde sie sonst fuer immer verdecken, + # auch nach einem Deployen mit geaendertem config.py. Zuruecksetzen also mit + # einer leeren Nachricht an /set/mount_host. + "mount_host": "", } -# Erlaubte Bereiche: (minimum, maximum). Die Prozente gehen an ESC H, das nur -# 0..100 kennt; die LDR-Werte kommen aus einem 12-Bit-ADC. +# Erlaubte Bereiche der Zahlenwerte: (minimum, maximum). Die Prozente gehen an +# ESC H, das nur 0..100 kennt; die LDR-Werte kommen aus einem 12-Bit-ADC. _GRENZEN = { "dunkel_prozent": (0, 100), "hell_prozent": (0, 100), @@ -46,25 +57,59 @@ _GRENZEN = { "hysterese": (0, 2000), } +# Zeichenketten: nur das, was in einer IP-Adresse oder einem Hostnamen vorkommen +# darf. Bewusst eng -- der Wert geht ungeprueft in socket.getaddrinfo, und von +# MQTT kommt alles Moegliche. 63 Zeichen ist die Laenge eines DNS-Labels und +# reicht fuer jede IP und jeden Namen im Sternwartennetz. +HOST_ZEICHEN = ("abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "0123456789.-") +HOST_MAXLEN = 63 + class SettingsError(ValueError): """Ein Wert liegt ausserhalb des erlaubten Bereichs oder ist unbekannt.""" +def _pruefe_zahl(name, wert): + # bool ist in Python ein int -- hier aber sicher ein Irrtum. + if isinstance(wert, bool) or not isinstance(wert, int): + raise SettingsError("%s: %r ist keine ganze Zahl" % (name, wert)) + low, high = _GRENZEN[name] + if not low <= wert <= high: + raise SettingsError("%s: %d liegt ausserhalb %d..%d" + % (name, wert, low, high)) + return wert + + +def _pruefe_text(name, wert): + if not isinstance(wert, str): + raise SettingsError("%s: %r ist keine Zeichenkette" % (name, wert)) + wert = wert.strip() + if len(wert) > HOST_MAXLEN: + raise SettingsError("%s: laenger als %d Zeichen" % (name, HOST_MAXLEN)) + for zeichen in wert: + if zeichen not in HOST_ZEICHEN: + raise SettingsError("%s: %r enthaelt das unerlaubte Zeichen %r" + % (name, wert, zeichen)) + return wert + + def _pruefe(werte): - """Werte auf Typ und Bereich pruefen. Liefert ein geprueftes neues dict.""" + """Werte auf Typ und Bereich pruefen. Liefert ein geprueftes neues dict. + + Ob eine Einstellung eine Zahl oder eine Zeichenkette ist, entscheidet der + Typ ihrer Vorgabe in DEFAULTS -- so gibt es nur eine Stelle, an der eine + neue Einstellung eingetragen werden muss. + """ geprueft = {} for name, wert in werte.items(): if name not in DEFAULTS: raise SettingsError("unbekannte Einstellung %r" % (name,)) - # bool ist in Python ein int -- hier aber sicher ein Irrtum. - if isinstance(wert, bool) or not isinstance(wert, int): - raise SettingsError("%s: %r ist keine ganze Zahl" % (name, wert)) - low, high = _GRENZEN[name] - if not low <= wert <= high: - raise SettingsError("%s: %d liegt ausserhalb %d..%d" - % (name, wert, low, high)) - geprueft[name] = wert + if isinstance(DEFAULTS[name], str): + geprueft[name] = _pruefe_text(name, wert) + else: + geprueft[name] = _pruefe_zahl(name, wert) return geprueft diff --git a/test_esp32.py b/test_esp32.py index 342027b..1f466fe 100644 --- a/test_esp32.py +++ b/test_esp32.py @@ -688,6 +688,36 @@ class TestEsp32GegenMock(unittest.TestCase): self.assertIn("TX: 02 81 80 81", hexdump) # STX, DA, SA, FC self.assertTrue(hexdump.strip().endswith("03")) # ETX + def test_gespeicherte_adresse_gilt_nach_dem_neustart(self): + """Der Sinn des Speicherns: per MQTT gesetzt, Stromausfall, laeuft weiter. + + main() ohne host nimmt, was in settings.json steht -- hier der Mock. + """ + import contextlib + import io + import os + import settings + + pfad = "test_esp32_settings_tmp.json" + alt = settings.PFAD + settings.PFAD = pfad + try: + settings.update({"mount_host": self.mock.host}) + with contextlib.redirect_stdout(io.StringIO()): + self.run_esp32.main(port=self.mock.port, fake_display=True, + once=True, with_mqtt=False, + log=self.log.append) + finally: + settings.PFAD = alt + try: + os.remove(pfad) + except OSError: + pass + + text = "\n".join(self.log) + self.assertIn("Montierung: %s:%d" % (self.mock.host, self.mock.port), text) + self.assertIn("RA 18h36.9m", text) # der Mock hat wirklich geantwortet + def test_ohne_mock_zeigt_die_anzeige_kein_link(self): """Faellt die Gegenstelle aus, laeuft die Schleife weiter.""" import config diff --git a/test_lx200.py b/test_lx200.py index 33d82dc..e20a136 100644 --- a/test_lx200.py +++ b/test_lx200.py @@ -4,12 +4,15 @@ python3 -m unittest -v test_lx200 """ +import os import unittest +import config import coords import lx200 import mount_mock -from mount_client import MountClient, MountError +import settings +from mount_client import MountClient, MountError, resolve_host class TestLX200(unittest.TestCase): @@ -106,5 +109,47 @@ class TestClientFehler(unittest.TestCase): c.get_ra() +class TestAdresswahl(unittest.TestCase): + """resolve_host: welche Adresse gilt -- Vorgabe, settings.json, config.py. + + Die Adresse ist per MQTT umstellbar (Mock <-> Montierung) und liegt dann in + settings.json, ueberlebt also einen Stromausfall. Diese Reihenfolge ist der + Kern davon. + """ + + def setUp(self): + self.pfad = "test_lx200_settings_tmp.json" + self._alt = settings.PFAD + settings.PFAD = self.pfad + self._weg() + + def tearDown(self): + settings.PFAD = self._alt + self._weg() + + def _weg(self): + try: + os.remove(self.pfad) + except OSError: + pass + + def test_ohne_settings_gilt_config(self): + self.assertEqual(resolve_host(), config.MOUNT_HOST) + + def test_gespeicherte_adresse_schlaegt_config(self): + settings.update({"mount_host": "192.168.1.115"}) + self.assertEqual(resolve_host(), "192.168.1.115") + + def test_leere_adresse_faellt_auf_config_zurueck(self): + settings.update({"mount_host": "192.168.1.115"}) + settings.update({"mount_host": ""}) + self.assertEqual(resolve_host(), config.MOUNT_HOST) + + def test_ausdrueckliche_vorgabe_schlaegt_alles(self): + """Wer beim Aufruf eine Adresse nennt, meint sie auch (REPL, Tests).""" + settings.update({"mount_host": "192.168.1.115"}) + self.assertEqual(resolve_host("10.0.0.9"), "10.0.0.9") + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/test_mqtt.py b/test_mqtt.py index a5543bb..b767b41 100644 --- a/test_mqtt.py +++ b/test_mqtt.py @@ -153,6 +153,31 @@ class TestEmpfang(MqttTest): self.assertEqual((w["schwelle"], w["dunkel_prozent"], w["hysterese"]), (2000, 3, 100)) + def test_mount_host_wird_als_text_uebernommen(self): + """Nicht jede Einstellung ist eine Zahl -- die Adresse ist Text.""" + self.sende("ga/set/mount_host", "192.168.1.115") + self.assertEqual(settings.load(self.pfad)["mount_host"], "192.168.1.115") + self.assertEqual(self.geaendert[-1]["mount_host"], "192.168.1.115") + + def test_mount_host_darf_ein_name_sein(self): + self.sende("ga/set/mount_host", "gm4000.sternwarte.local") + self.assertEqual(settings.load(self.pfad)["mount_host"], + "gm4000.sternwarte.local") + + def test_leerer_mount_host_setzt_auf_die_vorgabe_zurueck(self): + """Leer heisst: wieder config.MOUNT_HOST -- der Weg zurueck.""" + self.sende("ga/set/mount_host", "10.0.0.9") + self.sende("ga/set/mount_host", "") + self.assertEqual(settings.load(self.pfad)["mount_host"], "") + + def test_unsinnige_adresse_wird_abgelehnt(self): + """Der Wert geht in socket.getaddrinfo -- da darf kein Muell ankommen.""" + self.sende("ga/set/mount_host", "10.0.0.1; rm -rf /") + self.assertEqual(settings.load(self.pfad)["mount_host"], + settings.DEFAULTS["mount_host"]) + self.assertEqual(self.geaendert, []) + self.assertTrue(any("unerlaubte" in m for m in self.meldungen)) + def test_wert_ausserhalb_des_bereichs_wird_abgelehnt(self): """Der Kern der Pruefung: von aussen darf nichts Unsinniges durch.""" self.sende("ga/set/hell_prozent", "500") @@ -272,6 +297,17 @@ class TestStoerungen(MqttTest): self.assertFalse(b.publish("ra", "x")) # wirft nicht +class FakeMountClient: + """Nur so viel MountClient, wie der Rueckruf anfasst.""" + + def __init__(self, host="192.0.2.7"): + self.host = host + self.geschlossen = 0 + + def close(self): + self.geschlossen += 1 + + class TestHelligkeitWirdSofortSichtbar(MqttTest): """Am Geraet aufgefallen (2026-07-27): Einstellung uebernommen, Anzeige blieb. @@ -291,19 +327,11 @@ class TestHelligkeitWirdSofortSichtbar(MqttTest): dimmer.update(3000) # einschwingen auf HELL return d, dimmer - def _uebernehmen_bauen(self, d, dimmer, log): - """Dieselbe Logik wie in run_esp32.main.""" - def uebernehmen(werte): - try: - if dimmer is not None: - dimmer.reload() - if dimmer.level is not None: - d.set_brightness(dimmer.percent(dimmer.level)) - else: - d.set_brightness(werte["hell_prozent"]) - except Exception as e: - log("Anzeige: %s" % e) - return uebernehmen + def _uebernehmen_bauen(self, d, dimmer, log, client=None): + """Der echte Rueckruf aus run_esp32 -- keine Abschrift davon.""" + import run_esp32 + return run_esp32.make_on_change(d, client or FakeMountClient(), + dimmer, log=log) def _helligkeiten(self, transport): """Aus den Telegrammen die gesendeten Helligkeitswerte herausziehen.""" @@ -340,6 +368,104 @@ class TestHelligkeitWirdSofortSichtbar(MqttTest): self.assertEqual(self._helligkeiten(d._t), [80]) +class TestAdresswechselWirktSofort(MqttTest): + """Der Zweck der Uebung: zwischen Mock und Montierung umschalten. + + Der Rueckruf traegt die neue Adresse in den Client ein und trennt die + bestehende Verbindung -- neu verbunden wird in der Poll-Schleife. + """ + + def setUp(self): + super().setUp() + import config + import run_esp32 + from display import Display + from transport import FakeTransport + + # Ausgangslage wie nach dem Start: der Client fragt die Adresse ab, die + # resolve_host geliefert hat -- hier die Vorgabe aus config.py. + self.mount = FakeMountClient(config.MOUNT_HOST) + self.display = Display(FakeTransport(echo=False), response=True) + self.bridge._on_change = run_esp32.make_on_change( + self.display, self.mount, None, log=self.meldungen.append) + self.bridge.connect() + + def test_neue_adresse_landet_im_client(self): + self.sende("ga/set/mount_host", "192.168.1.115") + self.assertEqual(self.mount.host, "192.168.1.115") + + def test_bestehende_verbindung_wird_getrennt(self): + """Sonst liefe die Abfrage weiter zur alten Adresse.""" + self.sende("ga/set/mount_host", "192.168.1.115") + self.assertEqual(self.mount.geschlossen, 1) + + def test_dieselbe_adresse_trennt_nicht(self): + """Eine wiederholte Nachricht darf die Abfrage nicht stoeren.""" + self.sende("ga/set/mount_host", self.mount.host) + self.assertEqual(self.mount.geschlossen, 0) + + def test_helligkeit_laesst_die_verbindung_in_ruhe(self): + import config + self.sende("ga/set/hell_prozent", "60") + self.assertEqual(self.mount.geschlossen, 0) + self.assertEqual(self.mount.host, config.MOUNT_HOST) + + def test_leere_adresse_holt_die_vorgabe_aus_config(self): + import config + self.sende("ga/set/mount_host", "192.168.1.115") + self.sende("ga/set/mount_host", "") + self.assertEqual(self.mount.host, config.MOUNT_HOST) + self.assertEqual(self.mount.geschlossen, 2) # hin und zurueck + + +class TestUmschaltenImLaufendenBetrieb(MqttTest): + """Dieselbe Kette wie am Geraet, nur mit echten Sockets: + Nachricht -> settings.json -> MountClient -> naechste Abfrage. + + Angefangen wird an einer Adresse, an der niemand antwortet -- die Anzeige + steht auf "KEIN LINK". Nach dem set/mount_host holt die Poll-Schleife die + Koordinaten vom Mock, ohne dass jemand etwas neu startet. + """ + + def setUp(self): + super().setUp() + import run_esp32 + from display import Display + from mount_client import MountClient + from mount_mock import MountMock + from transport import FakeTransport + + self.mock = MountMock(host="127.0.0.1", port=0) + self.mock.start() + # 127.0.0.2 ist nicht belegt -- dort antwortet niemand. Kurzer Timeout, + # damit der erste Durchlauf den Test nicht aufhaelt. + self.mount = MountClient("127.0.0.2", self.mock.port, timeout=0.3) + self.display = Display(FakeTransport(echo=False), response=True) + self.bridge._on_change = run_esp32.make_on_change( + self.display, self.mount, None, log=self.meldungen.append) + self.bridge.connect() + + def tearDown(self): + self.mount.close() + self.mock.stop() + super().tearDown() + + def _durchlauf(self): + import displayloop + return displayloop.poll_loop(self.display, self.mount, once=True, + bridge=self.bridge, log=lambda *a: None) + + def test_von_kein_link_zur_montierung(self): + import config + self.assertEqual(self._durchlauf(), config.NOLINK_LINES) + + self.sende("ga/set/mount_host", "127.0.0.1") + + self.assertEqual(self._durchlauf()[0], "RA 18h36.9m") + self.assertIn(("ga/status/mount_host", b"127.0.0.1", True), + self.client.published) + + class TestOhneKonfiguration(unittest.TestCase): def test_fehlende_mqtt_config_liefert_none(self): @@ -369,6 +495,7 @@ class TestSchleifenanbindung(MqttTest): class Client: connected = True + host = "192.0.2.7" # wird als status/mount_host gemeldet def connect(self): pass @@ -391,6 +518,14 @@ class TestSchleifenanbindung(MqttTest): self.assertIn("ga/status/dec", self.topics()) self.assertIn("ga/status/link", self.topics()) + def test_abgefragte_adresse_wird_gemeldet(self): + """Sonst bliebe offen, ob ein set/mount_host angekommen ist.""" + self.bridge.connect() + self.client.published.clear() + self._loop_einmal(self.bridge) + self.assertIn(("ga/status/mount_host", b"192.0.2.7", True), + self.client.published) + def test_toter_broker_haelt_die_anzeige_nicht_auf(self): """Der wichtigste Test des Moduls.""" self.bridge.connect() diff --git a/test_settings.py b/test_settings.py index dbf07e0..7180f3d 100644 --- a/test_settings.py +++ b/test_settings.py @@ -63,6 +63,19 @@ class TestLaden(Tempdatei): self.assertEqual(w["hell_prozent"], 42) # uebernommen self.assertTrue(any("schwelle" in m for m in meldungen)) + def test_kaputte_adresse_in_der_datei_verwirft_nur_diese(self): + self._schreibe_roh(json.dumps({"mount_host": "kein host!", + "hell_prozent": 42})) + meldungen = [] + w = settings.load(self.pfad, log=meldungen.append) + self.assertEqual(w["mount_host"], settings.DEFAULTS["mount_host"]) + self.assertEqual(w["hell_prozent"], 42) + + def test_adresse_ueberlebt_das_speichern(self): + """Der eigentliche Zweck: sie muss den Stromausfall ueberstehen.""" + settings.update({"mount_host": "192.168.1.115"}, self.pfad) + self.assertEqual(settings.load(self.pfad)["mount_host"], "192.168.1.115") + def test_unbekannter_schluessel_wird_ignoriert(self): self._schreibe_roh(json.dumps({"voellig_unbekannt": 1})) w = settings.load(self.pfad) @@ -122,6 +135,31 @@ class TestPruefung(unittest.TestCase): with self.assertRaises(settings.SettingsError): settings.save({"hell_prozent": wert}, "/dev/null") + def test_adresse_wird_als_text_angenommen(self): + geprueft = settings._pruefe({"mount_host": "192.168.1.115"}) + self.assertEqual(geprueft["mount_host"], "192.168.1.115") + + def test_leere_adresse_ist_erlaubt(self): + """Leer heisst 'Vorgabe aus config.py' -- der Weg zurueck zum Code.""" + self.assertEqual(settings._pruefe({"mount_host": ""})["mount_host"], "") + + def test_umgebende_leerzeichen_fallen_weg(self): + """Bei MQTT-Nachrichten haengt schnell ein Zeilenende dran.""" + self.assertEqual(settings._pruefe({"mount_host": " 10.0.0.5 "})["mount_host"], + "10.0.0.5") + + def test_unerlaubte_zeichen_in_der_adresse(self): + """Der Wert geht in socket.getaddrinfo -- nur Adressen und Namen.""" + for wert in ("10.0.0.1 ; reboot", "hallo welt", "127.0.0.1:3490", + "192.168.1.5/24", "a" * 64): + with self.assertRaises(settings.SettingsError): + settings._pruefe({"mount_host": wert}) + + def test_adresse_muss_text_sein(self): + for wert in (42, None, True, ["10.0.0.1"]): + with self.assertRaises(settings.SettingsError): + settings._pruefe({"mount_host": wert}) + def test_die_raender_sind_erlaubt(self): geprueft = settings._pruefe({"hell_prozent": 100, "dunkel_prozent": 0, "schwelle": 4095, "hysterese": 0})