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

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

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

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

Zur Darstellung, alles am Geraet ausprobiert:

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

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

161 Tests gruen, am Geraet bestaetigt.

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

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)