Files
grossanzeige/mount_mock.py
T
admin 433c53aabd RA in Zehntelminuten mit Beschriftung: "RA 18h36.9m" / "DE +38°47'"
Die GM4000 liefert im Auslieferzustand keine Sekunden, sondern Zehntelminuten.
Bisher wurde daraus eine Sekundenanzeige gerechnet -- die sprang in
6-Sekunden-Schritten, weil die Rohdaten sie gar nicht hergeben. Die Zehntelform
ist ehrlicher und kuerzer, und der gewonnene Platz traegt die Beschriftung:

    RA 18h36.9m
    DE +38°47'

Bei Hochpraezision entfaellt die Beschriftung, dort braucht die Deklination mit
+38°47'01" alle Zellen.

Der Mock konnte das gar nicht nachstellen -- er sendete immer das
Hochpraezisionsformat. Neu: "mount_mock.py --low" bildet den Auslieferzustand ab
(RA als HH:MM.T, DEC ohne Bogensekunden). Vorgabe bleibt hoch, damit die
bestehenden Tests mit ihren festen Erwartungen unveraendert durchlaufen.

Zur Darstellung, alles am Geraet ausprobiert:

- **Schmale Zeichen** (config.CELL_NARROW): Dezimalpunkt und der Abstand
  zwischen Beschriftung und Wert bekommen 3 statt 6 px -- in der Sperrschrift
  standen sie sonst sehr luftig. Fuer die Leerzeichen am Zeilenende gilt das
  nicht, die sollen den Rest der Zeile ueberschreiben.
- Damit entscheidet die **Pixelbreite** ueber das Abschneiden, nicht mehr die
  Zeichenzahl: "RA 18h36.9m" sind 11 Zeichen und trotzdem nur 60 px.
- **Linksbuendig ab LINE_X = 0**, der Schalter zum Zentrieren ist entfallen.
  Zentriert standen die Zeilen um wenige Pixel gegeneinander versetzt, weil die
  RA-Zeile mit ihrem schmalen Punkt kuerzer ist als die DEC-Zeile.

Ein Zwischenschritt, der wieder verworfen wurde und als Warnung im Code steht:
Ein Doppelpunkt als Trenner ("RA:18h36.9m") sah in Zeichensatz 0 zu wuchtig aus;
der aus Zeichensatz 1 ist schlanker, steht aber mitten in der obersten Zeile --
und jeder Zeichensatzwechsel zerstoert die Home-Zelle. Das erste Zeichen danach
noch einmal zu schreiben machte es schlimmer: Das nachgezogene Zeichen bemalt
GLYPH_WIDTH Pixel und frisst die linke Spalte seines rechten Nachbarn. Deshalb
steht in der obersten Zeile jetzt bewusst kein Sonderzeichen.

161 Tests gruen, am Geraet bestaetigt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:23:07 +02:00

256 lines
8.7 KiB
Python

