Files
grossanzeige/migra.py
T
2026-07-16 09:05:56 +02:00

186 lines
6.2 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 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)."""
if value < 0 or value >= 10 ** places:
raise MigraError("Wert %d passt nicht in %d Stellen" % (value, places))
return ("%0*d" % (places, value)).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" % frame.hex())
if frame[1] != (0x80 | src):
raise MigraError("Antwort nicht an uns adressiert: %s" % frame.hex())
code = chr(frame[4])
if code != "0":
raise MigraError("Anzeige meldet Fehler '%s': %s" % (code, ERRORS.get(code, "unbekannt")))
return code