diff --git a/README.md b/README.md index bf8cb48..ee94d45 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ 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 run_display.py --mock --move --ldr 2000 # zusätzlich mit Helligkeitsregelung python3 mount_mock.py --move # Mock separat starten +python3 mount_mock.py --move --low # ... im Auslieferformat der GM4000 + # (RA als HH:MM.T, DEC ohne Sekunden) # Helligkeit aus einem LDR-Wert setzen: python3 brightness.py --ldr 1500 # LDR-Rohwert -> Helligkeit -> senden @@ -310,16 +312,23 @@ die Reihenfolge der Telegramme statt der Reihenfolge innerhalb eines Telegramms. - Die Deklination passt damit in **voller Form mit abschließendem `"`** (`+38°47'01"`, 10 Zeichen). `coords.format_dec` wählt automatisch die breiteste - Form, die in `CHARS_PER_LINE` passt — bei Niedrigpräzision entsprechend - `+38°47'` ohne Sekunden. + Form, die in die Zeile passt — bei Niedrigpräzision entsprechend `+38°47'` + ohne Sekunden. -- **Zeilen werden zentriert** (`CENTER_LINES = True`). Das wirkt auf zwei Ebenen: - `LINE_X` rückt den Zeichenblock in die Mitte (10·6 = 60 px lassen je 2 px Rand), - und kürzere Texte werden beidseitig mit Leerzeichen aufgefüllt. Der Block bleibt - dabei immer gleich breit und an derselben Stelle — so überschreibt er bei jedem - Durchlauf denselben Bereich und lässt keine Reste stehen. Ein pixelgenau - mitwandernder Offset könnte das nicht; dafür ist die Zentrierung nur auf eine - halbe Zelle (3 px) genau, wenn die Restbreite ungerade ist. +- **Schmale Zeichen bekommen weniger Platz** (`config.CELL_NARROW`). Der + Dezimalpunkt der Zehntelminuten und der Abstand zwischen Beschriftung und Wert + belegen statt 6 nur 3 px — in der Sperrschrift standen sie sonst sehr luftig. + Möglich ist das nur, weil ohnehin jedes Zeichen einzeln positioniert wird. + Für die Leerzeichen am Zeilenende gilt es *nicht*: Die sollen den Rest der + Zeile überschreiben und behalten die volle Breite. + + Deshalb entscheidet die **Pixelbreite** über das Abschneiden, nicht die + Zeichenzahl: `RA 18h36.9m` sind 11 Zeichen und trotzdem nur 60 px, `DE +38°47'` + wären als 11 volle Zellen dagegen 66 px und damit zu breit. + +- **Zeilen stehen linksbündig** ab `LINE_X = 0`. Zentriert waren sie + zwischenzeitlich, standen dann aber um wenige Pixel gegeneinander versetzt, weil + die RA-Zeile mit ihrem schmalen Dezimalpunkt kürzer ist als die DEC-Zeile. - **Es wird nur geschrieben, was sich geändert hat.** `show_lines` vergleicht gegen den zuletzt dargestellten Inhalt und schickt nur die betroffenen Zellen; eine @@ -353,15 +362,23 @@ RA als `HH:MM.T` (Zehntel-Minuten), DEC als `sDD°MM` (ganze Bogenminuten). Das Gradzeichen kommt als Byte `0xDF` — auf der Anzeige korrekt als Gradring dargestellt; in der Terminal-Konsole erscheint der **Rohwert** dieses Bytes als `ß` (rein kosmetisch). -`config.HIGH_PRECISION` steuert die DEC-Darstellung: -- `False` (Vorgabe): DEC ohne Sekunden (`-00°55'`). -- `True`: DEC mit Sekunden (`+38°47'01`) — erst sinnvoll, wenn die Montierung per - `:U#` auf Hochpräzision gestellt ist (schreibendes Kommando, hier bewusst nicht - automatisch gesendet). +`config.HIGH_PRECISION` steuert die ganze Darstellung — sie folgt dem, was die +Montierung wirklich liefert: -Die **RA** wird immer mit Sekunden gezeigt (`11h36m54s`); bei Niedrigpräzision -kommen die Sekunden aus den Zehntel-Minuten und springen daher in 6-Sekunden-Schritten -(kein Dezimalpunkt — der wirkt in der Sperrschrift zu luftig). +| | Niedrigpräzision (Vorgabe) | Hochpräzision (nach `:U#`) | +|---|---|---| +| Zeile 1 | `RA 18h36.9m` | `18h36m56s` | +| Zeile 2 | `DE +38°47'` | `+38°47'01"` | + +Bei Niedrigpräzision zeigt die **RA Zehntelminuten** statt Sekunden — genau die +Auflösung der Rohdaten. Bis 2026-07-27 wurde hier in Sekunden umgerechnet +(`11h36m54s`); die sprangen aber in 6-Sekunden-Schritten, weil die Montierung sie +gar nicht hergibt. Die kürzere Form lässt zugleich Platz für die **Beschriftung** +`RA` / `DE` (`config.LABEL_RA`, `LABEL_DEC`). + +Bei Hochpräzision entfällt die Beschriftung: Dort braucht die Deklination mit +`+38°47'01"` alle Zellen. `:U#` ist ein schreibendes Kommando und wird hier +bewusst nicht automatisch gesendet. `coords.format_ra`/`format_dec` nehmen `high_precision=` als Parameter (Vorgabe aus `config`). Die Montierungs-Rohwerte werden im `coords`-Parser unverändert korrekt diff --git a/config.py b/config.py index 1e05d37..e207f8e 100644 --- a/config.py +++ b/config.py @@ -55,33 +55,56 @@ CHARSET = 0 CHARSET_SPACED = True LINE_Y = (0, 9) -# 10 Zeichen zu 6 px -- mit "probe.py pitch 6" am Geraet entschieden (2026-07-27). +# 6 px pro Zeichenzelle -- mit "probe.py pitch 6" am Geraet gemessen (2026-07-27). # # Die Messung stellt den Auto-Vorschub der Anzeige gegen feste Pixelabstaende und # hat beides zugleich gezeigt: # - Auto-Vorschub der Sperrschrift ESC z ist 7 px. Eine Zeile aus einem Stueck -# bricht deshalb nach 9 Zeichen um (9*7 = 63 von 64 px). Daher stand hier -# frueher 9 -- das war der Vorschub, nicht die Zellenbreite. -# - Die Zeichenmatrix selbst ist aber nur 6 px breit: zehn Ziffern, einzeln auf +# bricht deshalb nach 9 Zeichen um (9*7 = 63 von 64 px). Frueher stand hier +# darum 9 -- das war der Vorschub, nicht die Zellenbreite. +# - Die Zeichenmatrix selbst ist nur 6 px breit: zehn Ziffern, einzeln auf # x = i*6 gesetzt, stehen sauber und getrennt nebeneinander (60 von 64 px). # # Deshalb positioniert display._emit_line jedes Zeichen selbst, statt den -# Auto-Vorschub laufen zu lassen. So passen 10 Zeichen, und die DEC-Form bekommt -# ihr abschliessendes " (siehe coords.format_dec). -CHARS_PER_LINE = 10 -CELL_WIDTH = 6 # Pixel pro Zeichenzelle bei fester Positionierung +# Auto-Vorschub laufen zu lassen. +CELL_WIDTH = 6 # Pixel pro Zeichenzelle DISPLAY_WIDTH = 64 # Pixelbreite der Anzeige -# Zeilen mittig setzen statt linksbuendig. Wirkt auf zwei Ebenen: -# - LINE_X rueckt den ganzen Zeichenblock in die Mitte: 10*6 = 60 px belegen -# nicht die vollen 64, das laesst je 2 px Rand. -# - Kuerzere Texte werden in _emit_line beidseitig mit Leerzeichen aufgefuellt, -# z.B. die 7 Zeichen von "+38°47'" bei Niedrigpraezision. -# Der Block bleibt dabei immer gleich breit und an derselben Stelle -- er -# ueberschreibt so bei jedem Durchlauf denselben Bereich, ohne Reste der vorigen -# Anzeige stehen zu lassen. Ein pixelgenau mitwandernder Offset koennte das nicht. -CENTER_LINES = True -LINE_X = (DISPLAY_WIDTH - CHARS_PER_LINE * CELL_WIDTH) // 2 +# Obergrenze in *Zeichen*. Die harte Grenze ist DISPLAY_WIDTH in Pixeln: Seit +# schmale Zeichen weniger Platz brauchen (CELL_NARROW), haengt die Breite einer +# Zeile davon ab, welche Zeichen darin stehen. 11 gehen nur auf, wenn mindestens +# eines schmal ist -- "RA:18h51.0m" braucht mit seinem Dezimalpunkt 63 der 64 px, +# "DE:+38°47'" waere als 11 volle Zellen dagegen 66 px und damit zu breit. +# display._aufbereiten schneidet deshalb nach Pixeln ab, nicht nach Zeichen. +CHARS_PER_LINE = 11 + +# Breite, die die Anzeige beim Schreiben eines Zeichens tatsaechlich bemalt -- +# mehr als CELL_WIDTH. Daher auch der Auto-Vorschub von 7 px. Wichtig fuer das +# Nachfuehren einzelner Zellen: Ein neu geschriebenes Zeichen loescht die erste +# Pixelspalte seines rechten Nachbarn, der muss also mitgeschrieben werden +# (siehe display._zelle_faellig). +GLYPH_WIDTH = 7 + +# Zeichen, die weniger Platz brauchen als eine volle Zelle -- in der Sperrschrift +# stehen sie sonst sehr luftig zwischen den Ziffern. Je 3 px am Geraet +# ausprobiert (2026-07-27); weil jedes Zeichen ohnehin einzeln positioniert wird, +# kostet das nichts. +# "." Dezimalpunkt der Zehntelminuten (RA 18h37.1m) +# " " Abstand zwischen Beschriftung und Wert (RA 18h37.1m) +# Achtung: Das gilt auch fuer die Leerzeichen, mit denen eine Zeile aufgefuellt +# wird. display._aufbereiten rechnet beim Auffuellen darum bewusst mit der vollen +# Zellenbreite -- sonst reichte die letzte Zelle ueber den Rand hinaus. +CELL_NARROW = {".": 3, " ": 3} + +# Alle Zeilen beginnen linksbuendig bei LINE_X. Bis 2026-07-27 gab es einen +# Schalter zum Zentrieren; er ist entfallen, weil die Zeilen seit den schmalen +# Zeichen (CELL_NARROW) unterschiedlich breit sind und zentriert um wenige Pixel +# gegeneinander versetzt standen. Mit der Beschriftung "RA:" / "DE:" sollen sie +# buendig untereinander beginnen. +# +# LINE_X = 0, ganz am Rand: Die RA-Zeile braucht mit der Minutenangabe am Ende +# ("RA:18h51.0m") 63 der 64 Pixel -- fuer einen Rand ist da kein Platz mehr. +LINE_X = 0 COLOR = migra.RED # einfarbig rote Anzeige BACKGROUND = migra.BLACK @@ -112,6 +135,22 @@ POLL_INTERVAL = 1.0 # Sekunden zwischen zwei Koordinatenabfragen # True = Hochpraezision (Montierung mit ":U#" umgestellt): RA/DEC mit Sekunden. HIGH_PRECISION = False +# Beschriftung der beiden Zeilen: +# +# RA 18h37.1m +# DE +38°47' +# +# Der Abstand ist ein Leerzeichen, das ueber CELL_NARROW nur 3 px breit gesetzt +# wird -- ein Doppelpunkt stand hier zwischenzeitlich, wirkte in Zeichensatz 0 +# aber zu wuchtig (2026-07-27). +# +# Die Beschriftung gibt es nur bei Niedrigpraezision: Dort liefert die Montierung +# ohnehin nur Zehntelminuten und ganze Bogenminuten, das laesst den Platz. Bei +# Hochpraezision braucht die Deklination alle Zellen (+38°47'01") -- dann +# entfaellt sie. +LABEL_RA = "RA " +LABEL_DEC = "DE " + # Anzeige, wenn die Montierung (noch) nicht erreichbar ist. # Achtung: hoechstens CHARS_PER_LINE Zeichen, sonst wird abgeschnitten -- # "MONTIERUNG" stand hier zuerst und erschien am Geraet als "MONTIERUN". diff --git a/coords.py b/coords.py index 5d7da00..d2face2 100644 --- a/coords.py +++ b/coords.py @@ -75,22 +75,53 @@ def _fit(candidates, width): return candidates[-1][:width] -def format_ra(value, width=None, high_precision=None): - """Rektaszension formatieren, immer mit Sekunden (HHhMMmSSs). +def _to_tenths(h, m, s): + """(h, m, s) in (Stunden, Minuten, Zehntelminuten) umrechnen. - Bei Niedrigpraezision liefert die Montierung nur Zehntel-Minuten; die - Sekunden kommen dann in 6-Sekunden-Schritten. Das Format ist dasselbe wie - bei Hochpraezision -- der Unterschied steckt allein in der Aufloesung der - Rohdaten, nicht in der Darstellung. high_precision wird daher fuer RA nicht - ausgewertet (nur bei DEC relevant, siehe format_dec). + Ganzzahlig gerechnet, damit es unter MicroPython keine Float-Ueberraschungen + gibt. Der Uebertrag ist wichtig: 18:36:57 sind 36,95 Minuten und werden zu + 18:37.0, nicht zu 18:36.10. + """ + zehntel = ((h * 3600 + m * 60 + s) * 10 + 30) // 60 # kaufmaennisch runden + minuten, t = divmod(zehntel, 10) + h, m = divmod(minuten, 60) + return h % 24, m, t + + +def format_ra(value, width=None, high_precision=None): + """Rektaszension formatieren. + + Die Darstellung folgt dem, was die Montierung wirklich liefert: + + Niedrigpraezision (Vorgabe): **HHhMM.T** mit Zehntelminuten -- genau die + Aufloesung, die die GM4000 in diesem Modus sendet. Sieben Zeichen, das laesst + Platz fuer die Beschriftung "RA:" auf der Anzeige (siehe + displayloop.format_coordinates). Das "h" statt eines Doppelpunkts ist + Nutzerwunsch -- der Doppelpunkt wirkt in der Sperrschrift zu wuchtig. + + Hochpraezision: HHhMMmSSs mit echten Sekunden. + + Bis 2026-07-27 wurde auch bei Niedrigpraezision mit Sekunden angezeigt. Die + sprangen dort in 6-Sekunden-Schritten, weil die Rohdaten sie gar nicht + hergeben -- die Zehntelform ist ehrlicher und kuerzer. """ width = config.CHARS_PER_LINE if width is None else width + high = config.HIGH_PRECISION if high_precision is None else high_precision 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) + if high: + candidates = [ + "%02dh%02dm%02ds" % (h, m, s), + "%02d:%02d:%02d" % (h, m, s), + "%02dh%02dm" % (h, m), + ] + else: + h, m, t = _to_tenths(h, m, s) + candidates = [ + "%02dh%02d.%dm" % (h, m, t), # 8: 18h36.9m + "%02dh%02d.%d" % (h, m, t), # 7: ohne Minutenzeichen + "%02dh%02dm" % (h, m), # 6: ohne Zehntel + ] + return _fit(candidates, width) def format_dec(value, width=None, high_precision=None): diff --git a/display.py b/display.py index cc0757b..ef263dd 100644 --- a/display.py +++ b/display.py @@ -115,13 +115,51 @@ class Display: self._gezeigt = {} self._zeile_schreiben(index, text) - def _aufbereiten(self, text): - """Auf Zeilenbreite bringen: abschneiden und auffuellen (ggf. mittig).""" + @staticmethod + def _breiten(text): + """Breite jeder einzelnen Zelle in Pixeln. + + Zeichen aus config.CELL_NARROW brauchen weniger Platz. Fuer Leerzeichen + gilt das aber nur *innerhalb* des Textes -- dort trennen sie Beschriftung + und Wert ("RA 18h36.9m"). Die Leerzeichen, mit denen die Zeile rechts + aufgefuellt wird, behalten die volle Breite: Sie sollen den Rest der + Zeile ueberschreiben, und je schmaler sie waeren, desto mehr Zellen + muesste man dafuer schreiben. + """ + ohne_fuellung = len(text.rstrip()) + return [config.CELL_NARROW.get(ch, config.CELL_WIDTH) + if i < ohne_fuellung else config.CELL_WIDTH + for i, ch in enumerate(text)] + + @staticmethod + def _breite(text): + """Pixelbreite eines Textes.""" + return sum(Display._breiten(text)) + + @staticmethod + def _aufbereiten(text): + """Auf die Zeilenbreite bringen: abschneiden und auffuellen. + + Massgeblich sind die **Pixel**, nicht die Zeichenzahl: Seit schmale + Zeichen weniger Platz brauchen (config.CELL_NARROW), passen mal 10 und + mal 11 Zeichen in dieselbe Zeile. Ein Text wird deshalb so lange + gekuerzt, bis er in DISPLAY_WIDTH passt, und nur so weit aufgefuellt, wie + eine weitere Zelle noch hineingeht. Ohne das ragte die letzte Zelle ueber + den Rand und die Anzeige quittierte mit Fehlercode 4. + """ # Ohne str.ljust/center: die kennt MicroPython nicht (am Geraet aufgefallen). + platz = config.DISPLAY_WIDTH - config.LINE_X text = text[:config.CHARS_PER_LINE] - rest = config.CHARS_PER_LINE - len(text) - links = rest // 2 if config.CENTER_LINES else 0 - return " " * links + text + " " * (rest - links) + while text and Display._breite(text) > platz: + text = text[:-1] + # Beim Auffuellen bewusst mit der *vollen* Zellenbreite rechnen, obwohl + # das Leerzeichen schmal gesetzt wird: Die Anzeige bemalt je Zeichen + # GLYPH_WIDTH Pixel, ein Leerzeichen am rechten Rand reichte sonst + # darueber hinaus. + while (len(text) < config.CHARS_PER_LINE + and Display._breite(text) + config.CELL_WIDTH <= platz): + text += " " + return text def _zeile_schreiben(self, index, text, voll=False): """Eine Zeile nachfuehren. Liefert True, wenn der Zeichensatz gewechselt wurde. @@ -129,19 +167,27 @@ class Display: voll=True schreibt alle Zellen, auch die unveraenderten. """ neu = self._aufbereiten(text) - alt = None if voll else self._gezeigt.get(index) - if alt == neu: + pos = self._positionen(neu) + alt, alt_pos = self._gezeigt.get(index, (None, None)) + if voll: + alt = None + elif alt_pos != pos: + # Die Zeile hat eine andere Form als vorher (z.B. ein Dezimalpunkt + # kommt dazu, der weniger Platz braucht). Dann sitzen alle Zellen + # woanders und der Vergleich waere sinnlos -- vollstaendig neu. + alt = None + elif alt == neu: return False # nichts zu tun, kein Telegramm du = migra.DataUnit() du.attributes(fg=config.COLOR, bg=config.BACKGROUND, blink=migra.STATIC) if alt is None: # Erste Ausgabe dieser Zeile: Zeichensatz sicherheitshalber setzen. - # Danach behaelt die Anzeige ihn, und _emit_degree schaltet nach - # jedem Gradzeichen wieder zurueck -- ein Wechsel je Telegramm waere - # nur unnoetiger Ballast und wuerde die Home-Zelle beschaedigen. + # Danach behaelt die Anzeige ihn, und _emit_fremd schaltet nach + # jedem Sonderzeichen wieder zurueck -- ein Wechsel je Telegramm + # waere nur Ballast und wuerde die Home-Zelle beschaedigen. du.charset(config.CHARSET, spaced=config.CHARSET_SPACED) - wechsel = self._emit_line(du, config.LINE_Y[index], neu, alt) + wechsel = self._emit_line(du, config.LINE_Y[index], neu, alt, pos) try: self.send(du) except Exception: @@ -151,23 +197,43 @@ class Display: # faelschlich fuer aktuell hielten. self._gezeigt.pop(index, None) raise - self._gezeigt[index] = neu + self._gezeigt[index] = (neu, pos) return wechsel or alt is None @staticmethod - def _zelle_faellig(neu, alt, i): + def _positionen(text): + """x-Position jeder Zelle einer Zeile. + + Normalerweise ein festes Raster aus CELL_WIDTH. Zeichen aus + config.CELL_NARROW bekommen weniger Platz -- der Dezimalpunkt der + Zehntelminuten stuende sonst sehr luftig zwischen den Ziffern. + """ + breiten = Display._breiten(text) + x = config.LINE_X + aus = [] + for b in breiten: + aus.append(x) + x += b + return aus + + @staticmethod + def _zelle_faellig(neu, alt, i, pos): """Muss Zelle i neu geschrieben werden? Ja, wenn sie sich selbst geaendert hat -- oder wenn ihr **linker** - Nachbar neu geschrieben wird: Der reicht 1 px in diese Zelle hinein und - loescht deren erste Pixelspalte (siehe _emit_line). + Nachbar neu geschrieben wird und bis in diese Zelle hineinragt: Die + Anzeige bemalt je Zeichen GLYPH_WIDTH Pixel, mehr als eine Zelle breit + ist, und loescht damit die erste Pixelspalte hier (siehe _emit_line). """ def anders(k): return k >= len(alt) or alt[k] != neu[k] - return anders(i) or (i > 0 and anders(i - 1)) + if anders(i): + return True + return (i > 0 and anders(i - 1) + and pos[i] < pos[i - 1] + config.GLYPH_WIDTH) - def _emit_line(self, du, y, text, alt=None): + def _emit_line(self, du, y, text, alt=None, pos=None): """Zellen einer Zeile ausgeben. Liefert True bei Zeichensatzwechsel. Jedes Zeichen wird einzeln auf x = LINE_X + i*CELL_WIDTH gesetzt, statt @@ -189,26 +255,45 @@ class Display: Am Geraet aufgefallen (2026-07-27): Beim Sekundenwechsel fehlten dem "s" die linken Pixel, beim Minutenwechsel dem "m". """ + if pos is None: + pos = self._positionen(text) wechsel = False for i in range(len(text)): - if alt is not None and not self._zelle_faellig(text, alt, i): + if alt is not None and not self._zelle_faellig(text, alt, i, pos): continue - du.cursor(config.LINE_X + i * config.CELL_WIDTH, y) - if text[i] == config.DEGREE_MARKER: - self._emit_degree(du) + du.cursor(pos[i], y) + if self._emit_zeichen(du, text[i]): wechsel = True - else: - du.text(text[i]) return wechsel - 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 _emit_zeichen(self, du, ch): + """Ein Zeichen ausgeben. Liefert True, wenn der Zeichensatz wechseln musste. + + Nur das Gradzeichen kommt aus einem anderen Zeichensatz -- in + Zeichensatz 0 gibt es keines. + + Ein Zeichensatzwechsel beschaedigt die Home-Zelle (0,0), also den Anfang + der obersten Zeile. Solange alle Wechsel in der *unteren* Zeile + stattfinden, genuegt die Reihenfolge in show_lines (unten vor oben). + + **Ein Wechsel mitten in der obersten Zeile laesst sich dagegen nicht + sauber reparieren.** Das erste Zeichen danach noch einmal zu schreiben, + wurde am Geraet ausprobiert (2026-07-27) und machte es schlimmer: Das + nachgezogene Zeichen bemalt GLYPH_WIDTH Pixel und frisst dabei die linke + Spalte seines rechten Nachbarn. Deshalb steht in der obersten Zeile + bewusst kein Sonderzeichen -- die Beschriftung nutzt ein schmales + Leerzeichen statt eines Doppelpunkts aus Zeichensatz 1. + """ + if ch == config.DEGREE_MARKER: + if config.DEGREE_CHARSET is None: + du.text(config.DEGREE_FALLBACK) + return False + du.charset(config.DEGREE_CHARSET, spaced=config.CHARSET_SPACED) + du.text(config.DEGREE_CHAR) + du.charset(config.CHARSET, spaced=config.CHARSET_SPACED) + return True + du.text(ch) + return False def close(self): self._t.close() diff --git a/displayloop.py b/displayloop.py index 4b254c4..e33556a 100644 --- a/displayloop.py +++ b/displayloop.py @@ -21,8 +21,29 @@ class LinkError(Exception): def format_coordinates(ra_raw, dec_raw): - """Rohstrings der Montierung fuer die Anzeige aufbereiten.""" - return coords.format_ra(ra_raw), coords.format_dec(dec_raw) + """Rohstrings der Montierung fuer die Anzeige aufbereiten. + + Bei **Niedrigpraezision** bekommen die Zeilen eine Beschriftung: + + RA:18h36.9 + DE:+38°47' + + Das geht genau auf, weil die Montierung in diesem Modus ohnehin nur + Zehntelminuten (7 Zeichen) und ganze Bogenminuten (7 Zeichen) liefert -- in + zehn Zellen bleiben also drei fuer "RA:"/"DE:" uebrig. Die Zeilen sind + linksbuendig gesetzt (config.CENTER_LINES), damit beide Beschriftungen + buendig untereinander beginnen. + + Bei **Hochpraezision** entfaellt die Beschriftung: Dort braucht die + Deklination mit +38°47'01" alle zehn Zeichen. + """ + if config.HIGH_PRECISION: + return coords.format_ra(ra_raw), coords.format_dec(dec_raw) + + breite_ra = config.CHARS_PER_LINE - len(config.LABEL_RA) + breite_dec = config.CHARS_PER_LINE - len(config.LABEL_DEC) + return (config.LABEL_RA + coords.format_ra(ra_raw, width=breite_ra), + config.LABEL_DEC + coords.format_dec(dec_raw, width=breite_dec)) def poll_loop(display, client, interval=None, once=False, diff --git a/mount_mock.py b/mount_mock.py index e9c7793..7c968af 100644 --- a/mount_mock.py +++ b/mount_mock.py @@ -58,31 +58,54 @@ def _numbers(text): return [int(x) for x in out] -def _fmt_ra(total_s): +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) - return "%02d:%02d:%02d" % (h, m, s) + 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): +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) - # Gradzeichen als '*', Minuten/Sekunden mit ':' -- der coords-Parser - # kommt mit beiden Trennzeichen klar. - return "%s%02d*%02d:%02d" % (sign, d, m, s) + 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): + 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() @@ -116,9 +139,9 @@ class MountMock: def _reply_for(self, cmd): """Antwort (inkl. '#') fuer ein Kommando ohne ':' und ohne '#'.""" if cmd == "GR": - return _fmt_ra(self._current_ra()) + "#" + return _fmt_ra(self._current_ra(), self._high) + "#" if cmd == "GD": - return _fmt_dec(self._current_dec()) + "#" + return _fmt_dec(self._current_dec(), self._high) + "#" if cmd == "GVP": return PRODUCT + "#" if cmd == "GVN": @@ -204,13 +227,18 @@ def main(argv=None): 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) - print("Mock-Montierung auf %s:%d (RA=%s DEC=%s%s)" - % (mock.host, mock.port, args.ra, args.dec, ", move" if args.move else "")) + 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: diff --git a/test_display.py b/test_display.py index 11ec6c1..33ffb7b 100644 --- a/test_display.py +++ b/test_display.py @@ -38,7 +38,14 @@ def zellen(frames): y = int(frame[i + 5:i + 8]) j = frame.find(bytes([migra.SEP]), i + 8) if j >= 0 and j + 1 < len(frame): - out[(x, y)] = chr(frame[j + 1]) + ch = chr(frame[j + 1]) + # Das Gradzeichen entsteht ueber einen Zeichensatzwechsel und + # steht als DEGREE_CHAR ("/") im Telegramm. Hier wieder in das + # zurueckuebersetzen, was auf der Anzeige zu sehen ist. + if (frame[i + 8:j].startswith(b"\x1bz") + and ch == config.DEGREE_CHAR): + ch = config.DEGREE_MARKER + out[(x, y)] = ch i += 8 else: i += 1 @@ -52,12 +59,22 @@ def zeile(frames, index): return "".join(ch for _, ch in sorted(treffer)) -def zentriert(text): - """Erwartungswert: Text so, wie _emit_line ihn auf die Zeile legt.""" - text = text[:config.CHARS_PER_LINE] - rest = config.CHARS_PER_LINE - len(text) - links = rest // 2 if config.CENTER_LINES else 0 - return " " * links + text + " " * (rest - links) +# So viele *volle* Zellen passen in eine Zeile. Das ist weniger als +# config.CHARS_PER_LINE: Dessen 11 Zeichen gehen nur auf, wenn mindestens ein +# schmales dabei ist (config.CELL_NARROW). Fuer Tests mit normalen Zeichen ist +# das hier die Zeilenbreite. +VOLLE_ZELLEN = (config.DISPLAY_WIDTH - config.LINE_X) // config.CELL_WIDTH + + +def aufbereitet(text): + """Erwartungswert: Text so, wie er auf der Zeile landet. + + Nutzt bewusst dieselbe Funktion wie der Treiber. Wie breit eine Zeile ist, + haengt seit config.CELL_NARROW davon ab, welche Zeichen darin stehen -- eine + zweite Nachbildung hier waere nur eine Fehlerquelle. Was der Wert *bedeutet*, + pruefen die Tests darunter mit eigenen Zusicherungen. + """ + return Display._aufbereiten(text) class TestFesteTexte(unittest.TestCase): @@ -174,8 +191,8 @@ class TestNurGeaenderteZellen(unittest.TestCase): frames = self._neu() # Loeschtelegramm + beide Zeilen vollstaendig self.assertEqual(len(frames), 3) - self.assertEqual(len(zeile(frames, 0)), config.CHARS_PER_LINE) - self.assertEqual(len(zeile(frames, 1)), config.CHARS_PER_LINE) + self.assertEqual(len(zeile(frames, 0)), VOLLE_ZELLEN) + self.assertEqual(len(zeile(frames, 1)), VOLLE_ZELLEN) def test_nach_clear_kein_rest_der_alten_anzeige(self): """clear muss den gemerkten Inhalt verwerfen. @@ -211,7 +228,7 @@ class TestNurGeaenderteZellen(unittest.TestCase): # Dieselbe Zeile noch einmal: muss vollstaendig geschrieben werden. self.d.show_lines("18h36m57s", "+38°47'01\"") - self.assertEqual(len(zeile(self._neu(), 0)), config.CHARS_PER_LINE) + self.assertEqual(len(zeile(self._neu(), 0)), VOLLE_ZELLEN) def test_gradzeichen_wird_nicht_staendig_neu_gesetzt(self): """Spart nicht nur Bytes: Jeder Zeichensatzwechsel beschaedigt (0,0).""" @@ -221,6 +238,8 @@ class TestNurGeaenderteZellen(unittest.TestCase): gesendet = b"".join(self._neu()) self.assertNotIn(b"\x1bz01", gesendet) + + def test_neues_gradzeichen_zieht_die_oberste_zeile_nach(self): """Wird der Zeichensatz doch gewechselt, ist die Home-Zelle hin. @@ -233,7 +252,66 @@ class TestNurGeaenderteZellen(unittest.TestCase): frames = self._neu() self.assertIn(b"\x1bz01", b"".join(frames)) # Wechsel fand statt # Obere Zeile trotz unveraendertem Text komplett neu geschrieben. - self.assertEqual(len(zeile(frames, 0)), config.CHARS_PER_LINE) + self.assertEqual(len(zeile(frames, 0)), VOLLE_ZELLEN) + + +class TestSchmaleZeichen(unittest.TestCase): + """Der Dezimalpunkt der Zehntelminuten braucht weniger als eine volle Zelle. + + In der Sperrschrift stand "RA 18:37.1" sonst sehr luftig -- der Punkt belegt + 6 px, obwohl er nur ein, zwei Pixel breit ist. 3 px am Geraet ausprobiert + (2026-07-27). Moeglich ist das nur, weil jedes Zeichen ohnehin einzeln + positioniert wird. + """ + + def setUp(self): + self.t = FakeTransport(echo=False) + self.d = Display(self.t, response=True) + + def _neu(self): + frames = self.t.frames[:] + self.t.frames.clear() + return frames + + def test_punkt_bekommt_weniger_platz(self): + pos = Display._positionen("1.2") + self.assertEqual(pos[1] - pos[0], config.CELL_WIDTH) # vor dem Punkt + self.assertEqual(pos[2] - pos[1], config.CELL_NARROW["."]) # nach dem Punkt + + def test_zeile_mit_punkt_wird_schmaler_und_bleibt_mittig(self): + def breite(positionen): + return positionen[-1] + config.CELL_WIDTH - positionen[0] + + ohne = Display._positionen("RA 18:3701") # gleiche Laenge, kein Punkt + mit = Display._positionen("RA 18:37.1") + self.assertEqual(breite(ohne) - breite(mit), + config.CELL_WIDTH - config.CELL_NARROW["."]) + # Weiterhin vollstaendig innerhalb der Anzeige. + self.assertGreaterEqual(mit[0], 0) + self.assertLessEqual(mit[-1] + config.CELL_WIDTH, config.DISPLAY_WIDTH) + + def test_zehntel_wechsel_schreibt_weiter_nur_wenige_zellen(self): + """Der Alltagsfall bei Niedrigpraezision -- das Nachfuehren muss greifen.""" + self.d.show_lines("RA 18:37.1", "DE +38°47'") + self._neu() + self.d.show_lines("RA 18:37.2", "DE +38°47'") + frames = self._neu() + self.assertEqual(len(frames), 1) # DEC unveraendert + self.assertLessEqual(len(zellen(frames)), 2) # nur die Zehntelziffer + + def test_strukturwechsel_schreibt_die_zeile_vollstaendig_neu(self): + """Kommt ein Punkt dazu, sitzen alle folgenden Zellen woanders. + + Ein Vergleich Zeichen fuer Zeichen waere dann sinnlos -- es muss alles + neu geschrieben werden. + """ + self.d.show_lines("RA 18:3701") # ohne Punkt: 10 volle Zellen + self._neu() + self.d.show_lines("RA 18:37.1") # mit Punkt: andere Positionen + gesetzt = zellen(self._neu()) + # Alles neu -- und dank des schmalen Punktes passt sogar ein Zeichen mehr. + self.assertEqual(len(gesetzt), len(Display._aufbereiten("RA 18:37.1"))) + self.assertGreater(len(gesetzt), VOLLE_ZELLEN) class TestZeilenaufbau(unittest.TestCase): @@ -252,18 +330,28 @@ class TestZeilenaufbau(unittest.TestCase): nicht kennt (am Geraet aufgefallen). """ self.d.show_lines("AB", "CD") - self.assertEqual(zeile(self._alle(), 0), zentriert("AB")) - self.assertEqual(zeile(self._alle(), 1), zentriert("CD")) + self.assertEqual(zeile(self._alle(), 0), aufbereitet("AB")) + self.assertEqual(zeile(self._alle(), 1), aufbereitet("CD")) # Es wird immer die volle Breite geschrieben, damit nichts stehen bleibt. - self.assertEqual(len(zeile(self._alle(), 0)), config.CHARS_PER_LINE) + self.assertEqual(len(zeile(self._alle(), 0)), VOLLE_ZELLEN) - def test_kuerzere_zeile_wird_mittig_gesetzt(self): - """Zentriert heisst: links und rechts gleich viel Luft (Nutzerwunsch).""" + def test_kuerzere_zeile_steht_linksbuendig(self): + """Nutzerwunsch 2026-07-27: beide Zeilen buendig untereinander. + + Zentriert standen sie um wenige Pixel gegeneinander versetzt, weil die + RA-Zeile mit ihrem schmalen Dezimalpunkt kuerzer ist als die DEC-Zeile. + """ self.d.show_lines("+38°47'") # 7 Zeichen in 10 Zellen text = zeile(self._alle(), 0) - links = len(text) - len(text.lstrip()) - rechts = len(text) - len(text.rstrip()) - self.assertLessEqual(abs(links - rechts), 1) + self.assertEqual(text, "+38°47'" + " " * (VOLLE_ZELLEN - 7)) + + def test_beide_zeilen_beginnen_an_derselben_stelle(self): + """Der eigentliche Punkt: RA und DE muessen buendig starten.""" + self.d.show_lines("RA:18h37.1", "DE:+38°47'") + gesetzt = zellen(self._alle()) + erste_oben = min(x for (x, y) in gesetzt if y == config.LINE_Y[0]) + erste_unten = min(x for (x, y) in gesetzt if y == config.LINE_Y[1]) + self.assertEqual(erste_oben, erste_unten) def test_jedes_zeichen_sitzt_auf_seiner_zelle(self): """Der Auto-Vorschub der Sperrschrift ist 7 px und liesse nur 9 Zeichen @@ -272,13 +360,13 @@ class TestZeilenaufbau(unittest.TestCase): self.d.show_lines("0123456789") gesetzt = zellen(self._alle()) y = config.LINE_Y[0] - for i in range(config.CHARS_PER_LINE): + for i in range(VOLLE_ZELLEN): self.assertIn((config.LINE_X + i * config.CELL_WIDTH, y), gesetzt) def test_zeile_passt_in_die_anzeigenbreite(self): """Auch die letzte Zelle darf nicht ueber den rechten Rand hinausragen -- sonst quittiert die Anzeige mit Fehlercode 4 (ausserhalb).""" - rechts = config.LINE_X + config.CHARS_PER_LINE * config.CELL_WIDTH + rechts = config.LINE_X + VOLLE_ZELLEN * config.CELL_WIDTH self.assertLessEqual(rechts, config.DISPLAY_WIDTH) self.assertGreaterEqual(config.LINE_X, 0) @@ -287,18 +375,7 @@ class TestZeilenaufbau(unittest.TestCase): beschaedigt die andere Zeile -- deshalb hart abschneiden.""" lang = "X" * (config.CHARS_PER_LINE + 5) self.d.show_lines(lang) - self.assertEqual(zeile(self._alle(), 0), "X" * config.CHARS_PER_LINE) - - def test_ohne_zentrierung_steht_der_text_links(self): - """CENTER_LINES = False muss wieder linksbuendig setzen.""" - alt = config.CENTER_LINES - try: - config.CENTER_LINES = False - self.d.show_lines("AB") - self.assertEqual(zeile(self._alle(), 0), - "AB" + " " * (config.CHARS_PER_LINE - 2)) - finally: - config.CENTER_LINES = alt + self.assertEqual(zeile(self._alle(), 0), "X" * VOLLE_ZELLEN) def test_oberste_zeile_wird_zuletzt_gezeichnet(self): """Schutz der Home-Zelle: ein Zeichensatzwechsel zerstoert (0,0), diff --git a/test_esp32.py b/test_esp32.py index 7d614a8..697c0ed 100644 --- a/test_esp32.py +++ b/test_esp32.py @@ -556,13 +556,17 @@ class TestEsp32GegenMock(unittest.TestCase): self.assertIn("Selbsttest bestanden", text) def test_main_once_zeigt_die_koordinaten(self): - self._run(self.run_esp32.main, fake_display=True, once=True) + """Der Mock sendet 18:36:56; bei Niedrigpraezision (config-Vorgabe) wird + daraus die Zehntelform mit Beschriftung.""" + self._run(self.run_esp32.main, fake_display=True, once=True, + with_mqtt=False) text = "\n".join(self.log) - self.assertIn("18h36m56s", text) # RA, fuer die Anzeige formatiert - self.assertIn("+38", text) # DEC + self.assertIn("RA 18h36.9m", text) + self.assertIn("DE +38", text) def test_main_once_baut_ein_gueltiges_telegramm(self): - hexdump = self._run(self.run_esp32.main, fake_display=True, once=True) + hexdump = self._run(self.run_esp32.main, fake_display=True, once=True, + with_mqtt=False) self.assertIn("TX: 02 81 80 81", hexdump) # STX, DA, SA, FC self.assertTrue(hexdump.strip().endswith("03")) # ETX @@ -574,7 +578,8 @@ class TestEsp32GegenMock(unittest.TestCase): self.mock.stop() with contextlib.redirect_stdout(io.StringIO()) as out: self.run_esp32.main(host="127.0.0.1", port=self.mock.port, - fake_display=True, once=True, log=self.log.append) + fake_display=True, once=True, with_mqtt=False, + log=self.log.append) self.assertTrue(any("Montierung" in line for line in self.log)) # Die Hinweiszeilen stehen wirklich im Telegramm -- ohne den Text hier # zu wiederholen, sonst bricht der Test bei jeder Umformulierung. @@ -598,7 +603,8 @@ class TestEsp32GegenMock(unittest.TestCase): import io with contextlib.redirect_stdout(io.StringIO()): self.run_esp32.main(host=self.mock.host, port=self.mock.port, - fake_display=True, once=True, log=self.log.append) + fake_display=True, once=True, with_mqtt=False, + log=self.log.append) finally: config.WIFI_TIMEOUT = orig text = "\n".join(self.log) diff --git a/test_migra.py b/test_migra.py index d61cda4..d3b7480 100644 --- a/test_migra.py +++ b/test_migra.py @@ -136,14 +136,38 @@ class TestKoordinaten(unittest.TestCase): self.assertEqual(coords.format_dec("+45d12m30s", width=7, **hp), "+45°12'") def test_formatierung_niedrigpraezision(self): + """RA in Zehntelminuten -- so, wie die Montierung sie liefert. + + Bis 2026-07-27 wurde hier in Sekunden umgerechnet (11h36m54s). Die + sprangen aber in 6-Sekunden-Schritten, weil die Rohdaten sie nicht + hergeben. Die Zehntelform ist ehrlicher und laesst mit sieben Zeichen + Platz fuer die Beschriftung "RA:". + """ lp = dict(high_precision=False) - # RA: Zehntel-Minute (0.9 min) kommt als Sekunden (54 s) heraus - self.assertEqual(coords.format_ra("11:36.9", width=9, **lp), "11h36m54s") + # Breiteste passende Form: mit Minutenzeichen am Ende, sonst ohne. + self.assertEqual(coords.format_ra("11:36.9", width=9, **lp), "11h36.9m") + self.assertEqual(coords.format_ra("11:36.9", width=8, **lp), "11h36.9m") + self.assertEqual(coords.format_ra("11:36.9", width=7, **lp), "11h36.9") self.assertEqual(coords.format_ra("11:36.9", width=6, **lp), "11h36m") # DEC: ohne Sekunden self.assertEqual(coords.format_dec("-00°55", width=9, **lp), "-00°55'") self.assertEqual(coords.format_dec("-00°55", width=6, **lp), "-00:55") + def test_zehntelminuten_mit_uebertrag(self): + """18:36:57 sind 36,95 Minuten -- das muss auf 18:37.0 aufgehen.""" + lp = dict(high_precision=False, width=7) + self.assertEqual(coords.format_ra("18:36:57", **lp), "18h37.0") + self.assertEqual(coords.format_ra("18:36:00", **lp), "18h36.0") + self.assertEqual(coords.format_ra("18:36:54", **lp), "18h36.9") + # Auch ueber die Stunde und ueber Mitternacht hinweg. + self.assertEqual(coords.format_ra("18:59:58", **lp), "19h00.0") + self.assertEqual(coords.format_ra("23:59:58", **lp), "00h00.0") + + def test_hochpraezision_behaelt_die_sekundenform(self): + hp = dict(high_precision=True, width=10) + self.assertEqual(coords.format_ra("18:36:56", **hp), "18h36m56s") + self.assertEqual(coords.format_dec("+38*47:01", **hp), "+38°47'01\"") + class TestDisplay(unittest.TestCase): @@ -173,20 +197,19 @@ class TestDisplay(unittest.TestCase): def zelle(i, y): return b"\x1bC%03d%03d" % (config.LINE_X + i * config.CELL_WIDTH, y) - # "12:34:56" hat 8 Zeichen und wird in 10 Zellen mittig gesetzt (ab Zelle 1), - # "+45:12:30" hat 9 und beginnt daher schon bei Zelle 0. - self.assertIn(zelle(1, 0) + b"\x1f1", oben) - self.assertIn(zelle(2, 0) + b"\x1f2", oben) + # Linksbuendig (config.CENTER_LINES): beide Zeilen beginnen bei Zelle 0. + self.assertIn(zelle(0, 0) + b"\x1f1", oben) + self.assertIn(zelle(1, 0) + b"\x1f2", oben) self.assertIn(zelle(0, 9) + b"\x1f+", unten) def test_ohne_clear_kein_fill_aber_aufgefuellt(self): """Standard (clear=False): kein Vollbild-Loeschen, Zeilen auf volle Breite.""" - from test_display import zeile, zentriert + from test_display import zeile, aufbereitet t = FakeTransport(echo=False) Display(t, response=True).show_lines("12:34:56", clear=False) self.assertNotIn(b"\x1bF0", t.frames[0]) # auf CHARS_PER_LINE mit Leerzeichen aufgefuellt (mittig, siehe CENTER_LINES) - self.assertEqual(zeile(t.frames, 0), zentriert("12:34:56")) + self.assertEqual(zeile(t.frames, 0), aufbereitet("12:34:56")) def test_gradzeichen_via_zeichensatz(self): """Gradzeichen wird durch kurzen Wechsel auf Zeichensatz 1 erzeugt.""" diff --git a/test_mqtt.py b/test_mqtt.py index 5336374..a5543bb 100644 --- a/test_mqtt.py +++ b/test_mqtt.py @@ -398,7 +398,8 @@ class TestSchleifenanbindung(MqttTest): self.client.fail_check = True gezeigt = self._loop_einmal(self.bridge) self.assertIsNotNone(gezeigt) # Anzeige wurde trotzdem bedient - self.assertEqual(gezeigt[0], "18h36m56s") + # Bei Niedrigpraezision (config-Vorgabe) die beschriftete Zehntelform. + self.assertEqual(gezeigt[0], "RA 18h36.9m") if __name__ == "__main__":