#!/usr/bin/env python3
"""Mock der 10Micron GM4000 HPS II -- ein TCP-Server, der LX200-Abfragen beantwortet.
Zum Entwickeln ohne echtes Teleskop. Antwortet auf die Koordinaten- und
Versions-Abfragen; unbekannte Kommandos werden mit "#" quittiert, damit der
Client nicht haengt.
python3 mount_mock.py # feste Koordinaten, Port 3490
python3 mount_mock.py --ra 18:36:56 --dec +38:47:01
python3 mount_mock.py --move # RA laeuft (Sekunden ticken hoch)
python3 mount_mock.py --lan --move # auch vom ESP32 aus erreichbar
Vorgabe ist 127.0.0.1, also nur lokal. Damit der ESP32 den Mock uebers WLAN
erreicht, braucht es --lan (bindet auf 0.0.0.0); die Adresse, die dann auf dem
ESP32 einzutragen ist, wird beim Start ausgegeben.
Nur fuer die Entwicklung (CPython). Auf dem ESP32 laeuft nur der Client.
"""
import argparse
import socket
import sys
import threading
import time
import lx200
PRODUCT = "10micron GM4000HPS"
FIRMWARE = "2.15.11"
def _parse_ra(text):
"""RA-String (HH:MM:SS o.ae.) in Sekunden 0..86399."""
nums = _numbers(text)
h, m, s = (nums + [0, 0, 0])[:3]
return (int(h) * 3600 + int(m) * 60 + int(s)) % 86400
def _parse_dec(text):
"""DEC-String (sDD*MM:SS) in vorzeichenbehaftete Bogensekunden."""
neg = text.strip().startswith("-")
nums = _numbers(text)
d, m, s = (nums + [0, 0, 0])[:3]
total = int(d) * 3600 + int(m) * 60 + int(s)
return -total if neg else total
def _numbers(text):
out, cur = [], ""
for ch in text:
if ch.isdigit():
cur += ch
elif cur:
out.append(cur)
cur = ""
if cur:
out.append(cur)
return [int(x) for x in out]
def _fmt_ra(total_s, high=True):
"""Rektaszension wie die GM4000.
high=True -> HH:MM:SS (Hochpraezision, nach ":U#")
high=False -> HH:MM.T (Zehntelminuten -- so liefert die Montierung im
Auslieferzustand, verifiziert 2026-07-18)
"""
total_s %= 86400
h, rest = divmod(total_s, 3600)
m, s = divmod(rest, 60)
if high:
return "%02d:%02d:%02d" % (h, m, s)
zehntel = (total_s * 10 + 30) // 60 # in Zehntelminuten, gerundet
minuten, t = divmod(zehntel, 10)
h, m = divmod(minuten, 60)
return "%02d:%02d.%d" % (h % 24, m, t)
def _fmt_dec(total_arcsec, high=True):
"""Deklination wie die GM4000.
high=True -> sDD*MM:SS (Hochpraezision)
high=False -> sDD*MM (ganze Bogenminuten, Auslieferzustand)
Gradzeichen als '*', Minuten/Sekunden mit ':' -- der coords-Parser kommt mit
beiden Trennzeichen klar. (Die echte Montierung sendet 0xDF als Gradzeichen.)
"""
sign = "-" if total_arcsec < 0 else "+"
total_arcsec = abs(total_arcsec)
d, rest = divmod(total_arcsec, 3600)
m, s = divmod(rest, 60)
if high:
return "%s%02d*%02d:%02d" % (sign, d, m, s)
return "%s%02d*%02d" % (sign, d, m)
class MountMock:
"""Simuliert die Montierung auf TCP-Ebene."""
def __init__(self, host="127.0.0.1", port=3490,
ra="18:36:56", dec="+38:47:01", move=False, high=True):
self._ra0 = _parse_ra(ra)
self._dec = _parse_dec(dec)
self._move = move
# high=False bildet den Auslieferzustand der GM4000 nach: keine Sekunden,
# RA als Zehntelminuten. Vorgabe bleibt True, damit bestehende Tests mit
# ihren festen Erwartungen unveraendert durchlaufen.
self._high = high
self._tick = 0 # zaehlt bei --move pro RA-Abfrage hoch
self._dectick = 0 # zaehlt bei --move pro DEC-Abfrage hoch
self._lock = threading.Lock()
self._srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._srv.bind((host, port))
self._srv.listen(5)
self.host, self.port = self._srv.getsockname() # echter Port (bei port=0)
self._running = False
self._thread = None
# -- aktuelle Koordinaten --------------------------------------------
def _current_ra(self):
"""RA liefern; bei --move pro Abfrage genau 1 Sekunde weiter (kein Zeit-Jitter)."""
if not self._move:
return self._ra0
with self._lock:
ra = self._ra0 + self._tick
self._tick += 1
return ra
def _current_dec(self):
"""DEC liefern; bei --move pro Abfrage 1 Bogensekunde weiter."""
if not self._move:
return self._dec
with self._lock:
dec = self._dec + self._dectick
self._dectick += 1
return dec
def _reply_for(self, cmd):
"""Antwort (inkl. '#') fuer ein Kommando ohne ':' und ohne '#'."""
if cmd == "GR":
return _fmt_ra(self._current_ra(), self._high) + "#"
if cmd == "GD":
return _fmt_dec(self._current_dec(), self._high) + "#"
if cmd == "GVP":
return PRODUCT + "#"
if cmd == "GVN":
return FIRMWARE + "#"
# Unbekannt: leere, aber abgeschlossene Antwort, damit der Client weiterlaeuft.
return "#"
def _handle(self, conn):
buf = ""
try:
while self._running:
data = conn.recv(64)
if not data:
break
buf += data.decode("latin-1")
# vollstaendige Kommandos (durch '#' getrennt) abarbeiten
while "#" in buf:
raw, buf = buf.split("#", 1)
cmd = raw.lstrip(":").strip()
if cmd:
conn.sendall(self._reply_for(cmd).encode("latin-1"))
except OSError:
pass
finally:
conn.close()
def _accept_loop(self):
while self._running:
try:
conn, _ = self._srv.accept()
except OSError:
break
threading.Thread(target=self._handle, args=(conn,), daemon=True).start()
def start(self):
"""Server im Hintergrund-Thread starten."""
self._running = True
self._thread = threading.Thread(target=self._accept_loop, daemon=True)
self._thread.start()
def stop(self):
self._running = False
try:
self._srv.close()
except OSError:
pass
def serve_forever(self):
self.start()
try:
while True:
time.sleep(0.5)
except KeyboardInterrupt:
pass
finally:
self.stop()
def lan_address():
"""Die eigene LAN-Adresse ermitteln -- die, die der ESP32 ansprechen muss.
Ueber einen UDP-Socket ans Gateway: der verschickt nichts, verraet aber,
welche lokale Adresse das Betriebssystem fuer den Weg nach draussen nimmt.
"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("192.168.1.1", 1))
return s.getsockname()[0]
except OSError:
return None
finally:
s.close()
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--lan", action="store_true",
help="auf allen Schnittstellen lauschen (0.0.0.0), damit der "
"ESP32 den Mock uebers WLAN erreicht")
p.add_argument("--port", type=int, default=3490)
p.add_argument("--ra", default="18:36:56")
p.add_argument("--dec", default="+38:47:01")
p.add_argument("--move", action="store_true", help="RA laeuft mit der Zeit hoch")
p.add_argument("--low", action="store_true",
help="Niedrigpraezision wie im Auslieferzustand der GM4000: "
"RA als HH:MM.T, DEC ohne Bogensekunden")
args = p.parse_args(argv)
host = "0.0.0.0" if args.lan else args.host
mock = MountMock(host=host, port=args.port, ra=args.ra, dec=args.dec,
move=args.move, high=not args.low)
print("Mock-Montierung auf %s:%d (RA=%s DEC=%s%s, %s)"
% (mock.host, mock.port, args.ra, args.dec,
", move" if args.move else "",
"Niedrigpraezision" if args.low else "Hochpraezision"))
if host == "0.0.0.0":
ip = lan_address()
if ip:
print("Vom ESP32 aus erreichbar unter: %s:%d" % (ip, mock.port))
print(" run_esp32.main(host=\"%s\", fake_display=True)" % ip)
print("Falls keine Verbindung zustande kommt: macOS fragt beim ersten Mal,")
print("ob python3 eingehende Verbindungen annehmen darf -- das muss erlaubt sein.")
print("Beenden mit Ctrl-C.")
mock.serve_forever()
return 0
if __name__ == "__main__":
sys.exit(main())