VersaGUI-py/server.py

157 lines
5.7 KiB
Python

"""
VersaPad Viewer -- Browser-Variante.
Liest bei jedem Request live die 3 Config-JSONs vom Desktop und rendert
die Steuermatrix (4x5 Grid + 4 Encoder) je Profil als HTML.
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_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"""<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="anim">{"" if empty else anim}</div>
</div>"""
def render_encoder(enc):
return f"""<div class="enc">
<div class="idx">Encoder {enc['index']}</div>
<div class="row"><span class="k">Druck</span><span class="v">{html.escape(enc['sw_label']) or ''}</span></div>
<div class="row"><span class="k">CW</span><span class="v">{html.escape(enc['cw_label']) or ''}</span></div>
<div class="row"><span class="k">CCW</span><span class="v">{html.escape(enc['ccw_label']) or ''}</span></div>
</div>"""
def render_page(profile):
cfg = vp.load_profile(profile)
tabs = "".join(
f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(vp.PROFILE_NAMES[p])}</a>'
for p in sorted(vp.PROFILE_NAMES)
)
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(vp.CONFIG_PATHS[profile])}</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 profile not in vp.PROFILE_NAMES:
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 as e:
self.send_response(500)
self.end_headers()
self.wfile.write(f"Config-Datei fehlt: {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()