Nur geaenderte Zellen schreiben -- gegen das Flackern im Sekundentakt

Seit der Einzelpositionierung blitzten bei jeder Aktualisierung willkuerliche
Pixel auf, in beiden Zeilen. Ursache ist die Telegrammlaenge: Eine volle Zeile
kostet mit einzeln gesetzten Zeichen rund 100 Bytes statt 20, bei 19200 Baud
und 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 zu
sehen -- im Sekundentakt ein Drittel der Zeit.

Von einer Sekunde zur naechsten aendert sich aber fast nichts: "18h36m56s" ->
"18h36m57s" ist eine einzige Ziffer, die Deklination steht meist still.
show_lines vergleicht deshalb gegen den zuletzt dargestellten Inhalt und
schreibt nur die betroffenen Zellen; eine unveraenderte Zeile erzeugt gar kein
Telegramm mehr. Am Geraet gemessen: 306 ms -> 66 ms je Bildwechsel.

Dabei am Geraet aufgefallen und mitbehoben: Dem "s" hinter den Sekunden fehlten
die linken Pixel, dem "m" hinter den Minuten ebenso -- beides Zeichen direkt
rechts von einer Stelle, die sich gerade geaendert hatte. Die Anzeige malt je
Zeichen 7 px breit (daher auch der 7-px-Auto-Vorschub), gesetzt wird aber auf
6-px-Raster: Ein neu geschriebenes Zeichen loescht die erste Pixelspalte seines
rechten Nachbarn. Beim vollstaendigen Neuaufbau fiel das nie auf, weil der
Nachbar gleich danach ohnehin neu gemalt wurde. _zelle_faellig zieht ihn jetzt
mit.

Weitere Details:
- clear() verwirft den gemerkten Inhalt, sonst haelt der Vergleich Zellen
  faelschlich fuer vorhanden und die Anzeige bliebe teilweise leer.
- Schlaegt ein Telegramm fehl, wird der gemerkte Inhalt der Zeile verworfen --
  was angekommen ist, ist dann ungewiss.
- Der Zeichensatz wird nur noch bei der ersten Ausgabe einer Zeile gesetzt. Das
  spart nicht nur Bytes: Jeder Wechsel beschaedigt die Home-Zelle. Findet doch
  einer statt (neues Gradzeichen), wird die oberste Zeile vollstaendig
  nachgezogen.

Zu beiden Befunden gibt es Tests mit Gegenprobe (mit der alten Fassung schlagen
sie fehl). 155 Tests gruen, am Geraet bestaetigt: ruhig, keine fehlenden Pixel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 17:18:11 +02:00
parent 41f5eb1821
commit dae7694a07
4 changed files with 306 additions and 58 deletions
+122 -50
View File
@@ -24,6 +24,9 @@ class Display:
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."""
@@ -44,6 +47,10 @@ class Display:
# -- 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):
@@ -52,82 +59,147 @@ class Display:
def show_lines(self, *lines, clear=False):
"""Zeilen ab Zeile 1 auf die Anzeige schreiben.
Pro Zeile ein eigenes Telegramm: Cursor setzen und Text schreiben. Das
Gradzeichen (config.DEGREE_MARKER) wird ueber einen kurzen
Zeichensatzwechsel eingefuegt.
Geschrieben wird nur, was sich seit dem letzten Mal geaendert hat --
Zelle fuer Zelle. Das ist der entscheidende Punkt fuer eine ruhige
Anzeige:
Frueher war das ein einziges Telegramm fuer beide Zeilen. Seit jedes
Zeichen einzeln positioniert wird (10 statt 9 Zeichen, siehe _emit_line),
kostet eine Zeile rund 100 Bytes -- beide zusammen kaemen mit Gradzeichen
und Vollbild-Loeschen auf 224 der erlaubten 230 Bytes (migra.MAX_DATA_UNIT).
Das ist zu wenig Reserve fuer ein Geraet, das monatelang durchlaeuft;
getrennte Telegramme halten jedes bei rund der Haelfte. Die Flusskontrolle
ueber das Antworttelegramm macht die zweite Sendung unproblematisch.
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.)
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).
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.
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.)
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))
first = True
if clear:
self.clear()
self._gezeigt = {}
wechsel = False
for i in reversed(range(len(lines))):
du = migra.DataUnit()
if clear and first:
du.fill(config.BACKGROUND)
du.attributes(fg=config.COLOR, bg=config.BACKGROUND, blink=migra.STATIC)
self._emit_line(du, config.LINE_Y[i], lines[i])
self.send(du)
first = False
# 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 (eigenes, kurzes Telegramm).
"""Nur eine einzelne Zeile aktualisieren.
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.
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)
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)
self.clear()
self._gezeigt = {}
self._zeile_schreiben(index, text)
def _emit_line(self, du, y, text):
"""Eine Zeile bei Pixelzeile y ausgeben, auf volle Breite aufgefuellt.
Jedes Zeichen wird einzeln auf 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).
"""
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
text = " " * links + text + " " * (rest - links)
du.charset(config.CHARSET, spaced=config.CHARSET_SPACED)
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."""