#!/usr/bin/env python3
"""
turzx.py – TURZX / Turing-USB-Display (VID 0x1CBE) ohne Hersteller-Software ansteuern.
Linux, Raspberry Pi, macOS. Verifiziert am 9.2"-Modell (1920×462), die anderen Modelle
nutzen laut turing-smart-screen-python dasselbe Protokoll, sind hier aber ungetestet.

  pip3 install pyusb pillow pycryptodome        (Playwright nur für "url")

  turzx.py test                          Testbild (Farbbalken, Rahmen)
  turzx.py image foto.jpg [--fit contain] Bild anzeigen (Standard: cover)
  turzx.py text "Hallo\\nWelt"             Textkarte
  turzx.py clear                         schwarz
  turzx.py brightness 40                 Helligkeit 0–100
  turzx.py url https://… [--interval 2]  Webseite als Screenshot-Loop (Playwright + Chromium)

Mehr: https://bergclimber.de/turzx/
"""
import argparse
import struct
import sys
import time
from io import BytesIO

import usb.core
import usb.util
from PIL import Image, ImageDraw, ImageFont

try:
    from Crypto.Cipher import DES
except ImportError:
    from Cryptodome.Cipher import DES

VENDOR_ID = 0x1CBE
# PID → native Auflösung (Portrait). Nur 0x0092 hier am Gerät verifiziert.
MODELS = {0x0028: (480, 480), 0x0046: (320, 960), 0x0050: (720, 1280), 0x0080: (800, 1280),
          0x0088: (480, 1920), 0x0092: (462, 1920), 0x0123: (720, 1920)}
DES_KEY = b"slv3tuzx"
CMD_SYNC, CMD_CLEAR_CACHE, CMD_BRIGHTNESS, CMD_RAW_IMAGE = 10, 13, 14, 41
CMD_JPEG, CMD_BULK_VIDEO, CMD_NO_PARTIAL = 101, 111, 112


def _header(cmd):
    pkt = bytearray(500)
    pkt[0] = cmd
    pkt[2], pkt[3] = 0x1A, 0x6D
    midnight = time.mktime(time.localtime()[:3] + (0, 0, 0, 0, 0, -1))
    pkt[4:8] = struct.pack("<I", int((time.time() - midnight) * 1000))
    return pkt


def _encrypt(pkt):
    cipher = DES.new(DES_KEY, DES.MODE_CBC, DES_KEY)
    n = (len(pkt) + 7) // 8 * 8
    out = bytearray(512)
    enc = cipher.encrypt(bytes(pkt).ljust(n, b"\x00"))
    out[: len(enc)] = enc
    out[510], out[511] = 0xA1, 0x1A
    return out


