VersaGUI-py/server.py
Julian Appel b4ad7698ed Move config storage next to the install and auto-create it if missing
versapad_combined.DEFAULT_PATH was hardcoded to this one machine's OneDrive
desktop, which made the tool unusable anywhere else. versapad_data.app_dir()
now resolves to the running .exe's own folder when frozen, or the project
directory when run from source, and DEFAULT_PATH hangs off that instead.

load_or_fetch() previously raised when both the file was missing and the
board unreachable, blocking a fresh install with no config and no board
attached. It now falls back to an empty default_combined() in that case, so
the tool is immediately usable either way. desktop_viewer's
_current_profile_view() picks up the same fallback instead of re-implementing
a narrower version of it.

Also drop the hardcoded PROFILE_NAMES dict, which had drifted out of sync
with the profile_names already stored in the combined JSON -- renaming a
profile in Programmiermodus never showed up in the read-only/browser views.
server.py and desktop_viewer.py now read names from the same JSON everywhere.

versapad_data.CONFIG_PATHS (read-only interop with the official C# VersaGUI's
JSON export) is intentionally left on the OneDrive desktop -- nothing in
this codebase writes there, it's not part of this tool's own config.

(cherry picked from commit 5d14bdd826)
2026-08-14 23:50:46 +02:00

166 lines
6.2 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, 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):
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'<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()