Compare commits

...

4 commits

Author SHA1 Message Date
cjjohn
9f0ae12794 Keep build_and_deploy.ps1 deploying to %LOCALAPPDATA%, document upstream cherry-picks
Cherry-picked commits from Julian Appel's dev/jappel branch changed the
default deploy target to $projectDir\dist\VersaPadViewer. Revert just
that to %LOCALAPPDATA%\VersaPadViewer, which is where the actually
installed/running instance on this machine lives -- switching would
orphan the existing install and any shortcuts pointing at it. Document
the cherry-pick provenance and what was deliberately left out
(.mcp.json's hardcoded path, the docs/ tree) in AGENTS.md.
2026-08-14 23:55:41 +02:00
02d6c0c5e9 Make build_and_deploy.ps1 self-installing and fail loudly
pip install of pyinstaller/pystray/pillow was a separate manual step the
README only mentioned in prose, so the script silently died on missing
packages -- especially bad on Explorer double-click, where the window
closes before any error is visible. Consolidate all dependencies into
requirements.txt (also used by README's plain "pip install -r" flow), have
the script install it itself, wrap the whole build in try/catch with
exit-code checks after every native call, and pause on both success and
failure unless -NoPause is passed.

Also move the build output from %LOCALAPPDATA% into dist/ next to the
script, so it's easy to find and matches the already-gitignored dist/ entry.

(cherry picked from commit 13c3745479)
2026-08-14 23:51:42 +02:00
9e21360393 Allow renaming profile tabs outside Programmiermodus
Double-click-to-rename already existed but silently no-op'd unless
Programmiermodus was on, and even there it only updated the in-memory
self.combined without saving -- the name was lost unless some later button
edit happened to trigger an autosave. Since profile_names lives purely in
the combined JSON and never touches the board, there's no reason to gate it
behind the heavier editing mode: it now works from any mode, loading/saving
the combined file directly (auto-creating it if needed) when not already in
an active Programmiermodus session, and always persists immediately.

(cherry picked from commit 82c3fd0056)
2026-08-14 23:51:34 +02:00
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
9 changed files with 292 additions and 93 deletions

4
.gitignore vendored
View file

@ -3,3 +3,7 @@ __pycache__/
*.log *.log
build/ build/
dist/ dist/
# Nutzer-Config -- landet neben der Installation (versapad_data.app_dir()),
# beim Start aus dem Quellcode also direkt hier im Projektordner
versapad_config_all.json

View file

@ -16,6 +16,12 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
- `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/ - `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/
Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie
(`index = spalte*5+reihe`), `hid_key_choices()`/`consumer_choices()`. (`index = spalte*5+reihe`), `hid_key_choices()`/`consumer_choices()`.
`app_dir()` liefert das Verzeichnis für die eigene Config
(`versapad_combined.DEFAULT_PATH`) — bei der `.exe` der Installations-
ordner, sonst der Projektordner, siehe Installierbarkeit-Notiz unten.
`CONFIG_PATHS` bleibt bewusst auf dem OneDrive-Desktop hartkodiert (Lese-
Interop mit einem JSON-Export der offiziellen VersaGUI, kein von diesem
Tool geschriebenes Format, siehe dort).
- `server.py` — stdlib `http.server`, generiert HTML pro Request neu, - `server.py` — stdlib `http.server`, generiert HTML pro Request neu,
Profil-Wechsel über `?profile=0|1|2`, Auto-Reload alle 4s. Rein lesend, Profil-Wechsel über `?profile=0|1|2`, Auto-Reload alle 4s. Rein lesend,
kein Programmiermodus (bewusst einfach gehalten). kein Programmiermodus (bewusst einfach gehalten).
@ -39,8 +45,14 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
liefert `READ_STATUS` schlicht Timeout, kein Absturz. liefert `READ_STATUS` schlicht Timeout, kein Absturz.
- `versapad_combined.py` — Ein-Datei-Format (alle 3 Profile + Makros + - `versapad_combined.py` — Ein-Datei-Format (alle 3 Profile + Makros +
**nur lokal gespeicherte** Profilnamen), Default-Pfad **nur lokal gespeicherte** Profilnamen), Default-Pfad
`~\OneDrive\Desktop\versapad_config_all.json`. Passt zum Wire-Protokoll: `versapad_data.app_dir()\versapad_config_all.json` (neben der
`CONFIG_BEGIN/COMMIT` überträgt ohnehin immer den kompletten 740B-Block. Installation, nicht mehr hartkodiert auf einer bestimmten Maschine).
Passt zum Wire-Protokoll: `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer
den kompletten 740B-Block. `load_or_fetch()` legt bei fehlender Datei
automatisch eine neue an — erst Versuch per Serial vom Board, sonst als
leere `default_combined()` (kein Board noetig fuer die Erstbenutzung).
`read_profile_names()` liest nur die Namen (kein Board-Zugriff, fuer
Tab-Beschriftungen im Nur-Lese-Modus, siehe Installierbarkeit-Notiz).
**UI:** **UI:**
- `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi - `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi
@ -93,6 +105,52 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
vorhanden, und fällt sonst auf die klassischen `versapad_config{1,2,3}.json` vorhanden, und fällt sonst auf die klassischen `versapad_config{1,2,3}.json`
zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine
Bearbeitung "verschwunden". Bearbeitung "verschwunden".
- **Installierbarkeit verbessert 2026-08-14:** Drei Probleme beim
Weitergeben an andere Leute behoben. (1) `build_and_deploy.ps1` starb bei
fehlenden Paketen (pyinstaller/pystray/pillow) kommentarlos, v.a. bei
Doppelklick im Explorer, weil das Fenster sich sofort schließt. Fix:
`requirements.txt` (alle Pakete an einer Stelle, README und Skript nutzen
dieselbe Datei), Skript installiert sie selbst per `pip install -r`,
läuft komplett in try/catch, prüft `$LASTEXITCODE` nach jedem nativen
Aufruf, und pausiert am Ende (Erfolg wie Fehler) auf Tastendruck --
`-NoPause` für CI/Automation. (2) `versapad_combined.DEFAULT_PATH` war
hartkodiert auf `~\OneDrive\Desktop` einer bestimmten Maschine -- für
andere Nutzer unbrauchbar. Fix: `versapad_data.app_dir()` (neue
Funktion) liefert bei der gebauten `.exe` deren Installationsordner
(`sys.executable`-Verzeichnis), sonst den Projektordner (`__file__`-
Verzeichnis) -- `DEFAULT_PATH` hängt jetzt daran, landet also immer neben
der laufenden Installation. `versapad_data.CONFIG_PATHS` (Lese-Interop
mit der C#-VersaGUI) bleibt bewusst auf dem Desktop, siehe oben. (3) Fehlte
die Config UND war kein Board erreichbar, blockierte das Tool mit einer
Fehlermeldung statt zu starten. Fix: `load_or_fetch()` legt jetzt bei
Board-Fehlschlag eine leere `default_combined()` an statt `RuntimeError`
zu werfen -- Erstbenutzung ganz ohne vorhandene Config oder Board
funktioniert jetzt. (4) Profilnamen waren zusätzlich hartkodiert in
`versapad_data.PROFILE_NAMES` (Dict, jetzt entfernt, ersetzt durch
`NUM_PROFILES = 3`) und liefen der JSON-`profile_names` parallel --
Programmiermodus zeigte umbenannte Profile, Lesemodus/Browser-Ansicht
weiterhin die alten Namen. Fix: `server.py` und `desktop_viewer.py` lesen
Namen jetzt immer aus der kombinierten JSON (`combined["profile_names"]`
bzw. `versapad_combined.read_profile_names()`), eine einzige Quelle der
Wahrheit für alle Frontends.
- **Herkunft der drei Punkte oben + Tab-Umbenennen-Fix:** per Cherry-Pick aus
Julian Appels eigenem `dev/jappel`-Branch (`git.jappel.io/jappel/
VersaGUI-py`) übernommen, der sich zeitgleich mit unserem eigenen
COM-Port-Fix entwickelt hat (kein Fork-Sync-Automatismus -- manuell
gegengelesen und übernommen, siehe Sessionlog). **Eine Abweichung vom
Original:** dort landet der Build jetzt in `$projectDir\dist\
VersaPadViewer` statt `%LOCALAPPDATA%\VersaPadViewer` -- hier bewusst
NICHT übernommen, weil das die tatsächlich installierte/genutzte Instanz
wäre und bestehende Verknüpfungen sonst ins Leere zeigen würden.
`build_and_deploy.ps1` hier weiterhin `%LOCALAPPDATA%\VersaPadViewer`.
Bestehende `versapad_config_all.json` vom alten Desktop-Pfad wurde einmalig
nach `%LOCALAPPDATA%\VersaPadViewer\` migriert (kopiert, Original bleibt).
Nicht übernommen: die `.mcp.json`-Registrierung (hartkodierter Pfad auf
Julians Maschine, `C:\Users\Julian\...`) -- dafür stattdessen ein Issue in
seinem Repo (git.jappel.io/jappel/VersaGUI-py) angelegt, damit relative/
portable Pfadauflösung dort nachgezogen werden kann; die drei
`docs/*.md`-Referenzdateien (unser eigener Dokumentationsstandard hält für
dieses Tool bewusst bei README + AGENTS.md ohne vollen `docs/`-Baum).
- **Bug behoben 2026-08-08:** `server.py` (`vp.load_profile()`) und - **Bug behoben 2026-08-08:** `server.py` (`vp.load_profile()`) und
`desktop_viewer.py` (`_current_profile_view()`) lasen im Nur-Lese-Modus `desktop_viewer.py` (`_current_profile_view()`) lasen im Nur-Lese-Modus
hart von den Desktop-JSONs -- fehlten sie (z.B. User loescht sie), gab es hart von den Desktop-JSONs -- fehlten sie (z.B. User loescht sie), gab es

View file

@ -11,10 +11,14 @@ Board-Schreibzugriff) und der MCP-Server sind funktionsfähig und gegen ein
echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original, echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original,
inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft
manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum
Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene
Standard-Dateipfade für die Config-JSONs sind aktuell für eine bestimmte Config-Datei (`versapad_config_all.json`) liegt automatisch neben der
Windows-Maschine hartkodiert (`versapad_data.CONFIG_PATHS`, Installation (siehe „Aufbau" unten) und wird bei Bedarf automatisch neu
`versapad_combined.DEFAULT_PATH`) — für einen anderen Rechner dort anpassen. angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale*
Lese-Interop mit den JSON-Exports der offiziellen VersaGUI
(`versapad_data.CONFIG_PATHS`) ist noch für eine bestimmte Windows-Maschine
hartkodiert (OneDrive-Desktop) — für einen anderen Rechner dort anpassen,
falls gewünscht.
## Features ## Features
@ -37,15 +41,18 @@ Windows-Maschine hartkodiert (`versapad_data.CONFIG_PATHS`,
## Voraussetzungen ## Voraussetzungen
- Python 3.11 oder neuer - Python 3.11 oder neuer
- Pakete: `pyserial` (Board-Kommunikation), `pystray` + `pillow` - Alle Pakete stehen in `requirements.txt` (`pyserial` fürs Board,
(Tray-Icon/Desktop-App), optional `mcp` (nur für den MCP-Server) `pystray` + `pillow` fürs Tray-Icon/Desktop-Fenster, `mcp` für den
MCP-Server, `pyinstaller` fürs `.exe`-Bauen):
```bash ```bash
pip install pyserial pystray pillow mcp pip install -r requirements.txt
``` ```
Für den Browser-Modus (`server.py`) reicht die Python-Standardbibliothek — Für den Browser-Modus (`server.py`) reicht die Python-Standardbibliothek —
keine zusätzlichen Pakete nötig. keine zusätzlichen Pakete nötig. `build_and_deploy.ps1` installiert
`requirements.txt` beim Bauen automatisch selbst — ein manuelles
`pip install` vorher ist dafür nicht nötig.
## Starten ## Starten
@ -65,8 +72,15 @@ Maschine/Python-Version unterschiedlich). Selbst bauen:
.\build_and_deploy.ps1 .\build_and_deploy.ps1
``` ```
Das Skript baut mit PyInstaller (`--onedir --windowed`, eigenes Icon) und Das Skript installiert/aktualisiert selbst alle nötigen Pakete aus
kopiert das Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`. `requirements.txt` (kein manuelles `pip install` vorher nötig), baut dann
mit PyInstaller (`--onedir --windowed`, eigenes Icon) und kopiert das
Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`.
Bricht ein Schritt ab (fehlendes Python, PyInstaller-Fehler, ...), zeigt das
Skript eine klare Fehlermeldung und wartet auf einen Tastendruck, statt sich
bei Doppelklick im Explorer kommentarlos zu schließen (`-NoPause`
unterdrückt das für automatisierte Aufrufe/CI).
**Wichtig:** Sowohl Bauen als auch Ausführen müssen auf einem lokalen **Wichtig:** Sowohl Bauen als auch Ausführen müssen auf einem lokalen
Laufwerk passieren — von einem Netzlaufwerk (SMB-Share) aus scheitert Laufwerk passieren — von einem Netzlaufwerk (SMB-Share) aus scheitert
PyInstaller beim Bauen (Pfadlängen-Problem mit Tcl/Tk-Zeitzonendaten) und PyInstaller beim Bauen (Pfadlängen-Problem mit Tcl/Tk-Zeitzonendaten) und
@ -75,8 +89,6 @@ das Nachladen der Bundle-DLLs von einem Netzwerkpfad, ohne jede
Fehlermeldung). `build_and_deploy.ps1` kopiert den Quellcode deshalb Fehlermeldung). `build_and_deploy.ps1` kopiert den Quellcode deshalb
automatisch zuerst nach `%TEMP%` und baut nur dort. automatisch zuerst nach `%TEMP%` und baut nur dort.
Voraussetzung: `pip install pyinstaller` zusätzlich zu den obigen Paketen.
## Aufbau ## Aufbau
| Datei | Zweck | | Datei | Zweck |
@ -94,11 +106,21 @@ Das Binärformat (`versapad_protocol.py`) ist 1:1 aus den
VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert
(Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC). (Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC).
Config-Dateien liegen standardmäßig auf dem Desktop Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile +
(`versapad_config1/2/3.json` für den reinen Lesemodus, Makros + lokale Profilnamen, siehe `versapad_combined.py`) liegt neben der
`versapad_config_all.json` für den Programmiermodus — Pfade sind aktuell Installation: bei der gebauten `.exe` im selben Ordner, beim Start aus dem
hartkodiert für eine bestimmte Windows-Maschine, siehe `CONFIG_PATHS` in Quellcode im Projektordner (`versapad_data.app_dir()`). Fehlt sie (z.B.
`versapad_data.py` bzw. `DEFAULT_PATH` in `versapad_combined.py`). frische Installation), wird sie automatisch angelegt — per Serial vom
Board, falls eins angeschlossen ist, sonst als leere Default-Config.
Profilnamen (`profile_names`) stehen direkt in dieser Datei und lassen sich
per Doppelklick auf einen Tab (in jedem Modus — Nur-Lesen, Live-Sync oder
Programmiermodus) oder `rename_profile()` (MCP) ändern.
Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool
geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export
der offiziellen VersaGUI (C#/.NET) — Pfad aktuell hartkodiert auf den
OneDrive-Desktop einer bestimmten Windows-Maschine, siehe `CONFIG_PATHS` in
`versapad_data.py`.
## MCP-Server ## MCP-Server

View file

@ -1,5 +1,8 @@
# Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis # Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis
# nach lokal (C:\Users\<user>\AppData\Local\VersaPadViewer). # nach %LOCALAPPDATA%\VersaPadViewer (nicht $projectDir\dist wie im
# jappel-Upstream -- hier bereits die tatsaechlich installierte/genutzte
# Instanz, siehe Speicher-Notiz "VersaPad Viewer Tool"; ein Pfadwechsel
# wuerde bestehende Verknuepfungen/Autostart-Eintraege brechen).
# #
# WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf # WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf
# dem Netzlaufwerk (Z:\Git\...): # dem Netzlaufwerk (Z:\Git\...):
@ -12,35 +15,87 @@
# SMB-Share, kombiniert mit dem eh schon langen Projektpfad. # SMB-Share, kombiniert mit dem eh schon langen Projektpfad.
# #
# Deshalb: Quellcode zuerst nach lokal (%TEMP%) kopieren, dort bauen, danach # Deshalb: Quellcode zuerst nach lokal (%TEMP%) kopieren, dort bauen, danach
# das fertige Bundle nach %LOCALAPPDATA% kopieren. Z: wird nur zum Lesen der # das fertige Bundle nach $projectDir\dist kopieren. Z: wird nur zum Lesen
# Quelldateien angefasst. # der Quelldateien angefasst.
#
# Fehlerverhalten: Bei Doppelklick im Explorer schliesst sich das Fenster
# sofort nach Skriptende -- ohne Pause waeren Fehlermeldungen unsichtbar
# ("stirbt ohne jede Fehlermeldung"). Deshalb: alles in try/catch, im
# Fehlerfall UND am Ende eine Pause, ausser bei -NoPause (fuer CI/Automation).
param(
[switch]$NoPause
)
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$projectDir = $PSScriptRoot $projectDir = $PSScriptRoot
$buildSrc = "$env:TEMP\versapad_build_src" $buildSrc = "$env:TEMP\versapad_build_src"
$localDir = "$env:LOCALAPPDATA\VersaPadViewer" $localDir = "$env:LOCALAPPDATA\VersaPadViewer"
$requirementsFile = "$projectDir\requirements.txt"
if (Test-Path $buildSrc) { function Assert-LastExitCode([string]$step) {
Remove-Item $buildSrc -Recurse -Force if ($LASTEXITCODE -ne 0) {
throw "$step ist fehlgeschlagen (Exit-Code $LASTEXITCODE) -- Ausgabe oben pruefen."
}
}
function Wait-ForKeyIfInteractive {
if ($NoPause) { return }
try {
Read-Host "Taste druecken zum Schliessen"
} catch {
# z.B. nicht-interaktiver Aufruf (stdin nicht verfuegbar) -- einfach ignorieren
}
} }
New-Item -ItemType Directory -Path $buildSrc | Out-Null
Copy-Item "$projectDir\*.py" -Destination $buildSrc
Copy-Item "$projectDir\icon.ico" -Destination $buildSrc
Copy-Item "$projectDir\icon.png" -Destination $buildSrc
Push-Location $buildSrc
try { try {
Write-Host "Pruefe Python-Installation..."
py --version
Assert-LastExitCode "'py --version' (ist Python installiert und im PATH?)"
if (-not (Test-Path $requirementsFile)) {
throw "requirements.txt nicht gefunden unter $requirementsFile"
}
Write-Host "Installiere/aktualisiere benoetigte Pakete aus requirements.txt..."
py -m pip install --quiet --disable-pip-version-check -r $requirementsFile
Assert-LastExitCode "Paketinstallation (pip install -r requirements.txt)"
if (Test-Path $buildSrc) {
Remove-Item $buildSrc -Recurse -Force
}
New-Item -ItemType Directory -Path $buildSrc | Out-Null
Copy-Item "$projectDir\*.py" -Destination $buildSrc
Copy-Item "$projectDir\icon.ico" -Destination $buildSrc
Copy-Item "$projectDir\icon.png" -Destination $buildSrc
Push-Location $buildSrc
try {
Write-Host "Baue mit PyInstaller (--onedir --windowed)..."
py -m PyInstaller --windowed --onedir --name VersaPadViewer ` py -m PyInstaller --windowed --onedir --name VersaPadViewer `
--icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." ` --icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." `
desktop_viewer.py --noconfirm desktop_viewer.py --noconfirm
} finally { Assert-LastExitCode "PyInstaller-Build"
} finally {
Pop-Location Pop-Location
} }
if (Test-Path $localDir) { if (-not (Test-Path "$buildSrc\dist\VersaPadViewer\VersaPadViewer.exe")) {
throw "PyInstaller hat keine VersaPadViewer.exe erzeugt, obwohl der Exit-Code 0 war -- Build-Ausgabe oben pruefen."
}
if (Test-Path $localDir) {
Remove-Item $localDir -Recurse -Force Remove-Item $localDir -Recurse -Force
} }
Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse
Remove-Item $buildSrc -Recurse -Force Remove-Item $buildSrc -Recurse -Force
Write-Host "Fertig: $localDir\VersaPadViewer.exe" Write-Host ""
Write-Host "Fertig: $localDir\VersaPadViewer.exe" -ForegroundColor Green
Wait-ForKeyIfInteractive
}
catch {
Write-Host ""
Write-Host "FEHLER: $($_.Exception.Message)" -ForegroundColor Red
Wait-ForKeyIfInteractive
exit 1
}

View file

@ -167,8 +167,8 @@ class VersaPadViewer(tk.Tk):
self.tabs = tk.Frame(self, bg=BG) self.tabs = tk.Frame(self, bg=BG)
self.tabs.pack(fill="x", padx=20, pady=(12, 10)) self.tabs.pack(fill="x", padx=20, pady=(12, 10))
self.tab_buttons = {} self.tab_buttons = {}
for p in sorted(vp.PROFILE_NAMES): for p in range(vp.NUM_PROFILES):
btn = tk.Label(self.tabs, text=vp.PROFILE_NAMES[p], bg=CARD_BG, fg=TEXT, btn = tk.Label(self.tabs, text=f"Profil {p}", bg=CARD_BG, fg=TEXT,
font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2") font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2")
btn.pack(side="left", padx=(0, 8)) btn.pack(side="left", padx=(0, 8))
btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True)) btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True))
@ -242,20 +242,48 @@ class VersaPadViewer(tk.Tk):
self._render() self._render()
def _update_tab_labels(self): def _update_tab_labels(self):
for p, btn in self.tab_buttons.items(): """Profilnamen kommen immer aus der kombinierten JSON (nicht mehr
hartkodiert) -- im Programmiermodus aus dem In-Memory-State, sonst
per schlankem Datei-Read (kein Board-Zugriff, siehe
versapad_combined.profile_names())."""
if self.editing.get() and self.combined: if self.editing.get() and self.combined:
btn.configure(text=self.combined["profile_names"][p]) names = self.combined["profile_names"]
else: else:
btn.configure(text=vp.PROFILE_NAMES[p]) names = vcomb.read_profile_names()
for p, btn in self.tab_buttons.items():
btn.configure(text=names[p])
def _rename_tab(self, profile): def _rename_tab(self, profile):
if not self.editing.get() or self.combined is None: """Profilname per Doppelklick auf den Tab umbenennen -- geht in
jedem Modus (rein lokal, landet nie aufs Board, siehe Kritische
Domänenregeln). Im Programmiermodus wird der bereits geladene
In-Memory-State direkt bearbeitet + autosaved; sonst wird die
kombinierte Datei frisch gelesen/geschrieben (legt sie bei Bedarf
automatisch an, siehe versapad_combined.load_or_fetch())."""
if self.editing.get() and self.combined is not None:
combined = self.combined
else:
try:
combined = vcomb.load_or_fetch()
except (OSError, ValueError, KeyError) as e:
messagebox.showerror("Umbenennen fehlgeschlagen", str(e))
return return
current = self.combined["profile_names"][profile]
current = combined["profile_names"][profile]
name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):", name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):",
initialvalue=current, parent=self) initialvalue=current, parent=self)
if name: if not name:
self.combined["profile_names"][profile] = name return
combined["profile_names"][profile] = name
if combined is self.combined:
self._autosave_combined()
else:
try:
vcomb.save_file(combined, vcomb.DEFAULT_PATH)
except OSError as e:
messagebox.showerror("Umbenennen fehlgeschlagen", f"Speichern fehlgeschlagen: {e}")
return
self._update_tab_labels() self._update_tab_labels()
def _show_mcp_info(self): def _show_mcp_info(self):
@ -526,16 +554,19 @@ class VersaPadViewer(tk.Tk):
Bevorzugt die kombinierte Datei (versapad_config_all.json), falls Bevorzugt die kombinierte Datei (versapad_config_all.json), falls
vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen
sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen. sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen.
Fehlt sie (z.B. versehentlich geloescht) und ist Live-Sync gerade aus Fehlt sie (z.B. versehentlich geloescht, oder frische Installation
(COM-Port frei), wird sie automatisch per Serial vom Board neu ganz ohne Config) und ist Live-Sync gerade aus (COM-Port frei), wird
aufgebaut und als neuer Cache gespeichert -- das Board ist die sie automatisch angelegt -- per Serial vom Board, oder als leere
eigentliche Quelle der Wahrheit, kein Datei-Handling von Hand mehr Default-Config, wenn auch kein Board erreichbar ist (siehe
noetig. Nur wenn das nicht klappt (Board nicht erreichbar, Live-Sync versapad_combined.load_or_fetch()) -- das Tool ist damit auch ganz
haelt den Port), weicht es zuletzt auf die klassischen ohne vorhandene Config sofort benutzbar, kein Datei-Handling von
versapad_config{1,2,3}.json aus.""" Hand mehr noetig. Nur wenn Live-Sync gerade an ist (haelt den
if os.path.exists(vcomb.DEFAULT_PATH): COM-Port) und noch keine Datei existiert, weicht es zuletzt auf die
klassischen versapad_config{1,2,3}.json (Export der offiziellen
VersaGUI) aus."""
if os.path.exists(vcomb.DEFAULT_PATH) or not self.live_sync.get():
try: try:
data = vcomb.load_file(vcomb.DEFAULT_PATH) data = vcomb.load_or_fetch(link=self._link)
raw = data["profiles"][self.profile] raw = data["profiles"][self.profile]
cfg = vp.annotate_profile({ cfg = vp.annotate_profile({
"buttons": [dict(b) for b in raw["buttons"]], "buttons": [dict(b) for b in raw["buttons"]],
@ -544,18 +575,6 @@ class VersaPadViewer(tk.Tk):
return cfg, f"Quelle: {vcomb.DEFAULT_PATH}" return cfg, f"Quelle: {vcomb.DEFAULT_PATH}"
except (KeyError, IndexError, ValueError): except (KeyError, IndexError, ValueError):
pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen
elif not self.live_sync.get():
try:
data = vcomb.fetch_from_board(link=self._link)
vcomb.save_file(data, vcomb.DEFAULT_PATH)
raw = data["profiles"][self.profile]
cfg = vp.annotate_profile({
"buttons": [dict(b) for b in raw["buttons"]],
"encoders": [dict(e) for e in raw["encoders"]],
})
return cfg, "Quelle: Board (neu vom Geraet geladen)"
except RuntimeError:
pass # Board nicht erreichbar -- weiter unten ausweichen
try: try:
return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}" return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}"
except FileNotFoundError as e: except FileNotFoundError as e:
@ -564,6 +583,7 @@ class VersaPadViewer(tk.Tk):
raise FileNotFoundError(f"{e}{hint}") from e raise FileNotFoundError(f"{e}{hint}") from e
def _render(self): def _render(self):
self._update_tab_labels()
for w in self.grid_frame.winfo_children(): for w in self.grid_frame.winfo_children():
w.destroy() w.destroy()
for w in self.enc_frame.winfo_children(): for w in self.enc_frame.winfo_children():

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
pyserial
pystray
pillow
mcp
pyinstaller

View file

@ -98,8 +98,8 @@ def render_page(profile):
"encoders": [dict(e) for e in raw["encoders"]], "encoders": [dict(e) for e in raw["encoders"]],
}) })
tabs = "".join( tabs = "".join(
f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(vp.PROFILE_NAMES[p])}</a>' f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(combined["profile_names"][p])}</a>'
for p in sorted(vp.PROFILE_NAMES) for p in range(vp.NUM_PROFILES)
) )
cells = "".join(render_cell(b) for b in cfg["buttons"]) cells = "".join(render_cell(b) for b in cfg["buttons"])
encoders = "".join(render_encoder(e) for e in cfg["encoders"]) encoders = "".join(render_encoder(e) for e in cfg["encoders"])
@ -130,7 +130,7 @@ class Handler(BaseHTTPRequestHandler):
profile = int(query.get("profile", ["0"])[0]) profile = int(query.get("profile", ["0"])[0])
except ValueError: except ValueError:
profile = 0 profile = 0
if profile not in vp.PROFILE_NAMES: if not (0 <= profile < vp.NUM_PROFILES):
profile = 0 profile = 0
try: try:
body = render_page(profile).encode("utf-8") body = render_page(profile).encode("utf-8")

View file

@ -19,7 +19,7 @@ import versapad_data as vp
import versapad_protocol as proto import versapad_protocol as proto
import versapad_serial as vs import versapad_serial as vs
DEFAULT_PATH = os.path.expanduser(r"~\OneDrive\Desktop\versapad_config_all.json") DEFAULT_PATH = os.path.join(vp.app_dir(), "versapad_config_all.json")
DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"] DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"]
@ -126,18 +126,37 @@ def fetch_from_board(link=None, profile_names=None):
def load_or_fetch(path=DEFAULT_PATH, link=None, profile_names=None): def load_or_fetch(path=DEFAULT_PATH, link=None, profile_names=None):
"""Bevorzugt die lokale Kombi-Datei. Fehlt sie (z.B. versehentlich """Bevorzugt die lokale Kombi-Datei. Fehlt sie (z.B. versehentlich
geloescht), wird sie automatisch per Serial vom Board neu aufgebaut und geloescht, oder frische Installation ohne jede Config), wird sie
als neuer Cache gespeichert, statt einen Fehler zu werfen -- das Board automatisch neu angelegt -- zuerst per Serial-Versuch vom Board (das
behaelt die Config dauerhaft im NVM, die Desktop-JSON ist nur ein behaelt die Config dauerhaft im NVM, die JSON ist nur ein Lesecache
Lesecache dafuer und muss nicht von Hand gepflegt werden. Ist das Board dafuer), und falls auch das Board nicht erreichbar ist (nicht
nicht erreichbar (nicht verbunden, COM-Port belegt), wirft es verbunden, COM-Port belegt, frisch installiert ohne Board in Reichweite)
RuntimeError mit Klartext-Ursache -- Aufrufer entscheidet, ob es einen als leere Default-Config (vgl. default_combined()) -- damit ist das
weiteren Fallback gibt (z.B. alte Einzel-JSONs).""" Tool auch ganz ohne vorhandene Config sofort benutzbar, statt mit
einem Fehler zu blockieren."""
if os.path.exists(path): if os.path.exists(path):
return load_file(path) return load_file(path)
try:
combined = fetch_from_board(link=link, profile_names=profile_names) combined = fetch_from_board(link=link, profile_names=profile_names)
except RuntimeError:
combined = default_combined()
if profile_names:
combined["profile_names"] = profile_names
try: try:
save_file(combined, path) save_file(combined, path)
except OSError: except OSError:
pass # Board-Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden pass # Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden
return combined return combined
def read_profile_names(path=DEFAULT_PATH):
"""Nur die (lokalen) Profilnamen lesen, ohne die volle Config zu
brauchen -- fuer Tab-Beschriftungen im Nur-Lese-Modus. Greift bewusst
nicht aufs Board zu (kein COM-Port-Konflikt mit Live-Sync), faellt bei
fehlender/kaputter Datei auf DEFAULT_NAMES zurueck."""
if os.path.exists(path):
try:
return load_file(path).get("profile_names", list(DEFAULT_NAMES))
except (OSError, ValueError):
pass
return list(DEFAULT_NAMES)

View file

@ -7,19 +7,35 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen.
""" """
import json import json
import os import os
import sys
NUM_PROFILES = 3
def app_dir():
"""Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json,
siehe versapad_combined.DEFAULT_PATH): bei der gebauten .exe (--onedir)
das Installationsverzeichnis neben der .exe, sonst der Ordner dieses
Moduls (Projektordner beim Start aus dem Quellcode). Kein hartkodierter
Pfad mehr -- so laesst sich das Tool auf jede Maschine kopieren/
installieren, ohne Pfade von Hand anzupassen."""
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
# CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind
# keine von diesem Tool geschriebenen Dateien, sondern ein Export der
# offiziellen (C#/.NET-)VersaGUI auf dieser einen Maschine (reine Lese-
# Interop, siehe README "Bekannte Einschraenkungen"). Fuer das eigentliche,
# von diesem Tool selbst gepflegte Format siehe versapad_combined.DEFAULT_PATH
# (liegt jetzt in app_dir(), nicht mehr hartkodiert auf dem Desktop).
CONFIG_PATHS = { CONFIG_PATHS = {
0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"), 0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"),
1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"), 1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"),
2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"), 2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"),
} }
PROFILE_NAMES = {
0: "Profil 0 Windows",
1: "Profil 1 Fusion 360",
2: "Profil 2 BricsCAD",
}
# index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge) # index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge)
GRID_COLS = 4 GRID_COLS = 4
GRID_ROWS = 5 GRID_ROWS = 5