VersaGUI-py/server.py
Julian Appel aba81463e5 Show macro key sequences in the browser view and MCP replies
Beide Ansichten reichen jetzt die Makrotabelle an action_label() durch, so
dass eine Makro-Belegung ihre echte Tastenfolge zeigt statt nur der
Slot-Nummer -- dieselbe Darstellung wie im Desktop-Fenster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:33:15 +02:00

179 lines
6.6 KiB
Python

"""
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, 104px);
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 .note { font-size: 11px; color: #8a8d98; margin-top: 4px; 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 0; }
.enc .row .k { color: #8a8d98; }
.enc .row .v { font-weight: 500; text-align: right; }
.enc .note { font-size: 11px; color: #6a6d78; padding-bottom: 6px; word-break: break-word; }
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 ""
note = html.escape(btn["note"]) if btn.get("note") else ""
return f"""<div class="{css_class}" style="{style}">
<div class="swatch" style="background:{vp.led_css(btn['led'])};"></div>
<div class="idx">#{btn['index']}</div>
<div class="label">{label}</div>
<div class="note">{note}</div>
<div class="anim">{"" if empty else anim}</div>
</div>"""
def _encoder_row(key, label, note):
label = html.escape(label) or ""
note_html = f'<div class="note">{html.escape(note)}</div>' if note else ""
return f"""<div class="row"><span class="k">{key}</span><span class="v">{label}</span></div>{note_html}"""
def render_encoder(enc):
rows = (
_encoder_row("Druck", enc["sw_label"], enc["sw_note"])
+ _encoder_row("CW", enc["cw_label"], enc["cw_note"])
+ _encoder_row("CCW", enc["ccw_label"], enc["ccw_note"])
)
return f"""<div class="enc">
<div class="idx">Encoder {enc['index']}</div>
{rows}
</div>"""
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"]],
}, combined.get("macros"))
tabs = "".join(
f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(combined["profile_names"][p])}</a>'
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"""<!doctype html>
<html><head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="4">
<title>VersaPad Steuermatrix</title>
<style>{PAGE_CSS}</style>
</head><body>
<h1>VersaPad Steuermatrix</h1>
<div class="sub">Live-Ansicht der Config-JSONs · aktualisiert alle 4s</div>
<div class="tabs">{tabs}</div>
<div class="grid">{cells}</div>
<h2>Encoder</h2>
<div class="encoders">{encoders}</div>
<footer>Quelle: {html.escape(vcomb.DEFAULT_PATH)}</footer>
</body></html>"""
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()