157 lines
5.7 KiB
Python
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()
|