Files
grossanzeige/mount_mock.py
T
admin 42fc7f52d4 Portierung auf den XIAO ESP32-C3 (MicroPython)
Die Kette laeuft auf dem Geraet gegen den Mock auf dem Mac. Getestet mit
MicroPython 1.28.0 (ESP32_GENERIC_C3): alle Module importieren, Telegrammbau
und Koordinaten-Formatierung sind byte-identisch zum Mac (inkl. Gradzeichen),
Poll-Schleife 22 Abfragen in 20 s ohne Aussetzer, Ausfall und Reconnect
verhalten sich wie vorgesehen. Offen bleibt allein die RS232-Strecke zur
Anzeige -- der Steckbrettaufbau mit MAX3232 fehlt noch.

Gemeinsamer Kern statt zweier Programme:
- displayloop.py: die Poll-Schleife, herausgeloest aus run_display. Mac und
  ESP32 fahren jetzt denselben Ablauf; drumherum liegt nur die Kommandozeile
  (run_display.py) bzw. das WLAN-Setup (run_esp32.py).
- ticks.py: Timeouts fuer beide Plattformen. time.ticks_ms() laeuft nach ~12
  Tagen ueber; das bisherige "jetzt + Timeout" haette eine Warteschleife
  danach tagelang haengen lassen. Jetzt ueber ticks_diff.
- wifi.py, run_esp32.py, wifi_config.py (gitignored, Vorlage dabei), deploy.sh.

Pinbelegung aus dem KiCad-Schaltplan uebernommen, nicht geraten: UART1 mit
TX=GPIO21 (Pad 7/D6) und RX=GPIO20 (Pad 8/D7) ueber den MAX3232, LDR an
GPIO2 (Pad 1/A0). UART0 bleibt die REPL.

LDR-Kennlinie ist auf der Platine umgekehrt: der Teiler lautet
3V3-[2k]-A0-[LDR]-GND, die Spannung faellt also mit steigender Helligkeit --
andersherum als beim MSP430, auf den die Schwellen zugeschnitten sind. Wird
im Code gespiegelt (config.LDR_INVERTED, brightness.raw_to_ldr). Die
Schwellen selbst muessen am realen Aufbau noch nachgemessen werden.

Am Geraet gefundene Fehler, die den Attrappen-Tests entgehen mussten, weil
CPython sie nicht hat:
- MicroPython kennt kein str.ljust (auch kein rjust/center/zfill).
  display._emit_line fuellt von Hand auf.
- NOLINK_LINES hatte 10 Zeichen bei 9 Zeichen Zeilenbreite und erschien als
  "MONTIERUN". Jetzt "KEIN LINK"/"TELESKOP".
Dagegen: TestMicropythonVertraeglichkeit prueft die deployten Dateien statisch
auf solche Methoden und auf modulweites argparse/threading und haelt die
Dateiliste mit deploy.sh synchron; test_display.py prueft die Breite fester
Anzeigetexte.

Weitere Portabilitaet: mount_client nutzt getaddrinfo und eine Sendeschleife
statt sendall (MicroPython laesst dessen Verhalten mit Timeout offen);
argparse in brightness wandert in die Funktion; migra._digits und die
Hex-Ausgabe verlassen sich nicht mehr auf %0*d bzw. bytes.hex(). Beides kann
1.28 zwar doch -- am Geraet nachgeprueft, die Kommentare sagen es jetzt richtig.

Widerspruch in der Dokumentation bereinigt: die Zeichenbreite stand an vier
Stellen als 9 oder 10. Gemessen sind 9; die Erklaerung duerfte sein, dass die
Sperrschrift 7 px pro Zelle braucht (9*7 = 63 von 64 px) und nicht 6, wie es
eine 5x7-Matrix mit 1 px Luecke waere. Nachgeprueft ist das nicht -- dafuer
gibt es jetzt "probe.py pitch", das den Auto-Vorschub gegen feste
Pixelabstaende stellt, statt am Umbruch abzuzaehlen.

mount_mock.py --lan bindet auf 0.0.0.0 und nennt die einzutragende LAN-IP,
damit der ESP32 den Mock erreicht.

92 Tests gruen.

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

228 lines
7.4 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):
total_s %= 86400
h, rest = divmod(total_s, 3600)
m, s = divmod(rest, 60)
return "%02d:%02d:%02d" % (h, m, s)
def _fmt_dec(total_arcsec):
sign = "-" if total_arcsec < 0 else "+"
total_arcsec = abs(total_arcsec)
d, rest = divmod(total_arcsec, 3600)
m, s = divmod(rest, 60)
# Gradzeichen als '*', Minuten/Sekunden mit ':' -- der coords-Parser
# kommt mit beiden Trennzeichen klar.
return "%s%02d*%02d:%02d" % (sign, d, m, s)
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):
self._ra0 = _parse_ra(ra)
self._dec = _parse_dec(dec)
self._move = move
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()) + "#"
if cmd == "GD":
return _fmt_dec(self._current_dec()) + "#"
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")
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)
print("Mock-Montierung auf %s:%d (RA=%s DEC=%s%s)"
% (mock.host, mock.port, args.ra, args.dec, ", move" if args.move else ""))
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())