"""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