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
+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