""" VersaPad Viewer -- Browser-Variante. Liest bei jedem Request die kombinierte Config-JSON vom Desktop und rendert die Steuermatrix (4x5 Grid + 4 Encoder) je Profil als HTML. Fehlt die JSON (z.B. geloescht), wird sie automatisch per Serial vom Board neu aufgebaut und als neuer Cache gespeichert -- das Board ist die eigentliche Quelle der Wahrheit, siehe versapad_combined.load_or_fetch(). Kein Build-Schritt, kein externes Framework -- nur stdlib. Start: python server.py [--port 8765] """ import argparse import html import webbrowser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs import versapad_combined as vcomb import versapad_data as vp PAGE_CSS = """ :root { color-scheme: dark; } * { box-sizing: border-box; } body { margin: 0; padding: 32px 24px 64px; background: #14161b; color: #e8e8ec; font-family: -apple-system, "Segoe UI", Roboto, sans-serif; } h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; } .sub { color: #8a8d98; font-size: 13px; margin-bottom: 24px; } .tabs { display: flex; gap: 8px; margin-bottom: 28px; } .tab { padding: 8px 16px; border-radius: 8px; text-decoration: none; color: #c4c6cf; background: #1e2129; font-size: 14px; font-weight: 500; border: 1px solid #2a2d37; } .tab.active { background: #3a6ff0; color: #fff; border-color: #3a6ff0; } .grid { display: grid; grid-template-columns: repeat(4, 140px); grid-template-rows: repeat(5, 76px); grid-auto-flow: column; gap: 10px; margin-bottom: 36px; } .cell { position: relative; border-radius: 10px; padding: 8px 10px; display: flex; flex-direction: column; justify-content: flex-end; background: #1e2129; border: 1px solid #2a2d37; overflow: hidden; } .cell .swatch { position: absolute; top: 0; left: 0; right: 0; height: 6px; } .cell .idx { position: absolute; top: 8px; right: 10px; font-size: 11px; color: #6a6d78; } .cell .label { font-size: 14px; font-weight: 600; line-height: 1.25; word-break: break-word; } .cell .anim { font-size: 11px; color: #8a8d98; margin-top: 2px; } .cell.empty .label { color: #4a4d58; font-weight: 400; } h2 { font-size: 15px; font-weight: 600; color: #c4c6cf; margin: 0 0 12px; } .encoders { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 720px; } .enc { background: #1e2129; border: 1px solid #2a2d37; border-radius: 10px; padding: 12px 14px; } .enc .idx { font-size: 12px; color: #6a6d78; margin-bottom: 8px; } .enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0; } .enc .row .k { color: #8a8d98; } .enc .row .v { font-weight: 500; text-align: right; } footer { margin-top: 40px; color: #6a6d78; font-size: 12px; } """ def render_cell(btn): empty = btn["action"]["type"] == "None" anim = vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"]) css_class = "cell empty" if empty else "cell" grid_col, grid_row = btn["col"] + 1, btn["row"] + 1 style = f"grid-column:{grid_col}; grid-row:{grid_row};" label = html.escape(btn["label"]) if btn["label"] else "—" return f"""
#{btn['index']}
{label}
{"" if empty else anim}
""" def render_encoder(enc): return f"""
Encoder {enc['index']}
Druck{html.escape(enc['sw_label']) or '—'}
CW{html.escape(enc['cw_label']) or '—'}
CCW{html.escape(enc['ccw_label']) or '—'}
""" def render_page(profile): combined = vcomb.load_or_fetch() raw = combined["profiles"][profile] cfg = vp.annotate_profile({ "buttons": [dict(b) for b in raw["buttons"]], "encoders": [dict(e) for e in raw["encoders"]], }) tabs = "".join( f'{html.escape(combined["profile_names"][p])}' for p in range(vp.NUM_PROFILES) ) cells = "".join(render_cell(b) for b in cfg["buttons"]) encoders = "".join(render_encoder(e) for e in cfg["encoders"]) return f""" VersaPad Steuermatrix

VersaPad Steuermatrix

Live-Ansicht der Config-JSONs · aktualisiert alle 4s
{tabs}
{cells}

Encoder

{encoders}
""" class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): pass def do_GET(self): query = parse_qs(urlparse(self.path).query) try: profile = int(query.get("profile", ["0"])[0]) except ValueError: profile = 0 if not (0 <= profile < vp.NUM_PROFILES): profile = 0 try: body = render_page(profile).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) except (FileNotFoundError, RuntimeError) as e: self.send_response(500) self.end_headers() self.wfile.write(f"Config nicht verfuegbar: {e}".encode("utf-8")) def main(): parser = argparse.ArgumentParser() parser.add_argument("--port", type=int, default=8765) parser.add_argument("--no-browser", action="store_true") args = parser.parse_args() server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) url = f"http://127.0.0.1:{args.port}/" print(f"VersaPad Viewer läuft auf {url} (Strg+C zum Beenden)") if not args.no_browser: webbrowser.open(url) try: server.serve_forever() except KeyboardInterrupt: pass if __name__ == "__main__": main()