diff --git a/README.md b/README.md index 617eae2..bf8cb48 100644 --- a/README.md +++ b/README.md @@ -321,11 +321,30 @@ die Reihenfolge der Telegramme statt der Reihenfolge innerhalb eines Telegramms. mitwandernder Offset könnte das nicht; dafür ist die Zentrierung nur auf eine halbe Zelle (3 px) genau, wenn die Restbreite ungerade ist. -- **Zyklische Updates flimmerfrei:** `show_lines` löscht nicht bei jeder Aktualisierung, - sondern überschreibt die Zeilen an Ort und Stelle (auf volle Breite aufgefüllt). - `clear=True` nur beim ersten Bild. Wichtig: Zeilen dürfen **nicht breiter** als - `CHARS_PER_LINE` aufgefüllt werden, sonst bricht das Füll-Leerzeichen um und - beschädigt die andere 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 + unveränderte Zeile erzeugt gar kein Telegramm. `clear=True` nur beim ersten Bild + und nach einem Verbindungsabriss. + + Das ist nicht nur Sparsamkeit, sondern der Grund für eine ruhige Anzeige. Mit + Einzelpositionierung 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 während des Empfangs dar, also war der Aufbau + als Flackern zu sehen — bei Aktualisierung im Sekundentakt ein Drittel der Zeit. + Von einer Sekunde zur nächsten ändert sich aber fast nichts: `18h36m56s` → + `18h36m57s` ist eine einzige Ziffer. Am Gerät gemessen (2026-07-27): + **306 ms → 66 ms** pro Bildwechsel. + +- **Der rechte Nachbar wird mitgeschrieben.** Die Anzeige malt je Zeichen 7 px + breit (daher der 7-px-Auto-Vorschub), gesetzt wird aber auf 6-px-Raster. Ein neu + geschriebenes Zeichen löscht deshalb die erste Pixelspalte des Zeichens rechts + daneben. Beim vollständigen Neuaufbau fiel das nie auf, weil der Nachbar gleich + danach ohnehin neu gemalt wurde — beim Schreiben einzelner Zellen blieb er + beschädigt stehen: dem `s` hinter den Sekunden fehlten die linken Pixel, dem `m` + hinter den Minuten ebenso. `_zelle_faellig` zieht den rechten Nachbarn deshalb mit. + +- Wichtig bleibt: Zeilen dürfen **nicht breiter** als `CHARS_PER_LINE` aufgefüllt + werden, sonst bricht das Füll-Leerzeichen um und beschädigt die andere Zeile. ## Präzision (Low / High) diff --git a/display.py b/display.py index 4279f3b..cc0757b 100644 --- a/display.py +++ b/display.py @@ -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.""" diff --git a/test_display.py b/test_display.py index 3ea0802..11ec6c1 100644 --- a/test_display.py +++ b/test_display.py @@ -81,6 +81,161 @@ class TestFesteTexte(unittest.TestCase): self.assertLessEqual(len(getattr(config, name)), len(config.LINE_Y)) +class TestNurGeaenderteZellen(unittest.TestCase): + """Der Grund fuer den Vergleich: eine ruhige Anzeige. + + Mit Einzelpositionierung kostet eine volle Zeile rund 100 Bytes; bei 19200 + Baud dauerte ein kompletter Bildwechsel damit rund 300 ms, und der Aufbau + war als Flackern zu sehen (am Geraet gemessen 2026-07-27). Deshalb wird nur + geschrieben, was sich geaendert hat. + """ + + def setUp(self): + self.t = FakeTransport(echo=False) + self.d = Display(self.t, response=True) + + def _neu(self): + """Telegramme seit dem letzten Aufruf.""" + frames = self.t.frames[:] + self.t.frames.clear() + return frames + + def test_sekundenwechsel_schreibt_nur_die_noetigen_zellen(self): + """Der Alltagsfall: RA tickt, alles andere steht still. + + Geschrieben werden die geaenderte Ziffer *und* ihr rechter Nachbar -- + siehe test_rechter_nachbar_wird_mitgeschrieben. + """ + self.d.show_lines("18h36m56s", "+38°47'01\"") + self._neu() + + self.d.show_lines("18h36m57s", "+38°47'01\"") + frames = self._neu() + + # Die Deklination ist unveraendert -- dafuer darf gar nichts gesendet werden. + self.assertEqual(len(frames), 1) + x = lambda i: config.LINE_X + i * config.CELL_WIDTH + self.assertEqual(zellen(frames), {(x(7), config.LINE_Y[0]): "7", + (x(8), config.LINE_Y[0]): "s"}) + + def test_rechter_nachbar_wird_mitgeschrieben(self): + """Am Geraet aufgefallen (2026-07-27): dem "s" fehlten die linken Pixel. + + Die Anzeige malt je Zeichen 7 px breit, gesetzt wird auf 6-px-Raster -- + ein neu geschriebenes Zeichen loescht die erste Pixelspalte seines + rechten Nachbarn. Der muss deshalb mit, sonst bleibt er beschaedigt. + """ + self.d.show_lines("AAAAAAAAAA") + self._neu() + self.d.show_lines("AAAAXAAAAA") # nur Zelle 4 aendert sich + gesetzt = zellen(self._neu()) + x = lambda i: config.LINE_X + i * config.CELL_WIDTH + y = config.LINE_Y[0] + self.assertIn((x(4), y), gesetzt) # die geaenderte Zelle + self.assertIn((x(5), y), gesetzt) # ihr rechter Nachbar + self.assertNotIn((x(3), y), gesetzt) # links davon bleibt unberuehrt + self.assertNotIn((x(6), y), gesetzt) # und uebernaechste auch + + def test_letzte_zelle_hat_keinen_nachbarn(self): + """Aendert sich die aeusserste rechte Zelle, gibt es nichts nachzuziehen.""" + self.d.show_lines("AAAAAAAAAA") + self._neu() + self.d.show_lines("AAAAAAAAAX") + gesetzt = zellen(self._neu()) + x = lambda i: config.LINE_X + i * config.CELL_WIDTH + self.assertEqual(list(gesetzt), [(x(9), config.LINE_Y[0])]) + + def test_unveraenderte_zeilen_erzeugen_kein_telegramm(self): + self.d.show_lines("18h36m56s", "+38°47'01\"") + self._neu() + self.d.show_lines("18h36m56s", "+38°47'01\"") + self.assertEqual(self._neu(), []) + + def test_telegramm_wird_deutlich_kuerzer(self): + """Vorher-nachher in Bytes -- das ist die eigentliche Wirkung.""" + self.d.show_lines("18h36m56s", "+38°47'01\"") + voll = sum(len(f) for f in self._neu()) + + self.d.show_lines("18h36m57s", "+38°47'01\"") + diff = sum(len(f) for f in self._neu()) + + # Gemessen: 236 Bytes gegen 30 (Faktor knapp 8). Die Schranke liegt + # bewusst darunter, damit der Test nicht bei jeder Kleinigkeit kippt -- + # er soll die Groessenordnung sichern, nicht eine exakte Zahl. + self.assertLess(diff * 5, voll, + "Der Sekundenwechsel sollte um ein Vielfaches " + "kuerzer sein als der Vollaufbau (%d vs %d Bytes)" + % (diff, voll)) + + def test_clear_erzwingt_vollstaendiges_neuschreiben(self): + self.d.show_lines("18h36m56s", "+38°47'01\"") + self._neu() + self.d.show_lines("18h36m56s", "+38°47'01\"", clear=True) + 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) + + def test_nach_clear_kein_rest_der_alten_anzeige(self): + """clear muss den gemerkten Inhalt verwerfen. + + Sonst haelt der Vergleich Zellen faelschlich fuer schon vorhanden und + die Anzeige bliebe nach dem Loeschen teilweise leer. + """ + self.d.show_lines("ABCDEFGHIJ", "0123456789") + self._neu() + self.d.clear() + self._neu() + self.d.show_lines("ABCDEFGHIJ", "0123456789") + frames = self._neu() + self.assertEqual(zeile(frames, 0), "ABCDEFGHIJ") + self.assertEqual(zeile(frames, 1), "0123456789") + + def test_nach_sendefehler_wird_die_zeile_voll_neu_geschrieben(self): + """Bei einem Fehler ist ungewiss, was ankam -- also nichts annehmen.""" + self.d.show_lines("18h36m56s", "+38°47'01\"") + self._neu() + + class Kaputt(Exception): + pass + + def platzt(_): + raise Kaputt() + + echt, self.d._t.write = self.d._t.write, platzt + with self.assertRaises(Kaputt): + self.d.show_lines("18h36m57s", "+38°47'01\"") + self.d._t.write = echt + self._neu() + + # 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) + + def test_gradzeichen_wird_nicht_staendig_neu_gesetzt(self): + """Spart nicht nur Bytes: Jeder Zeichensatzwechsel beschaedigt (0,0).""" + self.d.show_lines("18h36m56s", "+38°47'01\"") + self._neu() + self.d.show_lines("18h36m57s", "+38°47'02\"") + 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. + + Dann muss die oberste Zeile vollstaendig nachgezogen werden, sonst + bleibt ihr erstes Zeichen zerstoert. + """ + self.d.show_lines("18h36m56s", "kein Grad") + self._neu() + self.d.show_lines("18h36m56s", "+38°47'01\"") # Gradzeichen kommt neu dazu + 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) + + class TestZeilenaufbau(unittest.TestCase): def setUp(self): diff --git a/test_migra.py b/test_migra.py index dcfd1d4..d61cda4 100644 --- a/test_migra.py +++ b/test_migra.py @@ -157,14 +157,16 @@ class TestDisplay(unittest.TestCase): t = FakeTransport(echo=False) Display(t, response=True).show_lines("12:34:56", "+45:12:30", clear=True) - self.assertEqual(len(t.frames), 2) + # clear=True ist ein eigenes, sehr kurzes Telegramm, danach je Zeile eins. + self.assertEqual(len(t.frames), 3) for frame in t.frames: self.assertEqual(frame[:4], bytes([0x02, 0x81, 0x80, 0x81])) self.assertEqual(frame[-1], 0x03) self.assertLessEqual(len(frame), migra.MAX_DATA_UNIT) - unten, oben = t.frames # untere Zeile zuerst - self.assertIn(b"\x1bF0", unten) # mit clear=True: einmal loeschen + loeschen, unten, oben = t.frames # untere Zeile zuerst + self.assertIn(b"\x1bF0", loeschen) + self.assertNotIn(b"\x1bF0", unten) self.assertNotIn(b"\x1bF0", oben) # Jedes Zeichen sitzt auf seiner eigenen Zelle: x = LINE_X + i*CELL_WIDTH.