Files
grossanzeige/test_migra.py
T
admin dae7694a07 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>
2026-07-27 17:18:11 +02:00

266 lines
11 KiB
Python

#!/usr/bin/env python3
"""Tests fuer den Protokoll-Layer, gepruefte Referenz ist das Handbuch.
python3 -m unittest -v test_migra
"""
import unittest
import config
import coords
import migra
from display import Display
from transport import FakeTransport
class TestTelegramm(unittest.TestCase):
def test_handbuch_beispiel_mit_pruefsumme(self):
"""Handbuch 3.10, Beispiel 2: Zeichen 'A' an Anzeige mit Adresse 1.
Master: 02 81 80 83 F0 F1 41 FA F6 03
"""
frame = migra.build_telegram(b"A", dest=1, src=0, checksum=True, response=True)
self.assertEqual(frame, bytes.fromhex("02818083F0F141FAF603"))
def test_handbuch_beispiel_ohne_pruefsumme(self):
"""Handbuch 3.10, Beispiel 1: ohne Pruefsumme, mit Antwort.
Ohne Pruefsumme duerfen LEN-H/L gerade *nicht* mitgesendet werden.
"""
frame = migra.build_telegram(b"Hallo Welt", dest=1, src=0,
checksum=False, response=True)
self.assertEqual(frame[:4], bytes([0x02, 0x81, 0x80, 0x81]))
self.assertEqual(frame[4:-1], b"Hallo Welt")
self.assertEqual(frame[-1], 0x03)
def test_frame_control_bits(self):
def fc(checksum, response):
return migra.build_telegram(b"x", checksum=checksum, response=response)[3]
self.assertEqual(fc(False, False), 0x80)
self.assertEqual(fc(False, True), 0x81) # so faehrt das alte C-Programm
self.assertEqual(fc(True, False), 0x82)
self.assertEqual(fc(True, True), 0x83)
def test_data_unit_zu_lang(self):
with self.assertRaises(migra.MigraError):
migra.build_telegram(b"x" * 231)
class TestDataUnit(unittest.TestCase):
def test_trennzeichen_nach_esc_sequenz(self):
"""Handbuch 3.9: Data-Unit = 1B 'Z01' 1B 'C002003' 1B 'A301' 1F Text."""
du = (migra.DataUnit()
.charset(1, spaced=False)
.cursor(2, 3)
.attributes(fg=migra.YELLOW, bg=migra.BLACK, blink=migra.BLINKING)
.text("Online-Text"))
self.assertEqual(
du.bytes(),
b"\x1bZ01\x1bC002003\x1bA301\x1fOnline-Text",
)
def test_kein_trennzeichen_zwischen_esc_sequenzen(self):
du = migra.DataUnit().fill(migra.BLACK).charset(0)
self.assertEqual(du.bytes(), b"\x1bF0\x1bz00")
def test_trennzeichen_nur_einmal_pro_textblock(self):
du = migra.DataUnit().cursor(0, 0).text("AB").text("CD")
self.assertEqual(du.bytes(), b"\x1bC000000\x1fABCD")
def test_cursor_ist_ascii_codiert(self):
"""Handbuch 3.7: Position 123 wird als '1' '2' '3' uebertragen."""
self.assertEqual(migra.DataUnit().cursor(123, 45).bytes(), b"\x1bC123045")
def test_loeschen_und_helligkeit(self):
self.assertEqual(migra.DataUnit().fill(migra.BLACK).bytes(), b"\x1bF0")
self.assertEqual(migra.DataUnit().brightness(100, migra.RED).bytes(), b"\x1bH2100")
self.assertEqual(migra.DataUnit().brightness(80, migra.RED).bytes(), b"\x1bH2080")
def test_helligkeit_ausserhalb_bereich(self):
with self.assertRaises(migra.MigraError):
migra.DataUnit().brightness(101)
def test_text_encoding_latin1(self):
self.assertEqual(migra.encode_text("+45°12'"), b"+45\xb012'")
self.assertEqual(migra.encode_text(""), b"?") # nicht darstellbar
class TestAntwort(unittest.TestCase):
def test_ok(self):
frame = bytes([0x02, 0x80, 0x81, 0x80, ord("0"), 0x03])
self.assertEqual(migra.parse_response(frame), "0")
def test_fehlercode_wirft(self):
frame = bytes([0x02, 0x80, 0x81, 0x80, ord("3"), 0x03])
with self.assertRaises(migra.MigraError) as ctx:
migra.parse_response(frame)
self.assertIn("ESC-Sequenz", str(ctx.exception))
def test_timeout_wirft(self):
with self.assertRaises(migra.MigraError):
migra.parse_response(b"")
class TestKoordinaten(unittest.TestCase):
def test_ra_formate_werden_erkannt(self):
for text in ("12h34m56s", "12:34:56", "12 34 56"):
self.assertEqual(coords.parse_ra(text), (12, 34, 56))
def test_ra_dezimal(self):
self.assertEqual(coords.parse_ra("12.5"), (12, 30, 0))
def test_dec_mit_vorzeichen(self):
self.assertEqual(coords.parse_dec("+45d12m30s"), ("+", 45, 12, 30))
self.assertEqual(coords.parse_dec("-07:15:00"), ("-", 7, 15, 0))
def test_dec_ohne_vorzeichen_ist_positiv(self):
self.assertEqual(coords.parse_dec("45:12:30"), ("+", 45, 12, 30))
def test_bereichsgrenzen(self):
with self.assertRaises(ValueError):
coords.parse_ra("24:00:01")
with self.assertRaises(ValueError):
coords.parse_dec("+91:00:00")
def test_formatierung_hochpraezision(self):
hp = dict(high_precision=True)
self.assertEqual(coords.format_ra("12h34m56s", width=9, **hp), "12h34m56s")
self.assertEqual(coords.format_ra("12h34m56s", width=8, **hp), "12:34:56")
self.assertEqual(coords.format_dec("+45d12m30s", width=10, **hp), "+45°12'30\"")
self.assertEqual(coords.format_dec("+45d12m30s", width=9, **hp), "+45°12'30")
self.assertEqual(coords.format_dec("+45d12m30s", width=7, **hp), "+45°12'")
def test_formatierung_niedrigpraezision(self):
lp = dict(high_precision=False)
# RA: Zehntel-Minute (0.9 min) kommt als Sekunden (54 s) heraus
self.assertEqual(coords.format_ra("11:36.9", width=9, **lp), "11h36m54s")
self.assertEqual(coords.format_ra("11:36.9", width=6, **lp), "11h36m")
# DEC: ohne Sekunden
self.assertEqual(coords.format_dec("-00°55", width=9, **lp), "-00°55'")
self.assertEqual(coords.format_dec("-00°55", width=6, **lp), "-00:55")
class TestDisplay(unittest.TestCase):
def test_je_zeile_ein_telegramm(self):
"""Eine Zeile je Telegramm, jeweils sauber gerahmt.
Frueher war beides ein Telegramm. Seit jedes Zeichen einzeln
positioniert wird (10 statt 9 Zeichen), waeren beide Zeilen zusammen zu
dicht an migra.MAX_DATA_UNIT -- siehe display.show_lines.
"""
t = FakeTransport(echo=False)
Display(t, response=True).show_lines("12:34:56", "+45:12:30", clear=True)
# 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)
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.
def zelle(i, y):
return b"\x1bC%03d%03d" % (config.LINE_X + i * config.CELL_WIDTH, y)
# "12:34:56" hat 8 Zeichen und wird in 10 Zellen mittig gesetzt (ab Zelle 1),
# "+45:12:30" hat 9 und beginnt daher schon bei Zelle 0.
self.assertIn(zelle(1, 0) + b"\x1f1", oben)
self.assertIn(zelle(2, 0) + b"\x1f2", oben)
self.assertIn(zelle(0, 9) + b"\x1f+", unten)
def test_ohne_clear_kein_fill_aber_aufgefuellt(self):
"""Standard (clear=False): kein Vollbild-Loeschen, Zeilen auf volle Breite."""
from test_display import zeile, zentriert
t = FakeTransport(echo=False)
Display(t, response=True).show_lines("12:34:56", clear=False)
self.assertNotIn(b"\x1bF0", t.frames[0])
# auf CHARS_PER_LINE mit Leerzeichen aufgefuellt (mittig, siehe CENTER_LINES)
self.assertEqual(zeile(t.frames, 0), zentriert("12:34:56"))
def test_gradzeichen_via_zeichensatz(self):
"""Gradzeichen wird durch kurzen Wechsel auf Zeichensatz 1 erzeugt."""
t = FakeTransport(echo=False)
Display(t, response=True).show_lines("12h34m56s", "+45°12'30\"")
data = t.frames[0][4:-1]
# Umschalten auf Zeichensatz 1, "/", zurueck auf Zeichensatz 0
self.assertIn(b"\x1bz01\x1f/\x1bz00", data)
def test_oberste_zeile_wird_zuletzt_gezeichnet(self):
"""Wegen der Home-Zellen-Stoerung muss y=0 nach y=9 gesendet werden.
Seit es ein Telegramm je Zeile gibt, entscheidet die Reihenfolge der
Telegramme statt der Reihenfolge innerhalb eines Telegramms.
"""
t = FakeTransport(echo=False)
Display(t, response=True).show_lines("12h34m56s", "+45°12'30\"")
from test_display import zellen
self.assertEqual(len(t.frames), 2)
# Auf die y-Koordinaten schauen, nicht auf feste Cursor-Bytes.
y_zuerst = set(y for _, y in zellen(t.frames[:1]))
y_zuletzt = set(y for _, y in zellen(t.frames[1:]))
self.assertEqual(y_zuerst, {config.LINE_Y[1]}) # untere Zeile zuerst
self.assertEqual(y_zuletzt, {config.LINE_Y[0]}) # oberste Zeile zuletzt
def test_gradzeichen_fallback_ohne_zeichensatz(self):
"""Ist DEGREE_CHARSET None, wird der Fallbacktext statt der Umschaltung genutzt."""
import config
t = FakeTransport(echo=False)
alt = config.DEGREE_CHARSET
try:
config.DEGREE_CHARSET = None
Display(t, response=True).show_lines("+45°12'30\"")
finally:
config.DEGREE_CHARSET = alt
data = t.frames[0][4:-1]
self.assertNotIn(b"\x1bz01", data)
self.assertIn(config.DEGREE_FALLBACK.encode(), data)
def test_fehlercode_der_anzeige_wirft(self):
t = FakeTransport(echo=False)
t._response = bytes([0x02, 0x80, 0x81, 0x80, ord("4"), 0x03])
with self.assertRaises(migra.MigraError):
Display(t, response=True).clear()
def test_show_line_nur_eine_zeile(self):
"""show_line schreibt nur die angegebene Zeile, ohne Vollbild-Loeschen."""
from test_display import zellen
t = FakeTransport(echo=False)
Display(t, response=True).show_line(0, "18h36m56s")
self.assertNotIn(b"\x1bF0", t.frames[0]) # kein Loeschen
y_werte = set(y for _, y in zellen(t.frames))
self.assertEqual(y_werte, {config.LINE_Y[0]}) # nur Zeile 1, Zeile 2 unberuehrt
def test_show_line_ungueltiger_index(self):
t = FakeTransport(echo=False)
with self.assertRaises(ValueError):
Display(t, response=True).show_line(5, "x")
def test_ohne_antwort_fc_bit0_ist_null(self):
"""Standardbetrieb ohne Antwort: FC = 0x80, kein Fehlercode wird gelesen."""
import config
alt = config.QUIET_TIME
try:
config.QUIET_TIME = 0.0
t = FakeTransport(echo=False)
# selbst wenn die (ignorierte) Antwort einen Fehler meldet, darf nichts fliegen
t._response = bytes([0x02, 0x80, 0x81, 0x80, ord("4"), 0x03])
Display(t, response=False).clear()
finally:
config.QUIET_TIME = alt
self.assertEqual(t.frames[0][3], 0x80) # FC ohne Antwort-Bit
if __name__ == "__main__":
unittest.main(verbosity=2)