Merge branch 'main' into dev/jappel
Reconciles two independent lines of work: main cherry-picked and then extended dev/jappel's build-script/config-portability/rename-tab/ COM-port fixes, additionally fixing a config-loss bug (rebuild wiped the config when it lived in the install dir -- moved to roaming %APPDATA% instead) and adding free-text notes per action plus a frameless resizable window. dev/jappel keeps its .mcp.json registration and docs/ reference tree, which main deliberately left out. Conflict resolutions favored main's versions where the two sides solved the same problem (config location, build deploy target, COM-port release, tab rename) since main's fixes were validated against a real rebuild-wipes-config incident. docs/architecture.md and docs/data-model.md updated to describe the resulting %APPDATA% config path and the note field.
This commit is contained in:
commit
189ec01e68
11 changed files with 470 additions and 125 deletions
96
AGENTS.md
96
AGENTS.md
|
|
@ -93,9 +93,8 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
|
|||
Fehler hier live aufgedeckt.
|
||||
|
||||
Vollständige Modulübersicht mit Zeilenreferenzen bei Bedarf direkt im Code
|
||||
nachschlagen — die Dateien sind klein genug, dass eine separate
|
||||
`docs/current-architecture.md` hier keinen Mehrwert hätte (siehe
|
||||
Dokumentation und Verifikation unten für die Größeneinschätzung).
|
||||
nachschlagen. Menschenlesbare Referenzdoku (Architektur, Datenmodell,
|
||||
Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten.
|
||||
|
||||
## Kritische Domänenregeln
|
||||
|
||||
|
|
@ -114,6 +113,18 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
|
|||
(`profile_names`) — die Firmware-Structs haben keinen Platz für einen
|
||||
String (Header exakt 32B, jedes Profil exakt 236B, alles verplant). Sie
|
||||
landen nie aufs Board, egal welcher Schreibpfad benutzt wird.
|
||||
- **Notizen** (freier Text je Button/Encoder-Aktion, seit 2026-08-15) sind
|
||||
aus demselben Grund rein lokal: leben im `"note"`-Feld JEDER Action
|
||||
(`{"type","data","note"}`), nicht in einer separaten Struktur. `to_binary`/
|
||||
`pack_config` ignorieren das Feld beim Schreiben (liest nur type/data),
|
||||
`from_binary` liefert frisch vom Board immer `note=""` (Board kennt keine
|
||||
Notizen) — `versapad_combined.merge_notes(neu, alt)` kopiert bestehende
|
||||
Notizen nach jedem `load_from_board()`/`fetch_from_board()` zurück, sonst
|
||||
gingen sie bei jedem Board-Refresh verloren. Action-Typ wechseln
|
||||
(`set_button_key` etc.) darf die Notiz NICHT loeschen (baut die neue
|
||||
Action ueber `_replace_action()`/`dlg.action["note"]` mit der alten Notiz),
|
||||
nur `set_button_note()`/`set_encoder_note()`/das Notiz-Feld im
|
||||
Programmiermodus-Dialog aendern sie gezielt.
|
||||
- Der COM-Port ist exklusiv. Live-Sync und Programmiermodus schalten sich
|
||||
gegenseitig aus (ein `VersaPadLink` kann nicht von zwei Konsumenten
|
||||
gleichzeitig genutzt werden); VersaGUI läuft als Tray-App dauerhaft im
|
||||
|
|
@ -126,6 +137,28 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
|
|||
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
|
||||
Bearbeitung "verschwunden".
|
||||
- **Config-Pfad darf NIE im Installationsverzeichnis liegen (2026-08-15,
|
||||
Datenverlust-Bug):** `build_and_deploy.ps1` räumt sein Zielverzeichnis vor
|
||||
jedem Deploy komplett ab (`Remove-Item $localDir -Recurse -Force`). Nachdem
|
||||
`app_dir()` am selben Tag auf genau dieses Verzeichnis gezeigt hatte, hat
|
||||
**jeder Rebuild die Nutzer-Config mitgelöscht**; die App hat sie danach
|
||||
kommentarlos leer vom Board neu aufgebaut (`load_or_fetch()` →
|
||||
`fetch_from_board()`), wodurch alle Notizen weg waren. Besonders tückisch:
|
||||
Board-Bindings und LEDs sahen danach völlig normal aus, nur die rein
|
||||
lokalen Felder (Notizen, Profilnamen) fehlten -- der Schaden ist also
|
||||
unsichtbar, wenn man nur aufs Grid schaut. Fix: `app_dir()` liefert jetzt
|
||||
`%APPDATA%\VersaPadViewer` (Roaming), getrennt vom Installationsordner in
|
||||
`%LOCALAPPDATA%`; zusätzlich rettet `build_and_deploy.ps1` vorgefundene
|
||||
`versapad_config*.json` aus dem Zielverzeichnis über den Deploy hinweg
|
||||
(für Altinstallationen). **Regel für künftige Änderungen: Programm- und
|
||||
Datenverzeichnis nie zusammenlegen, egal wie praktisch "alles an einem
|
||||
Ort" klingt.**
|
||||
- **Nur EIN Config-Pfad, unabhängig vom Startweg (2026-08-15):** `app_dir()`
|
||||
hat bewusst KEINEN `sys.frozen`-Zweig mehr. Vorher sah die gebaute `.exe`
|
||||
eine andere Datei als der aus dem Quellcode gestartete MCP-Server, was zu
|
||||
zwei auseinanderlaufenden Configs führte (die `.exe` zeigte ein anderes
|
||||
Profil 0 als der MCP-Server meldete). Wer hier wieder nach Startweg
|
||||
unterscheidet, baut denselben Bug erneut ein.
|
||||
- **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
|
||||
|
|
@ -140,7 +173,10 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
|
|||
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
|
||||
der laufenden Installation. **Diese app_dir()-Variante ist seit
|
||||
2026-08-15 überholt und war aktiv schädlich, siehe oben "Config-Pfad
|
||||
darf nie im Installationsverzeichnis liegen".**
|
||||
`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
|
||||
|
|
@ -154,6 +190,18 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
|
|||
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:** ursprünglich per
|
||||
Cherry-Pick aus Julian Appels `dev/jappel`-Branch auf `main` übernommen
|
||||
(der sich zeitgleich mit dem eigenen COM-Port-Fix entwickelt hatte, kein
|
||||
Fork-Sync-Automatismus -- manuell gegengelesen). Auf `main` bewusst NICHT
|
||||
übernommen wurde damals der `dist/VersaPadViewer`-Build-Zielpfad (statt
|
||||
`%LOCALAPPDATA%\VersaPadViewer`) sowie `.mcp.json` und die `docs/*.md`-
|
||||
Referenzdateien. **2026-08-15: `main` wurde zurück in `dev/jappel`
|
||||
gemerged**, damit landen beide Linien wieder in einem Branch -- inklusive
|
||||
`.mcp.json`, `docs/*.md` und dem `%LOCALAPPDATA%`-Zielpfad (der hat sich
|
||||
im Merge durchgesetzt, siehe Config-Pfad-Bullets oben). Der Notes-Feature-
|
||||
Teil dieser Historie ist der Grund, warum `docs/data-model.md` das
|
||||
`"note"`-Feld nachträglich braucht.
|
||||
- **Bug behoben 2026-08-08:** `server.py` (`vp.load_profile()`) und
|
||||
`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
|
||||
|
|
@ -180,6 +228,24 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
|
|||
`vcomb.DEFAULT_PATH`, fällt nur bei fehlender/kaputter Datei auf
|
||||
`default_combined()` zurück. Bei jedem "komisches Layout"-Report hier immer
|
||||
ALLE 3 Profile prüfen, nicht nur das gemeldete.
|
||||
- **Randloses Fenster (seit 2026-08-15):** Die Titelleiste ist per reinem Tk
|
||||
`overrideredirect(True)` ausgeblendet -- **niemals** per ctypes/WinAPI
|
||||
nachhelfen (kein `SetWindowLongW`/`ShowWindow` auf das eigene Fenster),
|
||||
siehe Speicher-Notiz "keine Selbstversteck-Fenstertricks": genau diese
|
||||
Kombination hat in einem anderen Projekt Bitdefender-Fehlalarme
|
||||
ausgelöst. Konsequenzen, die mitgebaut werden müssen: kein
|
||||
Taskleisten-Eintrag (Rückweg nur über das Tray-Icon), kein Ziehen am
|
||||
Rahmen (Kopfzeile ist deshalb per `<B1-Motion>` verschiebbar), keine
|
||||
System-Buttons (eigene `✕`/`—` in der Kopfzeile, beide legen ins Tray)
|
||||
und **keine Resize-Ränder**.
|
||||
- **Der Größen-Anfasser hängt per `place()` an der Fensterecke, nicht
|
||||
gepackt am Ende des Inhalts.** Gepackt verschwindet er, sobald das Fenster
|
||||
kleiner als der Inhalt ist -- also exakt dann, wenn man ihn zum
|
||||
Vergrößern bräuchte. `MIN_W/MIN_H` begrenzen das Verkleinern.
|
||||
- Fensterhöhe muss zum Karteninhalt passen: seit die Karten eine Notizzeile
|
||||
haben (`CARD_H` 84 → 114) braucht das Fenster ~960px, sonst liegen
|
||||
Encoder-Bereich und Fußzeile unterhalb des Rands. Wer `CARD_H` ändert,
|
||||
muss `geometry()` mit anpassen.
|
||||
- HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein
|
||||
Tastendruck-Capture (bewusst — kein WinAPI-Hook, um keinen AV-Fehlalarm
|
||||
wie bei den Fensterverstecktricks in anderen Projekten zu riskieren).
|
||||
|
|
@ -230,6 +296,28 @@ Byte-Identität), nicht nur gegen Beispieldaten. Vor jedem Schreibvorgang
|
|||
aufs Board: erst mit unveränderten Daten testen (Identity-Write), bevor
|
||||
echte Änderungen geschrieben werden.
|
||||
|
||||
**Änderungen an der Config immer aus der Sicht prüfen, die die laufende App
|
||||
hat (2026-08-15 gelernt):** Claudes Bash- und PowerShell-Werkzeuge sehen
|
||||
unter `C:\Users\chris\AppData\...` teilweise *unterschiedliche* Dateien —
|
||||
derselbe Pfad lieferte gleichzeitig 29826 Bytes (Bash/MCP-Server) und
|
||||
28455 Bytes (PowerShell/`.exe`). Konkret heißt das: **Config-Schreibvorgänge
|
||||
über den versapad-MCP-Server (`save_local()`) erreichen die installierte
|
||||
`.exe` nicht zuverlässig.** Das hat eine Fehlersuche über viele Runden
|
||||
verschleppt, weil "Notizen sind in der Datei" (Bash) und "App zeigt keine
|
||||
Notizen" gleichzeitig stimmten. Vorgehen:
|
||||
- Config-Dateien, die die installierte App lesen soll, über **PowerShell**
|
||||
schreiben/prüfen (`Get-Item`, dort ausgeführtes `python`), nicht über Bash.
|
||||
- Bei "Änderung wirkt nicht"-Symptomen als Erstes Dateigröße/mtime aus
|
||||
*beiden* Sichten vergleichen, bevor Code-Ursachen gesucht werden.
|
||||
- `Z:\Git\...` (SMB-Share) ist von diesem Effekt nicht betroffen — Quellcode
|
||||
und Build verhalten sich normal.
|
||||
|
||||
Verlässlich zum Ziel führt bei solchen Widersprüchen ein temporäres
|
||||
Diagnose-Modul, das im gebauten Bundle mitläuft und `app_dir()`,
|
||||
`DEFAULT_PATH`, `os.stat()` und den tatsächlich geladenen JSON-Inhalt in eine
|
||||
Datei schreibt — damit war die Ursache in einem Durchlauf sichtbar, nachdem
|
||||
Vermutungen mehrfach danebenlagen.
|
||||
|
||||
## Naming
|
||||
|
||||
Code-Identifier englisch (Python-Konvention: `pack_config`, `read_macros`,
|
||||
|
|
|
|||
33
README.md
33
README.md
|
|
@ -12,9 +12,9 @@ echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original,
|
|||
inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft
|
||||
manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum
|
||||
Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene
|
||||
Config-Datei (`versapad_config_all.json`) liegt automatisch neben der
|
||||
Installation (siehe „Aufbau" unten) und wird bei Bedarf automatisch neu
|
||||
angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale*
|
||||
Config-Datei (`versapad_config_all.json`) liegt automatisch unter
|
||||
`%APPDATA%\VersaPadViewer\` (siehe „Aufbau" unten) und wird bei Bedarf
|
||||
automatisch neu 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,
|
||||
|
|
@ -33,10 +33,19 @@ falls gewünscht.
|
|||
oder als Datei speichern
|
||||
- **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte
|
||||
Makro-Tabelle vom Board
|
||||
- **Notizen** — freier Text pro Button/Encoder-Aktion, was sie tatsächlich
|
||||
tut (z.B. "Speichern in Fusion 360"), zusätzlich zur automatischen
|
||||
Beschriftung ("Strg+S"). Rein lokal wie Profilnamen, geht nie aufs Board,
|
||||
bleibt beim Tastenwechsel und beim "Vom Board laden" erhalten
|
||||
- **MCP-Server** — lässt eine KI (Claude o.ä.) die Belegung direkt per
|
||||
Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten)
|
||||
- **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie
|
||||
die offizielle VersaGUI
|
||||
- **Randloses Fenster** — ohne Windows-Titelleiste, dafür kompakter Kopf
|
||||
(Modus-Checkboxen direkt neben dem Titel). Verschieben durch Ziehen an
|
||||
der Kopfzeile, Größe ändern am Anfasser unten rechts, `✕`/`—` legen ins
|
||||
Tray. Einen Taskleisten-Eintrag gibt es dadurch nicht — das Fenster kommt
|
||||
über das Tray-Icon zurück.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
|
|
@ -75,7 +84,7 @@ Maschine/Python-Version unterschiedlich). Selbst bauen:
|
|||
Das Skript installiert/aktualisiert selbst alle nötigen Pakete aus
|
||||
`requirements.txt` (kein manuelles `pip install` vorher nötig), baut dann
|
||||
mit PyInstaller (`--onedir --windowed`, eigenes Icon) und kopiert das
|
||||
Ergebnis nach `dist\VersaPadViewer\VersaPadViewer.exe` im Projektordner.
|
||||
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`
|
||||
|
|
@ -107,14 +116,20 @@ VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert
|
|||
(Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC).
|
||||
|
||||
Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile +
|
||||
Makros + lokale Profilnamen, siehe `versapad_combined.py`) liegt neben der
|
||||
Installation: bei der gebauten `.exe` im selben Ordner, beim Start aus dem
|
||||
Quellcode im Projektordner (`versapad_data.app_dir()`). Fehlt sie (z.B.
|
||||
frische Installation), wird sie automatisch angelegt — per Serial vom
|
||||
Makros + lokale Profilnamen + Notizen, siehe `versapad_combined.py`) liegt
|
||||
unter `%APPDATA%\VersaPadViewer\` (`versapad_data.app_dir()`), also
|
||||
**getrennt vom Installationsordner** und unabhängig davon, ob die `.exe`
|
||||
oder der Quellcode gestartet wurde. Beides ist Absicht: das Build-Skript
|
||||
räumt sein Zielverzeichnis vor jedem Deploy komplett ab (läge die Config
|
||||
dort, würde jeder Rebuild sie löschen), und ein vom Startweg abhängiger
|
||||
Pfad hatte zu zwei auseinanderlaufenden Configs geführt. Fehlt die Datei
|
||||
(z.B. 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.
|
||||
Programmiermodus) oder `rename_profile()` (MCP) ändern. Notizen ebenso —
|
||||
im Bearbeiten-Dialog des Programmiermodus oder per
|
||||
`set_button_note()`/`set_encoder_note()` (MCP).
|
||||
|
||||
Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool
|
||||
geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export
|
||||
|
|
|
|||
|
|
@ -124,7 +124,9 @@ class MacroStepsDialog(_ModalDialog):
|
|||
|
||||
class ActionEditDialog(_ModalDialog):
|
||||
"""Ergebnis in self.action / self.led nach run()==True. led bleibt None
|
||||
wenn led_in None war (Encoder -- keine eigene Farbe)."""
|
||||
wenn led_in None war (Encoder -- keine eigene Farbe). self.action enthaelt
|
||||
immer ein "note"-Feld (freie Notiz, was die Aktion tut -- rein lokal wie
|
||||
Profilnamen, geht nie aufs Board)."""
|
||||
|
||||
def __init__(self, parent, title, action, led, macros):
|
||||
super().__init__(parent, title)
|
||||
|
|
@ -134,8 +136,15 @@ class ActionEditDialog(_ModalDialog):
|
|||
self.led = None
|
||||
|
||||
row = 0
|
||||
note_row = tk.Frame(self, bg=BG2)
|
||||
note_row.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(12, 4)); row += 1
|
||||
tk.Label(note_row, text="Notiz:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
|
||||
self._note_var = tk.StringVar(value=action.get("note", ""))
|
||||
tk.Entry(note_row, textvariable=self._note_var, width=36, bg=BG, fg=TEXT,
|
||||
insertbackground=TEXT, relief="flat").pack(side="left", padx=6)
|
||||
|
||||
tk.Label(self, text="Aktion", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).grid(
|
||||
row=row, column=0, sticky="w", padx=12, pady=(12, 4)); row += 1
|
||||
row=row, column=0, sticky="w", padx=12, pady=(4, 4)); row += 1
|
||||
|
||||
self._type_var = tk.StringVar(value=action["type"])
|
||||
type_frame = tk.Frame(self, bg=BG2)
|
||||
|
|
@ -320,6 +329,7 @@ class ActionEditDialog(_ModalDialog):
|
|||
else:
|
||||
action = {"type": "None", "data": 0}
|
||||
|
||||
action["note"] = self._note_var.get()
|
||||
self.action = action
|
||||
if self._led is not None:
|
||||
r, g, b = self._led_color
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
# Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis
|
||||
# nach $projectDir\dist\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
|
||||
# dem Netzlaufwerk (Z:\Git\...):
|
||||
|
|
@ -27,7 +30,7 @@ param(
|
|||
$ErrorActionPreference = "Stop"
|
||||
$projectDir = $PSScriptRoot
|
||||
$buildSrc = "$env:TEMP\versapad_build_src"
|
||||
$localDir = "$projectDir\dist\VersaPadViewer"
|
||||
$localDir = "$env:LOCALAPPDATA\VersaPadViewer"
|
||||
$requirementsFile = "$projectDir\requirements.txt"
|
||||
|
||||
function Assert-LastExitCode([string]$step) {
|
||||
|
|
@ -80,10 +83,29 @@ try {
|
|||
throw "PyInstaller hat keine VersaPadViewer.exe erzeugt, obwohl der Exit-Code 0 war -- Build-Ausgabe oben pruefen."
|
||||
}
|
||||
|
||||
# Zielverzeichnis abraeumen, aber NIE Nutzerdaten mitloeschen: die Config
|
||||
# liegt zwar inzwischen woanders (Roaming-AppData, siehe
|
||||
# versapad_data.app_dir()), aeltere Installationen haben sie aber noch
|
||||
# hier liegen -- ohne diese Sicherung loescht jeder Rebuild sie mit
|
||||
# (am 2026-08-15 genau so passiert, alle Notizen weg).
|
||||
$userData = Get-ChildItem $localDir -Filter "versapad_config*.json" -File -ErrorAction SilentlyContinue
|
||||
$rescued = @()
|
||||
foreach ($f in $userData) {
|
||||
$tmp = Join-Path $env:TEMP $f.Name
|
||||
Copy-Item $f.FullName $tmp -Force
|
||||
$rescued += @{ Tmp = $tmp; Name = $f.Name }
|
||||
Write-Host "Nutzerdatei gesichert: $($f.Name)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
if (Test-Path $localDir) {
|
||||
Remove-Item $localDir -Recurse -Force
|
||||
}
|
||||
Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse
|
||||
|
||||
foreach ($r in $rescued) {
|
||||
Copy-Item $r.Tmp (Join-Path $localDir $r.Name) -Force
|
||||
Remove-Item $r.Tmp -Force
|
||||
}
|
||||
Remove-Item $buildSrc -Recurse -Force
|
||||
|
||||
Write-Host ""
|
||||
|
|
|
|||
|
|
@ -54,7 +54,10 @@ OK_GREEN = "#3ecf6e"
|
|||
WARN_RED = "#e0895a"
|
||||
|
||||
POLL_MS = 1500
|
||||
CARD_W, CARD_H = 150, 84
|
||||
CARD_W, CARD_H = 150, 114
|
||||
# Untergrenze beim Ziehen am Anfasser -- knapp unter der Groesse, die das
|
||||
# 4x5-Grid + Encoder mindestens brauchen, damit nichts abgeschnitten wird.
|
||||
MIN_W, MIN_H = 700, 500
|
||||
SERIAL_POLL_S = 1.5
|
||||
SERIAL_IDLE_S = 3.0
|
||||
|
||||
|
|
@ -97,9 +100,11 @@ Buttons setzen (profile 0-2, index 0-19):
|
|||
set_button_profile_switch(profile, index, target)
|
||||
set_button_none(profile, index)
|
||||
set_button_led(profile, index, r, g, b, anim, period_ms)
|
||||
set_button_note(profile, index, note) freie Notiz, was der Button tut
|
||||
|
||||
Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'):
|
||||
set_encoder_key / _consumer / _macro / _profile_switch / _none(...)
|
||||
set_encoder_note(profile, index, field, note)
|
||||
|
||||
Makro:
|
||||
set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...]
|
||||
|
|
@ -122,7 +127,11 @@ class VersaPadViewer(tk.Tk):
|
|||
super().__init__()
|
||||
self.title("VersaPad Steuermatrix")
|
||||
self.configure(bg=BG)
|
||||
self.geometry("760x760")
|
||||
# Hoehe muss Kopfzeile + Tabs + 5 Kartenreihen + Encoder + Fusszeile
|
||||
# fassen -- seit die Karten eine Notizzeile haben (CARD_H 84 -> 114)
|
||||
# reichten die alten 760px nicht mehr: Encoder und der Groessen-
|
||||
# Anfasser lagen unterhalb des Fensterrands und waren unerreichbar.
|
||||
self.geometry("790x960")
|
||||
self.profile = 0
|
||||
self._mtimes = {}
|
||||
self.combined = None # kombinierter Programmiermodus-State, erst bei Bedarf befuellt
|
||||
|
|
@ -149,55 +158,66 @@ class VersaPadViewer(tk.Tk):
|
|||
)
|
||||
self._tray_icon.run_detached()
|
||||
|
||||
header = tk.Frame(self, bg=BG)
|
||||
header.pack(fill="x", padx=20, pady=(18, 4))
|
||||
tk.Label(header, text="VersaPad Steuermatrix", bg=BG, fg=TEXT,
|
||||
font=("Segoe UI", 15, "bold")).pack(anchor="w")
|
||||
self.header_sub = tk.Label(header, text="pollt Config-JSONs alle 1.5s", bg=BG,
|
||||
fg=TEXT_DIM, font=("Segoe UI", 9))
|
||||
self.header_sub.pack(anchor="w")
|
||||
# Titelleiste ausgeblendet (reines Tk `overrideredirect`, KEINE
|
||||
# ctypes/WinAPI-Fenstertricks -- siehe Speicher-Notiz "keine
|
||||
# Selbstversteck-Fenstertricks": das Nachruesten eines Taskleisten-
|
||||
# Icons per SetWindowLongW hat frueher AV-Fehlalarme ausgeloest).
|
||||
# Ersatz fuer die fehlende Systemleiste: Kopfzeile ist ziehbar, und
|
||||
# die Buttons rechts uebernehmen Minimieren/Schliessen (beides ins
|
||||
# Tray, wie vorher schon das X der echten Titelleiste).
|
||||
self.overrideredirect(True)
|
||||
|
||||
info_btn = tk.Label(header, text="ⓘ", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 14),
|
||||
cursor="hand2")
|
||||
info_btn.place(relx=1.0, x=0, y=-4, anchor="ne")
|
||||
self.toggles_row = header = tk.Frame(self, bg=BG)
|
||||
header.pack(fill="x", padx=20, pady=(10, 6))
|
||||
title = tk.Label(header, text="VersaPad", bg=BG, fg=TEXT,
|
||||
font=("Segoe UI", 11, "bold"))
|
||||
title.pack(side="left", anchor="w", padx=(0, 16))
|
||||
|
||||
for widget in (header, title):
|
||||
widget.bind("<Button-1>", self._start_move)
|
||||
widget.bind("<B1-Motion>", self._on_move)
|
||||
|
||||
for text in ("✕", "—"):
|
||||
btn = tk.Label(header, text=text, bg=BG, fg=TEXT_DIM,
|
||||
font=("Segoe UI", 11), cursor="hand2", padx=6)
|
||||
btn.pack(side="right")
|
||||
btn.bind("<Button-1>", lambda e: self._hide_to_tray())
|
||||
btn.bind("<Enter>", lambda e, b=btn: b.configure(fg=TEXT))
|
||||
btn.bind("<Leave>", lambda e, b=btn: b.configure(fg=TEXT_DIM))
|
||||
|
||||
info_btn = tk.Label(header, text="ⓘ", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 12),
|
||||
cursor="hand2", padx=6)
|
||||
info_btn.pack(side="right", padx=(0, 8))
|
||||
info_btn.bind("<Button-1>", lambda e: self._show_mcp_info())
|
||||
info_btn.bind("<Enter>", lambda e: info_btn.configure(fg=ACCENT))
|
||||
info_btn.bind("<Leave>", lambda e: info_btn.configure(fg=TEXT_DIM))
|
||||
|
||||
self.tabs = tk.Frame(self, bg=BG)
|
||||
self.tabs.pack(fill="x", padx=20, pady=(12, 10))
|
||||
self.tab_buttons = {}
|
||||
for p in range(vp.NUM_PROFILES):
|
||||
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")
|
||||
btn.pack(side="left", padx=(0, 8))
|
||||
btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True))
|
||||
btn.bind("<Double-Button-1>", lambda e, prof=p: self._rename_tab(prof))
|
||||
self.tab_buttons[p] = btn
|
||||
|
||||
toggles_row = tk.Frame(self, bg=BG)
|
||||
toggles_row.pack(fill="x", padx=20, pady=(0, 6))
|
||||
# Modus-Umschalter direkt neben der Ueberschrift statt in eigener
|
||||
# Zeile -- spart eine komplette Zeile Fensterhoehe.
|
||||
self.sync_check = tk.Checkbutton(
|
||||
toggles_row, text="Live-Sync mit Board", variable=self.live_sync,
|
||||
header, text="Live-Sync", variable=self.live_sync,
|
||||
command=self._on_toggle_sync, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold"))
|
||||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
|
||||
self.sync_check.pack(side="left")
|
||||
self.sync_status = tk.Label(toggles_row, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9))
|
||||
self.sync_status.pack(side="left", padx=(8, 20))
|
||||
self.sync_status = tk.Label(header, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
|
||||
self.sync_status.pack(side="left", padx=(4, 14))
|
||||
|
||||
self.edit_check = tk.Checkbutton(
|
||||
toggles_row, text="Programmiermodus", variable=self.editing,
|
||||
header, text="Programmiermodus", variable=self.editing,
|
||||
command=self._on_toggle_editing, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold"))
|
||||
self.edit_check.pack(side="left", padx=(0, 20))
|
||||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
|
||||
self.edit_check.pack(side="left", padx=(0, 14))
|
||||
|
||||
self.always_on_top = tk.BooleanVar(value=False)
|
||||
self.topmost_check = tk.Checkbutton(
|
||||
toggles_row, text="Immer im Vordergrund", variable=self.always_on_top,
|
||||
header, text="Immer im Vordergrund", variable=self.always_on_top,
|
||||
command=self._on_toggle_topmost, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold"))
|
||||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
|
||||
self.topmost_check.pack(side="left")
|
||||
|
||||
# Schmale Toolbar-Zeile fuers Board-I/O -- nur sichtbar im
|
||||
# Programmiermodus (siehe _on_toggle_editing), direkt unter den
|
||||
# Checkboxen statt weit unten zwischen Tabs und Matrix.
|
||||
self.prog_row = tk.Frame(self, bg=BG)
|
||||
for text, cmd in (
|
||||
("Vom Board laden", self._load_from_board),
|
||||
|
|
@ -207,21 +227,52 @@ class VersaPadViewer(tk.Tk):
|
|||
):
|
||||
tk.Button(self.prog_row, text=text, command=cmd, bg=CARD_BG, fg=TEXT,
|
||||
activebackground=ACCENT, activeforeground="#fff", relief="flat",
|
||||
padx=10, pady=4, font=("Segoe UI", 9)).pack(side="left", padx=(0, 8))
|
||||
self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9))
|
||||
padx=8, pady=2, font=("Segoe UI", 8)).pack(side="left", padx=(0, 6))
|
||||
self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
|
||||
self.prog_status.pack(side="left", padx=(8, 0))
|
||||
# prog_row wird erst bei aktivem Programmiermodus gepackt (siehe _on_toggle_editing)
|
||||
|
||||
# Profil-Tabs direkt ueber der Steuermatrix, nicht mehr oben am
|
||||
# Fensterkopf -- naeher an dem, was sie auswaehlen.
|
||||
self.tabs = tk.Frame(self, bg=BG)
|
||||
self.tabs.pack(fill="x", padx=20, pady=(8, 8))
|
||||
self.tab_buttons = {}
|
||||
for p in range(vp.NUM_PROFILES):
|
||||
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")
|
||||
btn.pack(side="left", padx=(0, 8))
|
||||
btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True))
|
||||
btn.bind("<Double-Button-1>", lambda e, prof=p: self._rename_tab(prof))
|
||||
self.tab_buttons[p] = btn
|
||||
|
||||
self.grid_frame = tk.Frame(self, bg=BG)
|
||||
self.grid_frame.pack(padx=20, pady=(8, 0))
|
||||
self.grid_frame.pack(padx=20, pady=(0, 0), anchor="w")
|
||||
|
||||
tk.Label(self, text="Encoder", bg=BG, fg=TEXT_DIM,
|
||||
font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=20, pady=(20, 8))
|
||||
self.enc_frame = tk.Frame(self, bg=BG)
|
||||
self.enc_frame.pack(fill="x", padx=20)
|
||||
|
||||
self.footer = tk.Label(self, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
|
||||
self.footer.pack(anchor="w", padx=20, pady=(20, 10))
|
||||
# Fusszeile + Anfasser zum Groessenaendern: mit ausgeblendeter
|
||||
# Titelleiste (overrideredirect) entfernt Windows auch die
|
||||
# Fensterraender, an denen man sonst zieht -- ohne diesen Griff
|
||||
# liesse sich das Fenster gar nicht mehr skalieren.
|
||||
footer_row = tk.Frame(self, bg=BG)
|
||||
footer_row.pack(fill="x", padx=20, pady=(14, 8))
|
||||
self.footer = tk.Label(footer_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
|
||||
self.footer.pack(side="left", anchor="w")
|
||||
|
||||
# Der Griff haengt per place() an der FENSTER-Ecke, nicht am Ende des
|
||||
# gepackten Inhalts: sonst wandert er mit dem Inhalt aus dem Bild,
|
||||
# sobald das Fenster kleiner als der Inhalt ist -- also genau dann,
|
||||
# wenn man ihn zum Vergroessern braucht.
|
||||
grip = tk.Label(self, text="◢", bg=BG, fg=TEXT_DIM,
|
||||
font=("Segoe UI", 11), cursor="sizing")
|
||||
grip.place(relx=1.0, rely=1.0, anchor="se", x=-3, y=-1)
|
||||
grip.bind("<Button-1>", self._start_resize)
|
||||
grip.bind("<B1-Motion>", self._on_resize)
|
||||
grip.bind("<Enter>", lambda e: grip.configure(fg=TEXT))
|
||||
grip.bind("<Leave>", lambda e: grip.configure(fg=TEXT_DIM))
|
||||
|
||||
self.protocol("WM_DELETE_WINDOW", self._hide_to_tray)
|
||||
self.bind("<Unmap>", self._on_unmap)
|
||||
|
|
@ -325,6 +376,25 @@ class VersaPadViewer(tk.Tk):
|
|||
def _on_toggle_topmost(self):
|
||||
self.attributes("-topmost", self.always_on_top.get())
|
||||
|
||||
# ── Fenster ziehen (Ersatz fuer die ausgeblendete Titelleiste) ──
|
||||
|
||||
def _start_move(self, event):
|
||||
self._drag_origin = (event.x_root - self.winfo_x(), event.y_root - self.winfo_y())
|
||||
|
||||
def _on_move(self, event):
|
||||
dx, dy = self._drag_origin
|
||||
self.geometry(f"+{event.x_root - dx}+{event.y_root - dy}")
|
||||
|
||||
def _start_resize(self, event):
|
||||
self._resize_origin = (event.x_root, event.y_root,
|
||||
self.winfo_width(), self.winfo_height())
|
||||
|
||||
def _on_resize(self, event):
|
||||
x0, y0, w0, h0 = self._resize_origin
|
||||
width = max(MIN_W, w0 + (event.x_root - x0))
|
||||
height = max(MIN_H, h0 + (event.y_root - y0))
|
||||
self.geometry(f"{width}x{height}")
|
||||
|
||||
# ── Live-Sync mit dem Board ────────────────────────────────────
|
||||
|
||||
def _on_toggle_sync(self):
|
||||
|
|
@ -426,12 +496,10 @@ class VersaPadViewer(tk.Tk):
|
|||
self.combined = vcomb.default_combined()
|
||||
else:
|
||||
self.combined = vcomb.default_combined()
|
||||
self.prog_row.pack(fill="x", padx=20, pady=(0, 14), after=self.tabs)
|
||||
self.header_sub.configure(text="Programmiermodus · Zelle anklicken zum Bearbeiten")
|
||||
self.prog_row.pack(fill="x", padx=20, pady=(0, 6), after=self.toggles_row)
|
||||
else:
|
||||
self.sync_check.configure(state="normal")
|
||||
self.prog_row.pack_forget()
|
||||
self.header_sub.configure(text="pollt Config-JSONs alle 1.5s")
|
||||
self._update_tab_labels()
|
||||
self._render()
|
||||
|
||||
|
|
@ -460,7 +528,9 @@ class VersaPadViewer(tk.Tk):
|
|||
macro_slots = vproto.unpack_macros(raw_macros)
|
||||
|
||||
names = self.combined["profile_names"] if self.combined else None
|
||||
self.combined = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names)
|
||||
previous = self.combined
|
||||
self.combined = vcomb.merge_notes(
|
||||
vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous)
|
||||
self.profile = self.combined["active_profile"]
|
||||
self._status("vom Board geladen", True)
|
||||
self._update_tab_labels()
|
||||
|
|
@ -639,12 +709,18 @@ class VersaPadViewer(tk.Tk):
|
|||
tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT,
|
||||
font=("Segoe UI", 10, "normal" if empty else "bold"),
|
||||
wraplength=CARD_W - 20, justify="left", anchor="nw").place(
|
||||
x=10, y=26, width=CARD_W - 20, height=36)
|
||||
x=10, y=24, width=CARD_W - 20, height=28)
|
||||
|
||||
# Notiz bekommt den ganzen Rest der Karte (bis zur Animationszeile) --
|
||||
# bei 34px wurden laengere Notizen mitten im Wort abgeschnitten.
|
||||
tk.Label(card, text=btn["note"], bg=CARD_BG, fg=TEXT_DIM,
|
||||
font=("Segoe UI", 8), wraplength=CARD_W - 20, justify="left", anchor="nw").place(
|
||||
x=10, y=53, width=CARD_W - 20, height=CARD_H - 69)
|
||||
|
||||
anim = "" if empty else vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"])
|
||||
tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM,
|
||||
font=("Segoe UI", 7), anchor="sw").place(
|
||||
x=10, y=CARD_H - 20, width=CARD_W - 20, height=14)
|
||||
x=10, y=CARD_H - 16, width=CARD_W - 20, height=13)
|
||||
|
||||
if editable:
|
||||
card.configure(cursor="hand2")
|
||||
|
|
@ -661,17 +737,31 @@ class VersaPadViewer(tk.Tk):
|
|||
inner.pack(fill="both", expand=True, padx=10, pady=8)
|
||||
tk.Label(inner, text=f"Encoder {enc['index']}", bg=CARD_BG, fg=TEXT_DIM,
|
||||
font=("Segoe UI", 8, "bold")).pack(anchor="w", pady=(0, 4))
|
||||
for key, field, val in (("Druck", "sw", enc["sw_label"]), ("CW", "cw", enc["cw_label"]),
|
||||
("CCW", "ccw", enc["ccw_label"])):
|
||||
for key, field, val, note in (("Druck", "sw", enc["sw_label"], enc["sw_note"]),
|
||||
("CW", "cw", enc["cw_label"], enc["cw_note"]),
|
||||
("CCW", "ccw", enc["ccw_label"], enc["ccw_note"])):
|
||||
row = tk.Frame(inner, bg=CARD_BG)
|
||||
row.pack(fill="x", pady=1)
|
||||
tk.Label(row, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
|
||||
tk.Label(row, text=val or "—", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
|
||||
row.pack(fill="x", pady=(1, 4))
|
||||
top = tk.Frame(row, bg=CARD_BG)
|
||||
top.pack(fill="x")
|
||||
tk.Label(top, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
|
||||
tk.Label(top, text=val or "—", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
|
||||
# Notizzeile nur anlegen, wenn es wirklich eine Notiz gibt --
|
||||
# ein leeres Label belegt sonst pro Encoder-Aktion eine Zeile
|
||||
# Hoehe (12x im Fenster) und schiebt die Fusszeile aus dem Bild.
|
||||
note_label = None
|
||||
if note:
|
||||
note_label = tk.Label(row, text=note, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7),
|
||||
wraplength=150, justify="left", anchor="w")
|
||||
note_label.pack(fill="x")
|
||||
if editable:
|
||||
row.configure(cursor="hand2")
|
||||
handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl)
|
||||
row.bind("<Button-1>", handler)
|
||||
for child in row.winfo_children():
|
||||
top.bind("<Button-1>", handler)
|
||||
if note_label is not None:
|
||||
note_label.bind("<Button-1>", handler)
|
||||
for child in top.winfo_children():
|
||||
child.bind("<Button-1>", handler)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -110,13 +110,18 @@ auf.
|
|||
|
||||
`versapad_data.app_dir()` bestimmt das Basisverzeichnis für die eigene
|
||||
Config-Datei (`versapad_combined.DEFAULT_PATH` =
|
||||
`app_dir()/versapad_config_all.json`):
|
||||
`app_dir()/versapad_config_all.json`): `%APPDATA%\VersaPadViewer`
|
||||
(Roaming-AppData), unabhängig davon, ob die gebaute `.exe` oder der
|
||||
Quellcode gestartet wurde (kein `sys.frozen`-Zweig).
|
||||
|
||||
- **Gebaute `.exe`** (PyInstaller `--onedir`): `sys.executable`s Ordner —
|
||||
die Config liegt also neben `VersaPadViewer.exe`, in welchem
|
||||
Installationsverzeichnis sie auch liegt.
|
||||
- **Start aus dem Quellcode** (`py desktop_viewer.py`, `py server.py`,
|
||||
`py versapad_mcp_server.py`): der Projektordner (`__file__`-Verzeichnis).
|
||||
Bewusst **nicht** das Installationsverzeichnis (`%LOCALAPPDATA%\
|
||||
VersaPadViewer`, wo die `.exe` liegt): `build_and_deploy.ps1` räumt das
|
||||
Zielverzeichnis vor jedem Deploy komplett ab, läge die Config dort, würde
|
||||
jeder Rebuild sie mitlöschen (genau das ist am 2026-08-15 passiert, siehe
|
||||
`AGENTS.md`). Ebenso bewusst **nicht** vom Startweg abhängig — sonst sähe
|
||||
die `.exe` eine andere Datei als ein aus dem Quellcode gestarteter
|
||||
`versapad_mcp_server.py`, und Änderungen aus dem einen Weg wären im
|
||||
anderen unsichtbar (ebenfalls am 2026-08-15 beobachtet).
|
||||
|
||||
Fehlt die Datei, legt `load_or_fetch()` sie automatisch an — zuerst per
|
||||
Serial-Versuch vom Board (das ist die eigentliche Quelle der Wahrheit, die
|
||||
|
|
@ -177,8 +182,11 @@ statt dem ACK allein zu vertrauen. Details siehe „Bug beobachtet
|
|||
`build_and_deploy.ps1` installiert `requirements.txt` selbst
|
||||
(`pip install -r`), baut mit PyInstaller (`--onedir --windowed`, nur
|
||||
`desktop_viewer.py` wird gebündelt) und kopiert das Ergebnis nach
|
||||
`dist\VersaPadViewer\` im Projektordner. `--onedir` statt `--onefile`, um
|
||||
AV-Fehlalarme zu verringern. Läuft komplett in try/catch mit
|
||||
`%LOCALAPPDATA%\VersaPadViewer\`. `--onedir` statt `--onefile`, um
|
||||
AV-Fehlalarme zu verringern. Räumt das Zielverzeichnis vor dem Kopieren
|
||||
komplett ab, rettet dabei aber zuvor gefundene `versapad_config*.json`
|
||||
(Altinstallationen, bei denen die Config noch im Installationsordner
|
||||
liegt) über den Deploy hinweg. Läuft komplett in try/catch mit
|
||||
Exit-Code-Prüfung und pausiert am Ende (Erfolg wie Fehler) auf
|
||||
Tastendruck, außer bei `-NoPause`. Muss lokal laufen, nicht auf einem
|
||||
Netzlaufwerk (Pfadlängen-/DLL-Ladeprobleme, siehe `AGENTS.md`). Details:
|
||||
|
|
|
|||
|
|
@ -50,6 +50,16 @@ siehe unten):
|
|||
| `0x04` | Alt |
|
||||
| `0x08` | Win |
|
||||
|
||||
Jede `Action` trägt zusätzlich ein optionales `note`-Feld (freier Text,
|
||||
z.B. `"Speichern in Fusion 360"`) — **rein lokal**, wie `profile_names`
|
||||
(siehe unten): kein Platz dafür in `SAction` (3 Byte, komplett verplant),
|
||||
`to_binary()`/`pack_config()` ignorieren das Feld beim Schreiben ans
|
||||
Board, `from_binary()` liefert frisch vom Board immer `note=""`.
|
||||
`versapad_combined.merge_notes(neu, alt)` kopiert bestehende Notizen nach
|
||||
jedem `load_from_board()`/`fetch_from_board()` zurück, sonst gingen sie
|
||||
bei jedem Board-Refresh verloren. Editierbar per Programmiermodus-Dialog
|
||||
oder MCP (`set_button_note()`/`set_encoder_note()`).
|
||||
|
||||
## LED
|
||||
|
||||
Pro MX-Button (nicht pro Encoder — Encoder haben keine eigene LED):
|
||||
|
|
@ -100,7 +110,7 @@ kompletten 740B-Block, nie nur ein Profil.
|
|||
"buttons": [
|
||||
{
|
||||
"index": 0,
|
||||
"action": { "type": "HidKey", "data": 30 },
|
||||
"action": { "type": "HidKey", "data": 30, "note": "Speichern in Fusion 360" },
|
||||
"led": { "r": 80, "g": 40, "b": 0, "brightness": 255,
|
||||
"anim": "Static", "period_ms": 4000 }
|
||||
}
|
||||
|
|
@ -128,7 +138,8 @@ kompletten 740B-Block, nie nur ein Profil.
|
|||
Profilnamen (`profile_names`) sind **rein lokal** — die Firmware-Structs
|
||||
haben keinen Platz für einen String (Header exakt 32B, jedes Profil exakt
|
||||
236B, alles verplant), sie landen nie aufs Board, egal welcher
|
||||
Schreibpfad benutzt wird.
|
||||
Schreibpfad benutzt wird. Dasselbe gilt für `note` in jeder `Action`
|
||||
(siehe oben).
|
||||
|
||||
## JSON: Legacy-Einzeldatei-Format (`versapad_config1/2/3.json`)
|
||||
|
||||
|
|
|
|||
23
server.py
23
server.py
|
|
@ -38,7 +38,7 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
|
|||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 140px);
|
||||
grid-template-rows: repeat(5, 76px);
|
||||
grid-template-rows: repeat(5, 104px);
|
||||
grid-auto-flow: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 36px;
|
||||
|
|
@ -53,15 +53,17 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
|
|||
}
|
||||
.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 .note { font-size: 11px; color: #8a8d98; margin-top: 4px; 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 { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0 0; }
|
||||
.enc .row .k { color: #8a8d98; }
|
||||
.enc .row .v { font-weight: 500; text-align: right; }
|
||||
.enc .note { font-size: 11px; color: #6a6d78; padding-bottom: 6px; word-break: break-word; }
|
||||
footer { margin-top: 40px; color: #6a6d78; font-size: 12px; }
|
||||
"""
|
||||
|
||||
|
|
@ -73,20 +75,31 @@ def render_cell(btn):
|
|||
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 "—"
|
||||
note = html.escape(btn["note"]) if btn.get("note") 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="note">{note}</div>
|
||||
<div class="anim">{"" if empty else anim}</div>
|
||||
</div>"""
|
||||
|
||||
|
||||
def _encoder_row(key, label, note):
|
||||
label = html.escape(label) or "—"
|
||||
note_html = f'<div class="note">{html.escape(note)}</div>' if note else ""
|
||||
return f"""<div class="row"><span class="k">{key}</span><span class="v">{label}</span></div>{note_html}"""
|
||||
|
||||
|
||||
def render_encoder(enc):
|
||||
rows = (
|
||||
_encoder_row("Druck", enc["sw_label"], enc["sw_note"])
|
||||
+ _encoder_row("CW", enc["cw_label"], enc["cw_note"])
|
||||
+ _encoder_row("CCW", enc["ccw_label"], enc["ccw_note"])
|
||||
)
|
||||
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>
|
||||
{rows}
|
||||
</div>"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,10 +25,10 @@ DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"]
|
|||
|
||||
|
||||
def _empty_profile():
|
||||
buttons = [{"index": i, "action": {"type": "None", "data": 0},
|
||||
buttons = [{"index": i, "action": {"type": "None", "data": 0, "note": ""},
|
||||
"led": {"r": 80, "g": 40, "b": 0, "brightness": 255,
|
||||
"anim": "Static", "period_ms": 4000}} for i in range(20)]
|
||||
none = {"type": "None", "data": 0}
|
||||
none = {"type": "None", "data": 0, "note": ""}
|
||||
encoders = [{"index": i, "sw": dict(none), "cw": dict(none), "ccw": dict(none)} for i in range(4)]
|
||||
return {"buttons": buttons, "encoders": encoders}
|
||||
|
||||
|
|
@ -61,17 +61,54 @@ def default_combined():
|
|||
|
||||
def from_binary(config_dict, macro_slots, profile_names=None):
|
||||
"""config_dict: Ergebnis von versapad_protocol.unpack_config().
|
||||
macro_slots: Ergebnis von versapad_protocol.unpack_macros()."""
|
||||
macro_slots: Ergebnis von versapad_protocol.unpack_macros(). Actions
|
||||
kommen frisch vom Board ohne "note" (die Firmware kennt keine Notizen,
|
||||
siehe merge_notes()) -- hier nur mit leerem Default versehen, damit das
|
||||
Feld ueberall verlaesslich existiert."""
|
||||
profiles = config_dict["profiles"]
|
||||
for profile in profiles:
|
||||
for b in profile["buttons"]:
|
||||
b["action"].setdefault("note", "")
|
||||
for e in profile["encoders"]:
|
||||
for field in ("sw", "cw", "ccw"):
|
||||
e[field].setdefault("note", "")
|
||||
return {
|
||||
"active_profile": config_dict["active_profile"],
|
||||
"global_brightness": config_dict["global_brightness"],
|
||||
"enc_sensitivity": config_dict["enc_sensitivity"],
|
||||
"profile_names": profile_names or list(DEFAULT_NAMES),
|
||||
"profiles": config_dict["profiles"],
|
||||
"profiles": profiles,
|
||||
"macros": macro_slots,
|
||||
}
|
||||
|
||||
|
||||
def merge_notes(combined, previous):
|
||||
"""Kopiert Notizen (Button/Encoder-Aktion) aus einem vorherigen State in
|
||||
einen frisch vom Board gelesenen State -- wie profile_names sind Notizen
|
||||
rein lokal und wuerden bei jedem load_from_board()/fetch_from_board()
|
||||
sonst verloren gehen, weil die Firmware sie nicht kennt. previous=None
|
||||
(z.B. allererstes Laden) -> nichts zu tun, combined unveraendert
|
||||
zurueckgegeben. Aendert combined in-place und gibt es zurueck."""
|
||||
if not previous:
|
||||
return combined
|
||||
for p_idx, profile in enumerate(combined["profiles"]):
|
||||
if p_idx >= len(previous["profiles"]):
|
||||
continue
|
||||
prev_profile = previous["profiles"][p_idx]
|
||||
prev_buttons = {b["index"]: b for b in prev_profile.get("buttons", [])}
|
||||
for b in profile["buttons"]:
|
||||
prev = prev_buttons.get(b["index"])
|
||||
if prev:
|
||||
b["action"]["note"] = prev["action"].get("note", "")
|
||||
prev_encoders = {e["index"]: e for e in prev_profile.get("encoders", [])}
|
||||
for e in profile["encoders"]:
|
||||
prev = prev_encoders.get(e["index"])
|
||||
if prev:
|
||||
for field in ("sw", "cw", "ccw"):
|
||||
e[field]["note"] = prev[field].get("note", "")
|
||||
return combined
|
||||
|
||||
|
||||
def to_binary(combined):
|
||||
"""-> (config_bytes[740], macro_bytes[512])"""
|
||||
config_bytes = proto.pack_config({
|
||||
|
|
@ -85,6 +122,7 @@ def to_binary(combined):
|
|||
|
||||
|
||||
def save_file(combined, path=DEFAULT_PATH):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(combined, f, indent=2, ensure_ascii=False)
|
||||
return path
|
||||
|
|
|
|||
|
|
@ -7,21 +7,35 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen.
|
|||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
NUM_PROFILES = 3
|
||||
|
||||
APP_NAME = "VersaPadViewer"
|
||||
|
||||
|
||||
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__))
|
||||
siehe versapad_combined.DEFAULT_PATH). Programmatisch aus der Umgebung
|
||||
abgeleitet, kein hartkodierter Pfad -- laeuft so auf jeder Maschine und
|
||||
unter jedem Benutzer.
|
||||
|
||||
Bewusst NICHT vom Startweg abhaengig (kein `sys.frozen`-Zweig): die
|
||||
gebaute .exe, der Start aus dem Quellcode und der MCP-Server muessen
|
||||
dieselbe Datei sehen, sonst laufen zwei Configs auseinander und
|
||||
Aenderungen aus dem einen Weg sind im anderen unsichtbar (genau das ist
|
||||
am 2026-08-15 passiert -- .exe zeigte ein anderes Profil 0 als der
|
||||
MCP-Server).
|
||||
|
||||
Bewusst auch NICHT das Installationsverzeichnis (%LOCALAPPDATA%\\
|
||||
VersaPadViewer, wo die .exe liegt): `build_and_deploy.ps1` raeumt das
|
||||
Zielverzeichnis vor jedem Deploy komplett ab (`Remove-Item -Recurse`) --
|
||||
laege die Config dort, wuerde JEDER Rebuild die Nutzerdaten mitloeschen
|
||||
(am 2026-08-15 genau so passiert, alle Notizen weg). Programm- und
|
||||
Datenverzeichnis bleiben deshalb getrennt: Roaming-AppData fuer die
|
||||
Config."""
|
||||
base = (os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA")
|
||||
or os.path.join(os.path.expanduser("~"), ".local", "share"))
|
||||
return os.path.join(base, APP_NAME)
|
||||
|
||||
|
||||
# CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind
|
||||
|
|
@ -206,15 +220,20 @@ def button_grid_position(index):
|
|||
|
||||
|
||||
def annotate_profile(cfg):
|
||||
"""Fuegt label/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus
|
||||
einer Einzel-JSON oder aus dem kombinierten Programmiermodus-State kommt."""
|
||||
"""Fuegt label/note/col/row-Felder hinzu (fuer die Anzeige) -- egal ob
|
||||
cfg aus einer Einzel-JSON (kennt keine Notizen, faellt auf "" zurueck)
|
||||
oder aus dem kombinierten Programmiermodus-State kommt."""
|
||||
for b in cfg["buttons"]:
|
||||
b["label"] = action_label(b["action"])
|
||||
b["note"] = b["action"].get("note", "")
|
||||
b["col"], b["row"] = button_grid_position(b["index"])
|
||||
for e in cfg["encoders"]:
|
||||
e["sw_label"] = action_label(e["sw"])
|
||||
e["sw_note"] = e["sw"].get("note", "")
|
||||
e["cw_label"] = action_label(e["cw"])
|
||||
e["cw_note"] = e["cw"].get("note", "")
|
||||
e["ccw_label"] = action_label(e["ccw"])
|
||||
e["ccw_note"] = e["ccw"].get("note", "")
|
||||
return cfg
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,17 @@ def _profile_switch_data(target):
|
|||
|
||||
|
||||
def _describe_action(action):
|
||||
return {"type": action["type"], "data": action["data"], "label": vp.action_label(action)}
|
||||
return {"type": action["type"], "data": action["data"], "label": vp.action_label(action),
|
||||
"note": action.get("note", "")}
|
||||
|
||||
|
||||
def _replace_action(old_action, new_type, new_data):
|
||||
"""Baut eine neue Action mit neuem Typ/Daten, behaelt aber die Notiz vom
|
||||
vorherigen Stand bei (rein lokal, unabhaengig davon was die Aktion tut --
|
||||
ein Tastenwechsel soll die Beschreibung 'was der Button macht' nicht
|
||||
loeschen). Zum Loeschen explizit set_button_note()/set_encoder_note()
|
||||
mit leerem String."""
|
||||
return {"type": new_type, "data": new_data, "note": old_action.get("note", "")}
|
||||
|
||||
|
||||
# ── Lesen ────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -116,10 +126,10 @@ def get_macro(slot: int) -> dict:
|
|||
def get_board_status() -> dict:
|
||||
"""Prueft per Serial, ob das Board erreichbar ist und welches Profil dort
|
||||
gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der
|
||||
Tkinter-Viewer den COM-Port gerade halten. Schliesst die Verbindung
|
||||
danach wieder (siehe write_to_board() fuer den Grund) -- der Port
|
||||
ist exklusiv, ein einzelner Status-Check darf ihn nicht dauerhaft
|
||||
fuer Live-Sync/VersaGUI blockieren."""
|
||||
Tkinter-Viewer den COM-Port gerade halten. Gibt den COM-Port danach
|
||||
sofort wieder frei (kein dauerhaft offen gehaltener Serial-Handle --
|
||||
sonst blockiert dieser Prozess andere Tools/Viewer mit "busy", bis er
|
||||
beendet wird)."""
|
||||
try:
|
||||
profile = _link.read_active_profile()
|
||||
return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error}
|
||||
|
|
@ -138,7 +148,7 @@ def set_button_key(profile: int, index: int, key: str, modifiers: list[str] = []
|
|||
btn = _find(_profile(profile)["buttons"], index)
|
||||
keycode = vp.hid_key_code_for_name(key)
|
||||
mod_bits = vp.modifier_bits_for_names(modifiers)
|
||||
btn["action"] = {"type": "HidKey", "data": (mod_bits << 8) | keycode}
|
||||
btn["action"] = _replace_action(btn["action"], "HidKey", (mod_bits << 8) | keycode)
|
||||
return _describe_action(btn["action"])
|
||||
|
||||
|
||||
|
|
@ -148,7 +158,7 @@ def set_button_consumer(profile: int, index: int, consumer: str) -> dict:
|
|||
'Lauter', 'Leiser', 'Nächster Titel', 'Vorheriger Titel'."""
|
||||
btn = _find(_profile(profile)["buttons"], index)
|
||||
cid = vp.consumer_id_for_name(consumer)
|
||||
btn["action"] = {"type": "HidConsumer", "data": cid}
|
||||
btn["action"] = _replace_action(btn["action"], "HidConsumer", cid)
|
||||
return _describe_action(btn["action"])
|
||||
|
||||
|
||||
|
|
@ -157,7 +167,7 @@ def set_button_macro(profile: int, index: int, slot: int) -> dict:
|
|||
"""Belegt einen MX-Button mit einem Makro-Slot (0-31). Die Schritte selbst
|
||||
mit set_macro() befuellen."""
|
||||
btn = _find(_profile(profile)["buttons"], index)
|
||||
btn["action"] = {"type": "Macro", "data": slot}
|
||||
btn["action"] = _replace_action(btn["action"], "Macro", slot)
|
||||
return _describe_action(btn["action"])
|
||||
|
||||
|
||||
|
|
@ -165,15 +175,27 @@ def set_button_macro(profile: int, index: int, slot: int) -> dict:
|
|||
def set_button_profile_switch(profile: int, index: int, target) -> dict:
|
||||
"""Belegt einen MX-Button mit Profilwechsel. target: 'next' (Zyklus) oder 0/1/2."""
|
||||
btn = _find(_profile(profile)["buttons"], index)
|
||||
btn["action"] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)}
|
||||
btn["action"] = _replace_action(btn["action"], "ProfileSwitch", _profile_switch_data(target))
|
||||
return _describe_action(btn["action"])
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_button_none(profile: int, index: int) -> dict:
|
||||
"""Entfernt die Belegung eines MX-Buttons (Action = None)."""
|
||||
"""Entfernt die Belegung eines MX-Buttons (Action = None). Notiz bleibt
|
||||
erhalten -- zum Loeschen set_button_note(profile, index, "")."""
|
||||
btn = _find(_profile(profile)["buttons"], index)
|
||||
btn["action"] = {"type": "None", "data": 0}
|
||||
btn["action"] = _replace_action(btn["action"], "None", 0)
|
||||
return _describe_action(btn["action"])
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_button_note(profile: int, index: int, note: str) -> dict:
|
||||
"""Setzt/aendert die freie Notiz eines MX-Buttons -- was der Button tut,
|
||||
unabhaengig von der technischen Aktion (z.B. 'Speichern in Fusion 360').
|
||||
Rein lokal, landet nie aufs Board (wie Profilnamen). Leerer String
|
||||
loescht die Notiz."""
|
||||
btn = _find(_profile(profile)["buttons"], index)
|
||||
btn["action"]["note"] = note
|
||||
return _describe_action(btn["action"])
|
||||
|
||||
|
||||
|
|
@ -205,7 +227,7 @@ def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: l
|
|||
enc, f = _encoder_field(profile, index, field)
|
||||
keycode = vp.hid_key_code_for_name(key)
|
||||
mod_bits = vp.modifier_bits_for_names(modifiers)
|
||||
enc[f] = {"type": "HidKey", "data": (mod_bits << 8) | keycode}
|
||||
enc[f] = _replace_action(enc[f], "HidKey", (mod_bits << 8) | keycode)
|
||||
return _describe_action(enc[f])
|
||||
|
||||
|
||||
|
|
@ -213,7 +235,7 @@ def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: l
|
|||
def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) -> dict:
|
||||
"""Belegt eine Encoder-Aktion mit einer Medientaste."""
|
||||
enc, f = _encoder_field(profile, index, field)
|
||||
enc[f] = {"type": "HidConsumer", "data": vp.consumer_id_for_name(consumer)}
|
||||
enc[f] = _replace_action(enc[f], "HidConsumer", vp.consumer_id_for_name(consumer))
|
||||
return _describe_action(enc[f])
|
||||
|
||||
|
||||
|
|
@ -221,7 +243,7 @@ def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) ->
|
|||
def set_encoder_macro(profile: int, index: int, field: str, slot: int) -> dict:
|
||||
"""Belegt eine Encoder-Aktion mit einem Makro-Slot (0-31)."""
|
||||
enc, f = _encoder_field(profile, index, field)
|
||||
enc[f] = {"type": "Macro", "data": slot}
|
||||
enc[f] = _replace_action(enc[f], "Macro", slot)
|
||||
return _describe_action(enc[f])
|
||||
|
||||
|
||||
|
|
@ -231,15 +253,26 @@ def set_encoder_profile_switch(profile: int, index: int, field: str, target) ->
|
|||
Achtung: Encoder 0 'sw' ist normalerweise auf allen 3 Profilen der
|
||||
Profilwechsel -- nicht ohne Ruecksprache mit dem User aendern."""
|
||||
enc, f = _encoder_field(profile, index, field)
|
||||
enc[f] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)}
|
||||
enc[f] = _replace_action(enc[f], "ProfileSwitch", _profile_switch_data(target))
|
||||
return _describe_action(enc[f])
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_encoder_none(profile: int, index: int, field: str) -> dict:
|
||||
"""Entfernt eine Encoder-Belegung (Action = None)."""
|
||||
"""Entfernt eine Encoder-Belegung (Action = None). Notiz bleibt erhalten
|
||||
-- zum Loeschen set_encoder_note(profile, index, field, "")."""
|
||||
enc, f = _encoder_field(profile, index, field)
|
||||
enc[f] = {"type": "None", "data": 0}
|
||||
enc[f] = _replace_action(enc[f], "None", 0)
|
||||
return _describe_action(enc[f])
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_encoder_note(profile: int, index: int, field: str, note: str) -> dict:
|
||||
"""Setzt/aendert die freie Notiz einer Encoder-Aktion (sw/cw/ccw) -- was
|
||||
sie tut, unabhaengig von der technischen Aktion. Rein lokal, landet nie
|
||||
aufs Board. Leerer String loescht die Notiz."""
|
||||
enc, f = _encoder_field(profile, index, field)
|
||||
enc[f]["note"] = note
|
||||
return _describe_action(enc[f])
|
||||
|
||||
|
||||
|
|
@ -303,10 +336,10 @@ def load_local(path: str = None) -> dict:
|
|||
@mcp.tool()
|
||||
def load_from_board() -> dict:
|
||||
"""Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und
|
||||
ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die
|
||||
kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade
|
||||
von VersaGUI/dem Tkinter-Viewer gehalten wird. Schliesst die Verbindung
|
||||
danach wieder (siehe write_to_board() fuer den Grund)."""
|
||||
ersetzt damit den In-Memory-State. Profilnamen UND Notizen bleiben
|
||||
erhalten (kennt nur wir, nicht das Board). Schlaegt fehl, wenn der
|
||||
COM-Port gerade von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den
|
||||
COM-Port danach sofort wieder frei (siehe get_board_status())."""
|
||||
try:
|
||||
raw_cfg = _link.read_full_config()
|
||||
if raw_cfg is None:
|
||||
|
|
@ -321,7 +354,9 @@ def load_from_board() -> dict:
|
|||
macro_slots = vproto.unpack_macros(raw_macros)
|
||||
|
||||
names = _cfg()["profile_names"]
|
||||
_state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names)
|
||||
previous = _state["combined"]
|
||||
_state["combined"] = vcomb.merge_notes(
|
||||
vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous)
|
||||
return list_profiles()
|
||||
finally:
|
||||
_link.close()
|
||||
|
|
@ -333,11 +368,7 @@ def write_to_board() -> dict:
|
|||
Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft
|
||||
Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit
|
||||
NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port.
|
||||
Schliesst die Verbindung danach wieder -- VersaPadLink haelt den Port
|
||||
sonst dauerhaft offen (kein automatisches Schliessen nach einem Befehl),
|
||||
was Live-Sync/VersaGUI/den naechsten MCP-Aufruf sonst dauerhaft mit
|
||||
"busy" blockieren wuerde, obwohl der eigentliche Vorgang laengst fertig
|
||||
ist -- der Port ist exklusiv (siehe versapad_serial.py)."""
|
||||
Gibt den COM-Port danach sofort wieder frei (siehe get_board_status())."""
|
||||
try:
|
||||
cfg_bytes, macro_bytes = vcomb.to_binary(_cfg())
|
||||
if not _link.write_full_config(cfg_bytes):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue