Adresse der Montierung per MQTT umstellbar und gespeichert

Zwischen Mock und echter GM4000 umschalten, ohne zu deployen und ohne Neustart:

    mosquitto_pub -t grossanzeige/set/mount_host -m 192.168.1.115
    mosquitto_pub -t grossanzeige/set/mount_host -m 192.168.178.26
    mosquitto_pub -t grossanzeige/set/mount_host -m ''

Die Adresse liegt in settings.json auf dem Geraet und uebersteht damit
Stromausfall und Watchdog-Neustart. deploy.sh kopiert die Datei bewusst nicht
mit, die Einstellung ueberlebt also auch ein Deployen.

**Leer heisst: config.MOUNT_HOST gilt.** mount_client.resolve_host loest auf in
der Reihenfolge: ausdruecklich mitgegebener Host (REPL, run_display --host) ->
mount_host aus settings.json -> config.MOUNT_HOST. Ohne diesen Rueckweg wuerde
eine einmal per MQTT gesetzte Adresse die Zeile in config.py fuer immer
verdecken, auch nach einem Deployen mit geaendertem Code.

Pruefung: settings._pruefe unterscheidet jetzt Zahl und Text am Typ der Vorgabe
in DEFAULTS -- eine neue Einstellung wird weiterhin an genau einer Stelle
eingetragen. Fuer mount_host sind nur Buchstaben, Ziffern, Punkt und
Bindestrich erlaubt, hoechstens 63 Zeichen: Der Wert geht ungeprueft in
socket.getaddrinfo, und von MQTT kommt alles Moegliche. Ungueltiges wird
verworfen, settings.json bleibt unberuehrt.

Sofort wirksam: Der Rueckruf traegt die neue Adresse in den MountClient ein und
trennt nur die bestehende Verbindung -- neu verbindet die Poll-Schleife im
naechsten Durchlauf, mit der gewohnten Fehlerbehandlung. Antwortet dort niemand,
steht "KEIN LINK", und eine weitere Nachricht holt das Geraet zurueck.
grossanzeige/status/mount_host meldet, welche Adresse gerade abgefragt wird --
die Rueckmeldung steht direkt hinter bridge.check() und nicht im Erfolgspfad,
kommt also auch dann, wenn die Montierung schweigt.

Der on_change-Rueckruf ist als run_esp32.make_on_change aus main() herausgeloest.
test_mqtt.py prueft ihn bisher als Abschrift ("Dieselbe Logik wie in
run_esp32.main"); jetzt laeuft der echte Code durch die Tests.

201 Tests gruen. Neu, unter anderem:
- TestUmschaltenImLaufendenBetrieb faehrt die ganze Kette an echten Sockets:
  erst "KEIN LINK" an einer toten Adresse, nach dem set/mount_host die
  Koordinaten vom Mock.
- TestAdresswahl (test_lx200) haelt die Reihenfolge von resolve_host fest.
- test_esp32: main() ohne host nimmt nach einem Neustart, was gespeichert ist.

Nicht angefasst: run_display.py auf dem Mac behaelt config.MOUNT_HOST als
Vorgabe fuer --host. Dort gibt es keine MQTT-Bruecke, und settings.json ist
Zustand des Geraets, nicht des Macs.

Am Geraet bestaetigt (2026-07-29), am Broker mitgeschnitten. Umschalten im
laufenden Betrieb, mit dem Mock auf dem Mac als Ausgangslage:

    0.0s  status/mount_host  192.168.178.26   Ausgangslage, link = 1
    ->  set/mount_host = 192.168.178.99
    5.1s  status/mount_host  192.168.178.99   Rueckmeldung
    5.9s  status/link        0                dort antwortet niemand
    ->  set/mount_host = ''
   17.8s  status/mount_host  192.168.178.26   Vorgabe aus config.py
   18.6s  status/link        1                Koordinaten laufen wieder

Und ueber den Neustart hinweg -- der eigentliche Zweck des Speicherns:

    ->  set/mount_host = 192.168.178.99
    1.6s  status/mount_host  192.168.178.99
    settings.json auf dem Geraet:
      {"hysterese": 150, "mount_host": "192.168.178.99", "schwelle": 1800, ...}
    ->  Hardware-Reset
    8.6s  status/online      0                Last Will
   15.5s  status/online      1                Autostart durch
   15.5s  status/mount_host  192.168.178.99   aus der Datei, nicht aus config.py

Die alte settings.json ohne mount_host wird beim Laden einfach um die Vorgabe
ergaenzt; die eingestellten Helligkeiten bleiben unberuehrt. Eine Migration war
nicht noetig.

set/mount_host wird ohne Retain-Flag geschickt: Das ist ein Befehl, kein
Zustand. Retained laege er im Broker und wuerde bei jedem Reconnect erneut
zugestellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 19:21:05 +02:00
parent 0876d8e827
commit 4461035777
12 changed files with 490 additions and 67 deletions
+58 -6
View File
@@ -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 0100, Schwelle 04095). Das ist der Grund, warum die Prüfung überhaupt
Prozente 0100, Schwelle 04095, `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 (`<PREFIX>/set/<name>`, Nutzlast als Zahl):
Empfangen (`<PREFIX>/set/<name>`):
| Topic | Beispiel | Wirkung |
|---|---|---|
@@ -537,6 +547,10 @@ Empfangen (`<PREFIX>/set/<name>`, 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 (`<PREFIX>/status/<name>`, alle *retained*):
@@ -546,6 +560,7 @@ Gesendet (`<PREFIX>/status/<name>`, 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 <broker> -t grossanzeige/set/mount_host -m 192.168.1.115 # echte GM4000
mosquitto_pub -h <broker> -t grossanzeige/set/mount_host -m 192.168.178.26 # Mock auf dem Mac
mosquitto_pub -h <broker> -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
+6
View File
@@ -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 (<PREFIX>/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
# <PREFIX>/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.
+11 -1
View File
@@ -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()
+25
View File
@@ -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.
+17 -6
View File
@@ -4,11 +4,14 @@
<PREFIX>/set/dunkel_prozent 5
<PREFIX>/set/schwelle 1800
<PREFIX>/set/hysterese 150
<PREFIX>/set/mount_host 192.168.1.115 Adresse der Montierung; leer =
Vorgabe aus config.MOUNT_HOST
<PREFIX>/status/ra 18h36m56s ausgehend
<PREFIX>/status/dec +38°47'01"
<PREFIX>/status/ldr 2453
<PREFIX>/status/helligkeit 50
<PREFIX>/status/mount_host 192.168.1.115 Adresse, die gerade abgefragt wird
<PREFIX>/status/link 1 Montierung erreichbar
<PREFIX>/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)
+4 -2
View File
@@ -16,8 +16,10 @@ USER = ""
PASSWORD = ""
# Wurzel aller Topics. Darunter entstehen:
# <PREFIX>/set/<name> eingehend (hell_prozent, dunkel_prozent, schwelle, hysterese)
# <PREFIX>/status/<name> ausgehend (ra, dec, ldr, helligkeit, link, online)
# <PREFIX>/set/<name> eingehend (hell_prozent, dunkel_prozent, schwelle,
# hysterese, mount_host)
# <PREFIX>/status/<name> ausgehend (ra, dec, ldr, helligkeit, mount_host,
# link, online)
PREFIX = "grossanzeige"
# Name des Geraets am Broker. Muss eindeutig sein, sonst werfen sich zwei
+51 -27
View File
@@ -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,
+56 -11
View File
@@ -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 <PREFIX>/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
+30
View File
@@ -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
+46 -1
View File
@@ -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)
+148 -13
View File
@@ -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()
+38
View File
@@ -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})