42fc7f52d4
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>
202 lines
6.8 KiB
Python
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
|