433c53aabd
Die GM4000 liefert im Auslieferzustand keine Sekunden, sondern Zehntelminuten.
Bisher wurde daraus eine Sekundenanzeige gerechnet -- die sprang in
6-Sekunden-Schritten, weil die Rohdaten sie gar nicht hergeben. Die Zehntelform
ist ehrlicher und kuerzer, und der gewonnene Platz traegt die Beschriftung:
RA 18h36.9m
DE +38°47'
Bei Hochpraezision entfaellt die Beschriftung, dort braucht die Deklination mit
+38°47'01" alle Zellen.
Der Mock konnte das gar nicht nachstellen -- er sendete immer das
Hochpraezisionsformat. Neu: "mount_mock.py --low" bildet den Auslieferzustand ab
(RA als HH:MM.T, DEC ohne Bogensekunden). Vorgabe bleibt hoch, damit die
bestehenden Tests mit ihren festen Erwartungen unveraendert durchlaufen.
Zur Darstellung, alles am Geraet ausprobiert:
- **Schmale Zeichen** (config.CELL_NARROW): Dezimalpunkt und der Abstand
zwischen Beschriftung und Wert bekommen 3 statt 6 px -- in der Sperrschrift
standen sie sonst sehr luftig. Fuer die Leerzeichen am Zeilenende gilt das
nicht, die sollen den Rest der Zeile ueberschreiben.
- Damit entscheidet die **Pixelbreite** ueber das Abschneiden, nicht mehr die
Zeichenzahl: "RA 18h36.9m" sind 11 Zeichen und trotzdem nur 60 px.
- **Linksbuendig ab LINE_X = 0**, der Schalter zum Zentrieren ist entfallen.
Zentriert standen die Zeilen um wenige Pixel gegeneinander versetzt, weil die
RA-Zeile mit ihrem schmalen Punkt kuerzer ist als die DEC-Zeile.
Ein Zwischenschritt, der wieder verworfen wurde und als Warnung im Code steht:
Ein Doppelpunkt als Trenner ("RA:18h36.9m") sah in Zeichensatz 0 zu wuchtig aus;
der aus Zeichensatz 1 ist schlanker, steht aber mitten in der obersten Zeile --
und jeder Zeichensatzwechsel zerstoert die Home-Zelle. Das erste Zeichen danach
noch einmal zu schreiben machte es schlimmer: Das nachgezogene Zeichen bemalt
GLYPH_WIDTH Pixel und frisst die linke Spalte seines rechten Nachbarn. Deshalb
steht in der obersten Zeile jetzt bewusst kein Sonderzeichen.
161 Tests gruen, am Geraet bestaetigt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
334 lines
14 KiB
Python
334 lines
14 KiB
Python
"""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
|
|
# Was gerade auf welcher Zeile steht. Grundlage fuer den Vergleich in
|
|
# show_lines: geschrieben wird nur, was sich geaendert hat.
|
|
self._gezeigt = {}
|
|
|
|
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):
|
|
# Der gemerkte Inhalt gilt danach nicht mehr -- sonst hielte der
|
|
# Vergleich in show_lines Zellen faelschlich fuer schon vorhanden und
|
|
# die Anzeige bliebe nach dem Loeschen teilweise leer.
|
|
self._gezeigt = {}
|
|
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.
|
|
|
|
Geschrieben wird nur, was sich seit dem letzten Mal geaendert hat --
|
|
Zelle fuer Zelle. Das ist der entscheidende Punkt fuer eine ruhige
|
|
Anzeige:
|
|
|
|
Seit jedes Zeichen einzeln positioniert wird (10 statt 9 Zeichen, siehe
|
|
_emit_line), kostet eine volle Zeile rund 100 Bytes statt 20. Bei 19200
|
|
Baud mit 8E1 (11 Bit je Byte) dauerte ein kompletter Bildwechsel damit
|
|
rund 300 ms. Die Anzeige stellt schon waehrend des Empfangs dar, also
|
|
war der Aufbau als Flackern zu sehen -- bei Aktualisierung im
|
|
Sekundentakt ein Drittel der Zeit. (Am Geraet gemessen 2026-07-27.)
|
|
|
|
Von einer Sekunde zur naechsten aendert sich aber fast nichts:
|
|
"18h36m56s" -> "18h36m57s" ist ein einziges Zeichen, die Deklination
|
|
steht meist voellig still. Mit dem Vergleich gegen den zuletzt
|
|
dargestellten Inhalt schrumpft der Bildwechsel auf ein bis zwei Zellen,
|
|
und eine unveraenderte Zeile erzeugt gar kein Telegramm mehr.
|
|
|
|
clear=True loescht das Vollbild und schreibt beide Zeilen komplett neu
|
|
(einmal beim Start und nach jedem Verbindungsabriss).
|
|
|
|
Reihenfolge: Die Zeilen werden von unten nach oben geschrieben, damit die
|
|
oberste zuletzt kommt. Grund: Ein Zeichensatzwechsel beschaedigt die
|
|
Home-Zelle (0,0), also den Anfang der obersten Zeile. Findet ein solcher
|
|
Wechsel statt (Gradzeichen), wird die oberste Zeile deshalb vollstaendig
|
|
nachgezogen -- siehe _zeile_schreiben. (Am Geraet verifiziert 2026-07-15.)
|
|
"""
|
|
if len(lines) > len(config.LINE_Y):
|
|
raise ValueError("Anzeige hat nur %d Zeilen" % len(config.LINE_Y))
|
|
|
|
if clear:
|
|
self.clear()
|
|
self._gezeigt = {}
|
|
|
|
wechsel = False
|
|
for i in reversed(range(len(lines))):
|
|
# Die oberste Zeile (i == 0) komplett neu, wenn weiter unten ein
|
|
# Zeichensatzwechsel war: der hat ihren Anfang zerstoert.
|
|
voll = wechsel and i == 0
|
|
if self._zeile_schreiben(i, lines[i], voll=voll):
|
|
wechsel = True
|
|
|
|
def show_line(self, index, text, clear=False):
|
|
"""Nur eine einzelne Zeile aktualisieren.
|
|
|
|
Schreibt ebenfalls nur die geaenderten Zellen. Achtung: Enthaelt die
|
|
Zeile ein *neues* Gradzeichen, wird dafuer der Zeichensatz gewechselt und
|
|
damit die Home-Zelle beschaedigt -- die oberste Zeile muesste danach
|
|
nachgezogen werden. Wer beide Zeilen hat, nimmt darum show_lines.
|
|
"""
|
|
if not 0 <= index < len(config.LINE_Y):
|
|
raise ValueError("ungueltiger Zeilenindex %d" % index)
|
|
if clear:
|
|
self.clear()
|
|
self._gezeigt = {}
|
|
self._zeile_schreiben(index, text)
|
|
|
|
@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]
|
|
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.
|
|
|
|
voll=True schreibt alle Zellen, auch die unveraenderten.
|
|
"""
|
|
neu = self._aufbereiten(text)
|
|
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_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, pos)
|
|
try:
|
|
self.send(du)
|
|
except Exception:
|
|
# Ungewiss, wie viel die Anzeige bekommen hat. Den gemerkten Inhalt
|
|
# verwerfen, damit die Zeile beim naechsten Mal vollstaendig neu
|
|
# geschrieben wird -- sonst blieben Zellen stehen, die wir
|
|
# faelschlich fuer aktuell hielten.
|
|
self._gezeigt.pop(index, None)
|
|
raise
|
|
self._gezeigt[index] = (neu, pos)
|
|
return wechsel or alt is None
|
|
|
|
@staticmethod
|
|
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 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]
|
|
|
|
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, pos=None):
|
|
"""Zellen einer Zeile ausgeben. Liefert True bei Zeichensatzwechsel.
|
|
|
|
Jedes Zeichen wird einzeln auf x = LINE_X + i*CELL_WIDTH gesetzt, statt
|
|
den Auto-Vorschub der Anzeige laufen zu lassen. Der Vorschub der
|
|
Sperrschrift ist 7 px und liesse nur 9 Zeichen zu; die Zeichenmatrix ist
|
|
aber nur 6 px breit, sodass 10 Zeichen sauber nebeneinander passen (am
|
|
Geraet gemessen 2026-07-27, siehe config.CHARS_PER_LINE).
|
|
|
|
alt = zuletzt dargestellter Inhalt derselben Zeile. Ist er gegeben,
|
|
werden nur die Zellen geschrieben, die sich unterscheiden -- das haelt
|
|
den Bildwechsel kurz und die Anzeige ruhig (siehe show_lines).
|
|
|
|
**Der rechte Nachbar muss dabei mit.** Die Anzeige malt je Zeichen 7 px
|
|
breit (daher auch der 7-px-Auto-Vorschub), wir setzen die Zeichen aber
|
|
auf 6-px-Raster. Ein neu geschriebenes Zeichen loescht damit die erste
|
|
Pixelspalte des Zeichens rechts daneben. Beim vollstaendigen Neuaufbau
|
|
fiel das nie auf, weil der Nachbar gleich danach ohnehin neu gemalt
|
|
wurde; beim Schreiben einzelner Zellen bliebe er beschaedigt stehen.
|
|
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, pos):
|
|
continue
|
|
du.cursor(pos[i], y)
|
|
if self._emit_zeichen(du, text[i]):
|
|
wechsel = True
|
|
return wechsel
|
|
|
|
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()
|
|
|
|
|
|
def open_display(port=None, fake=False, response=config.WANT_RESPONSE):
|
|
"""Transport passend zur Plattform oeffnen und Display-Objekt liefern.
|
|
|
|
Auf dem ESP32-C3 wird machine.UART genommen (Pins aus config), auf dem Mac
|
|
pyserial mit config.PORT. fake=True haengt stattdessen den Mitschreiber ein.
|
|
"""
|
|
if fake:
|
|
from transport import FakeTransport
|
|
t = FakeTransport(src=config.HOST_ADDR, display_addr=config.DISPLAY_ADDR)
|
|
elif config.MICROPYTHON:
|
|
from transport import UartTransport
|
|
t = UartTransport(
|
|
uart_id=config.UART_ID,
|
|
baudrate=config.BAUDRATE,
|
|
parity=config.PARITY,
|
|
bytesize=config.BYTESIZE,
|
|
stopbits=config.STOPBITS,
|
|
timeout=config.RESPONSE_TIMEOUT,
|
|
tx=config.UART_TX,
|
|
rx=config.UART_RX,
|
|
)
|
|
else:
|
|
from transport import SerialTransport
|
|
t = SerialTransport(
|
|
port=config.PORT if port is None else port,
|
|
baudrate=config.BAUDRATE,
|
|
parity=config.PARITY,
|
|
bytesize=config.BYTESIZE,
|
|
stopbits=config.STOPBITS,
|
|
timeout=config.RESPONSE_TIMEOUT,
|
|
)
|
|
return Display(t, response=response)
|