"""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) def _aufbereiten(self, text): """Auf Zeilenbreite bringen: abschneiden und auffuellen (ggf. mittig).""" # Ohne str.ljust/center: die kennt MicroPython nicht (am Geraet aufgefallen). 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) 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) alt = None if voll else self._gezeigt.get(index) if 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. du.charset(config.CHARSET, spaced=config.CHARSET_SPACED) wechsel = self._emit_line(du, config.LINE_Y[index], neu, alt) 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 return wechsel or alt is None @staticmethod def _zelle_faellig(neu, alt, i): """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). """ def anders(k): return k >= len(alt) or alt[k] != neu[k] return anders(i) or (i > 0 and anders(i - 1)) def _emit_line(self, du, y, text, alt=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". """ wechsel = False for i in range(len(text)): if alt is not None and not self._zelle_faellig(text, alt, i): continue du.cursor(config.LINE_X + i * config.CELL_WIDTH, y) if text[i] == config.DEGREE_MARKER: self._emit_degree(du) 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 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)