4461035777
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>
163 lines
6.2 KiB
Python
163 lines
6.2 KiB
Python
"""Einstellungen, die sich im Betrieb aendern lassen -- als JSON auf dem Geraet.
|
|
|
|
config.py ist Programmcode: Pinbelegung, Protokoll, Geometrie. Das aendert sich
|
|
nur beim Deployen. Werte, die im laufenden Betrieb verstellt werden sollen --
|
|
spaeter ueber MQTT -- gehoeren dagegen hierher. Sie liegen als JSON im
|
|
Dateisystem (settings.json) und lassen sich lesen und zurueckschreiben, ohne das
|
|
Programm neu zu spielen.
|
|
|
|
import settings
|
|
w = settings.load() # mit Vorgaben aufgefuellt
|
|
settings.update({"hell_prozent": 60}) # aendern und speichern
|
|
|
|
Fehlt die Datei oder ist sie beschaedigt, gelten die Vorgaben aus DEFAULTS --
|
|
die Anzeige laeuft dann weiter, statt beim Start auszufallen. Das ist Absicht:
|
|
ein Geraet in der Sternwarte soll sich nicht an einer kaputten Konfigurationsdatei
|
|
aufhaengen.
|
|
|
|
Alle Werte werden beim Laden UND beim Schreiben geprueft (siehe _pruefe). Das ist
|
|
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
|
|
|
|
PFAD = "settings.json"
|
|
|
|
# Vorgaben. Die Helligkeiten und die Schwelle stammen aus der LDR-Messung vom
|
|
# 2026-07-27 (siehe README, Abschnitt Helligkeitsregelung): abgedeckt ergab 0,
|
|
# Raumlicht 2453, angeleuchtet 3552 -- 1800 liegt mit Abstand zwischen dunkel
|
|
# und Raumlicht. Die Hysterese deckt das gemessene ADC-Rauschen ab (~32 nach
|
|
# der Mittelung, 1 Sigma).
|
|
DEFAULTS = {
|
|
"dunkel_prozent": 5, # Helligkeit unterhalb der Schwelle
|
|
"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 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),
|
|
"schwelle": (0, 4095),
|
|
"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.
|
|
|
|
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,))
|
|
if isinstance(DEFAULTS[name], str):
|
|
geprueft[name] = _pruefe_text(name, wert)
|
|
else:
|
|
geprueft[name] = _pruefe_zahl(name, wert)
|
|
return geprueft
|
|
|
|
|
|
def load(pfad=None, log=None):
|
|
"""Einstellungen lesen, fehlende Werte aus DEFAULTS ergaenzen.
|
|
|
|
Wirft nicht: Fehlt die Datei, ist sie unlesbar oder steht Unsinn darin, gibt
|
|
es die Vorgaben. Ein einzelner unbrauchbarer Wert verwirft nur diesen, nicht
|
|
die ganze Datei.
|
|
"""
|
|
pfad = PFAD if pfad is None else pfad
|
|
werte = dict(DEFAULTS)
|
|
try:
|
|
with open(pfad) as f:
|
|
gelesen = json.load(f)
|
|
except (OSError, ValueError):
|
|
return werte # keine Datei oder kaputtes JSON
|
|
if not isinstance(gelesen, dict):
|
|
return werte
|
|
|
|
for name, wert in gelesen.items():
|
|
try:
|
|
werte.update(_pruefe({name: wert}))
|
|
except SettingsError as e:
|
|
if log:
|
|
log("settings: %s -- Vorgabe %r bleibt"
|
|
% (e, DEFAULTS.get(name)))
|
|
return werte
|
|
|
|
|
|
def save(werte, pfad=None):
|
|
"""Gepruefte Einstellungen schreiben. Liefert die gespeicherten Werte."""
|
|
pfad = PFAD if pfad is None else pfad
|
|
vollstaendig = dict(DEFAULTS)
|
|
vollstaendig.update(_pruefe(werte))
|
|
with open(pfad, "w") as f:
|
|
json.dump(vollstaendig, f)
|
|
return vollstaendig
|
|
|
|
|
|
def update(aenderungen, pfad=None, log=None):
|
|
"""Einzelne Werte aendern und speichern -- der Weg fuer MQTT.
|
|
|
|
Erst pruefen, dann mit dem Bestand mischen, dann schreiben. Ist etwas
|
|
ungueltig, bleibt die Datei unveraendert.
|
|
"""
|
|
geprueft = _pruefe(aenderungen)
|
|
werte = load(pfad, log=log)
|
|
werte.update(geprueft)
|
|
return save(werte, pfad)
|