Initial commit: VersaPad viewer + programming mode
This commit is contained in:
parent
ef0faec100
commit
6227903ebd
10 changed files with 1765 additions and 0 deletions
157
server.py
Normal file
157
server.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""
|
||||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue