Files
grossanzeige/display.py
T
admin 42fc7f52d4 Portierung auf den XIAO ESP32-C3 (MicroPython)
Die Kette laeuft auf dem Geraet gegen den Mock auf dem Mac. Getestet mit
MicroPython 1.28.0 (ESP32_GENERIC_C3): alle Module importieren, Telegrammbau
und Koordinaten-Formatierung sind byte-identisch zum Mac (inkl. Gradzeichen),
Poll-Schleife 22 Abfragen in 20 s ohne Aussetzer, Ausfall und Reconnect
verhalten sich wie vorgesehen. Offen bleibt allein die RS232-Strecke zur
Anzeige -- der Steckbrettaufbau mit MAX3232 fehlt noch.

Gemeinsamer Kern statt zweier Programme:
- displayloop.py: die Poll-Schleife, herausgeloest aus run_display. Mac und
  ESP32 fahren jetzt denselben Ablauf; drumherum liegt nur die Kommandozeile
  (run_display.py) bzw. das WLAN-Setup (run_esp32.py).
- ticks.py: Timeouts fuer beide Plattformen. time.ticks_ms() laeuft nach ~12
  Tagen ueber; das bisherige "jetzt + Timeout" haette eine Warteschleife
  danach tagelang haengen lassen. Jetzt ueber ticks_diff.
- wifi.py, run_esp32.py, wifi_config.py (gitignored, Vorlage dabei), deploy.sh.

Pinbelegung aus dem KiCad-Schaltplan uebernommen, nicht geraten: UART1 mit
TX=GPIO21 (Pad 7/D6) und RX=GPIO20 (Pad 8/D7) ueber den MAX3232, LDR an
GPIO2 (Pad 1/A0). UART0 bleibt die REPL.

LDR-Kennlinie ist auf der Platine umgekehrt: der Teiler lautet
3V3-[2k]-A0-[LDR]-GND, die Spannung faellt also mit steigender Helligkeit --
andersherum als beim MSP430, auf den die Schwellen zugeschnitten sind. Wird
im Code gespiegelt (config.LDR_INVERTED, brightness.raw_to_ldr). Die
Schwellen selbst muessen am realen Aufbau noch nachgemessen werden.

Am Geraet gefundene Fehler, die den Attrappen-Tests entgehen mussten, weil
CPython sie nicht hat:
- MicroPython kennt kein str.ljust (auch kein rjust/center/zfill).
  display._emit_line fuellt von Hand auf.
- NOLINK_LINES hatte 10 Zeichen bei 9 Zeichen Zeilenbreite und erschien als
  "MONTIERUN". Jetzt "KEIN LINK"/"TELESKOP".
Dagegen: TestMicropythonVertraeglichkeit prueft die deployten Dateien statisch
auf solche Methoden und auf modulweites argparse/threading und haelt die
Dateiliste mit deploy.sh synchron; test_display.py prueft die Breite fester
Anzeigetexte.

Weitere Portabilitaet: mount_client nutzt getaddrinfo und eine Sendeschleife
statt sendall (MicroPython laesst dessen Verhalten mit Timeout offen);
argparse in brightness wandert in die Funktion; migra._digits und die
Hex-Ausgabe verlassen sich nicht mehr auf %0*d bzw. bytes.hex(). Beides kann
1.28 zwar doch -- am Geraet nachgeprueft, die Kommentare sagen es jetzt richtig.

Widerspruch in der Dokumentation bereinigt: die Zeichenbreite stand an vier
Stellen als 9 oder 10. Gemessen sind 9; die Erklaerung duerfte sein, dass die
Sperrschrift 7 px pro Zelle braucht (9*7 = 63 von 64 px) und nicht 6, wie es
eine 5x7-Matrix mit 1 px Luecke waere. Nachgeprueft ist das nicht -- dafuer
gibt es jetzt "probe.py pitch", das den Auto-Vorschub gegen feste
Pixelabstaende stellt, statt am Umbruch abzuzaehlen.

mount_mock.py --lan bindet auf 0.0.0.0 und nennt die einzutragende LAN-IP,
damit der ESP32 den Mock erreicht.

92 Tests gruen.

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

158 lines
6.2 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
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):
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.
Baut ein einziges Telegramm: pro Zeile Cursor setzen und den Text
schreiben. Das Gradzeichen (config.DEGREE_MARKER) wird ueber einen kurzen
Zeichensatzwechsel eingefuegt.
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).
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.)
"""
if len(lines) > len(config.LINE_Y):
raise ValueError("Anzeige hat nur %d Zeilen" % len(config.LINE_Y))
du = migra.DataUnit()
if clear:
du.fill(config.BACKGROUND)
du.attributes(fg=config.COLOR, bg=config.BACKGROUND, blink=migra.STATIC)
for i in reversed(range(len(lines))):
self._emit_line(du, config.LINE_Y[i], lines[i])
self.send(du)
def show_line(self, index, text, clear=False):
"""Nur eine einzelne Zeile aktualisieren (eigenes, kurzes Telegramm).
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.
"""
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)
def _emit_line(self, du, y, text):
"""Eine Zeile bei Pixelzeile y ausgeben, auf volle Breite aufgefuellt."""
# Ohne str.ljust: das kennt MicroPython nicht (am Geraet aufgefallen).
text = text[:config.CHARS_PER_LINE]
text += " " * (config.CHARS_PER_LINE - len(text))
du.charset(config.CHARSET, spaced=config.CHARSET_SPACED)
du.cursor(0, y)
for i, part in enumerate(text.split(config.DEGREE_MARKER)):
if i > 0:
self._emit_degree(du)
if part:
du.text(part)
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)