class Turzx:
    def __init__(self):
        self.dev = None
        for pid, (w, h) in MODELS.items():
            self.dev = usb.core.find(idVendor=VENDOR_ID, idProduct=pid)
            if self.dev is not None:
                self.pid, self.native_w, self.native_h = pid, w, h
                break
        if self.dev is None:
            sys.exit("Kein TURZX-Display gefunden (lsusb | grep 1cbe). Unter Linux udev-Regel prüfen.")
        # Querformat: Breite = native Höhe, Höhe = native Breite, auf 8er-Raster gepolstert (462 → 464)
        self.w, self.h = self.native_h, self.native_w
        self.h_padded = (self.h + 7) // 8 * 8
        try:
            self.dev.set_configuration()
        except usb.core.USBError:
            pass
        try:
            if self.dev.is_kernel_driver_active(0):
                self.dev.detach_kernel_driver(0)
        except (usb.core.USBError, NotImplementedError):
            pass
        intf = usb.util.find_descriptor(self.dev.get_active_configuration(), bInterfaceNumber=0)
        self.ep_out = usb.util.find_descriptor(intf, custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT)
        self.ep_in = usb.util.find_descriptor(intf, custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN)

    def _write(self, data, timeout=5000):
        try:
            self.ep_out.write(data, timeout)
        except usb.core.USBError:
            return
        try:
            self.ep_in.read(512, timeout)
            for _ in range(5):
                try:
                    self.ep_in.read(512, timeout=100)
                except usb.core.USBError:
                    break
        except usb.core.USBError:
            pass

    def cmd(self, cmd_id, setup=None):
        pkt = _header(cmd_id)
        if setup:
            setup(pkt)
        self._write(_encrypt(pkt))

    def init(self):
        """Pflicht nach dem Verbinden, sonst scheint das Default-Theme halbtransparent durch."""
        for c in (CMD_SYNC, CMD_BULK_VIDEO, CMD_NO_PARTIAL, CMD_CLEAR_CACHE, CMD_RAW_IMAGE):
            self.cmd(c)
        self.show(Image.new("RGB", (self.w, self.h), (0, 0, 0)))  # gegen Ghosting

    def brightness(self, pct):
        self.cmd(CMD_BRIGHTNESS, lambda p: p.__setitem__(8, int(max(0, min(100, pct)) / 100 * 102)))

    def show(self, img):
        """Querformat-Bild (w×h) → polstern → ROTATE_270 → JPEG → CMD 101. Nie PNG (CMD 102)."""
        img = img.convert("RGB")
        if img.size != (self.w, self.h):
            img = fit(img, self.w, self.h)
        if self.h_padded != self.h:
            padded = Image.new("RGB", (self.w, self.h_padded), (0, 0, 0))
            padded.paste(img, (0, (self.h_padded - self.h) // 2))
            img = padded
        img = img.transpose(Image.Transpose.ROTATE_270)
        buf = BytesIO()
        img.save(buf, format="JPEG", quality=90)
        payload = buf.getvalue()
        pkt = _header(CMD_JPEG)
        pkt[8:12] = struct.pack(">I", len(payload))
        self._write(_encrypt(pkt) + payload)


def fit(img, w, h, mode="cover"):
    s = (max if mode == "cover" else min)(w / img.width, h / img.height)
    img = img.resize((max(1, round(img.width * s)), max(1, round(img.height * s))), Image.LANCZOS)
    out = Image.new("RGB", (w, h), (0, 0, 0))
    out.paste(img, ((w - img.width) // 2, (h - img.height) // 2))
    return out


def text_card(text, w, h):
    img = Image.new("RGB", (w, h), (17, 17, 17))
    d = ImageDraw.Draw(img)
    lines = text.replace("\\n", "\n").split("\n")
    size = int(h * 0.8 / len(lines) / 1.2)
    try:
        font = ImageFont.truetype("DejaVuSans-Bold.ttf", size)
    except OSError:
        font = ImageFont.load_default()
    widest = max(d.textlength(l, font=font) for l in lines)
    if widest > w * 0.9 and hasattr(font, "path"):
        font = ImageFont.truetype(font.path, int(size * w * 0.9 / widest))
    lh = size * 1.2
    y = h / 2 - (len(lines) - 1) * lh / 2
    for l in lines:
        d.text((w / 2, y), l, fill=(243, 239, 230), font=font, anchor="mm")
        y += lh
    return img


def test_pattern(w, h):
    img = Image.new("RGB", (w, h))
    d = ImageDraw.Draw(img)
    bars = ["#ffffff", "#ffff00", "#00ffff", "#00ff00", "#ff00ff", "#ff0000", "#0000ff", "#000000"]
    bw = w / len(bars)
    for i, c in enumerate(bars):
        d.rectangle([int(i * bw), 0, int((i + 1) * bw), h], fill=c)
    d.rectangle([1, 1, w - 2, h - 2], outline="#ff8800", width=2)
    d.rectangle([0, h // 2 - 40, w, h // 2 + 40], fill="black")
    d.text((w / 2, h / 2), f"{w} x {h}  oben links <-   -> oben rechts", fill="white", anchor="mm")
    return img


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--brightness", type=int, help="Helligkeit vorab setzen (0–100)")
    sub = ap.add_subparsers(dest="action", required=True)
    sub.add_parser("test")
    sub.add_parser("clear")
    p = sub.add_parser("image"); p.add_argument("file"); p.add_argument("--fit", choices=["cover", "contain"], default="cover")
    p = sub.add_parser("text"); p.add_argument("text")
    p = sub.add_parser("brightness"); p.add_argument("pct", type=int)
    p = sub.add_parser("url"); p.add_argument("url"); p.add_argument("--interval", type=float, default=2)
    a = ap.parse_args()

    d = Turzx()
    print(f"Display PID 0x{d.pid:04x}, {d.w}×{d.h} quer (JPEG {d.h_padded}×{d.w})")
    d.init()
    if a.brightness is not None:
        d.brightness(a.brightness)

    if a.action == "test":
        d.show(test_pattern(d.w, d.h))
    elif a.action == "clear":
        pass
    elif a.action == "image":
        d.show(fit(Image.open(a.file), d.w, d.h, a.fit))
    elif a.action == "text":
        d.show(text_card(a.text, d.w, d.h))
    elif a.action == "brightness":
        d.brightness(a.pct)
    elif a.action == "url":
        from playwright.sync_api import sync_playwright  # pip3 install playwright && playwright install chromium
        with sync_playwright() as p:
            browser = p.chromium.launch(headless=True)
            page = browser.new_page(viewport={"width": d.w, "height": d.h}, ignore_https_errors=True)
            page.goto(a.url, wait_until="load", timeout=20000)  # nicht "networkidle": pollende Seiten hängen sonst
            print("Loop läuft, Strg+C beendet")
            while True:
                t0 = time.time()
                d.show(Image.open(BytesIO(page.screenshot(type="jpeg", quality=90))))
                time.sleep(max(0, a.interval - (time.time() - t0)))


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        pass
