From 81e0e1a0369bb8598ba6cafbb69eded6ba7a6568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Reinhard=20X=2E=20F=C3=BCrst?= Date: Thu, 16 Jul 2026 09:05:56 +0200 Subject: [PATCH] First commit --- README.md | 138 ++++++++++++++ __pycache__/config.cpython-313.pyc | Bin 0 -> 1046 bytes __pycache__/coords.cpython-313.pyc | Bin 0 -> 4665 bytes __pycache__/display.cpython-313.pyc | Bin 0 -> 8747 bytes __pycache__/lx200.cpython-313.pyc | Bin 0 -> 2064 bytes __pycache__/migra.cpython-313.pyc | Bin 0 -> 9507 bytes __pycache__/mount_client.cpython-313.pyc | Bin 0 -> 5166 bytes __pycache__/mount_mock.cpython-313.pyc | Bin 0 -> 10435 bytes __pycache__/test_lx200.cpython-313.pyc | Bin 0 -> 8659 bytes __pycache__/test_migra.cpython-313.pyc | Bin 0 -> 18664 bytes __pycache__/transport.cpython-313.pyc | Bin 0 -> 6212 bytes config.py | 62 ++++++ coords.py | 98 ++++++++++ display.py | 139 ++++++++++++++ loopback.py | 49 +++++ lx200.py | 46 +++++ migra.py | 185 ++++++++++++++++++ mount_client.py | 88 +++++++++ mount_mock.py | 195 +++++++++++++++++++ probe.py | 92 +++++++++ run_display.py | 96 ++++++++++ scope.py | 102 ++++++++++ show_coords.py | 67 +++++++ test_lx200.py | 109 +++++++++++ test_migra.py | 229 +++++++++++++++++++++++ transport.py | 126 +++++++++++++ 26 files changed, 1821 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/config.cpython-313.pyc create mode 100644 __pycache__/coords.cpython-313.pyc create mode 100644 __pycache__/display.cpython-313.pyc create mode 100644 __pycache__/lx200.cpython-313.pyc create mode 100644 __pycache__/migra.cpython-313.pyc create mode 100644 __pycache__/mount_client.cpython-313.pyc create mode 100644 __pycache__/mount_mock.cpython-313.pyc create mode 100644 __pycache__/test_lx200.cpython-313.pyc create mode 100644 __pycache__/test_migra.cpython-313.pyc create mode 100644 __pycache__/transport.cpython-313.pyc create mode 100644 config.py create mode 100644 coords.py create mode 100644 display.py create mode 100644 loopback.py create mode 100644 lx200.py create mode 100644 migra.py create mode 100644 mount_client.py create mode 100644 mount_mock.py create mode 100644 probe.py create mode 100644 run_display.py create mode 100644 scope.py create mode 100644 show_coords.py create mode 100644 test_lx200.py create mode 100644 test_migra.py create mode 100644 transport.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3f6d74 --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# Großanzeige + +Ansteuerung einer microSYST **migra SC/MC Seriell** LED-Großanzeige mit den +Koordinaten (Rektaszension / Deklination) einer 10Micron GM4000 HPS II. + +Endziel ist ein XIAO ESP32-C3, der die Montierung per WLAN abfragt und die +Koordinaten über UART/RS232 auf die Anzeige schreibt. **Phase 1** (dieser Stand) +ist das Testprogramm auf dem Mac: Koordinaten von Hand übergeben, Ausgabe über +einen USB-RS232-Wandler. + +## Benutzung + +```bash +# Phase 1 -- Koordinaten von Hand: +python3 show_coords.py 12h34m56s +45d12m30s # Koordinaten anzeigen +python3 show_coords.py 12:34:56 -07:15:00 # Trennzeichen sind egal +python3 show_coords.py --clear # Anzeige löschen +python3 show_coords.py 12h34m56s +45d12m30s --dry-run # nur Telegramm als Hex + +# Phase 2 -- Koordinaten von der Montierung (10Micron GM4000): +python3 run_display.py # Montierung aus config.py pollen +python3 run_display.py --host 10.0.0.42 # andere IP +python3 run_display.py --mock --move # ohne Teleskop: lokaler Mock, RA läuft +python3 mount_mock.py --move # Mock separat starten + +python3 -m unittest test_migra test_lx200 # alle Tests +python3 probe.py ruler # Geometrie der Anzeige ausmessen +``` + +Einzige Abhängigkeit auf dem Mac ist `pyserial`. + +## Aufbau + +| Datei | Zweck | +|---|---| +| `migra.py` | Protokoll: Telegrammrahmen, Prüfsumme, ESC-Sequenzen. Ohne Abhängigkeiten, **läuft unverändert unter MicroPython**. | +| `transport.py` | UART: `pyserial` (Mac), `machine.UART` (ESP32-C3), `FakeTransport` (Trockenlauf). | +| `display.py` | Treiber: Telegramm senden, Antwort auswerten. | +| `coords.py` | Koordinaten einlesen und auf die Zeilenbreite formatieren. | +| `config.py` | Port, Adresse, Geometrie, Helligkeit — alles an einer Stelle. | +| `show_coords.py` | Das Testprogramm. | +| `probe.py` | Testmuster zum Ausmessen von Zeilenbreite und Zeilenposition. | +| `test_migra.py` | Tests, u. a. gegen die Beispieltelegramme aus dem Handbuch. | + +## Protokoll (Kurzfassung) + +Referenz: Benutzerhandbuch `X-M31-9AXX5X-006 (V4.20)`, Kapitel 3.5–3.9. + +**Schnittstelle:** 19200 Baud, 8 Datenbits, **Even Parity**, 1 Stopbit. +9-pol. Sub-D: Pin 2 = RxD, Pin 3 = TxD, Pin 5 = GND. + +**Telegramm:** + +``` +STX | DA | SA | FC | [LEN-H LEN-L] | Data-Unit | [CHK-H CHK-L] | ETX +02 81 80 81 03 +``` + +- `DA`/`SA`: Adressen mit gesetztem Bit 7 → Anzeige = 1 (`0x81`), Host = 0 (`0x80`). +- `FC`: Bit 7 fest 1, Bit 1 = Prüfsumme, Bit 0 = Antwort anfordern. + `0x81` = ohne Prüfsumme, mit Antwort (so fuhr auch das alte MSP430-Programm). +- Ohne Prüfsumme dürfen `LEN-H/L` und `CHK-H/L` **nicht** mitgesendet werden. +- Prüfsumme = Low-Byte der Summe aus `DA, SA, FC, LEN-H, LEN-L` und allen Datenbytes, + als zwei Nibbles mit gesetztem High-Nibble (`0xF0 | nibble`). + +**Antwort:** `02 80 81 80 03`, Fehlercode `'0'` = kein Fehler. +Nach Erhalt der Antwort darf sofort das nächste Telegramm folgen — das ist die +Flusskontrolle. Ohne Antwort muss zwischen den Telegrammen gewartet werden +(Empfangstimeout + ca. 150 ms). + +**ESC-Sequenzen** (alle Zahlen ASCII-codiert, Position 123 → `'1' '2' '3'`): + +| Sequenz | Bedeutung | +|---|---| +| `ESC z NN` / `ESC Z NN` | Zeichensatz NN (`z` = Sperrschrift, gleiche Zeichenbreite) | +| `ESC C xxxyyy` | Cursor auf **Pixel**position x/y, 0/0 ist links oben | +| `ESC A ` | Attribute; Farbe 0 = schwarz, 1 = grün, 2 = rot, 3 = gelb | +| `ESC F ` | Anzeige füllen; `ESC F 0` = löschen | +| `ESC H ` | Helligkeit 0–100 % | +| `ESC T <+/->` | gespeicherten Text ein-/ausblenden | + +Folgt auf eine ESC-Sequenz ein Online-Text, muss **`0x1F` als Trennzeichen** +dazwischen. Data-Unit max. 230 Zeichen. `migra.DataUnit` setzt das Trennzeichen +selbst. + +## Offene Punkte + +- **Antworttelegramm-Pfad (RX) noch offen, absichtlich zurückgestellt.** + `WANT_RESPONSE = False` ist Default, der Normalbetrieb läuft ohne Rückkanal. + Diagnose 2026-07-15: Adapter-Loopback ok, aber über den MAX232 und am echten + Gerät kommt keine Antwort zurück → der Empfangszweig (Anzeige Pin 3 TxD → MAX232 + R_IN → R_OUT → Adapter RxD) leitet nicht. `loopback.py` hilft beim Eingrenzen. + Die Anzeige *antwortet* nachweislich (das alte MSP430-Programm nutzte die Antwort + als Handshake); es ist reine Verdrahtung. Wird der RX-Pfad fertig, `WANT_RESPONSE` + in `config.py` auf True stellen. +- **Echte Montierungs-IP:** In `config.py` steht `MOUNT_HOST` noch als Platzhalter. + Sobald die GM4000 im Netz erreichbar ist, dort die IP eintragen (Port 3490 ist + Vorgabe). Bis dahin läuft alles gegen den Mock (`run_display.py --mock`). +- Portierung auf den XIAO ESP32-C3 (MicroPython): `migra.py`, `lx200.py`, + `mount_client.py` sind bewusst abhängigkeitsfrei; nur `transport` (UART) und das + WLAN-Setup kommen hinzu. + +## Gradzeichen + +Der Standard-Zeichensatz 0 der Anzeige hat **kein** `°`. Der von der Sternwarte +geladene **Zeichensatz 1** enthält an Position `/` (0x2F) ein hochgestelltes +Gradzeichen (deckt sich mit dem alten Programm, `asciitaf.h`: Code 0xAF → per +`& 0x7F` = 0x2F in Zeichensatz 1). Zur Ausgabe wird für dieses eine Zeichen kurz +auf Zeichensatz 1 umgeschaltet und wieder zurück (`config.DEGREE_*`, `display._emit_degree`). + +**Fallstrick:** Ein Zeichensatzwechsel mitten im Telegramm beschädigt die Home-Zelle +(0,0) — das erste Zeichen der obersten Zeile wird zerstört. Deshalb zeichnet +`show_lines` die Zeilen **von unten nach oben** (oberste Zeile zuletzt), damit sie +diese Zelle überschreibt. + +## Geometrie und Darstellung + +- **9 Zeichen pro Zeile** in Sperrschrift (`CHARSET_SPACED = True`, am Gerät + ausgemessen). `probe.py ruler` misst es nach. +- Die Deklination wird deshalb als **9-Zeichen-Form ohne abschließendes `"`** + ausgegeben (`+38°47'01`) — Gradzeichen und Bogenminuten bleiben, die Bogensekunden + stehen als Ziffern da. `coords.format_dec` wählt automatisch die breiteste Form, + die in `CHARS_PER_LINE` passt. +- **Zyklische Updates flimmerfrei:** `show_lines` löscht nicht bei jeder Aktualisierung, + sondern überschreibt die Zeilen an Ort und Stelle (auf volle Breite aufgefüllt). + `clear=True` nur beim ersten Bild. Wichtig: Zeilen dürfen **nicht breiter** als + `CHARS_PER_LINE` aufgefüllt werden, sonst bricht das Füll-Leerzeichen um und + beschädigt die andere Zeile. + +## Erledigt + +- **Phase 1 + 2 am Gerät bestätigt (2026-07-15):** Koordinaten erscheinen korrekt, + inkl. echtem Gradzeichen; im `run_display`-Betrieb aktualisieren beide Zeilen + laufend, sauber und ohne Flimmern. Protokoll, 19200 8E1, Zeichensatz und + Zeilengeometrie stimmen. +- Schrift: Sperrschrift (`ESC z`, `CHARSET_SPACED = True`), 10 Zeichen/Zeile. + Die schmale `1` wirkt dadurch etwas luftig — Font-Eigenschaft, nur per eigenem + Font (microSYST-PC-Software) änderbar. diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e391fe877fdb61612b20d66dac3394cc68fbc5a1 GIT binary patch literal 1046 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 literal 0 HcmV?d00001 diff --git a/__pycache__/display.cpython-313.pyc b/__pycache__/display.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f4b0c122222efe83666b5a39bed1e67003bfa6cd GIT binary patch literal 8747 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 literal 0 HcmV?d00001 diff --git a/__pycache__/migra.cpython-313.pyc b/__pycache__/migra.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..29860ca216b4b70485f530f98caa1785d378768c GIT binary patch literal 9507 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 W|M}th@e3!vV5@I46x*P!=)VAzJpg+E literal 0 HcmV?d00001 diff --git a/__pycache__/test_lx200.cpython-313.pyc b/__pycache__/test_lx200.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1fd1d4dc7f6d3300c1d622102aa66af0727bad93 GIT binary patch literal 8659 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 literal 0 HcmV?d00001 diff --git a/__pycache__/transport.cpython-313.pyc b/__pycache__/transport.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a8574c115ee694f746b9c1982c880c0469abd5d0 GIT binary patch literal 6212 zcmeHL+jA4w89zt6E3GcR+u#ef7cdtk_yU2X7>7hjAjA|yy;jB@Cso(d+Fp=X>e*GW zHIod(L&k2B0h%(9HZwSFrom55GMTooO#cB~hl#RrJDq7C%A2vt&^9mqzO#}n$&i`! zvD0s)Z_l3lez)Iu&U#f`e1lG`ewRC1^ zNoDUTE#7g$ktgK44eLD}+(7M2||D02Uys0%%iGQKw$CE9O(Z zs9)8`{F8Fn@6@PVDIK+a(nI-O>$F7t1%E1ZgXazsrBP2*j0#aH>Qz05y-|`Z&NKSsWO2+S z2@6BnuHp_S^r@uXucVE%Vwje#C6mS7hrN!7Qg`HWEt6oH4f-3OwROwL>W(+9F~bIR zSavEsZrGONLk*`gc35(}t^qrt;f#s3#~4d7$LmVakz($wvBChKBZ5avFw`TfMOKfj z4p{@TMqm~SP;rT&03A6NGfcyd#hm5}-SiY%+Hv3junS}*ST}!krM}~K=dtDb zR?=`${u?dPqDGZRje?j|Nd?zZ$Rt^$Lt%k6Ll;Ln4;s)NX(E}irWQR64bl-4nyp#5 zSw-PuVx3ssR4E}o_AzL(x`16EIXQGGFdz6rZeEg`uQz@!ca+uKky~9c6x;{~aS?Da z^g%6{aj~tiVGvBI*l-LG`n*ZUiXU3&ZF(Lu1MLS}&|W1dE(p7oG>n!|K^tyk3Q=Ki zzBK_mIR&9C>KO(ntE8wP0|tjZc}I*VQ%cj%uursx39fDmT26|3pl2{JKVn4}f$$U#qEH+^<%SP3iDQNb^ zVu@5d7GrI&5Dx@wKbG8m#bOZe0gz*wZ8Kv$1GWfVG0bwIxzPiCfFZ-^Vxd{RNxl>h ztcgKMS!?x3ZEJE+YW=#EN^R~a>>G!Fkx<@9>rVpP47`x)`4M){Qh;9sUM~k;p9+Z_ zz^@GOD})11Ra9g42KYV$b7ha9(QN|X(nJYTsFFmfcOyIM8H$%i4ooGuO?<}pxB<40 z24vNv3Mx^>9>Mf)#4-fo(RVR=M5VDJEChTP>AhrbhGz^kd2;O#E!f#o zW&}l)e-7F#8yJL_V9ophNBN`6_0QZ6J@ePLZ43Kv9$aebn+vSCP+!-QYiM6;*p;hm zS!vvQ-M7&7UiEV0o_j%WePGVJT1`UL^G9=S?UxVEe?J$jDwbE8TCVR|YTCE(GBC^! za?PFJnjQ4RmcC1XDa{TxLd;2+#r56o_?@W6GH7oo7YYaVm$7FfuuZ^zNAB1h5>d0< z{QnQ+APJyCsE@ko2gv-}fP9F%RUU@fZWzz~fuSQau0BjcSQr_mTl|)~$6w5F!};7;uvpv)FIOm zcqwW+{((u|)MwJ{sI$9VLmWGM29l{bxYW^}V!s*K1&7nSptIZCk9?Z=xNc$h&HcZ5 z?9;@*xaKd`H&V!>;Isz;OiQQXfW>}jyuVm0YmqQvap?l~CQ~UDq3KG{4upwEf5@iRJA_mRpY82@R|YB(%k~ zjZ&dvncD`Y@{h8OuKdx4G7Wld3c`4NaN5c@Tn0{A=wP8=!p3VFF1<1T#%;O%TlOMC z*QM~v-OZym@U{dc&LMi_p`jlR@`NG7NNb_z zzDe$R1WCRh5T${&kRUz17NAnc*Ip{QmqEODxQgM;l8fj~P;!DVP2LxfhX ziUO_wN~~Lz1zP)Oaoega(gRa3^g~11b9klHp0rp^!?xEZ65nvBSXcMhk zBf$Q-`nre64G?2!eXgqR;&EJ{{+rc6zOojq@8SzsS^Lduh{#*8=YcQl_Fo*t$^-wl v&~fL)h(O-)M(W7VYa^ZHmLNx}g Adapter RxD) noch nicht verdrahtet ist. Ist der RX-Pfad +# fertig, hier wieder auf True stellen -- dann wird der Fehlercode ausgewertet und +# ohne feste Pause gesendet. +WANT_RESPONSE = False +RESPONSE_TIMEOUT = 1.0 # Sekunden (nur relevant, wenn WANT_RESPONSE = True) +QUIET_TIME = 0.4 # Pause zwischen Telegrammen, wenn ohne Antwort gefahren wird + +# -- Darstellung ---------------------------------------------------------- +# Zeichensatz 0, Zeile 1 ab Pixelzeile 0, Zeile 2 ab Pixelzeile 9 (aus dem alten +# C-Programm). Am echten Geraet ausgemessen (2026-07-15): +# CHARSET_SPACED=True -> ESC z (Sperrschrift, alle Zeichen gleich breit) -- vom +# Nutzer bevorzugt, weil sich sonst schmale Ziffern beruehren. In dieser Schrift +# passen mind. 10 Zeichen pro Zeile (RA/DEC brauchen max. 10). +# CHARSET_SPACED=False -> ESC Z (proportional, schmaler, ~11 Zeichen, aber die "1" +# klebt an Nachbarn). +CHARSET = 0 +CHARSET_SPACED = True +LINE_Y = (0, 9) +CHARS_PER_LINE = 9 # in Sperrschrift am Geraet ausgemessen (2026-07-15) + +COLOR = migra.RED # einfarbig rote Anzeige +BACKGROUND = migra.BLACK +BRIGHTNESS = 100 # Prozent + +# Gradzeichen: Im Standard-Zeichensatz 0 der Anzeige fehlt es. Der von der +# Sternwarte geladene Zeichensatz 1 hat an Position "/" (0x2F) ein hochgestelltes +# Gradzeichen -- verifiziert 2026-07-15, deckt sich mit dem alten Programm +# (asciitaf.h: Code 0xAF, per & 0x7F = 0x2F in Zeichensatz 1). Zur Ausgabe wird +# fuer dieses eine Zeichen kurz auf DEGREE_CHARSET umgeschaltet. +DEGREE_MARKER = "°" # Platzhalter im formatierten Text (siehe coords.py) +DEGREE_CHARSET = 1 # Zeichensatz mit dem Gradzeichen; None -> Fallback nutzen +DEGREE_CHAR = "/" # Zeichen, das in DEGREE_CHARSET als Grad erscheint +DEGREE_FALLBACK = "d" # Ersatz, falls DEGREE_CHARSET = None + +# -- Montierung (Phase 2) ------------------------------------------------- +# 10Micron GM4000 HPS II: LX200-Protokoll ueber TCP. Die IP der Montierung +# eintragen, sobald sie erreichbar ist; Port 3490 ist die Vorgabe von 10Micron. +MOUNT_HOST = "192.168.0.50" # PLATZHALTER -- echte IP der Montierung eintragen +MOUNT_PORT = 3490 +MOUNT_TIMEOUT = 3.0 # Sekunden fuer Verbindung/Antwort +POLL_INTERVAL = 1.0 # Sekunden zwischen zwei Koordinatenabfragen + +# Anzeige, wenn die Montierung (noch) nicht erreichbar ist. +NOLINK_LINES = ("KEIN LINK", "MONTIERUNG") diff --git a/coords.py b/coords.py new file mode 100644 index 0000000..8f52ae3 --- /dev/null +++ b/coords.py @@ -0,0 +1,98 @@ +"""Rektaszension und Deklination einlesen und fuer die Anzeige formatieren. + +Die Eingabe ist bewusst tolerant, damit man beim Testen nicht auf ein Format +festgelegt ist: 12h34m56s | 12:34:56 | "12 34 56" | 12.5824 (Stunden dezimal) +Bei der Deklination entsprechend mit Vorzeichen und Grad/Bogenminuten. + +Ausgegeben wird die breiteste Schreibweise, die noch in die Zeile passt -- +die tatsaechliche Zeichenbreite der Anzeige kennen wir erst nach dem Livetest. +""" + +import config + +_SEPARATORS = "hHmMsSdD°'\"::," + + +def _split(value): + """Zahlenfelder aus einer Koordinatenangabe holen, Trennzeichen egal.""" + out, cur = [], "" + for ch in str(value).strip(): + if ch.isdigit() or ch == ".": + cur += ch + elif ch in "+-" and not cur and not out: + cur += ch + else: + if cur: + out.append(cur) + cur = "" + if cur: + out.append(cur) + if not out: + raise ValueError("keine Zahlen in %r gefunden" % (value,)) + return out + + +def _to_sexagesimal(value, unit_max, name): + """Eingabe in (Vorzeichen, Ganzteil, Minuten, Sekunden) zerlegen.""" + fields = _split(value) + negative = fields[0].startswith("-") + fields[0] = fields[0].lstrip("+-") + + if len(fields) == 1: # dezimal, z.B. 12.5824 + total = float(fields[0]) * 3600.0 + else: + parts = [float(f) for f in fields[:3]] + [0.0] * (3 - len(fields[:3])) + total = parts[0] * 3600.0 + parts[1] * 60.0 + parts[2] + + total = int(round(total)) + big, rest = divmod(total, 3600) + minutes, seconds = divmod(rest, 60) + + if big >= unit_max: + raise ValueError("%s: %s ausserhalb des gueltigen Bereichs" % (name, value)) + return negative, big, minutes, seconds + + +def parse_ra(value): + """Rektaszension -> (Stunden, Minuten, Sekunden).""" + _, h, m, s = _to_sexagesimal(value, 24, "Rektaszension") + return h, m, s + + +def parse_dec(value): + """Deklination -> (Vorzeichen als '+'/'-', Grad, Bogenminuten, Bogensekunden).""" + neg, d, m, s = _to_sexagesimal(value, 91, "Deklination") + if d > 90 or (d == 90 and (m or s)): + raise ValueError("Deklination %s liegt ausserhalb +/-90 Grad" % (value,)) + return ("-" if neg else "+"), d, m, s + + +def _fit(candidates, width): + """Erste Schreibweise nehmen, die in die Zeile passt; sonst die kuerzeste.""" + for text in candidates: + if len(text) <= width: + return text + return candidates[-1][:width] + + +def format_ra(value, width=None): + width = config.CHARS_PER_LINE if width is None else width + h, m, s = parse_ra(value) + return _fit([ + "%02dh%02dm%02ds" % (h, m, s), + "%02d:%02d:%02d" % (h, m, s), + "%02dh%02dm" % (h, m), + ], width) + + +def format_dec(value, width=None): + width = config.CHARS_PER_LINE if width is None else width + sign, d, m, s = parse_dec(value) + deg = config.DEGREE_MARKER + return _fit([ + "%s%02d%s%02d'%02d\"" % (sign, d, deg, m, s), # 10: mit Bogensekundenzeichen + "%s%02d%s%02d'%02d" % (sign, d, deg, m, s), # 9: Gradzeichen, ohne " am Ende + "%s%02d:%02d:%02d" % (sign, d, m, s), # 9: Doppelpunkte, kein Gradzeichen + "%s%02d%s%02d'" % (sign, d, deg, m), # 7: ohne Bogensekunden + "%s%02d:%02d" % (sign, d, m), # 6 + ], width) diff --git a/display.py b/display.py new file mode 100644 index 0000000..23b13c1 --- /dev/null +++ b/display.py @@ -0,0 +1,139 @@ +"""Treiber fuer die Grossanzeige: Telegramme bauen, senden, Antwort pruefen.""" + +import time + +import migra +import config + + +class Display: + """Sendet Telegramme an die Anzeige und wertet das Antworttelegramm aus. + + Das Antworttelegramm ist zugleich die Flusskontrolle: Nach seinem Erhalt + darf sofort das naechste Telegramm folgen (Handbuch 3.5). Ohne Antwort + muesste zwischen den Telegrammen "Empfangstimeout + 150 ms" gewartet werden. + """ + + def __init__(self, transport, + dest=config.DISPLAY_ADDR, + src=config.HOST_ADDR, + checksum=config.USE_CHECKSUM, + response=config.WANT_RESPONSE): + self._t = transport + self._dest = dest + self._src = src + self._checksum = checksum + self._response = response + + def send(self, data_unit): + """Data-Unit rahmen, senden und ggf. auf das Antworttelegramm warten.""" + frame = migra.build_telegram( + data_unit, + dest=self._dest, + src=self._src, + checksum=self._checksum, + response=self._response, + ) + self._t.write(frame) + if self._response: + migra.parse_response(self._t.read_response(), src=self._src) + else: + # Ohne Antwort ist die Anzeige erst nach Abarbeitung des Telegramms + # wieder empfangsbereit: Empfangstimeout + 150 ms (Handbuch 3.5). + time.sleep(config.QUIET_TIME) + + # -- Hoehere Funktionen ------------------------------------------------ + def clear(self): + self.send(migra.DataUnit().fill(config.BACKGROUND)) + + def set_brightness(self, percent): + self.send(migra.DataUnit().brightness(percent, color=config.COLOR)) + + def show_lines(self, *lines, clear=False): + """Zeilen ab Zeile 1 auf die Anzeige schreiben. + + Baut ein einziges Telegramm: pro Zeile Cursor setzen und den Text + schreiben. Das Gradzeichen (config.DEGREE_MARKER) wird ueber einen kurzen + Zeichensatzwechsel eingefuegt. + + Jede Zeile wird mit Leerzeichen auf volle Breite (CHARS_PER_LINE) + aufgefuellt und damit *an Ort und Stelle* ueberschrieben -- ohne die + Anzeige vorher komplett zu loeschen. Das vermeidet das sichtbare Blitzen + bei zyklischen Updates und haelt das Telegramm kurz (sonst kommt das + naechste zu frueh und das letzte Zeichen kann verloren gehen). + clear=True erzwingt zusaetzlich ein Vollbild-Loeschen (einmal beim Start). + + Reihenfolge: Die Zeilen werden von unten nach oben gezeichnet, damit die + oberste Zeile zuletzt kommt. Grund: Ein Zeichensatzwechsel mitten im + Telegramm beschaedigt die Home-Zelle (0,0) -- die oberste Zeile muss also + nach allen Wechseln geschrieben werden, damit sie diese Zelle ueberschreibt. + (Am Geraet verifiziert 2026-07-15.) + """ + if len(lines) > len(config.LINE_Y): + raise ValueError("Anzeige hat nur %d Zeilen" % len(config.LINE_Y)) + + du = migra.DataUnit() + if clear: + du.fill(config.BACKGROUND) + du.attributes(fg=config.COLOR, bg=config.BACKGROUND, blink=migra.STATIC) + for i in reversed(range(len(lines))): + self._emit_line(du, config.LINE_Y[i], lines[i]) + self.send(du) + + def show_line(self, index, text, clear=False): + """Nur eine einzelne Zeile aktualisieren (eigenes, kurzes Telegramm). + + Fuer zyklische Updates: die Zeile ohne Gradzeichen (also ohne + Zeichensatzwechsel) laesst sich beliebig oft neu schreiben, ohne dass die + Anzeige Bytes verschluckt. Zeilen *mit* Gradzeichen sollten dagegen selten + und moeglichst per show_lines (Vollbild) gezeichnet werden. + """ + if not 0 <= index < len(config.LINE_Y): + raise ValueError("ungueltiger Zeilenindex %d" % index) + du = migra.DataUnit() + if clear: + du.fill(config.BACKGROUND) + du.attributes(fg=config.COLOR, bg=config.BACKGROUND, blink=migra.STATIC) + self._emit_line(du, config.LINE_Y[index], text) + self.send(du) + + def _emit_line(self, du, y, text): + """Eine Zeile bei Pixelzeile y ausgeben, auf volle Breite aufgefuellt.""" + text = text[:config.CHARS_PER_LINE].ljust(config.CHARS_PER_LINE) + du.charset(config.CHARSET, spaced=config.CHARSET_SPACED) + du.cursor(0, y) + for i, part in enumerate(text.split(config.DEGREE_MARKER)): + if i > 0: + self._emit_degree(du) + if part: + du.text(part) + + def _emit_degree(self, du): + """Das Gradzeichen einfuegen -- ueber Zeichensatzwechsel oder als Fallback.""" + if config.DEGREE_CHARSET is None: + du.text(config.DEGREE_FALLBACK) + return + du.charset(config.DEGREE_CHARSET, spaced=config.CHARSET_SPACED) + du.text(config.DEGREE_CHAR) + du.charset(config.CHARSET, spaced=config.CHARSET_SPACED) + + def close(self): + self._t.close() + + +def open_display(port=config.PORT, fake=False, response=config.WANT_RESPONSE): + """Transport passend zur Plattform oeffnen und Display-Objekt liefern.""" + if fake: + from transport import FakeTransport + t = FakeTransport(src=config.HOST_ADDR, display_addr=config.DISPLAY_ADDR) + else: + from transport import SerialTransport + t = SerialTransport( + port=port, + baudrate=config.BAUDRATE, + parity=config.PARITY, + bytesize=config.BYTESIZE, + stopbits=config.STOPBITS, + timeout=config.RESPONSE_TIMEOUT, + ) + return Display(t, response=response) diff --git a/loopback.py b/loopback.py new file mode 100644 index 0000000..55aee75 --- /dev/null +++ b/loopback.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Loopback-Test des seriellen Adapters. + +Sendet ein paar Bytes und prueft, ob sie zurueckkommen. Dazu auf der TTL-Seite +des Adapters TXD und RXD bruecken. + + - Kommen die Bytes zurueck -> Adapter-RX + Software + Port sind in Ordnung, + das Problem liegt in der Verdrahtung MAX232 <-> Anzeige. + - Kommt nichts zurueck -> Adapter-RX, Port oder Bruecke pruefen. + + python3 loopback.py +""" + +import sys +import config +from transport import SerialTransport + + +def main(): + t = SerialTransport(port=config.PORT, baudrate=config.BAUDRATE, + parity=config.PARITY, bytesize=config.BYTESIZE, + stopbits=config.STOPBITS, timeout=1.0) + probe = bytes([0x55, 0xAA, 0x02, ord("A"), 0x03]) + print("Port:", config.PORT) + print("TX:", " ".join("%02X" % b for b in probe)) + t.write(probe) + + import time + deadline = time.monotonic() + 1.0 + buf = bytearray() + while time.monotonic() < deadline and len(buf) < len(probe): + chunk = t._port.read(len(probe)) + if chunk: + buf.extend(chunk) + t.close() + + print("RX:", " ".join("%02X" % b for b in buf) if buf else "(nichts)") + if bytes(buf) == probe: + print("OK -> Adapter-Empfang funktioniert. Fehler liegt in der Verkabelung zur Anzeige.") + return 0 + if buf: + print("Teilweise/verfaelscht -> Baudrate/Parity oder Wackelkontakt pruefen.") + return 1 + print("Nichts empfangen -> Bruecke TXD<->RXD sitzt nicht, oder Adapter-RX defekt.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lx200.py b/lx200.py new file mode 100644 index 0000000..7c9a2c1 --- /dev/null +++ b/lx200.py @@ -0,0 +1,46 @@ +"""LX200-Protokoll fuer die 10Micron GM4000 HPS II. + +Nur das Rahmen der Kommandos und Entpacken der Antworten. Das Parsen der +Koordinatenwerte macht coords.py (dessen Parser ist bewusst tolerant gegenueber +den verschiedenen LX200-Trennzeichen ':', '*', 0xDF, "'"). + +Reines Python, laeuft auch unter MicroPython. + +Kommando: ":" "#" z.B. :GR# +Antwort: "#" z.B. 18:36:56# (Get-Kommandos) + bzw. ein einzelnes Zeichen ohne "#" bei Steuerkommandos (hier ungenutzt) +""" + +TERMINATOR = "#" + +# Abfragekommandos (Get) -- liefern eine mit "#" abgeschlossene Antwort. +GET_RA = ":GR#" # Rektaszension des Teleskops +GET_DEC = ":GD#" # Deklination des Teleskops +GET_PRODUCT = ":GVP#" # Produktname, z.B. "10micron GM4000HPS" +GET_FIRMWARE = ":GVN#" # Firmware-Version + + +class LX200Error(Exception): + """Fehler im LX200-Protokoll.""" + + +def command(cmd): + """Einen Befehl in die Rahmenform ":#" bringen. + + Nimmt sowohl "GR" als auch bereits gerahmte ":GR#" entgegen. + """ + cmd = cmd.strip() + if cmd.startswith(":"): + cmd = cmd[1:] + if cmd.endswith(TERMINATOR): + cmd = cmd[:-1] + if not cmd: + raise LX200Error("leeres Kommando") + return ":" + cmd + TERMINATOR + + +def strip_terminator(response): + """Das abschliessende "#" entfernen und Rest liefern.""" + if not response.endswith(TERMINATOR): + raise LX200Error("Antwort ohne Abschluss '#': %r" % (response,)) + return response[:-len(TERMINATOR)] diff --git a/migra.py b/migra.py new file mode 100644 index 0000000..29bf739 --- /dev/null +++ b/migra.py @@ -0,0 +1,185 @@ +"""Protokoll-Layer fuer die microSYST migra SC/MC Seriell LED-Grossanzeige. + +Reines Python ohne Abhaengigkeiten, laeuft unter CPython und MicroPython. +Referenz: Benutzerhandbuch X-M31-9AXX5X-006 (V4.20), Kapitel 3.5 bis 3.9. + +Telegrammaufbau: STX | DA | SA | FC | [LEN-H LEN-L] | Data-Unit | [CHK-H CHK-L] | ETX +""" + +STX = 0x02 +ETX = 0x03 +ESC = 0x1B +SEP = 0x1F # Trennzeichen ESC-Sequenz -> Online-Text (Handbuch 3.9) + +MAX_DATA_UNIT = 230 + +# Farben (ESC-A, ESC-F, ESC-P, ESC-H) +BLACK = "0" +GREEN = "1" +RED = "2" +YELLOW = "3" +TRANSPARENT = "T" # nur als Hintergrundfarbe + +STATIC = "0" +BLINKING = "1" + +# Fehlercodes aus dem Antworttelegramm (Handbuch 3.8) +ERRORS = { + "0": "kein Fehler", + "1": "falsche Pruefsumme", + "2": "reserviert", + "3": "falsche Anzahl Datenbytes (LEN-H/L) oder ungueltige ESC-Sequenz", + "4": "Element nicht vorhanden oder ungueltiger Parameter", + "5": "Flash ungueltig", +} + + +class MigraError(Exception): + """Fehler im Protokoll oder von der Anzeige gemeldeter Fehlercode.""" + + +def encode_text(s): + """Text in Anzeige-Bytes wandeln. + + Die Data-Unit erlaubt 0x20-0xFF sowie 0x0A und 0x0D. Zeichen darueber + hinaus werden zu '?'. Die Abbildung entspricht Latin-1, das sich mit dem + Windows-Zeichensatz der Anzeige im Bereich 0xA0-0xFF deckt (u.a. Grad 0xB0). + """ + out = bytearray() + for ch in s: + c = ord(ch) + if c in (0x0A, 0x0D) or 0x20 <= c <= 0xFF: + out.append(c) + else: + out.append(0x3F) # '?' + return bytes(out) + + +def _digits(value, places): + """Zahl als ASCII-Ziffern fester Laenge (Handbuch 3.7: alle Werte ASCII-codiert).""" + if value < 0 or value >= 10 ** places: + raise MigraError("Wert %d passt nicht in %d Stellen" % (value, places)) + return ("%0*d" % (places, value)).encode("ascii") + + +class DataUnit: + """Baut eine Data-Unit aus mehreren Teiltelegrammen. + + Setzt das Trennzeichen 0x1F selbsttaetig, wenn auf eine ESC-Sequenz ein + Online-Text folgt. Nur dieser Uebergang braucht es (Handbuch 3.9). + """ + + def __init__(self): + self._buf = bytearray() + self._after_esc = False + + def __len__(self): + return len(self._buf) + + def bytes(self): + return bytes(self._buf) + + def _esc(self, *parts): + self._buf.append(ESC) + for p in parts: + self._buf.extend(p if isinstance(p, bytes) else p.encode("ascii")) + self._after_esc = True + return self + + # -- Online-Text ------------------------------------------------------ + def text(self, s): + if self._after_esc: + self._buf.append(SEP) + self._buf.extend(encode_text(s)) + self._after_esc = False + return self + + # -- Zeichensatz, Cursor, Attribute (3.7.1) --------------------------- + def charset(self, number, spaced=True): + """spaced=True -> 'z' (Sperrschrift, gleiche Breite), False -> 'Z'.""" + return self._esc("z" if spaced else "Z", _digits(number, 2)) + + def cursor(self, x, y): + """Cursor auf Pixelposition x/y. 0/0 ist links oben.""" + return self._esc("C", _digits(x, 3), _digits(y, 3)) + + def attributes(self, fg=RED, bg=BLACK, blink=STATIC): + return self._esc("A", fg, bg, blink) + + # -- Direkte Grafik (3.7.3) ------------------------------------------- + def fill(self, color=BLACK): + """Anzeige komplett fuellen. fill(BLACK) = loeschen.""" + return self._esc("F", color) + + def point(self, x, y, color=RED): + return self._esc("P", color, _digits(x, 3), _digits(y, 3)) + + # -- Gespeicherte Objekte (3.7.2) ------------------------------------- + def stored_text(self, number, show=True): + return self._esc("T", "+" if show else "-", _digits(number, 3)) + + def graphic(self, number, show=True): + return self._esc("G", "+" if show else "-", _digits(number, 3)) + + # -- Allgemeine Funktionen (3.7.4) ------------------------------------ + def brightness(self, percent, color=RED): + """Helligkeit 0-100 %. Wird geraeteintern noch mit dem HEX-Schalter multipliziert.""" + if not 0 <= percent <= 100: + raise MigraError("Helligkeit %r ausserhalb 0-100 %%" % percent) + return self._esc("H", color, _digits(percent, 3)) + + def blink_period(self, level): + """0 = 2 s bis 9 = 0,2 s.""" + return self._esc("B", _digits(level, 1)) + + +def build_telegram(data, dest=1, src=0, checksum=False, response=True): + """Data-Unit in ein vollstaendiges Telegramm rahmen. + + dest/src sind die reinen Adressen (0-126); Bit 7 wird hier gesetzt. + checksum=False bedeutet: LEN-H/L und CHK-H/L duerfen *nicht* mitgesendet + werden (Handbuch 3.5.1). + """ + if isinstance(data, DataUnit): + data = data.bytes() + if len(data) > MAX_DATA_UNIT: + raise MigraError("Data-Unit hat %d Bytes, erlaubt sind %d" % (len(data), MAX_DATA_UNIT)) + if not 0 <= dest <= 127 or not 0 <= src <= 126: + raise MigraError("Adresse ausserhalb des gueltigen Bereichs") + + da = 0x80 | dest + sa = 0x80 | src + fc = 0x80 | (0x02 if checksum else 0x00) | (0x01 if response else 0x00) + + frame = bytearray([STX, da, sa, fc]) + if checksum: + n = len(data) + frame.append(0xF0 | ((n >> 4) & 0x0F)) + frame.append(0xF0 | (n & 0x0F)) + frame.extend(data) + if checksum: + # Summe aus DA, SA, FC, LEN-H, LEN-L und allen Datenbytes; ohne STX. + chk = sum(frame[1:]) & 0xFF + frame.append(0xF0 | ((chk >> 4) & 0x0F)) + frame.append(0xF0 | (chk & 0x0F)) + frame.append(ETX) + return bytes(frame) + + +def parse_response(frame, src=0): + """Antworttelegramm auswerten und den Fehlercode als Zeichen liefern. + + Erwartet: STX | DA | SA | FC | Fehlercode | ETX (Handbuch 3.8) + Wirft MigraError, wenn die Anzeige einen Fehler ungleich '0' meldet. + """ + if not frame: + raise MigraError("keine Antwort von der Anzeige (Timeout)") + if len(frame) < 6 or frame[0] != STX or frame[-1] != ETX: + raise MigraError("unplausibles Antworttelegramm: %s" % frame.hex()) + if frame[1] != (0x80 | src): + raise MigraError("Antwort nicht an uns adressiert: %s" % frame.hex()) + + code = chr(frame[4]) + if code != "0": + raise MigraError("Anzeige meldet Fehler '%s': %s" % (code, ERRORS.get(code, "unbekannt"))) + return code diff --git a/mount_client.py b/mount_client.py new file mode 100644 index 0000000..f7e8159 --- /dev/null +++ b/mount_client.py @@ -0,0 +1,88 @@ +"""TCP-Client zur 10Micron-Montierung (LX200 ueber Ethernet). + +Die Socket-API (socket.socket/connect/send/recv) ist unter CPython und +MicroPython gleich, laeuft also spaeter auch auf dem ESP32-C3. +""" + +import socket + +import lx200 + + +class MountError(Exception): + """Verbindungs- oder Kommunikationsfehler mit der Montierung.""" + + +class MountClient: + """Fragt Koordinaten von der Montierung ab. + + Verbindung wird nicht automatisch gehalten -- bei einem Fehler wirft die + Abfrage MountError; der Aufrufer entscheidet ueber Reconnect (siehe + run_display.py). + """ + + def __init__(self, host, port=3490, timeout=3.0): + self.host = host + self.port = port + self.timeout = timeout + self._sock = None + + def connect(self): + self.close() + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(self.timeout) + try: + sock.connect((self.host, self.port)) + except OSError as e: + sock.close() + raise MountError("Verbindung zu %s:%d fehlgeschlagen: %s" + % (self.host, self.port, e)) + self._sock = sock + + @property + def connected(self): + return self._sock is not None + + def _recv_until_terminator(self): + buf = bytearray() + while True: + try: + chunk = self._sock.recv(64) + except OSError as e: + raise MountError("Empfang gestoert: %s" % e) + if not chunk: + raise MountError("Verbindung von der Montierung geschlossen") + buf.extend(chunk) + if buf.endswith(lx200.TERMINATOR.encode("latin-1")): + return bytes(buf) + + def query(self, framed_cmd): + """Ein gerahmtes Kommando senden und die Antwort ohne '#' zurueckgeben.""" + if not self.connected: + raise MountError("nicht verbunden") + try: + self._sock.sendall(framed_cmd.encode("latin-1")) + except OSError as e: + raise MountError("Senden gestoert: %s" % e) + raw = self._recv_until_terminator().decode("latin-1") + return lx200.strip_terminator(raw) + + def get_ra(self): + """Rektaszension als Rohstring, z.B. '18:36:56'.""" + return self.query(lx200.GET_RA) + + def get_dec(self): + """Deklination als Rohstring, z.B. '+38*47:01'.""" + return self.query(lx200.GET_DEC) + + def get_coordinates(self): + """(ra, dec) als Rohstrings in einem Rutsch.""" + return self.get_ra(), self.get_dec() + + def close(self): + if self._sock is not None: + try: + self._sock.close() + except OSError: + pass + self._sock = None diff --git a/mount_mock.py b/mount_mock.py new file mode 100644 index 0000000..3bfed0d --- /dev/null +++ b/mount_mock.py @@ -0,0 +1,195 @@ +#!/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) + +Nur fuer die Entwicklung (CPython). Auf dem ESP32 laeuft spaeter 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 main(argv=None): + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--host", default="127.0.0.1") + 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) + + mock = MountMock(host=args.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 "")) + print("Beenden mit Ctrl-C.") + mock.serve_forever() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/probe.py b/probe.py new file mode 100644 index 0000000..1939d21 --- /dev/null +++ b/probe.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Geometrie der Anzeige ausmessen. + +LINE_Y und CHARS_PER_LINE in config.py sind aus dem alten C-Programm abgeleitet, +nicht aus einem Datenblatt. Dieses Skript hilft, die echten Werte zu finden. + + python3 probe.py ruler Ziffernlineal 12345678901234... -> Zeichen zaehlen + python3 probe.py lines Zeile 1 und Zeile 2 an den Positionen aus config.py + python3 probe.py scan-y dieselbe Zeile schrittweise nach unten schieben + python3 probe.py corners Punkte in die vier Ecken -> Pixelaufloesung pruefen +""" + +import sys +import time + +import config +import migra +from display import open_display + + +def ruler(d): + """Ziffernlineal auf EINER Zeile: die Position, an der es umbricht, ist die Breite. + + Bewusst nur eine Zeile und etwas mehr Zeichen als erwartet, damit man den + Umbruch sieht, ohne dass sich mehrere Zeilen ueberschreiben. + """ + n = config.CHARS_PER_LINE + 5 + line = "".join(str((i + 1) % 10) for i in range(n)) + du = migra.DataUnit() + du.fill(config.BACKGROUND) + du.charset(config.CHARSET, spaced=config.CHARSET_SPACED) + du.attributes(fg=config.COLOR, bg=config.BACKGROUND) + du.cursor(0, config.LINE_Y[0]) + du.text(line) + d.send(du) + print("Bis zu welcher Ziffer laeuft die obere Zeile, bevor sie umbricht?") + print("Das ist CHARS_PER_LINE (aktuell in config.py: %d)." % config.CHARS_PER_LINE) + + +def lines(d): + d.show_lines("Zeile 1", "Zeile 2") + print("Stehen beide Zeilen sauber untereinander? Sonst LINE_Y anpassen.") + + +def scan_y(d): + """Zeile schrittweise nach unten schieben, um die y-Position zu finden.""" + for y in range(0, 33, 2): + du = migra.DataUnit() + du.fill(config.BACKGROUND) + du.charset(config.CHARSET, spaced=config.CHARSET_SPACED) + du.attributes(fg=config.COLOR, bg=config.BACKGROUND) + du.cursor(0, y) + du.text("y=%d" % y) + try: + d.send(du) + except migra.MigraError as e: + print("y=%-3d -> %s" % (y, e)) + continue + print("y=%-3d -> ok" % y) + time.sleep(1.5) + + +def corners(d): + """Punkte in die Ecken setzen. Ein Fehlercode '4' heisst: ausserhalb der Anzeige.""" + for x, y in ((0, 0), (63, 0), (0, 15), (63, 15), (127, 31)): + du = migra.DataUnit().point(x, y, color=config.COLOR) + try: + d.send(du) + print("Punkt %3d/%-3d -> ok" % (x, y)) + except migra.MigraError as e: + print("Punkt %3d/%-3d -> %s" % (x, y, e)) + + +MODES = {"ruler": ruler, "lines": lines, "scan-y": scan_y, "corners": corners} + + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith("-")] + if len(args) != 1 or args[0] not in MODES: + print(__doc__) + return 2 + response = config.WANT_RESPONSE and "--no-response" not in sys.argv + d = open_display(response=response) + try: + MODES[args[0]](d) + finally: + d.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/run_display.py b/run_display.py new file mode 100644 index 0000000..6a33d8c --- /dev/null +++ b/run_display.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Hauptprogramm Phase 2: Koordinaten von der Montierung pollen und anzeigen. + +Fragt zyklisch RA/DEC von der 10Micron-Montierung ab und schreibt sie ueber die +Grossanzeige. Bei Verbindungsproblemen wird eine Hinweiszeile angezeigt und im +Hintergrund erneut verbunden. + + python3 run_display.py # Montierung aus config.py + python3 run_display.py --host 10.0.0.42 + python3 run_display.py --mock # lokalen Mock starten und nutzen + python3 run_display.py --mock --move --once # einmalig, mit laufender RA +""" + +import argparse +import sys +import time + +import config +import coords +from display import open_display +from mount_client import MountClient, MountError + + +def _format(ra_raw, dec_raw): + """Rohstrings der Montierung fuer die Anzeige aufbereiten.""" + return coords.format_ra(ra_raw), coords.format_dec(dec_raw) + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--host", default=config.MOUNT_HOST) + p.add_argument("--port", type=int, default=config.MOUNT_PORT) + p.add_argument("--interval", type=float, default=config.POLL_INTERVAL) + p.add_argument("--once", action="store_true", help="nur eine Abfrage, dann Ende") + p.add_argument("--mock", action="store_true", + help="lokalen Mock starten und gegen ihn laufen") + p.add_argument("--move", action="store_true", + help="nur mit --mock: RA laeuft mit der Zeit") + p.add_argument("--dry-run", action="store_true", + help="Anzeige nur als Hex mitschreiben (keine serielle Hardware)") + args = p.parse_args(argv) + + host, port = args.host, args.port + mock = None + if args.mock: + from mount_mock import MountMock + mock = MountMock(host="127.0.0.1", port=0, move=args.move) + mock.start() + host, port = mock.host, mock.port + print("Mock-Montierung laeuft auf %s:%d" % (host, port)) + + d = open_display(fake=args.dry_run) + client = MountClient(host, port, timeout=config.MOUNT_TIMEOUT) + last_shown = None + + try: + while True: + try: + if not client.connected: + client.connect() + ra_raw, dec_raw = client.get_coordinates() + ra_line, dec_line = _format(ra_raw, dec_raw) + + if (ra_line, dec_line) != last_shown: + # Bei jeder Aenderung beide Zeilen neu zeichnen. Kein Vollbild- + # Loeschen (kein Flimmern); die umgekehrte Zeilenreihenfolge in + # show_lines schuetzt die Home-Zelle vor dem Gradzeichen- + # Zeichensatzwechsel, auch bei staendiger Aktualisierung. + first = last_shown is None or last_shown == config.NOLINK_LINES + d.show_lines(ra_line, dec_line, clear=first) + last_shown = (ra_line, dec_line) + print("RA %-11s DEC %-12s -> %s | %s" + % (ra_raw, dec_raw, ra_line, dec_line)) + except (MountError, ValueError) as e: + print("Montierung: %s" % e, file=sys.stderr) + client.close() + if config.NOLINK_LINES != last_shown: + d.show_lines(*config.NOLINK_LINES, clear=True) + last_shown = config.NOLINK_LINES + + if args.once: + break + time.sleep(args.interval) + except KeyboardInterrupt: + print("\nbeendet") + finally: + client.close() + d.close() + if mock is not None: + mock.stop() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scope.py b/scope.py new file mode 100644 index 0000000..9c41b27 --- /dev/null +++ b/scope.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Dauersenden auf der seriellen Schnittstelle, zum Messen mit dem Oszilloskop. + +Es wird ohne Antworttelegramm gesendet (die Anzeige haengt ja noch nicht dran), +d.h. FC = 0x80 statt 0x81. + +Muster: + telegram das echte Koordinaten-Telegramm (Vorgabe) + 0x55 01010101 -- bei 8E1 die schnellste Bitfolge, ideal zum + Ausmessen der Bitzeit (1 Bit = 52,08 us bei 19200 Baud) + 0xAA 10101010 + 0x00 / 0xFF konstant, zum Pruefen der Ruhepegel + count 0x00, 0x01, 0x02 ... fortlaufend + +Beispiele: + python3 scope.py # 5 min, Telegramm alle 200 ms + python3 scope.py --pattern 0x55 --interval 0.05 --minutes 10 + python3 scope.py --pattern 0x55 --interval 0 # so schnell wie moeglich +""" + +import argparse +import sys +import time + +import config +import coords +import migra +from transport import SerialTransport + +PATTERNS = {"0x55": 0x55, "0xAA": 0xAA, "0x00": 0x00, "0xFF": 0xFF} + + +def build_frame(pattern, counter): + """Die zu sendenden Bytes fuer einen Durchlauf liefern.""" + if pattern == "telegram": + du = migra.DataUnit() + du.fill(config.BACKGROUND) + du.charset(config.CHARSET) + du.attributes(fg=config.COLOR, bg=config.BACKGROUND) + du.cursor(0, config.LINE_Y[0]) + du.text(coords.format_ra("12h34m56s")) + du.cursor(0, config.LINE_Y[1]) + du.text(coords.format_dec("+45d12m30s")) + return migra.build_telegram(du, dest=config.DISPLAY_ADDR, + src=config.HOST_ADDR, + checksum=config.USE_CHECKSUM, + response=False) + if pattern == "count": + return bytes([counter & 0xFF]) + return bytes([PATTERNS[pattern]] * 16) + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--pattern", default="telegram", + choices=["telegram", "count"] + list(PATTERNS)) + p.add_argument("--port", default=config.PORT) + p.add_argument("--interval", type=float, default=0.2, + help="Pause zwischen den Sendungen in Sekunden (Vorgabe: %(default)s)") + p.add_argument("--minutes", type=float, default=5.0, + help="Laufzeit in Minuten (Vorgabe: %(default)s)") + args = p.parse_args(argv) + + t = SerialTransport(port=args.port, baudrate=config.BAUDRATE, + parity=config.PARITY, bytesize=config.BYTESIZE, + stopbits=config.STOPBITS, timeout=0.1) + + first = build_frame(args.pattern, 0) + bit_us = 1e6 / config.BAUDRATE + print("Port : %s" % args.port) + print("Format : %d Baud, %d%s%d (1 Bit = %.2f us, 1 Byte = 11 Bit = %.1f us)" + % (config.BAUDRATE, config.BYTESIZE, config.PARITY, config.STOPBITS, + bit_us, bit_us * 11)) + print("Muster : %s, %d Byte pro Durchlauf" % (args.pattern, len(first))) + print(" %s" % " ".join("%02X" % b for b in first)) + print("Intervall : %s s, Laufzeit %s min" % (args.interval, args.minutes)) + print("Abbruch mit Ctrl-C.\n") + + deadline = time.monotonic() + args.minutes * 60 + n = 0 + try: + while time.monotonic() < deadline: + t.write(build_frame(args.pattern, n)) + n += 1 + if n % 25 == 0: + rest = deadline - time.monotonic() + print("\r%6d Sendungen, noch %4.1f min " % (n, rest / 60), end="") + sys.stdout.flush() + if args.interval: + time.sleep(args.interval) + except KeyboardInterrupt: + print("\nabgebrochen") + finally: + t.close() + + print("\n%d Sendungen abgesetzt." % n) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/show_coords.py b/show_coords.py new file mode 100644 index 0000000..5f285be --- /dev/null +++ b/show_coords.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Testprogramm: Koordinaten von Hand uebergeben und auf der Grossanzeige ausgeben. + +Beispiele: + python3 show_coords.py 12h34m56s +45d12m30s + python3 show_coords.py 12:34:56 -07:15:00 + python3 show_coords.py 12h34m56s +45d12m30s --dry-run + python3 show_coords.py --clear +""" + +import argparse +import sys + +import config +import coords +import migra +from display import open_display + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("ra", nargs="?", help="Rektaszension, z.B. 12h34m56s oder 12:34:56") + p.add_argument("dec", nargs="?", help="Deklination, z.B. +45d12m30s oder +45:12:30") + p.add_argument("--port", default=config.PORT, help="serieller Port (Vorgabe: %(default)s)") + p.add_argument("--brightness", type=int, help="Helligkeit 0-100 %% setzen") + p.add_argument("--clear", action="store_true", help="Anzeige loeschen und beenden") + p.add_argument("--dry-run", action="store_true", + help="nichts senden, nur die Telegramme als Hex zeigen") + p.add_argument("--no-response", action="store_true", + help="ohne Antworttelegramm senden (wenn die RS232-Rueckleitung fehlt)") + args = p.parse_args(argv) + + if not args.clear and (args.ra is None or args.dec is None): + p.error("Rektaszension und Deklination angeben (oder --clear)") + + if not args.clear: + line1 = coords.format_ra(args.ra) + line2 = coords.format_dec(args.dec) + print("Zeile 1: %r" % line1) + print("Zeile 2: %r" % line2) + for name, line in (("Zeile 1", line1), ("Zeile 2", line2)): + if len(line) > config.CHARS_PER_LINE: + print("Warnung: %s ist laenger als CHARS_PER_LINE=%d" + % (name, config.CHARS_PER_LINE), file=sys.stderr) + + d = open_display(port=args.port, fake=args.dry_run, + response=config.WANT_RESPONSE and not args.no_response) + try: + if args.brightness is not None: + d.set_brightness(args.brightness) + if args.clear: + d.clear() + else: + d.show_lines(line1, line2, clear=True) # Einzelaufruf: einmal sauber loeschen + except migra.MigraError as e: + print("Fehler: %s" % e, file=sys.stderr) + return 1 + finally: + d.close() + + print("OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test_lx200.py b/test_lx200.py new file mode 100644 index 0000000..01887e7 --- /dev/null +++ b/test_lx200.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Tests fuer LX200-Protokoll, Mock und Client (Phase 2). + + python3 -m unittest -v test_lx200 +""" + +import unittest + +import coords +import lx200 +import mount_mock +from mount_client import MountClient, MountError + + +class TestLX200(unittest.TestCase): + + def test_command_rahmen(self): + self.assertEqual(lx200.command("GR"), ":GR#") + self.assertEqual(lx200.command(":GR#"), ":GR#") + self.assertEqual(lx200.command("GR#"), ":GR#") + self.assertEqual(lx200.command(":GR"), ":GR#") + + def test_command_leer(self): + with self.assertRaises(lx200.LX200Error): + lx200.command(":#") + + def test_strip_terminator(self): + self.assertEqual(lx200.strip_terminator("18:36:56#"), "18:36:56") + with self.assertRaises(lx200.LX200Error): + lx200.strip_terminator("18:36:56") + + +class TestAntwortParsen(unittest.TestCase): + """Die LX200-Rohantworten muessen direkt in den coords-Parser passen.""" + + def test_ra_antwort(self): + self.assertEqual(coords.parse_ra("18:36:56"), (18, 36, 56)) + + def test_dec_antwort_mit_stern(self): + self.assertEqual(coords.parse_dec("+38*47:01"), ("+", 38, 47, 1)) + + def test_dec_antwort_mit_gradzeichen_0xDF(self): + # Meade-Stil: 0xDF als Gradzeichen + self.assertEqual(coords.parse_dec("-07\xdf15:30"), ("-", 7, 15, 30)) + + +class TestMockFormat(unittest.TestCase): + + def test_ra_roundtrip(self): + self.assertEqual(mount_mock._fmt_ra(mount_mock._parse_ra("18:36:56")), "18:36:56") + + def test_dec_roundtrip(self): + s = mount_mock._parse_dec("-07*15:30") + self.assertEqual(mount_mock._fmt_dec(s), "-07*15:30") + + def test_ra_wrap(self): + # 23:59:59 + 2s -> 00:00:01 + self.assertEqual(mount_mock._fmt_ra(mount_mock._parse_ra("23:59:59") + 2), "00:00:01") + + +class TestClientGegenMock(unittest.TestCase): + """Vollstaendiger Weg: Client <-> Mock ueber echtes TCP.""" + + def setUp(self): + self.mock = mount_mock.MountMock(host="127.0.0.1", port=0, + ra="18:36:56", dec="+38:47:01") + self.mock.start() + self.client = MountClient("127.0.0.1", self.mock.port, timeout=2.0) + self.client.connect() + + def tearDown(self): + self.client.close() + self.mock.stop() + + def test_get_ra(self): + self.assertEqual(self.client.get_ra(), "18:36:56") + + def test_get_dec(self): + self.assertEqual(self.client.get_dec(), "+38*47:01") + + def test_get_coordinates_und_formatierung(self): + ra_raw, dec_raw = self.client.get_coordinates() + self.assertEqual(coords.format_ra(ra_raw), "18h36m56s") + self.assertEqual(coords.format_dec(dec_raw), "+38°47'01") # 9 Zeichen, ohne " am Ende + + def test_produktname(self): + self.assertEqual(self.client.query(lx200.GET_PRODUCT), mount_mock.PRODUCT) + + def test_unbekanntes_kommando_haengt_nicht(self): + # Muss eine (leere) abgeschlossene Antwort liefern, nicht blockieren. + self.assertEqual(self.client.query(":GXYZ#"), "") + + +class TestClientFehler(unittest.TestCase): + + def test_verbindung_verweigert(self): + # Port, auf dem nichts lauscht + c = MountClient("127.0.0.1", 1, timeout=0.5) + with self.assertRaises(MountError): + c.connect() + + def test_query_ohne_verbindung(self): + c = MountClient("127.0.0.1", 3490) + with self.assertRaises(MountError): + c.get_ra() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test_migra.py b/test_migra.py new file mode 100644 index 0000000..7444219 --- /dev/null +++ b/test_migra.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Tests fuer den Protokoll-Layer, gepruefte Referenz ist das Handbuch. + + python3 -m unittest -v test_migra +""" + +import unittest + +import coords +import migra +from display import Display +from transport import FakeTransport + + +class TestTelegramm(unittest.TestCase): + + def test_handbuch_beispiel_mit_pruefsumme(self): + """Handbuch 3.10, Beispiel 2: Zeichen 'A' an Anzeige mit Adresse 1. + + Master: 02 81 80 83 F0 F1 41 FA F6 03 + """ + frame = migra.build_telegram(b"A", dest=1, src=0, checksum=True, response=True) + self.assertEqual(frame, bytes.fromhex("02818083F0F141FAF603")) + + def test_handbuch_beispiel_ohne_pruefsumme(self): + """Handbuch 3.10, Beispiel 1: ohne Pruefsumme, mit Antwort. + + Ohne Pruefsumme duerfen LEN-H/L gerade *nicht* mitgesendet werden. + """ + frame = migra.build_telegram(b"Hallo Welt", dest=1, src=0, + checksum=False, response=True) + self.assertEqual(frame[:4], bytes([0x02, 0x81, 0x80, 0x81])) + self.assertEqual(frame[4:-1], b"Hallo Welt") + self.assertEqual(frame[-1], 0x03) + + def test_frame_control_bits(self): + def fc(checksum, response): + return migra.build_telegram(b"x", checksum=checksum, response=response)[3] + + self.assertEqual(fc(False, False), 0x80) + self.assertEqual(fc(False, True), 0x81) # so faehrt das alte C-Programm + self.assertEqual(fc(True, False), 0x82) + self.assertEqual(fc(True, True), 0x83) + + def test_data_unit_zu_lang(self): + with self.assertRaises(migra.MigraError): + migra.build_telegram(b"x" * 231) + + +class TestDataUnit(unittest.TestCase): + + def test_trennzeichen_nach_esc_sequenz(self): + """Handbuch 3.9: Data-Unit = 1B 'Z01' 1B 'C002003' 1B 'A301' 1F Text.""" + du = (migra.DataUnit() + .charset(1, spaced=False) + .cursor(2, 3) + .attributes(fg=migra.YELLOW, bg=migra.BLACK, blink=migra.BLINKING) + .text("Online-Text")) + self.assertEqual( + du.bytes(), + b"\x1bZ01\x1bC002003\x1bA301\x1fOnline-Text", + ) + + def test_kein_trennzeichen_zwischen_esc_sequenzen(self): + du = migra.DataUnit().fill(migra.BLACK).charset(0) + self.assertEqual(du.bytes(), b"\x1bF0\x1bz00") + + def test_trennzeichen_nur_einmal_pro_textblock(self): + du = migra.DataUnit().cursor(0, 0).text("AB").text("CD") + self.assertEqual(du.bytes(), b"\x1bC000000\x1fABCD") + + def test_cursor_ist_ascii_codiert(self): + """Handbuch 3.7: Position 123 wird als '1' '2' '3' uebertragen.""" + self.assertEqual(migra.DataUnit().cursor(123, 45).bytes(), b"\x1bC123045") + + def test_loeschen_und_helligkeit(self): + self.assertEqual(migra.DataUnit().fill(migra.BLACK).bytes(), b"\x1bF0") + self.assertEqual(migra.DataUnit().brightness(100, migra.RED).bytes(), b"\x1bH2100") + self.assertEqual(migra.DataUnit().brightness(80, migra.RED).bytes(), b"\x1bH2080") + + def test_helligkeit_ausserhalb_bereich(self): + with self.assertRaises(migra.MigraError): + migra.DataUnit().brightness(101) + + def test_text_encoding_latin1(self): + self.assertEqual(migra.encode_text("+45°12'"), b"+45\xb012'") + self.assertEqual(migra.encode_text("✓"), b"?") # nicht darstellbar + + +class TestAntwort(unittest.TestCase): + + def test_ok(self): + frame = bytes([0x02, 0x80, 0x81, 0x80, ord("0"), 0x03]) + self.assertEqual(migra.parse_response(frame), "0") + + def test_fehlercode_wirft(self): + frame = bytes([0x02, 0x80, 0x81, 0x80, ord("3"), 0x03]) + with self.assertRaises(migra.MigraError) as ctx: + migra.parse_response(frame) + self.assertIn("ESC-Sequenz", str(ctx.exception)) + + def test_timeout_wirft(self): + with self.assertRaises(migra.MigraError): + migra.parse_response(b"") + + +class TestKoordinaten(unittest.TestCase): + + def test_ra_formate_werden_erkannt(self): + for text in ("12h34m56s", "12:34:56", "12 34 56"): + self.assertEqual(coords.parse_ra(text), (12, 34, 56)) + + def test_ra_dezimal(self): + self.assertEqual(coords.parse_ra("12.5"), (12, 30, 0)) + + def test_dec_mit_vorzeichen(self): + self.assertEqual(coords.parse_dec("+45d12m30s"), ("+", 45, 12, 30)) + self.assertEqual(coords.parse_dec("-07:15:00"), ("-", 7, 15, 0)) + + def test_dec_ohne_vorzeichen_ist_positiv(self): + self.assertEqual(coords.parse_dec("45:12:30"), ("+", 45, 12, 30)) + + def test_bereichsgrenzen(self): + with self.assertRaises(ValueError): + coords.parse_ra("24:00:01") + with self.assertRaises(ValueError): + coords.parse_dec("+91:00:00") + + def test_formatierung_passt_sich_der_breite_an(self): + self.assertEqual(coords.format_ra("12h34m56s", width=9), "12h34m56s") + self.assertEqual(coords.format_ra("12h34m56s", width=8), "12:34:56") + self.assertEqual(coords.format_dec("+45d12m30s", width=10), "+45°12'30\"") + self.assertEqual(coords.format_dec("+45d12m30s", width=9), "+45°12'30") # 9: Grad, ohne " + self.assertEqual(coords.format_dec("+45d12m30s", width=7), "+45°12'") # 7: ohne Bogensekunden + + +class TestDisplay(unittest.TestCase): + + def test_zeilen_ergeben_ein_telegramm(self): + t = FakeTransport(echo=False) + Display(t, response=True).show_lines("12:34:56", "+45:12:30", clear=True) + + self.assertEqual(len(t.frames), 1) + frame = t.frames[0] + self.assertEqual(frame[:4], bytes([0x02, 0x81, 0x80, 0x81])) + self.assertEqual(frame[-1], 0x03) + + data = frame[4:-1] + self.assertIn(b"\x1bF0", data) # mit clear=True: vorher loeschen + self.assertIn(b"\x1bC000000\x1f12:34:56", data) # Zeile 1 bei y=0 + self.assertIn(b"\x1bC000009\x1f+45:12:30", data) # Zeile 2 bei y=9 + + def test_ohne_clear_kein_fill_aber_aufgefuellt(self): + """Standard (clear=False): kein Vollbild-Loeschen, Zeilen auf volle Breite.""" + t = FakeTransport(echo=False) + Display(t, response=True).show_lines("12:34:56", clear=False) + data = t.frames[0][4:-1] + self.assertNotIn(b"\x1bF0", data) + # auf CHARS_PER_LINE mit Leerzeichen aufgefuellt + import config + padded = "12:34:56".ljust(config.CHARS_PER_LINE).encode() + self.assertIn(padded, data) + + def test_gradzeichen_via_zeichensatz(self): + """Gradzeichen wird durch kurzen Wechsel auf Zeichensatz 1 erzeugt.""" + t = FakeTransport(echo=False) + Display(t, response=True).show_lines("12h34m56s", "+45°12'30\"") + data = t.frames[0][4:-1] + # Umschalten auf Zeichensatz 1, "/", zurueck auf Zeichensatz 0 + self.assertIn(b"\x1bz01\x1f/\x1bz00", data) + + def test_oberste_zeile_wird_zuletzt_gezeichnet(self): + """Wegen der Home-Zellen-Stoerung muss y=0 nach y=9 im Telegramm stehen.""" + t = FakeTransport(echo=False) + Display(t, response=True).show_lines("12h34m56s", "+45°12'30\"") + data = t.frames[0][4:-1] + self.assertLess(data.index(b"C000009"), data.index(b"C000000")) + + def test_gradzeichen_fallback_ohne_zeichensatz(self): + """Ist DEGREE_CHARSET None, wird der Fallbacktext statt der Umschaltung genutzt.""" + import config + t = FakeTransport(echo=False) + alt = config.DEGREE_CHARSET + try: + config.DEGREE_CHARSET = None + Display(t, response=True).show_lines("+45°12'30\"") + finally: + config.DEGREE_CHARSET = alt + data = t.frames[0][4:-1] + self.assertNotIn(b"\x1bz01", data) + self.assertIn(config.DEGREE_FALLBACK.encode(), data) + + def test_fehlercode_der_anzeige_wirft(self): + t = FakeTransport(echo=False) + t._response = bytes([0x02, 0x80, 0x81, 0x80, ord("4"), 0x03]) + with self.assertRaises(migra.MigraError): + Display(t, response=True).clear() + + def test_show_line_nur_eine_zeile(self): + """show_line schreibt nur die angegebene Zeile, ohne Vollbild-Loeschen.""" + t = FakeTransport(echo=False) + Display(t, response=True).show_line(0, "18h36m56s") + data = t.frames[0][4:-1] + self.assertNotIn(b"\x1bF0", data) # kein Loeschen + self.assertIn(b"\x1bC000000", data) # Cursor auf Zeile 1 (y=0) + self.assertNotIn(b"C000009", data) # Zeile 2 unberuehrt + + def test_show_line_ungueltiger_index(self): + t = FakeTransport(echo=False) + with self.assertRaises(ValueError): + Display(t, response=True).show_line(5, "x") + + def test_ohne_antwort_fc_bit0_ist_null(self): + """Standardbetrieb ohne Antwort: FC = 0x80, kein Fehlercode wird gelesen.""" + import config + alt = config.QUIET_TIME + try: + config.QUIET_TIME = 0.0 + t = FakeTransport(echo=False) + # selbst wenn die (ignorierte) Antwort einen Fehler meldet, darf nichts fliegen + t._response = bytes([0x02, 0x80, 0x81, 0x80, ord("4"), 0x03]) + Display(t, response=False).clear() + finally: + config.QUIET_TIME = alt + self.assertEqual(t.frames[0][3], 0x80) # FC ohne Antwort-Bit + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/transport.py b/transport.py new file mode 100644 index 0000000..d315c10 --- /dev/null +++ b/transport.py @@ -0,0 +1,126 @@ +"""UART-Anbindung der Grossanzeige. + +Kapselt den Unterschied zwischen pyserial (Mac/PC) und machine.UART +(MicroPython auf dem XIAO ESP32-C3), damit display.py auf beiden laeuft. +""" + +import migra + +try: # CPython + import serial +except ImportError: + serial = None + +try: # MicroPython + from machine import UART +except ImportError: + UART = None + +try: + import time + _monotonic = time.monotonic +except AttributeError: # MicroPython kennt kein time.monotonic + _monotonic = lambda: time.ticks_ms() / 1000.0 + + +class SerialTransport: + """Serieller Port ueber pyserial (CPython).""" + + def __init__(self, port, baudrate, parity, bytesize, stopbits, timeout): + if serial is None: + raise RuntimeError("pyserial fehlt: pip install pyserial") + self._port = serial.Serial( + port=port, + baudrate=baudrate, + bytesize=bytesize, + parity=parity, # "E" = even, wie von der Anzeige erwartet + stopbits=stopbits, + timeout=timeout, + ) + self._timeout = timeout + + def write(self, data): + self._port.write(data) + self._port.flush() + + def read_response(self): + """Bytes bis einschliesslich ETX lesen, oder bis der Timeout greift.""" + deadline = _monotonic() + self._timeout + buf = bytearray() + while _monotonic() < deadline: + chunk = self._port.read(1) + if not chunk: + continue + buf.extend(chunk) + if chunk[0] == migra.ETX: + break + return bytes(buf) + + def close(self): + self._port.close() + + +class UartTransport: + """Serieller Port ueber machine.UART (MicroPython / ESP32-C3).""" + + def __init__(self, uart_id, baudrate, parity, bytesize, stopbits, timeout, + tx=None, rx=None): + if UART is None: + raise RuntimeError("machine.UART nicht verfuegbar") + # MicroPython: parity 0 = even, 1 = odd, None = keine + parity_map = {"E": 0, "O": 1, "N": None} + kwargs = dict( + baudrate=baudrate, + bits=bytesize, + parity=parity_map[parity], + stop=stopbits, + timeout=int(timeout * 1000), + ) + if tx is not None: + kwargs["tx"] = tx + if rx is not None: + kwargs["rx"] = rx + self._uart = UART(uart_id, **kwargs) + self._timeout = timeout + + def write(self, data): + self._uart.write(data) + + def read_response(self): + deadline = _monotonic() + self._timeout + buf = bytearray() + while _monotonic() < deadline: + chunk = self._uart.read(1) + if not chunk: + continue + buf.extend(chunk) + if chunk[0] == migra.ETX: + break + return bytes(buf) + + def close(self): + self._uart.deinit() + + +class FakeTransport: + """Schreibt die Telegramme nur als Hex mit, ohne Hardware. + + Fuer Trockenlauf und Tests: liefert immer ein fehlerfreies Antworttelegramm. + """ + + def __init__(self, src=0, display_addr=1, echo=True): + self.frames = [] + self._echo = echo + self._response = bytes([migra.STX, 0x80 | src, 0x80 | display_addr, + 0x80, ord("0"), migra.ETX]) + + def write(self, data): + self.frames.append(data) + if self._echo: + print("TX:", " ".join("%02X" % b for b in data)) + + def read_response(self): + return self._response + + def close(self): + pass