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