Files
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

202 lines
6.8 KiB
Python

"""Protokoll-Layer fuer die microSYST migra SC/MC Seriell LED-Grossanzeige.
Reines Python ohne Abhaengigkeiten, laeuft unter CPython und MicroPython.
Referenz: Benutzerhandbuch X-M31-9AXX5X-006 (V4.20), Kapitel 3.5 bis 3.9.
Telegrammaufbau: STX | DA | SA | FC | [LEN-H LEN-L] | Data-Unit | [CHK-H CHK-L] | ETX
"""
STX = 0x02
ETX = 0x03
ESC = 0x1B
SEP = 0x1F # Trennzeichen ESC-Sequenz -> Online-Text (Handbuch 3.9)
MAX_DATA_UNIT = 230
# Farben (ESC-A, ESC-F, ESC-P, ESC-H)
BLACK = "0"
GREEN = "1"
RED = "2"
YELLOW = "3"
TRANSPARENT = "T" # nur als Hintergrundfarbe
STATIC = "0"
BLINKING = "1"
# Fehlercodes aus dem Antworttelegramm (Handbuch 3.8)
ERRORS = {
"0": "kein Fehler",
"1": "falsche Pruefsumme",
"2": "reserviert",
"3": "falsche Anzahl Datenbytes (LEN-H/L) oder ungueltige ESC-Sequenz",
"4": "Element nicht vorhanden oder ungueltiger Parameter",
"5": "Flash ungueltig",
}
class MigraError(Exception):
"""Fehler im Protokoll oder von der Anzeige gemeldeter Fehlercode."""
def _hex(data):
"""Bytes als Hexstring, ohne sich auf bytes.hex() zu verlassen.
MicroPython 1.28 hat bytes.hex() (am Geraet nachgeprueft), aber nicht jede
Firmware ist damit gebaut. Wird nur in Fehlermeldungen gebraucht -- ein
AttributeError ausgerechnet dort wuerde den eigentlichen Fehler verdecken.
"""
return "".join("%02x" % b for b in data)
def encode_text(s):
"""Text in Anzeige-Bytes wandeln.
Die Data-Unit erlaubt 0x20-0xFF sowie 0x0A und 0x0D. Zeichen darueber
hinaus werden zu '?'. Die Abbildung entspricht Latin-1, das sich mit dem
Windows-Zeichensatz der Anzeige im Bereich 0xA0-0xFF deckt (u.a. Grad 0xB0).
"""
out = bytearray()
for ch in s:
c = ord(ch)
if c in (0x0A, 0x0D) or 0x20 <= c <= 0xFF:
out.append(c)
else:
out.append(0x3F) # '?'
return bytes(out)
def _digits(value, places):
"""Zahl als ASCII-Ziffern fester Laenge (Handbuch 3.7: alle Werte ASCII-codiert).
Von Hand aufgefuellt statt mit "%0*d". MicroPython 1.28 beherrscht die
dynamische Feldbreite zwar (am Geraet nachgeprueft), sie stand aber lange
als nicht unterstuetzt in den Docs -- so bleibt es unabhaengig davon.
"""
if value < 0 or value >= 10 ** places:
raise MigraError("Wert %d passt nicht in %d Stellen" % (value, places))
s = "%d" % value
return ("0" * (places - len(s)) + s).encode("ascii")
class DataUnit:
"""Baut eine Data-Unit aus mehreren Teiltelegrammen.
Setzt das Trennzeichen 0x1F selbsttaetig, wenn auf eine ESC-Sequenz ein
Online-Text folgt. Nur dieser Uebergang braucht es (Handbuch 3.9).
"""
def __init__(self):
self._buf = bytearray()
self._after_esc = False
def __len__(self):
return len(self._buf)
def bytes(self):
return bytes(self._buf)
def _esc(self, *parts):
self._buf.append(ESC)
for p in parts:
self._buf.extend(p if isinstance(p, bytes) else p.encode("ascii"))
self._after_esc = True
return self
# -- Online-Text ------------------------------------------------------
def text(self, s):
if self._after_esc:
self._buf.append(SEP)
self._buf.extend(encode_text(s))
self._after_esc = False
return self
# -- Zeichensatz, Cursor, Attribute (3.7.1) ---------------------------
def charset(self, number, spaced=True):
"""spaced=True -> 'z' (Sperrschrift, gleiche Breite), False -> 'Z'."""
return self._esc("z" if spaced else "Z", _digits(number, 2))
def cursor(self, x, y):
"""Cursor auf Pixelposition x/y. 0/0 ist links oben."""
return self._esc("C", _digits(x, 3), _digits(y, 3))
def attributes(self, fg=RED, bg=BLACK, blink=STATIC):
return self._esc("A", fg, bg, blink)
# -- Direkte Grafik (3.7.3) -------------------------------------------
def fill(self, color=BLACK):
"""Anzeige komplett fuellen. fill(BLACK) = loeschen."""
return self._esc("F", color)
def point(self, x, y, color=RED):
return self._esc("P", color, _digits(x, 3), _digits(y, 3))
# -- Gespeicherte Objekte (3.7.2) -------------------------------------
def stored_text(self, number, show=True):
return self._esc("T", "+" if show else "-", _digits(number, 3))
def graphic(self, number, show=True):
return self._esc("G", "+" if show else "-", _digits(number, 3))
# -- Allgemeine Funktionen (3.7.4) ------------------------------------
def brightness(self, percent, color=RED):
"""Helligkeit 0-100 %. Wird geraeteintern noch mit dem HEX-Schalter multipliziert."""
if not 0 <= percent <= 100:
raise MigraError("Helligkeit %r ausserhalb 0-100 %%" % percent)
return self._esc("H", color, _digits(percent, 3))
def blink_period(self, level):
"""0 = 2 s bis 9 = 0,2 s."""
return self._esc("B", _digits(level, 1))
def build_telegram(data, dest=1, src=0, checksum=False, response=True):
"""Data-Unit in ein vollstaendiges Telegramm rahmen.
dest/src sind die reinen Adressen (0-126); Bit 7 wird hier gesetzt.
checksum=False bedeutet: LEN-H/L und CHK-H/L duerfen *nicht* mitgesendet
werden (Handbuch 3.5.1).
"""
if isinstance(data, DataUnit):
data = data.bytes()
if len(data) > MAX_DATA_UNIT:
raise MigraError("Data-Unit hat %d Bytes, erlaubt sind %d" % (len(data), MAX_DATA_UNIT))
if not 0 <= dest <= 127 or not 0 <= src <= 126:
raise MigraError("Adresse ausserhalb des gueltigen Bereichs")
da = 0x80 | dest
sa = 0x80 | src
fc = 0x80 | (0x02 if checksum else 0x00) | (0x01 if response else 0x00)
frame = bytearray([STX, da, sa, fc])
if checksum:
n = len(data)
frame.append(0xF0 | ((n >> 4) & 0x0F))
frame.append(0xF0 | (n & 0x0F))
frame.extend(data)
if checksum:
# Summe aus DA, SA, FC, LEN-H, LEN-L und allen Datenbytes; ohne STX.
chk = sum(frame[1:]) & 0xFF
frame.append(0xF0 | ((chk >> 4) & 0x0F))
frame.append(0xF0 | (chk & 0x0F))
frame.append(ETX)
return bytes(frame)
def parse_response(frame, src=0):
"""Antworttelegramm auswerten und den Fehlercode als Zeichen liefern.
Erwartet: STX | DA | SA | FC | Fehlercode | ETX (Handbuch 3.8)
Wirft MigraError, wenn die Anzeige einen Fehler ungleich '0' meldet.
"""
if not frame:
raise MigraError("keine Antwort von der Anzeige (Timeout)")
if len(frame) < 6 or frame[0] != STX or frame[-1] != ETX:
raise MigraError("unplausibles Antworttelegramm: %s" % _hex(frame))
if frame[1] != (0x80 | src):
raise MigraError("Antwort nicht an uns adressiert: %s" % _hex(frame))
code = chr(frame[4])
if code != "0":
raise MigraError("Anzeige meldet Fehler '%s': %s" % (code, ERRORS.get(code, "unbekannt")))
return code