Compare commits

..

17 commits

Author SHA1 Message Date
d9d7d90654 Merge branch 'dev/jappel' into main
Bringt die UI/UX-Ueberarbeitung des Programmiermodus nach main:
Tastendruck-Erkennung ueber die physische Tastenposition (statt ueber das
erzeugte Zeichen -- auf deutschem Layout waren Y/Z vertauscht und
AeOeUe/#/+ gar nicht erfassbar), layoutrichtige Tastennamen, Makros zeigen
ihre echte Tastenfolge, Slot-Auswahl mit Vorschau, Farb-Schnellwahl,
Kopieren/Einfuegen zwischen Tasten per Rechtsklick, Dialoge oeffnen ueber
dem Hauptfenster (auch auf einem zweiten Monitor), Enter/Escape und
Fenster-Kuerzel, sowie die Rueckkehr zur nativen Titelleiste fuer einen
Taskleisten-Eintrag.

Dazu die aeltere dev/jappel-Historie, die auf main noch fehlte
(.mcp.json, docs/*.md, Notizen pro Aktion, Config-Pfad in Roaming-AppData,
selbstinstallierendes build_and_deploy.ps1).
2026-08-29 01:09:36 +02:00
78e9640dec Document the layout fix and the return of the title bar
AGENTS.md: die WinAPI-Regel praeziser gefasst -- verboten bleiben
Fenstermanipulation und globale Hooks, passive Layout-Abfragen sind es
nicht (und die offizielle VersaGUI macht dasselbe). Dazu die neue
Aufloesungsreihenfolge samt Warnung, nicht auf "Zeichen auswerten"
zurueckzubauen, die Kollisionsregel bei Tastennamen, der Mehrmonitor-Fix
und die Folgen der Titelleiste (welche Eigenbauten dadurch entfallen sind
und warum randlos nicht ohne Ruecksprache zurueckkommt).

README.md und docs/architecture.md entsprechend: Position statt Zeichen,
layoutrichtige Beschriftungen inkl. der Anzeigeaenderung an bestehenden
Belegungen, Taskleisten-Eintrag statt randlosem Fenster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:02:18 +02:00
e1e3d0e466 Bring back the native title bar so the app has a taskbar entry
Das randlose Fenster (overrideredirect) kostet unter Windows zwingend den
Taskleisten-Eintrag. Nachruesten liesse er sich nur per SetWindowLongW
(WS_EX_APPWINDOW) -- genau der Aufruf, der laut AGENTS.md schon
AV-Fehlalarme ausgeloest hat. Der User hat sich deshalb fuer die native
Titelleiste entschieden.

Damit kommen Taskleiste, Alt+Tab, Aero-Snap sowie Ziehen und
Groessenaendern am Rahmen nativ zurueck, und die Eigenbau-Loesungen dafuer
entfallen ersatzlos: ziehbare Kopfzeile, Anfasser unten rechts (ersetzt
durch self.minsize()), eigene ✕/—-Knoepfe und der <Unmap>-Handler, der
Minimieren ins Tray umgeleitet hat.

Schliessen legt weiterhin ins Tray statt zu beenden (wie die offizielle
VersaGUI). Minimieren geht jetzt normal in die Taskleiste, und Escape
minimiert ebenfalls statt ins Tray zu legen -- mit Taskleisten-Eintrag
waere "verschwindet spurlos" die unangenehmere Ueberraschung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:02:07 +02:00
50868ab081 Keep edit dialogs on the monitor the main window is on
_center_on_parent() hat gegen winfo_screenwidth()/screenheight() begrenzt --
Tk meldet dort aber nur den Hauptbildschirm. Lag das Hauptfenster auf einem
zweiten Monitor, zog genau diese Begrenzung den Dialog zurueck an den Rand
des ersten. Begrenzt wird jetzt gegen das Elternfenster: passt der Dialog
hinein, wird er zentriert, sonst an dessen linker oberer Ecke ausgerichtet
-- so bleibt er in jedem Fall dort, wo gearbeitet wird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:02:07 +02:00
42897d553c Resolve captured keys by physical position, not by character
Die Tastendruck-Erkennung ging bisher ueber das erzeugte Zeichen (keysym
bzw. Virtual-Key). Das dreht die Kette falsch herum: HID-Keycodes SIND
physische Tastenpositionen -- das Board sendet eine Position, erst Windows
macht daraus ueber das aktive Layout ein Zeichen. Auf deutschem Layout
landete deshalb jedes Y auf der Z-Taste des Boards und umgekehrt, und
AeOeUe/#/+/ss waren gar nicht erfassbar.

versapad_keylayout.py loest das ueber MapVirtualKeyW (Virtual-Key ->
Scan-Code) und eine layoutunabhaengige Scan-Code-zu-HID-Tabelle.
GetKeyNameTextW liefert dazu den Namen, den eine Taste auf dem aktiven
Layout traegt, so dass Dropdown und Grid "Strg+Z" zeigen, wenn Strg+Z
gemeint ist. Beides sind passive Layout-Abfragen -- kein Hook, keine
Fenstermanipulation, also nicht das, was AGENTS.md verbietet (die
offizielle VersaGUI benutzt GetKeyNameText fuer denselben Zweck).

Benannte Tasten laufen weiterhin zuerst ueber den Tk-keysym, und das ist
kein Schoenheitsfehler: MapVirtualKeyW liefert fuer die Pfeiltasten
denselben Scan-Code wie fuer ihre Numpad-Zwillinge (gemessen: VK_LEFT und
VK_NUMPAD4 beide 0x4B). Ohne diesen Schritt waeren beide nicht zu
unterscheiden.

Namen sind zugleich Schluessel (Dropdown, hid_key_code_for_name), muessen
also eindeutig bleiben -- auf deutschem Layout heisst HID 0x31 "#", ein
Name den bisher HID 0x32 trug. Der Layoutname gewinnt, der verdraengte
US-Name wird gekennzeichnet statt verworfen. Bestehende Belegungen aendern
damit ihre Anzeige, nicht ihre Daten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:01:51 +02:00
8a00b3ae36 Document key capture, dialog placement and copy/paste
AGENTS.md: die Regel "Auswahl ist ein Dropdown, kein Tastendruck-Capture"
ist ueberholt -- Capture gibt es jetzt, aber weiterhin ohne WinAPI-Hook, und
genau diese Grenze muss bleiben. Dazu die Layout-Naeherung (Y/Z vertauscht
auf deutschem Layout, minus-Kollision bewusst zugunsten der US-Position
aufgeloest), die neuen UI-Regeln (Dialogposition, Grab-Rueckgabe bei
verschachtelten Dialogen, feste Panel-Hoehe, In-Memory-Ablage fuers
Kopieren) und ein Verifikationsrezept fuer UI-Aenderungen inkl. der
DPI-Falle beim Screenshot-Vergleich. Der Deferred-Work-Eintrag zum Capture
faellt weg.

README.md: neue Bedienung in den Features, Tastenkuerzel-Uebersicht, und die
Einschraenkung praezisiert (nicht mehr "Dropdown statt Capture", sondern
was die Fenster-basierte Erkennung nicht sehen kann).

docs/architecture.md: _ModalDialog/_KeyCapture in der UI-Schicht, plus zwei
neue Abschnitte zu den Grenzen der Erkennung und zur Kopier-Ablage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:33:38 +02:00
21f9cbc2ed Copy button settings between keys; add window shortcuts
Rechtsklick auf eine Karte im Programmiermodus: Belegung und/oder LED-Farbe
kopieren und auf andere Tasten anwenden, Belegung leeren, bearbeiten. Auf
Encodern dasselbe fuer die drei Aktionen (dort ohne Farbe -- Encoder haben
keine eigene LED). Strg+C/Strg+V wirken auf die Karte unter dem Mauszeiger.

Die Ablage ist bewusst eine reine In-Memory-Struktur (self._clip), nicht die
System-Zwischenablage: uebertragen werden ganze Action-Dicts, kein Text.
Eingefuegt wird immer eine deepcopy, sonst teilen sich zwei Tasten dasselbe
Dict und eine spaetere Bearbeitung aendert beide. "Leeren" behaelt die Notiz
-- gleiche Regel wie beim Typwechsel im Dialog, die Notiz beschreibt die
Taste, nicht die konkrete Aktion.

Ausserdem: Makro-Belegungen zeigen im Grid ihre echte Tastenfolge (Makrotabelle
wird an annotate_profile() durchgereicht), und das Fenster kennt Strg+1/2/3
(Profil), F2 (umbenennen), Strg+E (Programmiermodus), Esc (ins Tray).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:33:26 +02:00
aba81463e5 Show macro key sequences in the browser view and MCP replies
Beide Ansichten reichen jetzt die Makrotabelle an action_label() durch, so
dass eine Makro-Belegung ihre echte Tastenfolge zeigt statt nur der
Slot-Nummer -- dieselbe Darstellung wie im Desktop-Fenster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:33:15 +02:00
f6f4edbdde Rework the edit dialogs: key capture, placement, shortcuts, quick colours
Der Bearbeiten-Dialog war der langsamste Teil der Bedienung. Fuenf Punkte,
alle auf gemeinsamer Basis _ModalDialog:

- Oeffnet mittig ueber dem aufrufenden Fenster statt bei +0+0 in der
  Bildschirmecke -- bei 20 Tasten hintereinander wanderte der Blick sonst
  jedes Mal dorthin. Aufbau erfolgt withdraw()'t, damit er nicht kurz in der
  Ecke aufblitzt, bevor er springt.
- Enter = OK, Escape = Abbrechen. Die Sequenzen sind nicht einzeln
  gebunden, sondern werden aus dem <KeyPress>-Handler verteilt -- waehrend
  einer laufenden Tastendruck-Aufnahme muessen sie als normale Tasten
  erfassbar bleiben.
- "Taste druecken" erfasst Taste + Modifier per Tastendruck, Makro-Schritte
  zusaetzlich als Folge am Stueck ("Folge aufnehmen"). _KeyCapture haengt an
  einem Label, nicht an einem Button: Buttons reagieren per Klassen-Binding
  selbst auf Leertaste/Enter und wuerden die Aufnahme mit ihrem eigenen
  Klick beantworten. Das Dropdown bleibt daneben stehen, damit ein falsch
  erkanntes Zeichen (US-Layout-Naeherung) korrigierbar ist.
- Die Makro-Slot-Auswahl ist ein Dropdown mit allen 32 Slots samt Inhalt
  statt einer Spinbox, durch die man sich klicken musste.
- Zwoelf Grundfarben direkt in der LED-Zeile, der System-Farbdialog nur noch
  fuer den Rest.

Die Panel-Hoehe ist fix (grid_propagate(False)), sonst springt die
Fenstergroesse bei jedem Typwechsel und OK/Abbrechen wandern unter dem
Mauszeiger weg. run() gibt den Grab an einen aufrufenden Dialog zurueck --
grab_release() des verschachtelten MacroStepsDialog nimmt ihn sonst mit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:33:08 +02:00
73c1135655 Map Tk key events to HID codes; show real macro sequences
Datenschicht fuer zwei UI-Verbesserungen:

tk_event_to_hid() uebersetzt einen Tk-Tastendruck (keysym, Windows-VK-Code,
state) in HID-Keycode + Modifier-Bits -- Grundlage fuer die
Tastendruck-Erkennung im Bearbeiten-Dialog. Bewusst nur aus Fenster-Events
ableitbar, kein WinAPI-Hook. Bei gehaltenem Shift zaehlt zuerst der VK-Code,
weil der keysym dann das verschobene Zeichen ist (deutsch: Shift+7 ->
"slash", was sonst faelschlich auf Taste 0x38 zeigen wuerde).

action_label()/annotate_profile() nehmen die Makrotabelle optional entgegen
und zeigen dann "Makro 3: Strg+C -> Strg+V" statt "Makro (Slot 3)" -- ohne
das sagt eine Makro-Belegung im Grid nichts darueber aus, was sie tut.
macro_slot_choices()/macro_slot_from_choice() liefern dieselbe Ansicht fuer
eine Slot-Auswahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:32:52 +02:00
189ec01e68 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.
2026-08-15 18:16:00 +02:00
83429363c1 Add architecture/data-model/protocol reference docs
Human-facing reference documentation, split from AGENTS.md's agent-facing
domain rules and bug history (which stays there, not duplicated here):

- docs/architecture.md: layer diagram, module responsibilities, config
  storage location, the three GUI modes, and the port-exclusivity /
  multi-process caveats around concurrent access
- docs/data-model.md: the combined and legacy JSON formats, the binary
  SDeviceConfig/SDeviceProfile/SMacroTable NVM layout byte-for-byte, action
  types, LED fields, macro-slot conventions, button grid geometry
- docs/protocol.md: the 8-byte serial packet format, command/event tables,
  the read/write/status-poll flows, connection lifecycle, and error states

README.md now links to all three from a new "Dokumentation" section, and
AGENTS.md's outdated "docs/ tree isn't warranted yet" note is removed now
that it exists on explicit user request.
2026-08-14 23:19:13 +02:00
7d40fdaa60 Close the serial link after each MCP board operation
VersaPadLink never closes itself; get_board_status()/load_from_board()/
write_to_board() were leaving the exclusive COM port open for the rest of
the MCP server process's lifetime after a single call. That locked out
Live-Sync, the official VersaGUI, and even the MCP server's own next call
with "busy", live-observed today after a single write_to_board() call. Each
of the three now closes the link in a finally block regardless of outcome.

Also documented in AGENTS.md: this environment can run several independent
versapad_mcp_server.py processes at once, each with its own in-memory
state, which caused a write_to_board() call to silently write blank data
from a fresh process instead of the config that had just been built up on
another one (ACK still said {"ok": true}). Recommended workaround noted
there: load_local() right before write_to_board(), and read back with
load_from_board() + get_profile() afterwards instead of trusting the ACK.
2026-08-14 23:18:32 +02:00
09fbd6ad96 Register versapad MCP server via project-scoped .mcp.json
Lets Claude Code offer the "versapad" MCP server automatically when this
project is opened, instead of requiring a manual `claude mcp add -s user`
per machine. Note: the script path is currently absolute (this machine's
checkout location) rather than relative -- .mcp.json doesn't reliably
support workspace-relative variables across Claude Code environments, so
this only works as-is on this specific checkout path for now.
2026-08-14 23:18:08 +02:00
82c3fd0056 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.
2026-08-14 23:17:38 +02:00
5d14bdd826 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.
2026-08-14 23:17:11 +02:00
13c3745479 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.
2026-08-14 23:15:25 +02:00
12 changed files with 1937 additions and 292 deletions

8
.mcp.json Normal file
View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"versapad": {
"command": "py",
"args": ["C:\\Users\\Julian\\Documents\\__CODE\\VersaGUI-py\\versapad_mcp_server.py"]
}
}
}

255
AGENTS.md
View file

@ -16,6 +16,11 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
- `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/
Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie
(`index = spalte*5+reihe`), `hid_key_choices()`/`consumer_choices()`.
Seit 2026-08-28 zusätzlich `tk_event_to_hid()` (Tk-Tastendruck →
HID-Keycode+Modifier, siehe Tastendruck-Erkennung unten) und
`macro_slot_choices()`/`macro_slot_from_choice()` für die Slot-Auswahl.
`action_label()`/`annotate_profile()` nehmen die Makrotabelle optional
entgegen und zeigen dann statt „Makro (Slot 7)" die echte Tastenfolge.
`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.
@ -54,27 +59,56 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
`read_profile_names()` liest nur die Namen (kein Board-Zugriff, fuer
Tab-Beschriftungen im Nur-Lese-Modus, siehe Installierbarkeit-Notiz).
- `versapad_keylayout.py` (seit 2026-08-29) — passive Abfragen ans aktive
Windows-Tastaturlayout: `hid_for_vk()` (Virtual-Key → Scan-Code → HID über
`MapVirtualKeyW`) und `key_name()` (Beschriftung über `GetKeyNameTextW`).
Optional: auf Nicht-Windows/ohne ctypes bleibt `AVAILABLE` False und
`versapad_data` faellt auf seine US-Naeherung zurueck. Zur Abgrenzung
gegen die WinAPI-Verbote siehe Domaenenregeln.
**UI:**
- `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi
(Nur lesen / Live-Sync / Programmiermodus, siehe Kritische Domänenregeln),
Tray-Icon statt Taskleisten-Minimierung, Info-Button mit MCP-Doku.
- `action_dialog.py` — Bearbeiten-Dialoge (`ActionEditDialog`,
`MacroStepsDialog`) für den Programmiermodus.
`MacroStepsDialog`) für den Programmiermodus. Gemeinsame Basis
`_ModalDialog` (Positionierung über dem Elternfenster, Enter/Escape,
Verteilung der Tastenevents) und `_KeyCapture` (Tastendruck-Erkennung).
**MCP-Server:**
- `versapad_mcp_server.py` — registriert als User-Scope-MCP-Server
"versapad" (`claude mcp add -s user versapad -- <python> versapad_mcp_server.py`).
- `versapad_mcp_server.py` — registriert als projektgebundener MCP-Server
"versapad" über `.mcp.json` im Projektordner (Alternative:
`claude mcp add -s user versapad -- <python> versapad_mcp_server.py`).
Nutzt MCP-SDK v2 (Paket `mcp`, Klasse `mcp.server.mcpserver.MCPServer`
**nicht** `FastMCP` aus `mcp.server.fastmcp`, das existiert in dieser
SDK-Version nicht mehr, wurde umbenannt). `@mcp.tool()`-dekorierte
Funktionen bleiben direkt aufrufbar (kein `.fn`-Unterschied wie bei
älteren FastMCP-Versionen). Tool-Liste: siehe README oder
`MCP_INFO_TEXT` in `desktop_viewer.py`.
- **Board-Serial-Tools schliessen den Link nach jedem Aufruf** (`get_board_status`,
`load_from_board`, `write_to_board``finally: _link.close()`). Grund:
`VersaPadLink` schliesst nie von selbst, ein einzelner Aufruf hätte sonst
den exklusiven COM-Port dauerhaft für den Rest des MCP-Serverprozesses
blockiert und Live-Sync/VersaGUI/den nächsten Aufruf mit "busy" ausgesperrt
(am 2026-08-14 live so aufgetreten, siehe unten).
- **Bug beobachtet 2026-08-14:** In diesem Agenten-Environment (Claude-Code-
VSCode-Extension) können mehrere unabhängige `versapad_mcp_server.py`-
Prozesse gleichzeitig laufen (bis zu 8 beobachtet, vermutlich durch
wiederholte Tool-Ladevorgänge/Reconnects innerhalb einer Session) — jeder
mit eigenem, nicht geteiltem In-Memory-State (`_state["combined"]`).
Konkret beobachtet: `set_button_*`/`set_macro` + `save_local()` liefen
korrekt auf einem Prozess, ein späterer `write_to_board()`-Aufruf landete
aber auf einem anderen (frischen, leeren) Prozess und schrieb versehentlich
eine leere Default-Config aufs Board, trotz `{"ok": true}`-Antwort. Fix:
vor `write_to_board()` immer erst `load_local()` (liest die Datei frisch
von der Platte, unabhängig davon welcher Prozess antwortet), und nach
jedem Schreibvorgang mit `load_from_board()` + `get_profile()` gegenlesen
statt dem ACK allein zu vertrauen — genau dieses Verify-Pattern hat den
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
@ -154,7 +188,7 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
(`sys.executable`-Verzeichnis), sonst den Projektordner (`__file__`-
Verzeichnis) -- `DEFAULT_PATH` hängt jetzt daran, landet also immer neben
der laufenden Installation. **Diese app_dir()-Variante ist seit
2026-08-15 überholt und war aktiv schädlich, siehe unten "Config-Pfad
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
@ -170,24 +204,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:** 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).
- **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
@ -214,30 +242,135 @@ 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.
- **WinAPI: was verboten bleibt und was nicht.** Verboten sind
**Fenstermanipulation am eigenen Fenster** (`SetWindowLongW`,
`ShowWindow`) und **globale Eingabehooks** (`SetWindowsHookEx`) — genau
diese Kombination hat laut Speicher-Notiz „keine Selbstversteck-
Fenstertricks" in einem anderen Projekt Bitdefender-Fehlalarme ausgeloest.
Erlaubt und seit 2026-08-29 in Benutzung sind **passive Layout-Abfragen**
(`MapVirtualKeyW`, `GetKeyNameTextW` in `versapad_keylayout.py`): sie
lesen nur die Tastaturbelegung, fassen weder Fenster noch Prozesse noch
den Eingabestrom an; die offizielle VersaGUI (C#) benutzt
`GetKeyNameText()` fuer denselben Zweck. Die Grenze verlaeuft also nicht
bei „ctypes", sondern bei „greift ins System ein".
- **Normales Fenster mit Titelleiste (seit 2026-08-29).** Von 2026-08-15
bis dahin lief das Fenster randlos (`overrideredirect(True)`) — das kostet
unter Windows zwingend den Taskleisten-Eintrag. Als der ausdruecklich
gebraucht wurde, gab es nur drei Wege: Titelleiste zurueck,
`SetWindowLongW`+WS_EX_APPWINDOW (siehe Verbot oben) oder ein
unsichtbares Proxy-Fenster. Der User hat sich fuer die Titelleiste
entschieden. Damit sind ersatzlos entfallen: ziehbare Kopfzeile
(`_start_move`/`_on_move`), Groessen-Anfasser unten rechts
(`_start_resize`/`_on_resize`, ersetzt durch `self.minsize()`), die
eigenen `✕`/`—`-Knoepfe und der `<Unmap>`-Handler, der Minimieren ins Tray
umgeleitet hat. **Wer das Fenster wieder randlos machen will, nimmt dem
User den Taskleisten-Eintrag weg** — nicht ohne Ruecksprache.
- Schliessen (`✕`) legt weiterhin ins Tray statt zu beenden (wie die
offizielle VersaGUI, das Programm laeuft im Hintergrund weiter),
Minimieren geht jetzt aber ganz normal in die Taskleiste. `Escape`
minimiert ebenfalls, statt wie frueher ins Tray zu legen — mit
Taskleisten-Eintrag waere „verschwindet spurlos" die unangenehmere
Ueberraschung.
- 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).
- Zeichentasten-Labels zeigen eine US-Layout-Näherung, nicht das tatsächlich
aktive Windows-Tastaturlayout (die echte VersaGUI löst das über
`GetKeyNameText()`, das bilden wir ohne WinAPI-Call nicht nach).
- **Dialogposition wird gegen das ELTERNFENSTER begrenzt, nie gegen
`winfo_screenwidth()` (2026-08-29):** Tk meldet dort nur den
Hauptbildschirm. Die erste Fassung hat damit geklemmt — lag das
Hauptfenster auf einem zweiten Monitor, zog genau diese Begrenzung den
Dialog zurueck an den Rand des ersten. Passt der Dialog nicht ins
Elternfenster (der Makro-Schritte-Dialog ist breiter als der
Action-Dialog), wird er an dessen linker oberer Ecke ausgerichtet statt
zentriert.
- **Dialoge öffnen über dem Hauptfenster, nicht in der Bildschirmecke
(2026-08-28):** `_ModalDialog` baut sich `withdraw()`n auf, positioniert
sich in `run()` per `_center_on_parent()` und wird erst dann
`deiconify()`t. Ohne das legt Tk jeden Toplevel bei `+0+0` an — bei 20
Tasten hintereinander wandert der Blick jedes Mal in die linke obere Ecke.
Das `withdraw()` gehört zwingend dazu, sonst blitzt der Dialog dort auf,
bevor er springt.
- **Verschachtelte Dialoge geben den Grab zurück:** `MacroStepsDialog` läuft
im `ActionEditDialog`. `_finish()` ruft `grab_release()`, was den Grab des
*aufrufenden* Dialogs mit wegnimmt — `run()` setzt ihn deshalb am Ende
wieder, wenn der Parent ein `_ModalDialog` ist.
- **Panel-Höhe im `ActionEditDialog` ist fix** (`grid_propagate(False)`):
sonst springt die Fenstergröße bei jedem Typwechsel (Keine/Taste/Makro/…)
und OK/Abbrechen wandern unter dem Mauszeiger weg.
- **Kopieren/Einfügen zwischen Tasten** (Rechtsklickmenü bzw. Strg+C/Strg+V
auf der Karte unter dem Mauszeiger) benutzt eine reine In-Memory-Ablage
(`self._clip`), **nicht** die System-Zwischenablage — dort liegen
Action-Dicts, kein Text. Immer `copy.deepcopy()`, sonst teilen sich zwei
Tasten dasselbe Dict und eine spätere Bearbeitung ändert beide. „Leeren"
behält die Notiz (gleiche Regel wie beim Typwechsel im Dialog, siehe
Notizen-Bullet oben). Eine kopierte LED-Farbe auf einen Encoder
einzufügen ist wirkungslos (Encoder haben keine eigene LED) — das ist
Absicht, kein Fehler.
- Die Toolbar des Programmiermodus ist bei der Standardbreite (790px) fast
voll — zusätzliche Hinweistexte dort kurz halten, sonst werden sie rechts
abgeschnitten.
- **Tastendruck-Erkennung (seit 2026-08-28, auf expliziten User-Wunsch):**
Tasten lassen sich im Bearbeiten-Dialog per „⌨ Taste drücken" erfassen,
Makro-Schritte zusätzlich als Folge am Stück („⏺ Folge aufnehmen").
Umgesetzt **ausschließlich über Tk-Fenster-Events** (`<KeyPress>`/
`<KeyRelease>` auf dem Dialog-Toplevel, ausgewertet in
`versapad_data.tk_event_to_hid()`) — **weiterhin kein WinAPI-Hook**
(`SetWindowsHookEx` o.ä.), aus demselben AV-Fehlalarm-Grund wie bei den
Fensterverstecktricks. Wer hier auf einen globalen Hook „aufrüstet",
baut genau dieses Risiko ein. Konsequenzen, die so bleiben müssen:
- Erkannt wird nur, was das fokussierte Fenster erreicht — Win+L,
Strg+Alt+Entf und andere vom System abgefangene Kombinationen nicht.
- **Welche Taste gemeint ist, wird ueber die PHYSISCHE POSITION
aufgeloest, nie ueber das erzeugte Zeichen** (2026-08-29, nach
Fehlermeldung aus der Praxis). HID-Keycodes *sind* Positionen: das Board
sendet eine Position, erst Windows macht daraus ein Zeichen. Die erste
Fassung ging ueber keysym/Zeichen und war auf deutschem Layout
entsprechend kaputt — Y und Z landeten vertauscht auf dem Board, ÄÖÜ
und #/+ waren gar nicht erfassbar. Reihenfolge jetzt: (1) benannte
Tasten ueber den keysym, (2) Zeichentasten ueber
`versapad_keylayout.hid_for_vk()`, (3) die alte Naeherung nur noch als
Fallback ohne WinAPI. **Nicht auf "Zeichen auswerten" zurueckbauen**
das ist genau der Fehler, der hier behoben wurde.
- Schritt (1) ist keine Bequemlichkeit, sondern noetig: `MapVirtualKeyW`
liefert fuer die Pfeiltasten denselben Scan-Code wie fuer ihre
Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B, das
E0-Praefix von `MAPVK_VK_TO_VSC_EX` bleibt dort aus). Ueber den
Scan-Code allein waeren Pfeiltasten nicht von Numpad-Tasten zu
unterscheiden.
- Das Dropdown bleibt daneben stehen (Korrekturmöglichkeit), es ersetzt
die Erkennung nicht und wird von ihr nicht ersetzt.
- Modifier werden doppelt ermittelt (selbst mitgeführte Press/Release-Bits
*plus* `event.state`), weil ein KeyRelease bei Fokuswechsel verloren
gehen kann.
- `_KeyCapture` hängt an einem `tk.Label`, nicht an einem `tk.Button`:
Buttons reagieren per Klassen-Binding selbst auf Leertaste/Enter und
würden die laufende Aufnahme mit ihrem eigenen Klick beantworten.
- Makro-Schritte filtern das Win-Bit weg (`allow_win=False`), passend zur
Firmware-Regel oben.
- **Tastenbeschriftungen kommen vom aktiven Layout** (seit 2026-08-29).
`versapad_data.hid_key_name()` fragt fuer Zeichentasten
`GetKeyNameTextW` (deutsch: HID 0x1C → „Z", 0x34 → „ä"); fuer alles
andere bleiben die gepflegten deutschen Namen aus `_SPECIAL_KEYS`
(„Enter", „Bild↑", „Num5") — die lesen sich besser als das, was Windows
liefert („EINGABE", „4 (ZEHNERTASTATUR)"). Ohne Layout-Abfrage
(Nicht-Windows) faellt alles auf die alte US-Naeherung zurueck.
Konsequenzen, die man kennen muss:
- Bestehende Belegungen aendern ihre **Anzeige**, nicht ihre Daten. Wer
frueher im Dropdown „Z" gewaehlt hat, bekam 0x1D — das steht so in der
Config und zeigt jetzt wahrheitsgemaess „Y", weil es auf dieser Tastatur
ein Y tippt. Das ist keine Regression, sondern der sichtbar gewordene
Altfehler.
- Namen sind Schluessel (Dropdown, `hid_key_code_for_name()`) und muessen
eindeutig bleiben. Es gibt echte Kollisionen: auf deutschem Layout heisst
HID 0x31 schlicht „#" — den Namen trug bisher HID 0x32. Der Layoutname
gewinnt, der verdraengte US-Name wird als „# (US-Layout)" gekennzeichnet
statt verworfen, damit die Taste ansprechbar bleibt.
- `hid_key_code_for_name()` akzeptiert weiterhin beide Schreibweisen, bei
Kollision gewinnt das Layout. Fuer den MCP-Server heisst das:
`set_button_key(key="Z")` trifft die Taste, die auf dieser Tastatur ein
Z tippt (0x1C), nicht mehr die US-Position 0x1D.
- Ein Layoutwechsel zur Laufzeit wird nicht bemerkt (Namen werden einmal
ermittelt und behalten).
- Tk-Aufrufe (`self.after()`, Widget-Konfiguration) NIE direkt aus einem
Fremdthread (Serial-Thread, pystray-Thread) — hat in einer früheren
Version einen stillen Absturz verursacht. Threads legen Ergebnisse nur in
@ -316,14 +449,10 @@ selbst vorgegeben (`SAction.data`), dort beibehalten statt umzubenennen.
## Deferred Work
- Volle `docs/`-Baumstruktur (siehe Dokumentation und Verifikation unten —
Projektgröße rechtfertigt das aktuell nicht, kein DB-/API-Dienst)
- Board-seitiges Umschalten des aktiven Profils per Button in der GUI —
explizit vom User abgelehnt ("lass uns weg"), Live-Sync bleibt read-only
- Profilnamen aufs Board schreiben — technisch unmöglich (kein Platz im
Firmware-Struct), bleibt lokal
- Tastendruck-Capture statt Dropdown im Programmiermodus — bewusst
vermieden (WinAPI-Hook-Risiko)
- Hintergrund-Thread für "Vom Board laden"/"Zum Board übertragen" — laufen
aktuell synchron im UI-Thread (kurzzeitiges Einfrieren möglich)
- Vorgefertigte `.exe` im Repo/als Release-Asset — bewusst nicht committet
@ -336,18 +465,32 @@ Nicht an diesen Punkten arbeiten, ohne dass der User es explizit anfragt.
- `README.md` ist der Einstiegspunkt (Installation, Nutzung, Architektur-
Überblick).
- `AGENTS.md` (diese Datei) ist die agentenseitige Quelle der Wahrheit für
Domänenregeln und Architekturgrenzen — jede Session aktualisieren, die
daran etwas ändert oder etwas Wichtiges lernt.
- Größeneinschätzung nach Projekt-Dokumentationsstandard: kleines/mittleres
Tool ohne eigene Datenbank und ohne persistenten API-Dienst (der
Browser-Server ist ein einfacher lokaler Lese-Viewer, kein
Mehrbenutzer-Backend) → `README.md` + `AGENTS.md` sind Pflicht und
vorhanden, ein voller `docs/`-Baum ist nicht angemessen.
Domänenregeln, Architekturgrenzen und Bug-Historie — jede Session
aktualisieren, die daran etwas ändert oder etwas Wichtiges lernt.
- `docs/` (seit 2026-08-14, auf expliziten User-Wunsch) enthält die
menschenlesbare Referenzdoku: `architecture.md` (Schichten, Prozess-/
Nebenläufigkeitsmodell, Config-Speicherort), `data-model.md` (JSON-
Formate, binäres NVM-Layout, Geometrie, Enums), `protocol.md`
(Serial-Wire-Protokoll). Bug-Historie/Domänenregeln bleiben bewusst nur
in `AGENTS.md`, nicht dupliziert in `docs/`. Bei Änderungen am
Binärformat/Protokoll/Datenmodell `docs/data-model.md` bzw.
`docs/protocol.md` mitpflegen.
Prüfungen vor einem Commit an Binärformat/Protokoll:
```bash
python -m py_compile *.py
```
Für UI-Änderungen (Dialoge, Tastendruck-Erkennung, Kopieren/Einfügen) hat
sich zusätzlich bewährt, ein Wegwerf-Skript im Scratchpad zu fahren, das die
Dialoge ohne Board aufbaut, Tk-Events als kleine Fake-Event-Objekte
(`keysym`/`keycode`/`state`) durchreicht und das Ergebnis-Dict prüft — die
komplette Capture- und Copy/Paste-Logik ist so ohne Klicken verifizierbar.
Wichtig dabei: `versapad_combined.DEFAULT_PATH` vorher auf eine Temp-Datei
umbiegen, sonst schreibt `_autosave_combined()` in die echte Nutzer-Config.
Für Screenshots gilt: Tk rechnet in logischen Pixeln, `ImageGrab` liefert
physische — bei aktiver Windows-Skalierung (hier 125%) sonst ein zu kleiner
Ausschnitt, der wie ein Layout-Fehler aussieht.
Danach ein Live-Testskript gegen ein angeschlossenes Board laufen lassen
(read → unpack → pack → Bytevergleich, siehe Existing-Codebase-Regel) —
es gibt keine automatisierten Unit-Tests dafür, die Verifikation läuft

View file

@ -30,9 +30,32 @@ falls gewünscht.
und schaltet die Ansicht automatisch mit
- **Programmiermodus** — Zellen anklicken und bearbeiten (Taste, Medientaste,
Makro, Profilwechsel, LED-Farbe/Animation), direkt aufs Board schreiben
oder als Datei speichern
oder als Datei speichern. Der Dialog öffnet über dem Hauptfenster,
Enter bestätigt, Escape bricht ab
- **Tastendruck-Erkennung** — statt die Taste im Dropdown zu suchen,
„⌨ Taste drücken" klicken und die gewünschte Kombination einfach
drücken (Modifier inklusive). Erkannt wird die *physische* Taste, nicht
das Zeichen — Y/Z, ÄÖÜ, `#`, `+` und `ß` landen also richtig auf dem
Board, auch auf deutschem Layout. Läuft über das Dialogfenster, nicht
über einen System-Hook: vom System abgefangene Kombinationen (Win+L,
Strg+Alt+Entf) kommen nicht an
- **Layoutrichtige Tastennamen** — Beschriftungen kommen vom aktiven
Windows-Layout (`Strg+Z` heißt auf deutscher Tastatur auch `Strg+Z`, und
`ä`/`ö`/`ü` heißen so). Bestehende Belegungen ändern dadurch ihre
Anzeige, nicht ihre Funktion
- **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte
Makro-Tabelle vom Board
Makro-Tabelle vom Board. Schritte einzeln erfassen oder die ganze Folge
am Stück aufnehmen („⏺ Folge aufnehmen"). Die Slot-Auswahl listet alle 32
Slots samt Inhalt, statt sie einzeln durchklicken zu müssen
- **Makros im Grid lesbar** — eine Makro-Belegung zeigt die tatsächliche
Tastenfolge (`Makro 3: Strg+C → Strg+V`) statt nur der Slot-Nummer; das
gilt auch in der Browser-Ansicht und in den MCP-Antworten
- **Farb-Schnellwahl** — zwölf Grundfarben direkt in der LED-Zeile des
Dialogs, der System-Farbdialog nur noch für den Rest („mehr…")
- **Kopieren/Einfügen zwischen Tasten** — Rechtsklick auf eine Karte im
Programmiermodus: Belegung und/oder Farbe kopieren und auf andere Tasten
anwenden, oder die Belegung leeren. Strg+C/Strg+V wirken auf die Karte
unter dem Mauszeiger
- **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,
@ -41,11 +64,13 @@ falls gewünscht.
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.
- **Normales Fenster mit Taskleisten-Eintrag** — Titelleiste, Alt+Tab,
Aero-Snap und Größe ändern am Rahmen funktionieren nativ. `✕` beendet
nicht, sondern legt ins Tray (wie die offizielle VersaGUI — das Programm
läuft im Hintergrund weiter); Minimieren geht normal in die Taskleiste.
- **Tastenkürzel im Hauptfenster**`Strg+1/2/3` Profil wechseln, `F2`
Profil umbenennen, `Strg+E` Programmiermodus an/aus, `Strg+C`/`Strg+V`
Taste unter dem Mauszeiger kopieren/einfügen, `Esc` minimieren.
## Voraussetzungen
@ -102,7 +127,8 @@ automatisch zuerst nach `%TEMP%` und baut nur dort.
| Datei | Zweck |
|---|---|
| `versapad_data.py` | Decoding für die Anzeige: JSON laden, HID-Keycodes/Consumer-IDs/Modifier → lesbarer Text |
| `versapad_data.py` | Decoding für die Anzeige: JSON laden, HID-Keycodes/Consumer-IDs/Modifier → lesbarer Text, Tastendruck → HID-Keycode |
| `versapad_keylayout.py` | Abfragen ans aktive Windows-Tastaturlayout: physische Tastenposition und Tastenname (optional, nur Windows) |
| `versapad_protocol.py` | Binäres NVM-Layout des Boards (740B Config + 512B Makros), CRC16 — pack/unpack |
| `versapad_serial.py` | Serial-Client: liest/schreibt Config + Makros per 8-Byte-Paket-Protokoll |
| `versapad_combined.py` | Ein-Datei-Format für alle 3 Profile + Makros + lokale Profilnamen |
@ -141,9 +167,12 @@ OneDrive-Desktop einer bestimmten Windows-Maschine, siehe `CONFIG_PATHS` in
`versapad_mcp_server.py` macht die Config per Tool-Aufruf statt Hand-JSON
programmierbar — nutzbar von jeder MCP-fähigen KI-Anwendung (Claude Code,
Claude Desktop, andere). In der MCP-Server-Liste der jeweiligen Anwendung
eintragen: Kommando `python`/`py`, Argument der Pfad zu
`versapad_mcp_server.py`.
Claude Desktop, andere). Für Claude Code liegt bereits eine projektgebundene
[`.mcp.json`](.mcp.json) im Repo (Server "versapad", Kommando `py
versapad_mcp_server.py`) — beim Öffnen des Projekts wird sie automatisch
zum Verbinden angeboten. Für andere Anwendungen oder user-scope-Registrierung
in der jeweiligen MCP-Server-Liste eintragen: Kommando `python`/`py`,
Argument der Pfad zu `versapad_mcp_server.py`.
Werkzeuge (Auszug): `list_profiles`, `get_profile`, `get_macro`,
`get_board_status` (lesen) · `set_button_key`/`_consumer`/`_macro`/
@ -160,18 +189,33 @@ rechts im Fenster, oder direkt in `versapad_mcp_server.py`.
gleichzeitig mit der offiziellen VersaGUI laufen (die läuft dauerhaft als
Tray-App weiter, auch wenn nur ihr Konfigurationsfenster geschlossen wird
— für Parallelbetrieb muss sie über ihr Tray-Menü beendet werden)
- HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein
Tastendruck-Capture (bewusst, um keinen WinAPI-Hook zu brauchen)
- Zeichentasten-Labels zeigen eine US-Layout-Näherung, nicht das tatsächlich
aktive Tastatur-Layout
- Die Tastendruck-Erkennung läuft bewusst über das Dialogfenster statt über
einen globalen WinAPI-Hook — vom System abgefangene Kombinationen (Win+L,
Strg+Alt+Entf) erreichen das Fenster nie und lassen sich so nicht erfassen
- Ein Wechsel des Tastaturlayouts im laufenden Programm wird nicht bemerkt
(die Tastennamen werden einmal beim ersten Zugriff ermittelt) — Neustart
hilft. Unter Nicht-Windows fällt die Beschriftung auf eine
US-Layout-Näherung zurück
- Unsignierte `.exe` — kann von Antivirus/Smart App Control blockiert
werden; `--onedir` (statt `--onefile`) verringert das Risiko, verhindert
es aber nicht
## Dokumentation
Ausführlichere technische Doku im [`docs/`](docs/)-Ordner:
- [`docs/architecture.md`](docs/architecture.md) — Schichtenmodell,
Prozessmodell, Modi, Config-Speicherort, Nebenläufigkeit
- [`docs/data-model.md`](docs/data-model.md) — JSON-Formate (kombiniert +
Legacy), binäres NVM-Layout, Geometrie, Enums
- [`docs/protocol.md`](docs/protocol.md) — Serial-Wire-Protokoll (Befehle,
Events, Paketformat, CRC16)
## Weiterentwicklung
Tiefere technische Notizen (Protokoll-Details, bekannte Stolpersteine beim
Bauen, Design-Entscheidungen) stehen in [`AGENTS.md`](AGENTS.md).
Agentenseitige Notizen (Domänenregeln, bekannte Bugs und ihre Fixes,
Implementierungsdisziplin, Design-Entscheidungen) stehen in
[`AGENTS.md`](AGENTS.md).
## Lizenz / Herkunft

View file

@ -1,12 +1,24 @@
"""
Modale Bearbeiten-Dialoge fuer den Programmiermodus -- Pendant zu
VersaGUI/src/ActionDialog.cs, aber in Tkinter und ohne Tastendruck-Capture
(kein WinAPI-Hook -- stattdessen Tasten-Auswahl per Dropdown, siehe
versapad_data.hid_key_choices()).
VersaGUI/src/ActionDialog.cs, in Tkinter.
ActionEditDialog: Action-Typ + Daten + optional LED (nur MX-Buttons).
MacroStepsDialog: bis zu 8 Schritte (Taste + Strg/Shift/Alt), passend zur
echten GUI ("8 Step-Buttons + je Strg/Shift/Alt-Checkboxen", kein Win).
Tastendruck-Erkennung (_KeyCapture): Tasten lassen sich statt per Dropdown
auch einfach druecken. Das laeuft ueber ganz normale Tk-Fokus-Events des
Dialogfensters -- KEIN globaler WinAPI-Tastaturhook (siehe Domaenenregel
"keine Selbstversteck-/Hook-Fenstertricks"). Konsequenz: erkannt wird nur,
was das fokussierte Fenster erreicht -- Win+L, Strg+Alt+Entf und aehnliche
vom System abgefangene Kombinationen also nicht.
Welche physische Taste gemeint ist, loest versapad_data.tk_event_to_hid()
ueber den Scan-Code des aktiven Layouts auf (versapad_keylayout), nicht
ueber das erzeugte Zeichen -- sonst landet auf deutschem Layout jedes Y auf
der Z-Taste des Boards und AeOeUe/#/+ sind gar nicht erfassbar. Das Dropdown
daneben zeigt dieselbe Taste unter ihrem Layout-Namen und bleibt als
Korrekturmoeglichkeit stehen.
"""
import tkinter as tk
from tkinter import colorchooser, ttk
@ -18,6 +30,7 @@ BG2 = "#14161b"
TEXT = "#e8e8ec"
TEXT_DIM = "#8a8d98"
ACCENT = "#3a6ff0"
CAPTURE_BG = "#c04a2a"
TYPE_CHOICES = [
("None", "Keine"),
@ -34,8 +47,100 @@ PROFILE_SWITCH_CHOICES = [
("Profil 3", 2),
]
# Schnellzugriff direkt in der LED-Zeile -- der Umweg ueber den
# System-Farbdialog lohnt sich fuer die paar Standardfarben nicht.
# ("aus" ist Schwarz: LED bleibt dunkel, die Firmware kennt kein
# separates Enable-Flag.)
QUICK_COLORS = [
("aus", (0, 0, 0)),
("weiß", (255, 255, 255)),
("rot", (255, 0, 0)),
("orange", (255, 80, 0)),
("gelb", (255, 200, 0)),
("grün", (0, 255, 0)),
("türkis", (0, 255, 160)),
("cyan", (0, 200, 255)),
("blau", (0, 64, 255)),
("violett", (128, 0, 255)),
("magenta", (255, 0, 200)),
("rosa", (255, 128, 128)),
]
MOD_CHECKBOXES = (("Strg", 0x01), ("Shift", 0x02), ("Alt", 0x04), ("Win", 0x08))
MACRO_MOD_CHECKBOXES = MOD_CHECKBOXES[:3] # Firmware kennt in Makros kein Win
class _KeyCapture:
"""Macht ein Label zum "Taste drücken"-Schalter: solange er aktiv ist,
wandert jeder Tastendruck im Dialog nicht ins Widget, sondern durch
versapad_data.tk_event_to_hid() in ein (keycode, modifier)-Paar.
on_result(keycode, modifier) wird im Tk-Main-Thread aufgerufen.
repeat=True laesst die Aufnahme nach einem Treffer weiterlaufen (fuer
"Folge aufnehmen" im Makro-Dialog), sonst endet sie nach dem ersten.
allow_win=False filtert das Win-Bit weg (Makro-Schritte).
"""
def __init__(self, dialog, label, on_result, idle_text, allow_win=True, repeat=False):
self.dialog = dialog
self.label = label
self.on_result = on_result
self.idle_text = idle_text
self.allow_win = allow_win
self.repeat = repeat
self.active = False
self._held = 0
label.configure(text=idle_text, cursor="hand2")
label.bind("<Button-1>", lambda e: self.toggle())
def toggle(self):
self.stop() if self.active else self.start()
def start(self):
self.active = True
self._held = 0
self.dialog.begin_capture(self)
self.label.configure(text="… jetzt drücken (Klick = Stopp)", bg=CAPTURE_BG, fg="#fff")
self.label.focus_set()
def stop(self):
if not self.active:
return
self.active = False
self._held = 0
self.dialog.end_capture(self)
self.label.configure(text=self.idle_text, bg=BG, fg=TEXT)
def handle_key(self, event, pressed):
"""Vom Dialog aufgerufen, solange diese Aufnahme aktiv ist.
Gibt immer "break" zurueck -- waehrend der Aufnahme darf kein
Tastendruck als Text im Notizfeld oder als Dialog-Shortcut landen."""
mod = vp.TK_MODIFIER_KEYSYMS.get(event.keysym)
if mod is not None:
# Modifier selbst sind nie das Ziel, sie sammeln nur Bits --
# ein KeyRelease sieht der Dialog nicht immer (Fokuswechsel),
# das state-Feld im Treffer-Event faengt das ab.
self._held = (self._held | mod) if pressed else (self._held & ~mod)
return "break"
if not pressed:
return "break"
hit = vp.tk_event_to_hid(event.keysym, event.keycode, event.state, self._held)
if hit is None:
return "break" # nicht zuordenbar -> einfach weiter warten
keycode, modifier = hit
if not self.allow_win:
modifier &= ~0x08
if not self.repeat:
self.stop()
self.on_result(keycode, modifier)
return "break"
class _ModalDialog(tk.Toplevel):
"""Basis: oeffnet mittig ueber dem aufrufenden Fenster (nicht in der
Bildschirmecke), Enter = OK, Escape = Abbrechen, und verteilt
Tastendruecke an eine ggf. laufende _KeyCapture."""
def __init__(self, parent, title):
super().__init__(parent)
self.title(title)
@ -43,18 +148,116 @@ class _ModalDialog(tk.Toplevel):
self.transient(parent)
self.resizable(False, False)
self.cancelled = True
self._active_capture = None
# Erst unsichtbar aufbauen, in run() positionieren und dann zeigen --
# sonst blitzt der Dialog kurz in der linken oberen Bildschirmecke auf.
self.withdraw()
self.bind("<KeyPress>", self._on_key_press)
self.bind("<KeyRelease>", self._on_key_release)
self.protocol("WM_DELETE_WINDOW", lambda: self._finish(True))
# ── Tastendruck-Aufnahme ──────────────────────────────────────────
def begin_capture(self, capture):
if self._active_capture is not None and self._active_capture is not capture:
self._active_capture.stop()
self._active_capture = capture
def end_capture(self, capture):
if self._active_capture is capture:
self._active_capture = None
def _on_key_press(self, event):
if self._active_capture is not None:
return self._active_capture.handle_key(event, pressed=True)
if event.keysym in ("Return", "KP_Enter"):
self._on_ok()
return "break"
if event.keysym == "Escape":
self._finish(True)
return "break"
return None
def _on_key_release(self, event):
if self._active_capture is not None:
return self._active_capture.handle_key(event, pressed=False)
return None
def _on_ok(self):
"""Von Enter und vom OK-Knopf -- Unterklassen ueberschreiben das."""
self._finish(False)
def _finish(self, cancelled):
if self._active_capture is not None:
self._active_capture.stop()
self.cancelled = cancelled
self.grab_release()
self.destroy()
# ── Positionierung + Ablauf ───────────────────────────────────────
def _center_on_parent(self):
"""Mittig ueber dem Elternfenster. Ohne das oeffnet Tk jeden Toplevel
bei +0+0, also bei jeder bearbeiteten Taste erneut in der
Bildschirmecke -- weit weg vom Fenster, in dem gerade geklickt wurde.
Begrenzt wird bewusst gegen das ELTERNFENSTER, nicht gegen
`winfo_screenwidth()`: Tk meldet dort nur den Hauptbildschirm. Lag
das Hauptfenster auf einem zweiten Monitor, hat genau diese
Begrenzung den Dialog wieder auf den Rand des ersten Monitors
gezogen. Passt der Dialog nicht ins Elternfenster (der
Makro-Schritte-Dialog ist breiter als der Action-Dialog), wird er an
dessen linker oberer Ecke ausgerichtet statt zentriert -- so bleibt
er in jedem Fall auf dem Monitor, auf dem gearbeitet wird."""
parent = self.master
width, height = self.winfo_reqwidth(), self.winfo_reqheight()
try:
px, py = parent.winfo_rootx(), parent.winfo_rooty()
pw, ph = parent.winfo_width(), parent.winfo_height()
except tk.TclError:
px = py = 0
pw, ph = self.winfo_screenwidth(), self.winfo_screenheight()
x = px + max(0, (pw - width) // 2)
y = py + max(0, (ph - height) // 3) # etwas oberhalb der Mitte wirkt ruhiger
self.geometry(f"+{x}+{y}")
def run(self):
self.update_idletasks()
self._center_on_parent()
self.deiconify()
self.grab_set()
self.focus_force()
self.wait_window(self)
# Ein verschachtelter Dialog (Makro-Schritte aus dem Action-Dialog)
# nimmt beim Schliessen den Grab mit -- ohne Rueckgabe waere der
# aufrufende Dialog danach nicht mehr modal.
parent = self.master
if isinstance(parent, _ModalDialog) and parent.winfo_exists():
parent.grab_set()
parent.focus_force()
return not self.cancelled
# ── gemeinsame kleine Bausteine ───────────────────────────────────
def _capture_label(self, parent, on_result, idle_text="⌨ Taste drücken",
allow_win=True, repeat=False):
"""Bewusst ein Label statt tk.Button: Buttons reagieren per
Klassen-Binding selbst auf Leertaste/Enter und wuerden die laufende
Aufnahme mit ihrem eigenen Klick beantworten."""
label = tk.Label(parent, bg=BG, fg=TEXT, font=("Segoe UI", 9),
padx=10, pady=3, relief="flat")
_KeyCapture(self, label, on_result, idle_text, allow_win=allow_win, repeat=repeat)
return label
def _button_row(self, row, columnspan, ok_text="OK"):
btns = tk.Frame(self, bg=BG2)
btns.grid(row=row, column=0, columnspan=columnspan, pady=14)
tk.Button(btns, text=f"{ok_text} (Enter)", command=self._on_ok, bg=ACCENT, fg="#fff",
activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4)
tk.Button(btns, text="Abbrechen (Esc)", command=lambda: self._finish(True), bg=BG,
fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4)
return btns
class MacroStepsDialog(_ModalDialog):
"""Ergebnis in self.steps nach run()==True."""
@ -68,43 +271,81 @@ class MacroStepsDialog(_ModalDialog):
self._code_by_name = {name: code for code, name in key_choices}
names = ["(leer)"] + [name for _, name in key_choices]
tk.Label(self, text="Bis zu 8 Schritte, Ausführung stoppt beim ersten leeren.",
bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).grid(
row=0, column=0, columnspan=5, sticky="w", padx=12, pady=(12, 6))
head = tk.Frame(self, bg=BG2)
head.grid(row=0, column=0, columnspan=6, sticky="w", padx=12, pady=(12, 6))
tk.Label(head, text="Bis zu 8 Schritte, Ausführung stoppt beim ersten leeren.",
bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w")
tk.Label(head, text="Einzeln erfassen mit ⌨ je Zeile, oder die ganze Folge am Stück "
"aufnehmen. Win ist in Makros nicht möglich (Firmware).",
bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w")
tools = tk.Frame(self, bg=BG2)
tools.grid(row=1, column=0, columnspan=6, sticky="w", padx=12, pady=(0, 6))
self._record_label = tk.Label(tools, bg=BG, fg=TEXT, font=("Segoe UI", 9),
padx=10, pady=3)
self._record = _KeyCapture(self, self._record_label, self._on_record_step,
"⏺ Folge aufnehmen", allow_win=False, repeat=True)
self._record_label.pack(side="left")
tk.Button(tools, text="Alle leeren", command=self._clear_all, bg=BG, fg=TEXT,
activebackground=BG, relief="flat", padx=10).pack(side="left", padx=6)
self._record_hint = tk.Label(tools, text="", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8))
self._record_hint.pack(side="left", padx=6)
self._key_vars = []
self._mod_vars = []
for i in range(8):
r = i + 1
r = i + 2
tk.Label(self, text=f"{i + 1}.", bg=BG2, fg=TEXT_DIM,
font=("Segoe UI", 9)).grid(row=r, column=0, padx=(12, 4), pady=2, sticky="e")
key_var = tk.StringVar(value="(leer)")
combo = ttk.Combobox(self, textvariable=key_var, values=names, width=16, state="readonly")
combo.grid(row=r, column=1, padx=4, pady=2)
ttk.Combobox(self, textvariable=key_var, values=names, width=16,
state="readonly").grid(row=r, column=1, padx=4, pady=2)
self._key_vars.append(key_var)
mods = {}
for j, label in enumerate(("Strg", "Shift", "Alt")):
for j, (label, _bit) in enumerate(MACRO_MOD_CHECKBOXES):
v = tk.BooleanVar(value=False)
tk.Checkbutton(self, text=label, variable=v, bg=BG2, fg=TEXT,
selectcolor=BG, activebackground=BG2, activeforeground=TEXT,
font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2, sticky="w")
font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2,
sticky="w")
mods[label] = v
self._mod_vars.append(mods)
for i, step in enumerate(steps[:8]):
self._key_vars[i].set(self._name_by_code.get(step["keycode"], "(leer)"))
self._mod_vars[i]["Strg"].set(bool(step["modifier"] & 0x01))
self._mod_vars[i]["Shift"].set(bool(step["modifier"] & 0x02))
self._mod_vars[i]["Alt"].set(bool(step["modifier"] & 0x04))
self._capture_label(
self, lambda code, mod, idx=i: self._apply_step(idx, code, mod),
idle_text="", allow_win=False,
).grid(row=r, column=5, padx=(6, 12), pady=2)
btns = tk.Frame(self, bg=BG2)
btns.grid(row=9, column=0, columnspan=5, pady=12)
tk.Button(btns, text="OK", command=self._on_ok, bg=ACCENT, fg="#fff",
activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4)
tk.Button(btns, text="Abbrechen", command=lambda: self._finish(True), bg=BG,
fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4)
for i, step in enumerate(steps[:8]):
self._apply_step(i, step["keycode"], step["modifier"])
self._button_row(row=10, columnspan=6)
def _apply_step(self, index, keycode, modifier):
self._key_vars[index].set(self._name_by_code.get(keycode, "(leer)"))
for label, bit in MACRO_MOD_CHECKBOXES:
self._mod_vars[index][label].set(bool(modifier & bit))
def _clear_all(self):
for i in range(8):
self._apply_step(i, 0, 0)
self._record_hint.configure(text="")
def _on_record_step(self, keycode, modifier):
"""Aufnahmemodus: jeder Tastendruck fuellt den naechsten freien
Schritt. Nach dem 8. stoppt die Aufnahme selbst (mehr Schritte
kennt SMacroTable nicht)."""
free = next((i for i in range(8) if self._key_vars[i].get() == "(leer)"), None)
if free is None:
self._record.stop()
self._record_hint.configure(text="alle 8 Schritte belegt")
return
self._apply_step(free, keycode, modifier)
self._record_hint.configure(text=f"Schritt {free + 1} aufgenommen")
if free == 7:
self._record.stop()
def _on_ok(self):
steps = []
@ -112,11 +353,8 @@ class MacroStepsDialog(_ModalDialog):
name = self._key_vars[i].get()
if name == "(leer)":
break # Firmware: keycode=0 beendet die Sequenz -> Rest ignorieren
modifier = (
(0x01 if self._mod_vars[i]["Strg"].get() else 0) |
(0x02 if self._mod_vars[i]["Shift"].get() else 0) |
(0x04 if self._mod_vars[i]["Alt"].get() else 0)
)
modifier = sum(bit for label, bit in MACRO_MOD_CHECKBOXES
if self._mod_vars[i][label].get())
steps.append({"keycode": self._code_by_name[name], "modifier": modifier})
self.steps = steps
self._finish(False)
@ -140,7 +378,7 @@ class ActionEditDialog(_ModalDialog):
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,
tk.Entry(note_row, textvariable=self._note_var, width=52, 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(
@ -155,9 +393,11 @@ class ActionEditDialog(_ModalDialog):
activebackground=BG2, activeforeground=TEXT,
font=("Segoe UI", 9)).pack(side="left", padx=(0, 8))
self._panel_row = row
self._panel = tk.Frame(self, bg=BG2)
self._panel.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=8)
# Feste Groesse: sonst springt die Fensterhoehe (und damit die
# Position von OK/Abbrechen) bei jedem Typwechsel.
self._panel = tk.Frame(self, bg=BG2, width=600, height=90)
self._panel.grid(row=row, column=0, columnspan=3, sticky="ew", padx=12, pady=8)
self._panel.grid_propagate(False)
row += 1
# HidKey-Panel
@ -174,17 +414,16 @@ class ActionEditDialog(_ModalDialog):
self._consumer_var = tk.StringVar()
# Macro-Panel
self._macro_slot_var = tk.IntVar(value=action["data"] if action["type"] == "Macro" else 0)
self._macro_preview_var = tk.StringVar()
self._macro_slot = action["data"] if action["type"] == "Macro" else 0
self._macro_choice_var = tk.StringVar()
# ProfileSwitch-Panel
self._profile_switch_var = tk.StringVar(value=PROFILE_SWITCH_CHOICES[0][0])
if action["type"] == "HidKey":
keycode = action["data"] & 0xFF
modifier = (action["data"] >> 8) & 0xFF
self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)"))
self._hidkey_mods_init = modifier
self._hidkey_mods_init = (action["data"] >> 8) & 0xFF
else:
self._hidkey_key_var.set("(leer)")
self._hidkey_mods_init = 0
@ -206,45 +445,67 @@ class ActionEditDialog(_ModalDialog):
self._led_color = (self._led["r"], self._led["g"], self._led["b"]) if self._led else (80, 40, 0)
if self._led is not None:
row = self._build_led_panel(row)
self._button_row(row=row, columnspan=3)
self._on_type_change()
# ── LED ───────────────────────────────────────────────────────────
def _build_led_panel(self, row):
led_frame = tk.Frame(self, bg=BG2)
led_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(4, 8)); row += 1
tk.Label(led_frame, text="LED", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).pack(anchor="w")
led_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(4, 8))
tk.Label(led_frame, text="LED", bg=BG2, fg=TEXT_DIM,
font=("Segoe UI", 9, "bold")).pack(anchor="w")
color_row = tk.Frame(led_frame, bg=BG2)
color_row.pack(anchor="w", pady=4)
self._swatch = tk.Label(color_row, text=" ", bg=self._hex(), relief="flat", width=4)
self._swatch.pack(side="left")
tk.Button(color_row, text="Farbe wählen...", command=self._pick_color, bg=BG,
fg=TEXT, activebackground=BG, relief="flat").pack(side="left", padx=8)
self._hex_label = tk.Label(color_row, text=self._hex(), bg=BG2, fg=TEXT_DIM,
font=("Consolas", 9), width=9)
self._hex_label.pack(side="left", padx=(6, 6))
for name, rgb in QUICK_COLORS:
chip = tk.Label(color_row, bg="#%02x%02x%02x" % rgb, width=2, height=1,
cursor="hand2", relief="flat",
highlightbackground="#3a3d47", highlightthickness=1)
chip.pack(side="left", padx=1)
chip.bind("<Button-1>", lambda e, c=rgb: self._set_color(c))
# Tk kennt keine Tooltips -- der Farbname landet in der Zeile darunter.
chip.bind("<Enter>", lambda e, n=name: self._color_hint.configure(text=n))
chip.bind("<Leave>", lambda e: self._color_hint.configure(text=""))
tk.Button(color_row, text="mehr…", command=self._pick_color, bg=BG, fg=TEXT,
activebackground=BG, relief="flat", padx=8).pack(side="left", padx=(8, 0))
self._color_hint = tk.Label(led_frame, text="", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8))
self._color_hint.pack(anchor="w")
anim_row = tk.Frame(led_frame, bg=BG2)
anim_row.pack(anchor="w", pady=4)
tk.Label(anim_row, text="Animation:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
tk.Label(anim_row, text="Animation:", bg=BG2, fg=TEXT_DIM,
font=("Segoe UI", 9)).pack(side="left")
ttk.Combobox(anim_row, textvariable=self._anim_var, values=list(vp.ANIM_LABELS.keys()),
width=12, state="readonly").pack(side="left", padx=6)
tk.Label(anim_row, text="Periode (ms):", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left", padx=(12, 0))
tk.Label(anim_row, text="Periode (ms):", bg=BG2, fg=TEXT_DIM,
font=("Segoe UI", 9)).pack(side="left", padx=(12, 0))
tk.Spinbox(anim_row, from_=2, to=10000, increment=100, textvariable=self._period_var,
width=7).pack(side="left", padx=6)
btns = tk.Frame(self, bg=BG2)
btns.grid(row=row, column=0, columnspan=3, pady=14)
tk.Button(btns, text="OK", command=self._on_ok, bg=ACCENT, fg="#fff",
activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4)
tk.Button(btns, text="Abbrechen", command=lambda: self._finish(True), bg=BG,
fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4)
self._on_type_change()
return row + 1
def _hex(self):
r, g, b = self._led_color
return f"#{r:02x}{g:02x}{b:02x}"
def _pick_color(self):
result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe")
if result and result[0]:
r, g, b = (int(c) for c in result[0])
self._led_color = (r, g, b)
def _set_color(self, rgb):
self._led_color = tuple(rgb)
self._swatch.configure(bg=self._hex())
self._hex_label.configure(text=self._hex())
def _pick_color(self):
result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe", parent=self)
if result and result[0]:
self._set_color(tuple(int(c) for c in result[0]))
# ── Action-Panels ─────────────────────────────────────────────────
def _on_type_change(self):
for w in self._panel.winfo_children():
@ -252,39 +513,16 @@ class ActionEditDialog(_ModalDialog):
t = self._type_var.get()
if t == "HidKey":
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
self._hidkey_mods = {}
for label, bit in (("Strg", 0x01), ("Shift", 0x02), ("Alt", 0x04), ("Win", 0x08)):
v = tk.BooleanVar(value=bool(self._hidkey_mods_init & bit))
tk.Checkbutton(row1, text=label, variable=v, bg=BG2, fg=TEXT, selectcolor=BG,
activebackground=BG2, activeforeground=TEXT,
font=("Segoe UI", 9)).pack(side="left", padx=(0, 6))
self._hidkey_mods[bit] = v
row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=4)
tk.Label(row2, text="Taste:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
names = ["(leer)"] + [n for _, n in vp.hid_key_choices()]
ttk.Combobox(row2, textvariable=self._hidkey_key_var, values=names,
width=18, state="readonly").pack(side="left", padx=6)
self._build_hidkey_panel()
elif t == "HidConsumer":
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
tk.Label(row1, text="Medienaktion:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
names = [n for _, n in vp.consumer_choices()]
ttk.Combobox(row1, textvariable=self._consumer_var, values=names,
tk.Label(row1, text="Medienaktion:", bg=BG2, fg=TEXT_DIM,
font=("Segoe UI", 9)).pack(side="left")
ttk.Combobox(row1, textvariable=self._consumer_var,
values=[n for _, n in vp.consumer_choices()],
width=20, state="readonly").pack(side="left", padx=6)
elif t == "Macro":
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
tk.Label(row1, text="Slot (0-31):", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
tk.Spinbox(row1, from_=0, to=31, textvariable=self._macro_slot_var, width=5,
command=self._refresh_macro_preview).pack(side="left", padx=6)
tk.Button(row1, text="Schritte bearbeiten...", command=self._edit_macro_steps,
bg=BG, fg=TEXT, activebackground=BG, relief="flat").pack(side="left", padx=8)
row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=(4, 0))
tk.Label(row2, textvariable=self._macro_preview_var, bg=BG2, fg=TEXT_DIM,
font=("Segoe UI", 8), wraplength=340, justify="left").pack(anchor="w")
self._refresh_macro_preview()
self._build_macro_panel()
elif t == "ProfileSwitch":
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
tk.Label(row1, text="Ziel:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
@ -294,38 +532,86 @@ class ActionEditDialog(_ModalDialog):
self.update_idletasks()
def _refresh_macro_preview(self):
slot = self._macro_slot_var.get()
steps = self._macros[slot] if 0 <= slot < len(self._macros) else []
self._macro_preview_var.set(f"Slot {slot}: {vp.macro_slot_label(steps)}")
def _build_hidkey_panel(self):
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
self._hidkey_mods = {}
for label, bit in MOD_CHECKBOXES:
v = tk.BooleanVar(value=bool(self._hidkey_mods_init & bit))
tk.Checkbutton(row1, text=label, variable=v, bg=BG2, fg=TEXT, selectcolor=BG,
activebackground=BG2, activeforeground=TEXT,
font=("Segoe UI", 9)).pack(side="left", padx=(0, 6))
self._hidkey_mods[bit] = v
row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=4)
tk.Label(row2, text="Taste:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
names = ["(leer)"] + [n for _, n in vp.hid_key_choices()]
ttk.Combobox(row2, textvariable=self._hidkey_key_var, values=names,
width=18, state="readonly").pack(side="left", padx=6)
self._capture_label(row2, self._apply_captured_key).pack(side="left", padx=(8, 0))
tk.Label(self._panel, text="Erkennung läuft über das Dialogfenster, nicht über einen "
"System-Hook: Win+L o.ä. fängt Windows selbst ab.",
bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w")
def _apply_captured_key(self, keycode, modifier):
self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)"))
self._hidkey_mods_init = modifier
for bit, var in self._hidkey_mods.items():
var.set(bool(modifier & bit))
def _build_macro_panel(self):
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
tk.Label(row1, text="Slot:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
# Dropdown listet alle 32 Slots MIT Inhalt -- vorher musste man sich
# per Spinbox durch die Tabelle klicken, um ein Makro wiederzufinden.
self._macro_combo = ttk.Combobox(row1, textvariable=self._macro_choice_var,
values=vp.macro_slot_choices(self._macros),
width=52, state="readonly")
self._macro_combo.pack(side="left", padx=6)
self._macro_combo.bind("<<ComboboxSelected>>", self._on_macro_slot_selected)
tk.Button(row1, text="Schritte bearbeiten…", command=self._edit_macro_steps,
bg=BG, fg=TEXT, activebackground=BG, relief="flat",
padx=8).pack(side="left", padx=8)
tk.Label(self._panel, text="Slots sind global (nicht pro Profil) dasselbe Makro auf "
"zwei Tasten meint denselben Slot.",
bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w", pady=(6, 0))
self._refresh_macro_choices()
def _on_macro_slot_selected(self, _event=None):
self._macro_slot = vp.macro_slot_from_choice(self._macro_choice_var.get())
def _refresh_macro_choices(self):
choices = vp.macro_slot_choices(self._macros)
self._macro_combo.configure(values=choices)
self._macro_choice_var.set(choices[self._macro_slot])
def _edit_macro_steps(self):
slot = self._macro_slot_var.get()
slot = self._macro_slot
current = self._macros[slot] if 0 <= slot < len(self._macros) else []
dlg = MacroStepsDialog(self, current)
if dlg.run():
while len(self._macros) <= slot:
self._macros.append([])
self._macros[slot] = dlg.steps
self._refresh_macro_preview()
self._refresh_macro_choices()
# ── Ergebnis ──────────────────────────────────────────────────────
def _on_ok(self):
t = self._type_var.get()
if t == "None":
action = {"type": "None", "data": 0}
elif t == "HidKey":
name = self._hidkey_key_var.get()
keycode = self._key_code_by_name.get(name, 0)
if t == "HidKey":
keycode = self._key_code_by_name.get(self._hidkey_key_var.get(), 0)
modifier = sum(bit for bit, v in self._hidkey_mods.items() if v.get())
action = {"type": "HidKey", "data": (modifier << 8) | keycode}
elif t == "HidConsumer":
action = {"type": "HidConsumer", "data": self._cons_id_by_name[self._consumer_var.get()]}
elif t == "Macro":
action = {"type": "Macro", "data": self._macro_slot_var.get()}
action = {"type": "Macro", "data": self._macro_slot}
elif t == "ProfileSwitch":
name = self._profile_switch_var.get()
data = next(v for n, v in PROFILE_SWITCH_CHOICES if n == name)
action = {"type": "ProfileSwitch", "data": data}
action = {"type": "ProfileSwitch",
"data": next(v for n, v in PROFILE_SWITCH_CHOICES if n == name)}
else:
action = {"type": "None", "data": 0}

View file

@ -19,6 +19,7 @@ automatisch ausgeschaltet, damit sich beide nicht um den Port streiten).
Start: python desktop_viewer.py
"""
import copy
import os
import queue
import sys
@ -55,8 +56,9 @@ WARN_RED = "#e0895a"
POLL_MS = 1500
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.
# Untergrenze fuers Verkleinern (self.minsize) -- 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
@ -129,8 +131,8 @@ class VersaPadViewer(tk.Tk):
self.configure(bg=BG)
# 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.
# reichten die alten 760px nicht mehr: der Encoder-Bereich lag
# unterhalb des Fensterrands. Wer CARD_H aendert, muss hier mit.
self.geometry("790x960")
self.profile = 0
self._mtimes = {}
@ -138,6 +140,12 @@ class VersaPadViewer(tk.Tk):
self._link = vs.VersaPadLink()
self._closing = False
# Zwischenablage fuer "Belegung/Farbe auf andere Taste uebertragen"
# (Rechtsklickmenue bzw. Strg+C/V auf der Karte unter dem Mauszeiger).
# Bewusst NUR im Speicher, nicht die System-Zwischenablage: hier
# liegen Action-Dicts, kein Text.
self._clip = None
self._hover = None
self.live_sync = tk.BooleanVar(value=False)
self.editing = tk.BooleanVar(value=False)
self._serial_results = queue.Queue()
@ -158,32 +166,23 @@ class VersaPadViewer(tk.Tk):
)
self._tray_icon.run_detached()
# 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)
# Normales Fenster MIT Windows-Titelleiste. Bis 2026-08-28 lief es
# randlos (`overrideredirect(True)`) -- das kostet unter Windows aber
# zwingend den Taskleisten-Eintrag, und der wurde ausdruecklich
# gebraucht. Nachruesten liesse er sich nur per `SetWindowLongW`
# (WS_EX_APPWINDOW) -- genau der Aufruf, der laut Speicher-Notiz
# "keine Selbstversteck-Fenstertricks" schon AV-Fehlalarme
# ausgeloest hat und deshalb nicht in Frage kommt. Mit der echten
# Titelleiste kommen Taskleiste, Alt+Tab, Aero-Snap, Ziehen und
# Groessenaendern am Rahmen nativ zurueck; die Eigenbau-Loesungen
# dafuer (ziehbare Kopfzeile, Anfasser unten rechts, eigene
# ✕/—-Knoepfe) sind damit ersatzlos entfallen.
self.minsize(MIN_W, MIN_H)
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))
tk.Label(header, text="VersaPad", bg=BG, fg=TEXT,
font=("Segoe UI", 11, "bold")).pack(side="left", anchor="w", padx=(0, 16))
info_btn = tk.Label(header, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 12),
cursor="hand2", padx=6)
@ -230,6 +229,10 @@ class VersaPadViewer(tk.Tk):
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))
# Kurz halten: die Toolbar ist bei der Standardbreite (790px) schon
# fast voll, laengerer Text wird rechts abgeschnitten.
tk.Label(self.prog_row, text="Rechtsklick = kopieren/einfügen",
bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="right")
# prog_row wird erst bei aktivem Programmiermodus gepackt (siehe _on_toggle_editing)
# Profil-Tabs direkt ueber der Steuermatrix, nicht mehr oben am
@ -253,29 +256,19 @@ class VersaPadViewer(tk.Tk):
self.enc_frame = tk.Frame(self, bg=BG)
self.enc_frame.pack(fill="x", padx=20)
# 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))
# Schliessen legt weiterhin ins Tray statt zu beenden (wie die
# offizielle VersaGUI -- das Programm soll im Hintergrund
# weiterlaufen). Minimieren geht jetzt aber ganz normal in die
# Taskleiste; frueher hat ein <Unmap>-Handler auch das ins Tray
# umgeleitet, was ohne Taskleisten-Eintrag sinnvoll war und mit
# einem das Fenster nur unauffindbar machen wuerde.
self.protocol("WM_DELETE_WINDOW", self._hide_to_tray)
self.bind("<Unmap>", self._on_unmap)
self._bind_shortcuts()
self._serial_thread.start()
self.set_profile(0)
@ -376,25 +369,6 @@ 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):
@ -440,13 +414,7 @@ class VersaPadViewer(tk.Tk):
text = SERIAL_STATUS_TEXT.get(error, error or "Fehler")
self.sync_status.configure(text=text, fg=WARN_RED)
# ── Tray-Icon (kein Taskleisten-Eintrag beim Minimieren, wie VersaGUI) ──
def _on_unmap(self, event):
"""Minimieren faengt Windows normalerweise als Taskleisten-Icon ab --
wir wollen stattdessen: Fenster komplett weg, nur noch Tray-Icon."""
if event.widget is self and self.state() == "iconic":
self._hide_to_tray()
# ── Tray-Icon (Schliessen beendet nicht, wie bei der VersaGUI) ──
def _hide_to_tray(self):
self.withdraw()
@ -617,6 +585,183 @@ class VersaPadViewer(tk.Tk):
except OSError as e:
self._status(f"Auto-Speichern fehlgeschlagen: {e}", False)
# ── Kopieren/Einfuegen + Tastenkuerzel ─────────────────────────
def _bind_shortcuts(self):
"""Fensterweite Kuerzel. Waehrend ein Bearbeiten-Dialog offen ist,
haelt dessen grab_set() die Tastatur -- die Kuerzel hier koennen ihm
also nicht dazwischenfunken."""
for seq, handler in (
# Minimieren statt ins Tray: seit das Fenster eine Titelleiste
# und damit einen Taskleisten-Eintrag hat, waere "verschwindet
# spurlos" die unangenehmere Ueberraschung. Ins Tray legt
# weiterhin das ✕ der Titelleiste.
("<Escape>", lambda e: self.iconify()),
("<F2>", lambda e: self._rename_tab(self.profile)),
("<Control-e>", lambda e: self._toggle_editing_shortcut()),
("<Control-E>", lambda e: self._toggle_editing_shortcut()),
("<Control-c>", lambda e: self._copy_hovered()),
("<Control-C>", lambda e: self._copy_hovered()),
("<Control-v>", lambda e: self._paste_hovered()),
("<Control-V>", lambda e: self._paste_hovered()),
):
self.bind(seq, handler)
for p in range(vp.NUM_PROFILES):
self.bind(f"<Control-Key-{p + 1}>",
lambda e, prof=p: self.set_profile(prof, manual=True))
def _toggle_editing_shortcut(self):
self.editing.set(not self.editing.get())
self._on_toggle_editing()
def _hint(self, text):
"""Kurze Rueckmeldung in der Fusszeile -- die Programmiermodus-
Statuszeile haengt an der Toolbar und ist sonst leicht zu uebersehen.
Der naechste _render() setzt die Quellenangabe wieder ein."""
self.footer.configure(text=text)
def _target_entry(self, target):
"""Liefert den Eintrag im combined-State, auf den ein Ziel zeigt --
Button-Karte oder Encoder (dort waehlt target["field"] danach noch
sw/cw/ccw aus)."""
profile = self.combined["profiles"][self.profile]
items = profile["buttons"] if target["kind"] == "button" else profile["encoders"]
return next(item for item in items if item["index"] == target["index"])
def _set_hover(self, target):
self._hover = target
def _clear_hover(self, target):
"""<Leave> feuert auch beim Wechsel auf ein Kindwidget derselben
Karte -- deshalb erst pruefen, ob der Zeiger die Karte wirklich
verlassen hat."""
card = target["card"]
if not card.winfo_exists():
self._hover = None
return
x, y = card.winfo_pointerxy()
inside = (card.winfo_rootx() <= x < card.winfo_rootx() + card.winfo_width()
and card.winfo_rooty() <= y < card.winfo_rooty() + card.winfo_height())
if not inside and self._hover is target:
self._hover = None
def _copy_hovered(self):
if self._hover is not None:
self._copy_target(self._hover, "all")
def _paste_hovered(self):
if self._hover is not None:
self._paste_target(self._hover, "all")
def _copy_target(self, target, what):
if not self.editing.get() or self.combined is None:
return
entry = self._target_entry(target)
if target["kind"] == "button":
action, led = entry["action"], entry["led"]
else:
action, led = entry[target["field"]], None
self._clip = {
"action": copy.deepcopy(action) if what in ("all", "action") else None,
"led": copy.deepcopy(led) if what in ("all", "led") else None,
}
parts = [name for name, key in (("Belegung", "action"), ("Farbe", "led"))
if self._clip[key] is not None]
self._hint("kopiert: {} von {}".format(" + ".join(parts) or "nichts", target["label"]))
def _paste_target(self, target, what):
if not self.editing.get() or self.combined is None or not self._clip:
return
entry = self._target_entry(target)
applied = []
if what in ("all", "action") and self._clip["action"] is not None:
action = copy.deepcopy(self._clip["action"])
if target["kind"] == "button":
entry["action"] = action
else:
entry[target["field"]] = action
applied.append("Belegung")
if what in ("all", "led") and self._clip["led"] is not None and target["kind"] == "button":
entry["led"] = copy.deepcopy(self._clip["led"])
applied.append("Farbe")
if not applied:
self._hint("nichts eingefügt -- Zwischenablage passt nicht zu diesem Ziel")
return
self._autosave_combined()
self._render()
self._hint("eingefügt: {}{}".format(" + ".join(applied), target["label"]))
def _clear_target(self, target):
if not self.editing.get() or self.combined is None:
return
entry = self._target_entry(target)
# Notiz bleibt bewusst erhalten (gleiche Regel wie beim Typwechsel im
# Dialog): sie beschreibt die Taste, nicht die konkrete Aktion.
if target["kind"] == "button":
entry["action"] = {"type": "None", "data": 0,
"note": entry["action"].get("note", "")}
else:
old = entry[target["field"]]
entry[target["field"]] = {"type": "None", "data": 0, "note": old.get("note", "")}
self._autosave_combined()
self._render()
self._hint("geleert: {}".format(target["label"]))
def _show_context_menu(self, event, target):
menu = tk.Menu(self, tearoff=0, bg=CARD_BG, fg=TEXT, activebackground=ACCENT,
activeforeground="#ffffff", bd=0, font=("Segoe UI", 9))
is_button = target["kind"] == "button"
has_action = bool(self._clip and self._clip.get("action"))
has_led = bool(self._clip and self._clip.get("led"))
menu.add_command(label="Bearbeiten…", command=lambda: self._edit_target(target))
menu.add_separator()
if is_button:
menu.add_command(label="Kopieren: Belegung + Farbe (Strg+C)",
command=lambda: self._copy_target(target, "all"))
menu.add_command(label="Kopieren: nur Belegung",
command=lambda: self._copy_target(target, "action"))
menu.add_command(label="Kopieren: nur Farbe",
command=lambda: self._copy_target(target, "led"))
else:
menu.add_command(label="Kopieren: Belegung (Strg+C)",
command=lambda: self._copy_target(target, "action"))
menu.add_separator()
paste_all = has_action or (has_led and is_button)
menu.add_command(label="Einfügen: alles (Strg+V)",
state="normal" if paste_all else "disabled",
command=lambda: self._paste_target(target, "all"))
menu.add_command(label="Einfügen: nur Belegung",
state="normal" if has_action else "disabled",
command=lambda: self._paste_target(target, "action"))
if is_button:
menu.add_command(label="Einfügen: nur Farbe",
state="normal" if has_led else "disabled",
command=lambda: self._paste_target(target, "led"))
menu.add_separator()
menu.add_command(label="Leeren (Belegung entfernen)",
command=lambda: self._clear_target(target))
try:
menu.tk_popup(event.x_root, event.y_root)
finally:
menu.grab_release()
def _edit_target(self, target):
if target["kind"] == "button":
self._edit_button(target["index"])
else:
self._edit_encoder_action(target["index"], target["field"], target["field_label"])
def _bind_cell_interaction(self, card, widgets, target):
"""Linksklick = bearbeiten, Rechtsklick = Kontextmenue; der
Mauszeiger merkt sich das Ziel fuer Strg+C/Strg+V."""
card.configure(cursor="hand2")
for widget in widgets:
widget.bind("<Button-1>", lambda e: self._edit_target(target))
widget.bind("<Button-3>", lambda e: self._show_context_menu(e, target))
widget.bind("<Enter>", lambda e: self._set_hover(target))
card.bind("<Leave>", lambda e: self._clear_hover(target))
# ── Rendering ────────────────────────────────────────────────────
def _current_profile_view(self):
@ -641,7 +786,7 @@ class VersaPadViewer(tk.Tk):
cfg = vp.annotate_profile({
"buttons": [dict(b) for b in raw["buttons"]],
"encoders": [dict(e) for e in raw["encoders"]],
})
}, data.get("macros"))
return cfg, f"Quelle: {vcomb.DEFAULT_PATH}"
except (KeyError, IndexError, ValueError):
pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen
@ -667,7 +812,7 @@ class VersaPadViewer(tk.Tk):
cfg = vp.annotate_profile({
"buttons": [dict(b) for b in raw["buttons"]],
"encoders": [dict(e) for e in raw["encoders"]],
})
}, self.combined.get("macros"))
source_text = "Programmiermodus -- nicht gespeichert, bis übertragen/exportiert"
else:
try:
@ -723,11 +868,9 @@ class VersaPadViewer(tk.Tk):
x=10, y=CARD_H - 16, width=CARD_W - 20, height=13)
if editable:
card.configure(cursor="hand2")
handler = lambda e, idx=btn["index"]: self._edit_button(idx)
card.bind("<Button-1>", handler)
for child in card.winfo_children():
child.bind("<Button-1>", handler)
target = {"kind": "button", "index": btn["index"], "card": card,
"label": "Button #{}".format(btn["index"])}
self._bind_cell_interaction(card, [card] + list(card.winfo_children()), target)
def _render_encoder(self, enc, editable=False):
card = tk.Frame(self.enc_frame, bg=CARD_BG, highlightbackground=CARD_BORDER,
@ -755,14 +898,13 @@ class VersaPadViewer(tk.Tk):
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)
top.bind("<Button-1>", handler)
target = {"kind": "encoder", "index": enc["index"], "field": field,
"field_label": key, "card": row,
"label": "Encoder {} {}".format(enc["index"], key)}
widgets = [row, top] + list(top.winfo_children())
if note_label is not None:
note_label.bind("<Button-1>", handler)
for child in top.winfo_children():
child.bind("<Button-1>", handler)
widgets.append(note_label)
self._bind_cell_interaction(row, widgets, target)
if __name__ == "__main__":

267
docs/architecture.md Normal file
View file

@ -0,0 +1,267 @@
# Architektur
Überblick über die Schichten, Prozesse und Datenflüsse von VersaPad Viewer.
Für Domänenregeln, bekannte Bugs und Implementierungsdisziplin siehe
[`AGENTS.md`](../AGENTS.md); für Installation/Nutzung siehe
[`README.md`](../README.md). Für die genauen Datenformate siehe
[`data-model.md`](data-model.md), für das Serial-Wire-Protokoll
[`protocol.md`](protocol.md).
## Ziel und Kontext
VersaPad Viewer ist ein eigenständiges Python-Tool für das VersaPad-Makropad
(4×5-Button-Grid + 4 Encoder, SAMD21-Firmware). Es ergänzt die offizielle
VersaGUI (C#/.NET) um eine schlankere, plattformunabhängigere Alternative
zum Anzeigen, Live-Synchronisieren und Neuprogrammieren der Belegung, plus
einen MCP-Server, der dieselbe Programmierung KI-gesteuert per Tool-Aufruf
erlaubt. Beide GUIs (die offizielle VersaGUI und dieses Tool) konkurrieren
um denselben exklusiven USB-CDC-Port — siehe „Nebenläufigkeit" unten.
## Schichtenmodell
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontends │
│ ┌────────────┐ ┌──────────────────┐ ┌────────────────────┐ │
│ │ server.py │ │ desktop_viewer.py │ │ versapad_mcp_ │ │
│ │ (Browser, │ │ (Tkinter, Tray, │ │ server.py │ │
│ │ read-only)│ │ Live-Sync, Edit) │ │ (KI-Tool-Aufrufe) │ │
│ └─────┬──────┘ └────────┬──────────┘ └─────────┬──────────┘ │
│ │ │ │ │
│ └───────────┬───────┴──────────────┬───────────┘ │
│ ▼ ▼ │
│ versapad_combined.py versapad_data.py │
│ (Ein-Datei-Format, (Decoding fürs Anzeigen, │
│ Board-Sync, Auto- Legacy-Einzel-JSONs) │
│ Create) │
│ │ │
│ ▼ │
│ versapad_protocol.py (pack/unpack, CRC16) │
│ │ │
│ ▼ │
│ versapad_serial.py (VersaPadLink, 8-Byte-Pakete) │
│ │ │
└─────────────────────┼──────────────────────────────────────────────┘
VersaPad-Board (USB-CDC, VID:PID 239A:0042)
```
`action_dialog.py` ist ein UI-Hilfsmodul von `desktop_viewer.py`
(Bearbeiten-Dialoge für den Programmiermodus) und taucht oben nicht separat
auf.
### Read-only-Schicht
- **`versapad_data.py`** — decodiert JSON-Rohdaten (Keycodes, Consumer-IDs,
Modifier-Bits) zu lesbarem Text, kennt die Grid-Geometrie
(`index = spalte*5 + reihe`). Liest wahlweise:
- die klassischen `versapad_config1/2/3.json` (`CONFIG_PATHS`) — Export
der offiziellen VersaGUI, kein von diesem Tool geschriebenes Format;
- oder (über `versapad_combined.py`) die kombinierte Datei.
- `app_dir()` liefert das Basisverzeichnis für die eigene Config, siehe
„Config-Speicherort" unten.
- **`server.py`** — generiert bei jedem HTTP-Request frisch HTML aus dem
aktuellen Zustand (`vcomb.load_or_fetch()`), Auto-Reload alle 4s per
`<meta http-equiv="refresh">`. Rein lesend, kein eigener Zustand zwischen
Requests, daher nie „veraltet" im Sinne von In-Memory-Staleness.
### Binär-/Serial-Schicht
- **`versapad_protocol.py`** — pack/unpack für `SDeviceConfig` (740B, alle
3 Profile) und `SMacroTable` (512B, 32 Slots), plus CRC16. 1:1 aus den
Firmware-Structs übernommen, siehe [`data-model.md`](data-model.md).
- **`versapad_serial.py`** — `VersaPadLink`: öffnet bei Bedarf den COM-Port
(per VID/PID-Erkennung), spricht das 8-Byte-Paket-Protokoll, siehe
[`protocol.md`](protocol.md). **Schließt die Verbindung nicht von selbst**
nach einem Befehl — Aufrufer müssen das selbst tun, wenn sie den Port
nicht dauerhaft blockieren wollen (siehe „Nebenläufigkeit" unten).
- **`versapad_combined.py`** — das Ein-Datei-Format: alle 3 Profile +
Makro-Tabelle + lokale Profilnamen in einer JSON
(`versapad_config_all.json`). Bindeglied zwischen den JSON-Strukturen und
den Binärblobs aus `versapad_protocol.py`. Zentrale Funktionen:
- `load_or_fetch()` — bevorzugt die lokale Datei, baut sie bei Bedarf
automatisch neu auf (erst Board-Versuch, sonst leere Default-Config).
- `save_file()`/`load_file()` — reines Lesen/Schreiben der JSON.
- `fetch_from_board()`/`to_binary()` — Konvertierung zu/von den
Binärblobs für Board-Lese-/Schreibvorgänge.
- `read_profile_names()` — liest nur die Profilnamen, ohne Board-Zugriff
(für Tab-Beschriftungen im Nur-Lese-Modus).
### UI
- **`desktop_viewer.py`** — Tkinter-Fenster mit drei unabhängig
umschaltbaren Modi (siehe „Modi" unten), Tray-Icon, Info-Dialog mit
MCP-Doku.
- **`action_dialog.py`** — modale Bearbeiten-Dialoge (`ActionEditDialog`,
`MacroStepsDialog`) für den Programmiermodus, auf gemeinsamer Basis
`_ModalDialog`:
- positioniert sich beim Öffnen mittig über dem aufrufenden Fenster
(Aufbau `withdraw()`n, `_center_on_parent()`, dann `deiconify()`);
- bindet `<KeyPress>`/`<KeyRelease>` auf dem Toplevel und verteilt sie:
entweder an eine laufende Tastendruck-Aufnahme, sonst als
Enter = OK / Escape = Abbrechen;
- `_KeyCapture` schaltet ein Label in den Aufnahmemodus und schickt jeden
Tastendruck durch `versapad_data.tk_event_to_hid()` — reine
Tk-Fenster-Events, **kein globaler Tastaturhook** (siehe „Grenzen der
Tastendruck-Erkennung" unten).
### MCP-Server
- **`versapad_mcp_server.py`** — registriert als projektgebundener
MCP-Server "versapad" (`.mcp.json`) oder wahlweise user-scope
(`claude mcp add -s user versapad -- <python> versapad_mcp_server.py`).
Hält einen eigenen In-Memory-Zustand (`_state["combined"]`,
unabhängig von jeder laufenden GUI), der explizit per `save_local()` /
`load_local()` mit der Datei bzw. `load_from_board()` / `write_to_board()`
mit dem Board synchronisiert wird. Board-Serial-Tools schließen die
Verbindung nach jedem Aufruf wieder (siehe unten).
## Config-Speicherort
`versapad_data.app_dir()` bestimmt das Basisverzeichnis für die eigene
Config-Datei (`versapad_combined.DEFAULT_PATH` =
`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).
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
Datei nur ein Lesecache dafür), sonst als leere Default-Config
(`default_combined()`). Das Tool ist damit auch ganz ohne vorhandene
Config oder angeschlossenes Board sofort benutzbar.
Die klassischen `versapad_config1/2/3.json` (`versapad_data.CONFIG_PATHS`)
bleiben bewusst getrennt hartkodiert auf `~\OneDrive\Desktop` — das ist
optionale Lese-Interop mit einem JSON-Export der offiziellen VersaGUI,
kein von diesem Tool selbst gepflegtes Format, und daher nicht Teil der
„portablen Installation".
## Modi in `desktop_viewer.py`
Drei Checkboxen, unabhängig voneinander:
| Modus | Zweck | Zustand |
|---|---|---|
| Nur-Lesen (Default) | Zeigt das aktuelle Profil an | Liest bei jedem Poll (alle 1,5s) frisch über `_current_profile_view()``vcomb.load_or_fetch()`. Kein eigener In-Memory-Snapshot, daher nie veraltet. |
| Live-Sync | Fragt per Serial das aktuell aktive Profil ab, schaltet die Ansicht mit | Hintergrund-Thread pollt `read_active_profile()`, hält dafür den COM-Port dauerhaft offen, solange die Checkbox an ist. Schließt sich mit VersaGUI/Programmiermodus/MCP-Board-Zugriff gegenseitig aus (exklusiver Port). |
| Programmiermodus | Zellen anklicken zum Bearbeiten | Lädt `self.combined` **einmalig pro Prozesslauf** beim ersten Aktivieren (bevorzugt `DEFAULT_PATH`, sonst `default_combined()`). Jede Bearbeitung speichert sofort automatisch (`_autosave_combined()`). **Achtung:** Da der Snapshot nur einmal geladen wird, sieht der Programmiermodus externe Änderungen (z.B. per MCP) erst nach einem Neustart der exe oder einem expliziten „Datei laden…“. |
### Tastendruck-Erkennung: Position statt Zeichen
Die Erkennung im Bearbeiten-Dialog nutzt ausschließlich Tk-Events des
fokussierten Fensters — kein globaler `SetWindowsHookEx`-Hook (siehe
`AGENTS.md`). Erste Folge: **nur was das Fenster erreicht, wird erkannt.**
Win+L, Strg+Alt+Entf und andere vom Betriebssystem abgefangene
Kombinationen kommen nie an.
Zweite und wichtigere Folge betrifft die *Zuordnung*. HID-Keycodes
bezeichnen **physische Tastenpositionen**: das Board sendet eine Position,
erst Windows macht daraus über das aktive Layout ein Zeichen. Wer die
Zuordnung über das *Zeichen* aufbaut, dreht diese Kette falsch herum — auf
deutschem Layout landete dadurch jedes Y auf der Z-Taste des Boards und
ÄÖÜ/#/+ waren gar nicht erfassbar. `versapad_data.tk_event_to_hid()` löst
deshalb in dieser Reihenfolge auf:
1. **Benannte Tasten über den Tk-keysym** (Enter, Escape, Pfeile, F-Tasten,
Numpad, Entf …). Layoutunabhängig eindeutig — und hier zwingend, weil
`MapVirtualKeyW` für die Pfeiltasten denselben Scan-Code liefert wie für
ihre Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B).
2. **Zeichentasten über die physische Position**
`versapad_keylayout.hid_for_vk()`: Virtual-Key → Scan-Code
(`MapVirtualKeyW`) → HID über die layoutunabhängige Tabelle
`SCANCODE_TO_HID`. Der Virtual-Key ist unabhängig davon, ob Shift oder
AltGr mitgehalten wird.
3. **Näherung ohne WinAPI** (keysym-Zeichentabelle, dann VK-Tabelle) — nur
relevant, wenn `versapad_keylayout` nicht verfügbar ist (Nicht-Windows,
kein ctypes). Auf dieser Ebene bleibt es bei der US-Layout-Näherung
inklusive vertauschtem Y/Z.
### Tastenbeschriftungen
`versapad_data.hid_key_name()` fragt für Zeichentasten `GetKeyNameTextW`
und zeigt damit den Namen des aktiven Layouts (deutsch: HID 0x1C → „Z“,
0x34 → „ä“). Für alles andere bleiben die gepflegten deutschen Namen aus
`_SPECIAL_KEYS` („Enter“, „Bild↑“, „Num5“) — die lesen sich besser als das,
was Windows dafür liefert („EINGABE“, „4 (ZEHNERTASTATUR)“).
Diese Namen sind zugleich Schlüssel (Dropdown-Einträge,
`hid_key_code_for_name()` für den MCP-Server) und müssen eindeutig bleiben.
Echte Kollisionen kommen vor: auf deutschem Layout heißt HID 0x31 schlicht
„#“, und diesen Namen trug bisher HID 0x32. Der Layoutname gewinnt, der
verdrängte US-Name wird als „# (US-Layout)“ gekennzeichnet statt verworfen.
`hid_key_code_for_name()` akzeptiert beide Schreibweisen; bei Kollision
gewinnt das Layout, damit `set_button_key(key="Z")` die Taste trifft, die
auf dieser Tastatur ein Z tippt.
Bestehende Belegungen ändern dadurch ihre **Anzeige, nicht ihre Daten**:
eine früher über das Dropdown gesetzte „Z“ steht als 0x1D in der Config und
wird jetzt wahrheitsgemäß als „Y“ angezeigt, weil sie auf dieser Tastatur
ein Y tippt. Ein Layoutwechsel zur Laufzeit wird nicht nachgezogen.
### Kopieren/Einfügen zwischen Tasten
Rechtsklick auf eine Karte im Programmiermodus (bzw. `Strg+C`/`Strg+V` auf
der Karte unter dem Mauszeiger) kopiert Belegung und/oder LED-Farbe auf
andere Tasten. Die Ablage ist eine reine In-Memory-Struktur in
`desktop_viewer.VersaPadViewer._clip` (`{"action": …, "led": …}`), **nicht**
die System-Zwischenablage — dort lägen nur Textrepräsentationen, hier
werden ganze Action-Dicts übertragen. Eingefügt wird immer eine
`copy.deepcopy()`, damit zwei Tasten nicht dasselbe Dict teilen. Encoder
haben keine eigene LED; eine kopierte Farbe auf einen Encoder einzufügen ist
deshalb wirkungslos.
Tk-Aufrufe passieren nie direkt aus dem Serial- oder Tray-Hintergrundthread
— Ergebnisse landen in einer `queue.Queue`, der Main-Thread holt sie per
`after()`-Polling ab (Absturzrisiko bei Cross-Thread-Tk-Zugriff, siehe
`AGENTS.md`).
## Nebenläufigkeit / Prozessmodell
Der USB-CDC-Port ist **exklusiv** — nur eine Verbindung gleichzeitig. Drei
potenzielle Halter existieren parallel und wissen nichts voneinander:
1. Die offizielle VersaGUI (C#/.NET), läuft dauerhaft als Tray-App.
2. `desktop_viewer.py`, wenn Live-Sync an ist (hält den Port dauerhaft) oder
während eines Board-Lese-/Schreibvorgangs im Programmiermodus (hält ihn
nur kurz).
3. `versapad_mcp_server.py`, während eines Board-Tool-Aufrufs — schließt
die Verbindung danach explizit wieder (`finally: _link.close()` in
`get_board_status()`, `load_from_board()`, `write_to_board()`), damit ein
einzelner MCP-Aufruf nicht dauerhaft blockiert, was Live-Sync/VersaGUI
sonst mit „busy“ aussperren würde.
**Mehrere MCP-Server-Prozesse:** Je nach Host-Umgebung können mehrere
unabhängige `versapad_mcp_server.py`-Prozesse gleichzeitig laufen (z.B.
durch wiederholte Tool-Ladevorgänge/Reconnects), jeder mit eigenem,
nicht geteiltem In-Memory-Zustand. Ein `write_to_board()`-Aufruf kann daher
auf einem anderen Prozess landen als vorherige `set_*`-Aufrufe und einen
veralteten/leeren Zustand schreiben, obwohl die Antwort `{"ok": true}`
meldet. Empfohlenes Muster: vor `write_to_board()` immer `load_local()`
aufrufen (liest die Datei prozessunabhängig frisch von der Platte) und
nach dem Schreiben mit `load_from_board()` + `get_profile()` gegenlesen,
statt dem ACK allein zu vertrauen. Details siehe „Bug beobachtet
2026-08-14“ in `AGENTS.md`.
## Build/Deploy
`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
`%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:
[`README.md`](../README.md#als-eigenständige-exe-windows).

216
docs/data-model.md Normal file
View file

@ -0,0 +1,216 @@
# Datenmodell
Alle Formate, die VersaPad Viewer liest/schreibt: die JSON-Repräsentationen
und das binäre NVM-Layout des Boards. Das binäre Layout ist 1:1 aus den
VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert
(Read → unpack → pack ist bytegenau identisch zum Original, inklusive
CRC) — siehe `versapad_protocol.py` und die „Existing-Codebase-Regel“ in
[`AGENTS.md`](../AGENTS.md), bevor hier etwas geändert wird.
## Geometrie
```
index = spalte * 5 + reihe
```
4 Spalten (`GRID_COLS`), 5 Reihen (`GRID_ROWS`), Reihe 0 = oben, Reihe 4 =
unten. Firmware-Reihenfolge, nicht neu herleiten. Damit ergeben sich die
20 Button-Indizes so auf dem physischen Grid:
| | Spalte 0 | Spalte 1 | Spalte 2 | Spalte 3 |
|---|---|---|---|---|
| Reihe 0 (oben) | 0 | 5 | 10 | 15 |
| Reihe 1 | 1 | 6 | 11 | 16 |
| Reihe 2 | 2 | 7 | 12 | 17 |
| Reihe 3 | 3 | 8 | 13 | 18 |
| Reihe 4 (unten) | 4 | 9 | 14 | 19 |
## Action
Eine `Action` beschreibt, was ein Button oder eine Encoder-Bewegung
auslöst. Sowohl in JSON als auch binär ein `{type, data}`-Paar
(binär: `SAction`, 3 Byte — 1 Byte Typ + 2 Byte `data`, little-endian).
| `type` | Enum-Index | `data`-Bedeutung |
|---|---|---|
| `None` | 0 | ungenutzt (0) |
| `HidKey` | 1 | `data = keycode \| (modifier << 8)` — Keycode HID Usage Page 0x07 im unteren Byte, Modifier-Bitmaske im oberen Byte |
| `HidConsumer` | 2 | `data` = HID-Consumer-Usage-ID (Usage Page 0x0C), z.B. `0x00CD` = Play/Pause |
| `HostCommand` | 3 | Enum-Wert existiert in der Firmware, wird von diesem Tool aktuell nicht gesetzt/editiert (kein `set_button_hostcommand`-Äquivalent) |
| `Macro` | 4 | `data` = Makro-Slot-Index (0-31) |
| `ProfileSwitch` | 5 | `data` = Ziel-Profil (0/1/2) oder `0xFFFF`/`0x00FF` = „nächstes Profil“ (Zyklus) |
Modifier-Bitmaske (für `HidKey`, gilt **nicht** 1:1 für Makro-Schritte,
siehe unten):
| Bit | Modifier |
|---|---|
| `0x01` | Strg |
| `0x02` | Shift |
| `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):
| Feld | Typ | Bedeutung |
|---|---|---|
| `r`, `g`, `b` | uint8 (0-255) | Farbe |
| `brightness` | uint8 (0-255) | Helligkeit |
| `anim` | Enum-String (JSON) / Enum-Index (binär) | `Static`, `Blink`, `Pulse`, `FadeIn`, `FadeOut`, `ColorCycle`, `ColorFade` |
| `period_ms` | uint16 (little-endian) | Animationsperiode in ms (Pulse braucht `>= 2`) |
## Makro-Schritt
Ein Makro-Schritt ist **kein** `Action` — Keycode und Modifier stehen in
zwei getrennten Bytes (nicht in einem gepackten 16-Bit-`data`-Feld wie bei
`HidKey`):
| Feld | Typ | Bedeutung |
|---|---|---|
| `keycode` | uint8 | HID-Keycode. `0` beendet die Sequenz (Firmware-Konvention — keine Lücken vor dem letzten belegten Schritt lassen) |
| `modifier` | uint8 | Bitmaske, aber **nur Strg/Shift/Alt** (kein Win — passend zu `ActionDialog.cs` im Original) |
Eine Makro-Tabelle hat 32 Slots (`MACRO_SLOTS`) mit je bis zu 8 Schritten
(`MACRO_MAX_STEPS`). Sie ist **eine einzige globale Tabelle**, nicht pro
Profil — zwei Profile, die per `Macro`-Action denselben Slot referenzieren,
spielen dieselben Schritte ab. Konvention für die Slot-Zuordnung (von den
Tools/der GUI benutzt, nicht von der Firmware erzwungen):
- Slot `0``19` = MX-Button-Index (Button `i` → Slot `i`)
- Slot `20``31` = `20 + enc*3 + act_idx` (Encoder `enc`, `act_idx`:
0=Druck/`sw`, 1=`cw`, 2=`ccw`)
## JSON: kombiniertes Format (`versapad_config_all.json`)
Das von diesem Tool selbst gepflegte Format (`versapad_combined.py`) — alle
3 Profile + Makro-Tabelle + lokale Profilnamen in einer Datei. Passt zum
Wire-Protokoll: `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer den
kompletten 740B-Block, nie nur ein Profil.
```jsonc
{
"active_profile": 0,
"global_brightness": 255,
"enc_sensitivity": [1, 1, 1, 1],
"profile_names": ["Windows", "Fusion 360", "BricsCAD"],
"profiles": [
{
"buttons": [
{
"index": 0,
"action": { "type": "HidKey", "data": 30, "note": "Speichern in Fusion 360" },
"led": { "r": 80, "g": 40, "b": 0, "brightness": 255,
"anim": "Static", "period_ms": 4000 }
}
// ... 20 Buttons (index 0-19)
],
"encoders": [
{
"index": 0,
"sw": { "type": "ProfileSwitch", "data": 65535 },
"cw": { "type": "None", "data": 0 },
"ccw": { "type": "None", "data": 0 }
}
// ... 4 Encoder (index 0-3)
]
}
// ... 3 Profile
],
"macros": [
[{ "keycode": 30, "modifier": 0 }, { "keycode": 39, "modifier": 0 }]
// ... 32 Slots, jeweils eine Liste mit 0-8 Schritten
]
}
```
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. Dasselbe gilt für `note` in jeder `Action`
(siehe oben).
## JSON: Legacy-Einzeldatei-Format (`versapad_config1/2/3.json`)
Kein von diesem Tool geschriebenes Format — optionaler Export der
offiziellen VersaGUI, gelesen von `versapad_data.load_profile()`. Enthält
nur ein einzelnes Profil, keine Makro-Schritte, keinen Profilnamen:
```jsonc
{
"buttons": [ /* wie oben, 20 Eintraege */ ],
"encoders": [ /* wie oben, 4 Eintraege */ ]
}
```
## MCP-Tool-Grenzfläche (`set_macro`, `set_button_key`, …)
Die MCP-Tools nehmen **menschenlesbare** Namen entgegen, keine Rohwerte —
`versapad_data.py` übersetzt:
```jsonc
// set_button_key(profile=0, index=0, key="S", modifiers=["Strg"])
// set_macro(slot=7, steps=[{"key": "1", "modifiers": []}, {"key": "0", "modifiers": []}])
```
`hid_key_code_for_name()` / `consumer_id_for_name()` / `modifier_bits_for_names()`
übersetzen Namen → Rohwerte (werfen `ValueError` mit einer Liste gültiger
Namen bei Tippfehlern). Zeichentasten-Labels sind eine US-Layout-Näherung
(keine `GetKeyNameText()`-Auflösung wie im C#-Original).
## Binäres NVM-Layout
### `SDeviceConfig` (740 Byte, `versapad_protocol.CONFIG_SIZE`)
| Offset | Größe | Feld |
|---|---|---|
| 0 | 4B (uint32 LE) | Magic (`0x56503203`, `NVM_CONFIG_MAGIC`) |
| 4 | 1B | Version (`3`, `NVM_CONFIG_VERSION`) |
| 5 | 2B (uint16 LE) | CRC16 über Byte 7-739 |
| 7 | 1B | `active_profile` (0-2) |
| 8 | 1B | `global_brightness` (0-255) |
| 9 | 4B | `enc_sensitivity` (4× uint8, einer je Encoder) |
| 13 | 19B | reserviert/ungenutzt (Padding) |
| 32 | 236B | Profil 0 (`SDeviceProfile`) |
| 268 | 236B | Profil 1 |
| 504 | 236B | Profil 2 |
### `SDeviceProfile` (236 Byte)
| Offset (relativ) | Größe | Feld |
|---|---|---|
| 0 | 60B (20× 3B `SAction`) | MX-Button-Actions, Index 0-19 |
| 60 | 36B (4× 3× 3B `SAction`) | Encoder-Actions: je Encoder `sw`, `cw`, `ccw` |
| 96 | 20B | LED `r` je Button |
| 116 | 20B | LED `g` je Button |
| 136 | 20B | LED `b` je Button |
| 156 | 20B | LED `brightness` je Button |
| 176 | 20B | LED `anim` (Enum-Index) je Button |
| 196 | 40B (20× uint16 LE) | LED `period_ms` je Button |
Die LED-Felder liegen **spaltenweise** (struct-of-arrays: alle 20
`r`-Werte, dann alle 20 `g`-Werte, …), nicht verschachtelt pro Button —
`pack_profile()`/`unpack_profile()` bauen das entsprechend um.
### `SMacroTable` (512 Byte, `versapad_protocol.MACRO_SIZE`)
32 Slots × 8 Schritte × 2 Byte (`keycode`, `modifier`) = 512 Byte, flach
hintereinander: `offset = (slot_idx * 8 + step_idx) * 2`.
### CRC16
`crc16()` in `versapad_protocol.py`: CRC-CCITT, Polynom `0x1021`, Init
`0xFFFF`, MSB-first, kein XOR-Out — exakt `nvm_config_crc()` aus der
Firmware (`nvm_config.cpp`). Wird über Byte 7-739 der Config berechnet
(alles nach Magic/Version/CRC-Header selbst).

136
docs/protocol.md Normal file
View file

@ -0,0 +1,136 @@
# Serial-Wire-Protokoll
Beschreibt, wie `versapad_serial.VersaPadLink` mit dem Board spricht. 1:1
aus `VersaGUI/src/Protocol.cs` und `VersaMCU/doc/07_serial_protocol.md`
übernommen (siehe „Existing-Codebase-Regel“ in
[`AGENTS.md`](../AGENTS.md) — bei Änderungen gegen die Firmware-/
C#-Quellen abgleichen, nicht aus dem Gedächtnis rekonstruieren). Für die
Bedeutung der übertragenen Bytes (Config/Makro-Layout) siehe
[`data-model.md`](data-model.md).
## Transport
- USB-CDC (virtueller COM-Port), 115200 Baud.
- Board-Erkennung über USB VID:PID `239A:0042` (`versapad_serial.find_port()`
durchsucht `serial.tools.list_ports.comports()`).
- Der Port ist **exklusiv** — siehe „Nebenläufigkeit“ in
[`architecture.md`](architecture.md).
- Jedes Paket ist exakt **8 Byte**:
| Byte | Bedeutung |
|---|---|
| `[0]` | Command-/Event-ID |
| `[1]` | je nach Befehl: Chunk-Index, Chunk-Anzahl, oder ungenutzt |
| `[2..7]` | Payload, 6 Byte (`PAYLOAD_SIZE`) |
## Befehle (Host → Board)
| Konstante | Wert | Zweck |
|---|---|---|
| `CMD_READ_STATUS` | `0x06` | Leichtgewichtiger Status-Poll (aktives Profil) |
| `CMD_CONFIG_BEGIN` | `0x10` | Start eines Config-Schreibvorgangs |
| `CMD_CONFIG_DATA` | `0x11` | Ein Config-Datenpaket |
| `CMD_CONFIG_COMMIT` | `0x12` | Config committen (NVM-Schreiben nach Validierung) |
| `CMD_CONFIG_READ` | `0x13` | Komplette Config vom Board anfordern |
| `CMD_MACRO_BEGIN` | `0x20` | Start eines Makro-Schreibvorgangs |
| `CMD_MACRO_DATA` | `0x21` | Ein Makro-Datenpaket |
| `CMD_MACRO_COMMIT` | `0x22` | Makros committen |
| `CMD_MACRO_READ` | `0x23` | Komplette Makro-Tabelle vom Board anfordern |
## Events (Board → Host)
| Konstante | Wert | Zweck |
|---|---|---|
| `EVT_STATUS` | `0x86` | Antwort auf `CMD_READ_STATUS` |
| `EVT_CONFIG_ACK` | `0x90` | Config-Commit erfolgreich |
| `EVT_CONFIG_NACK` | `0x91` | Config-Commit abgelehnt (Validierung fehlgeschlagen) |
| `EVT_CONFIG_BEGIN` | `0x92` | Board beginnt, Config-Dump zu senden |
| `EVT_CONFIG_DATA` | `0x93` | Ein Config-Datenpaket (Antwort auf `CMD_CONFIG_READ`) |
| `EVT_CONFIG_END` | `0x94` | Config-Dump vollständig |
| `EVT_MACRO_ACK` | `0x95` | Makro-Commit erfolgreich |
| `EVT_MACRO_BEGIN` | `0x96` | Board beginnt, Makro-Dump zu senden |
| `EVT_MACRO_DATA` | `0x97` | Ein Makro-Datenpaket |
| `EVT_MACRO_END` | `0x98` | Makro-Dump vollständig |
| `EVT_MACRO_NACK` | `0x99` | Makro-Commit abgelehnt |
## Ablauf: Lesen (`CONFIG_READ` / `MACRO_READ`)
Genutzt von `read_full_config()` (740B, ⌈740/6⌉ = 124 Datenpakete) und
`read_macros()` (512B, ⌈512/6⌉ = 86 Datenpakete). Deadline 3s.
```
Host → [CMD_CONFIG_READ, 0, 0,0,0,0,0,0]
Board → [EVT_CONFIG_BEGIN, ...]
Board → [EVT_CONFIG_DATA, chunk_idx=0, <=6B Payload]
Board → [EVT_CONFIG_DATA, chunk_idx=1, <=6B Payload]
... (124 Pakete insgesamt für Config, 86 für Makros)
Board → [EVT_CONFIG_END, ...]
```
`chunk_idx` (Byte `[1]`) bestimmt den Ziel-Offset im Empfangspuffer:
`offset = chunk_idx * 6`. Kommt `EVT_END`, ohne dass zuvor `EVT_BEGIN`
gesehen wurde, gilt das als Timeout (unvollständige/verpasste Antwort).
## Ablauf: Schreiben (`CONFIG_BEGIN/DATA/COMMIT` bzw. `MACRO_*`)
Genutzt von `write_full_config()`/`write_macros()`. Deadline 5s. Anzahl
Chunks maximal 255 (ein Byte) — bei größeren Blobs schlägt der Aufruf mit
`"too_large"` fehl, bevor überhaupt gesendet wird.
```
Host → [CMD_CONFIG_BEGIN, chunk_count, 0,0,0,0,0,0]
Host → [CMD_CONFIG_DATA, 0, <=6B Payload (zero-padded)]
Host → [CMD_CONFIG_DATA, 1, <=6B Payload]
... (ein Paket je Chunk)
Host → [CMD_CONFIG_COMMIT, 0, 0,0,0,0,0,0]
Board → [EVT_CONFIG_ACK, ...] -- oder EVT_CONFIG_NACK bei fehlgeschlagener Validierung
```
**Sicherheitsnetz:** Die Firmware prüft Magic/Version/CRC (Config) bzw.
Keycode-Bereich (Makros) **vor** jedem NVM-Schreiben und antwortet sonst
nur mit NACK — ein fehlerhafter Schreibversuch kann das Board laut
Firmware-Design nicht in einen inkonsistenten Zustand bringen, siehe
`nvm_config_validate()` in den Firmware-Quellen.
## Ablauf: Leichtgewichtiger Status-Poll (`READ_STATUS`)
Genutzt von `read_active_profile()` für kontinuierliches Live-Sync-Polling
(alle 1,5s). Deadline 1s.
```
Host → [CMD_READ_STATUS, 0, 0,0,0,0,0,0]
Board → [EVT_STATUS, active_profile (0-2), ...]
```
Ein einzelnes Antwortpaket statt eines vollen `CONFIG_READ`-Dumps (124
Pakete) — Letzterer blockiert die Firmware in `poll_vendor()` lang genug,
dass laufende LED-Pulse-Animationen sichtbar stottern. Ältere Firmware
ohne `CMD_READ_STATUS` antwortet einfach gar nicht → sauberer Timeout,
kein Absturz (siehe `AGENTS.md`, Eintrag „4215323“ in der Historie).
## Verbindungsaufbau (`VersaPadLink._ensure_open()`)
1. Board per VID/PID finden (`find_port()`).
2. `serial.Serial(port, 115200, timeout=0.5)` öffnen.
3. DTR auf `True` setzen, 0,2s warten (Board braucht kurz, bis es nach dem
Öffnen/DTR-Toggle wieder reagiert).
4. Input-Buffer leeren.
**Wichtig:** `VersaPadLink` schließt die Verbindung nicht automatisch nach
einem Befehl — nur bei `serial.SerialException` (Fehlerfall) oder
explizitem `.close()`-Aufruf. Aufrufer, die den exklusiven Port nicht
dauerhaft blockieren wollen, müssen selbst schließen (siehe
`versapad_mcp_server.py`, das nach jedem Board-Tool-Aufruf `_link.close()`
in einem `finally`-Block aufruft).
## Fehlerzustände (`VersaPadLink.last_error`)
| Wert | Bedeutung |
|---|---|
| `None` | kein Fehler |
| `"no_pyserial"` | Paket `pyserial` nicht installiert |
| `"not_found"` | kein Gerät mit passender VID/PID gefunden |
| `"busy"` | Port gefunden, aber Öffnen fehlgeschlagen (von woanders gehalten — VersaGUI, Live-Sync, ein anderer Prozess) |
| `"timeout"` | keine (gültige) Antwort innerhalb der Deadline |
| `"nack"` | Board hat den Commit explizit abgelehnt (Validierung fehlgeschlagen) |
| `"too_large"` | zu sendender Blob braucht mehr als 255 Chunks |

View file

@ -109,7 +109,7 @@ def render_page(profile):
cfg = vp.annotate_profile({
"buttons": [dict(b) for b in raw["buttons"]],
"encoders": [dict(e) for e in raw["encoders"]],
})
}, combined.get("macros"))
tabs = "".join(
f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(combined["profile_names"][p])}</a>'
for p in range(vp.NUM_PROFILES)

View file

@ -7,10 +7,15 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen.
"""
import json
import os
import sys
import versapad_keylayout as kl
NUM_PROFILES = 3
# Groesse der globalen Makrotabelle (SMacroTable, siehe versapad_protocol.
# MACRO_SLOTS) -- hier nochmal, damit dieses Modul importfrei bleibt.
MACRO_SLOTS = 32
APP_NAME = "VersaPadViewer"
@ -109,6 +114,164 @@ _CONSUMER_NAMES = {
0x00B0: "Aufnahme",
}
# ── Tastendruck-Erkennung (Tk-Events -> HID) ──────────────────────────────
#
# Bewusst OHNE WinAPI-Hook: die Zuordnung arbeitet nur mit dem, was Tk beim
# Fokus auf dem Bearbeiten-Dialog ohnehin liefert (keysym, keycode, state).
# Damit bleibt es ein normales Fenster-Tastaturereignis -- kein globaler
# Low-Level-Hook, der (wie die Fensterverstecktricks in anderen Projekten)
# AV-Fehlalarme provozieren koennte. Preis: erkannt wird nur, was das
# fokussierte Fenster ueberhaupt erreicht (Win+L, Strg+Alt+Entf und
# aehnliche vom System abgefangene Kombinationen also nicht).
# Modifier-Tasten selbst sind nie das "Ziel" eines Captures, sie setzen nur
# Bits -- Win ist hier mit dabei (Einzeltaste erlaubt Win), Makro-Schritte
# filtern es spaeter selbst wieder raus (Firmware kennt dort kein Win).
TK_MODIFIER_KEYSYMS = {
"Control_L": 0x01, "Control_R": 0x01,
"Shift_L": 0x02, "Shift_R": 0x02,
"Alt_L": 0x04, "Alt_R": 0x04, "ISO_Level3_Shift": 0x04,
"Super_L": 0x08, "Super_R": 0x08, "Win_L": 0x08, "Win_R": 0x08,
}
# event.state-Bits unter Windows-Tk (Fallback, falls ein KeyRelease der
# Modifier-Taste verloren ging -- z.B. weil der Dialog waehrenddessen den
# Fokus hatte/verlor).
TK_STATE_MODIFIER_BITS = [(0x0001, 0x02), (0x0004, 0x01), (0x20000, 0x04)]
# Benannte Tasten: keysym ist layoutunabhaengig, deshalb erste Wahl.
_TK_NAMED_KEYSYMS = {
"Return": 0x28, "Escape": 0x29, "BackSpace": 0x2A, "Tab": 0x2B,
"ISO_Left_Tab": 0x2B, "space": 0x2C, "Caps_Lock": 0x39,
"Print": 0x46, "Scroll_Lock": 0x47, "Pause": 0x48, "Cancel": 0x48,
"Insert": 0x49, "Home": 0x4A, "Prior": 0x4B, "Delete": 0x4C,
"End": 0x4D, "Next": 0x4E,
"Right": 0x4F, "Left": 0x50, "Down": 0x51, "Up": 0x52,
"Num_Lock": 0x53, "KP_Divide": 0x54, "KP_Multiply": 0x55,
"KP_Subtract": 0x56, "KP_Add": 0x57, "KP_Enter": 0x58,
"KP_1": 0x59, "KP_End": 0x59, "KP_2": 0x5A, "KP_Down": 0x5A,
"KP_3": 0x5B, "KP_Next": 0x5B, "KP_4": 0x5C, "KP_Left": 0x5C,
"KP_5": 0x5D, "KP_Begin": 0x5D, "KP_6": 0x5E, "KP_Right": 0x5E,
"KP_7": 0x5F, "KP_Home": 0x5F, "KP_8": 0x60, "KP_Up": 0x60,
"KP_9": 0x61, "KP_Prior": 0x61, "KP_0": 0x62, "KP_Insert": 0x62,
"KP_Decimal": 0x63, "KP_Delete": 0x63,
"Menu": 0x65, "App": 0x65,
}
for _i in range(12):
_TK_NAMED_KEYSYMS[f"F{_i + 1}"] = 0x3A + _i
# Zeichentasten: HID-Keycodes sind US-Positionen, die keysyms kommen aber vom
# aktiven Windows-Layout. Beide Belegungen (US + Deutsch) stehen deshalb
# nebeneinander. Einzige echte Kollision ist "minus" (US-Layout: Taste neben
# der 0 = 0x2D, deutsches Layout: Taste neben dem Punkt = 0x38) -- dort
# gewinnt die US-Position, damit die Erkennung dieselbe Naeherung liefert wie
# die Dropdown-Beschriftung (siehe Layout-Vorbehalt bei tk_event_to_hid).
_TK_CHAR_KEYSYMS = {
# US-Layout
"minus": 0x2D, "equal": 0x2E, "bracketleft": 0x2F, "bracketright": 0x30,
"backslash": 0x31, "semicolon": 0x33, "apostrophe": 0x34,
"quoteright": 0x34, "grave": 0x35, "quoteleft": 0x35,
"comma": 0x36, "period": 0x37, "slash": 0x38,
# Deutsches Layout -- gleiche physische Tasten, andere Zeichen
"ssharp": 0x2D, "acute": 0x2E, "dead_acute": 0x2E,
"udiaeresis": 0x2F, "plus": 0x30, "numbersign": 0x32,
"odiaeresis": 0x33, "adiaeresis": 0x34,
"asciicircum": 0x35, "dead_circumflex": 0x35,
"less": 0x64, "greater": 0x64, "bar": 0x64,
}
# Windows-Virtual-Key-Codes (event.keycode) als Rueckfallebene, wenn der
# keysym nichts hergibt -- z.B. wenn Shift/AltGr das Zeichen veraendert
# ("exclam" statt "1"). Nur die Bereiche, die layoutstabil sind.
_WIN_VK_TO_HID = {
0x08: 0x2A, 0x09: 0x2B, 0x0D: 0x28, 0x13: 0x48, 0x14: 0x39, 0x1B: 0x29,
0x20: 0x2C, 0x21: 0x4B, 0x22: 0x4E, 0x23: 0x4D, 0x24: 0x4A,
0x25: 0x50, 0x26: 0x52, 0x27: 0x4F, 0x28: 0x51,
0x2C: 0x46, 0x2D: 0x49, 0x2E: 0x4C,
0x30: 0x27, 0x6A: 0x55, 0x6B: 0x57, 0x6D: 0x56, 0x6E: 0x63, 0x6F: 0x54,
0x90: 0x53, 0x91: 0x47, 0x5D: 0x65,
}
for _i in range(9):
_WIN_VK_TO_HID[0x31 + _i] = 0x1E + _i # '1'-'9'
for _i in range(26):
_WIN_VK_TO_HID[0x41 + _i] = 0x04 + _i # 'A'-'Z'
for _i in range(12):
_WIN_VK_TO_HID[0x70 + _i] = 0x3A + _i # F1-F12
_WIN_VK_TO_HID[0x60] = 0x62 # Numpad 0
for _i in range(9):
_WIN_VK_TO_HID[0x61 + _i] = 0x59 + _i # Numpad 1-9
def tk_keysym_to_hid(keysym):
"""Tk-keysym -> HID-Keycode, oder None wenn nicht zuordenbar.
Modifier-Tasten liefern bewusst None (sie sind kein Capture-Ziel)."""
if keysym in TK_MODIFIER_KEYSYMS:
return None
if len(keysym) == 1:
upper = keysym.upper()
if "A" <= upper <= "Z":
return 0x04 + (ord(upper) - ord("A"))
if "1" <= keysym <= "9":
return 0x1E + (ord(keysym) - ord("1"))
if keysym == "0":
return 0x27
if keysym in _TK_NAMED_KEYSYMS:
return _TK_NAMED_KEYSYMS[keysym]
return _TK_CHAR_KEYSYMS.get(keysym)
def tk_event_to_hid(keysym, keycode, state, held_modifier=0):
"""Ein Tk-KeyPress -> (hid_keycode, modifier_bits) oder None, wenn die
Taste sich nicht auf einen HID-Keycode abbilden laesst (dann im Dialog
einfach weiter warten statt Muell zu speichern).
keysym/keycode/state kommen direkt aus dem Tk-Event, held_modifier ist
das vom Dialog selbst mitgefuehrte Bitfeld der aktuell gedrueckten
Modifier (siehe TK_MODIFIER_KEYSYMS) -- beides wird verodert, damit ein
verlorenes KeyRelease die Erkennung nicht verfaelscht.
Aufloesungsreihenfolge, und warum genau so:
1. **Benannte Tasten ueber den keysym** (Enter, Pfeile, F-Tasten,
Numpad, Entf ...). Die sind layoutunabhaengig eindeutig -- und der
Weg ueber den Scan-Code waere hier sogar gefaehrlich: Windows liefert
fuer die Pfeiltasten denselben Scan-Code wie fuer ihre
Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B).
2. **Zeichentasten ueber die physische Position**
(`versapad_keylayout.hid_for_vk()`, Virtual-Key -> Scan-Code -> HID).
HID-Keycodes SIND Positionen; alles, was ueber das erzeugte Zeichen
geht, ist auf nicht-US-Layouts falsch. Genau hier lag der Fehler, der
auf deutschem Layout Y und Z vertauscht hat und AeOeUe/#/+ gar nicht
erfassbar machte. Der Virtual-Key ist ausserdem unabhaengig davon, ob
Shift oder AltGr mitgehalten wird.
3. **Naeherung ohne WinAPI** (keysym-Zeichentabelle, dann VK-Tabelle) --
nur relevant, wenn `versapad_keylayout` nicht verfuegbar ist
(Nicht-Windows, kein ctypes). Auf dieser Ebene bleibt es bei der
US-Layout-Naeherung inklusive vertauschtem Y/Z; bei gehaltenem Shift
zaehlt dort zuerst der VK-Code, weil der keysym dann das verschobene
Zeichen ist (deutsch: Shift+7 -> "slash").
"""
if keysym in TK_MODIFIER_KEYSYMS:
return None # Modifier sind nie das Ziel, sie setzen nur Bits
code = _TK_NAMED_KEYSYMS.get(keysym)
if code is None:
code = kl.hid_for_vk(keycode)
if code is None:
if state & 0x0001 or held_modifier & 0x02:
code = _WIN_VK_TO_HID.get(keycode) or tk_keysym_to_hid(keysym)
else:
code = tk_keysym_to_hid(keysym) or _WIN_VK_TO_HID.get(keycode)
if code is None:
return None
modifier = held_modifier
for bit, mod in TK_STATE_MODIFIER_BITS:
if state & bit:
modifier |= mod
return code, modifier
ANIM_LABELS = {
"Static": "● statisch",
"Blink": "◎ blinkend",
@ -120,9 +283,55 @@ ANIM_LABELS = {
}
_display_name_cache = None
def _display_key_names():
"""{hid_code: Anzeigename} -- Zeichentasten mit dem Namen des aktiven
Windows-Layouts, alles andere mit der gepflegten deutschen Bezeichnung
aus _SPECIAL_KEYS ("Enter", "Bild↑", "Num5"); die liest sich besser als
das, was Windows liefert ("EINGABE", "4 (ZEHNERTASTATUR)").
Einmal ermittelt und behalten -- ein Layoutwechsel zur Laufzeit wird
bewusst nicht nachgezogen (siehe versapad_keylayout.key_name()).
Die Namen sind gleichzeitig Schluessel im Dropdown und in
hid_key_code_for_name(), muessen also eindeutig bleiben. Kollisionen
entstehen real: auf deutschem Layout heisst HID 0x31 (US-Backslash-
Position) schlicht "#" -- und diesen Namen trug bisher HID 0x32
(Non-US-#). Der Layoutname gewinnt, der verdraengte US-Name wird
gekennzeichnet statt verworfen, damit die Taste ansprechbar bleibt."""
global _display_name_cache
if _display_name_cache is None:
names = dict(_SPECIAL_KEYS)
layout = {}
for code in sorted(_SPECIAL_KEYS):
if code not in kl.CHARACTER_HID_CODES:
continue
name = kl.key_name(code)
if name:
layout[code] = name
names.update(layout)
claimed = set(layout.values())
for code, name in list(names.items()):
if code not in layout and name in claimed:
names[code] = f"{name} (US-Layout)"
elif code not in layout:
claimed.add(name)
_display_name_cache = names
return _display_name_cache
def hid_key_name(keycode):
"""Anzeigename einer Taste, layoutrichtig wo es darauf ankommt
(deutsch: 0x1C -> "Z", 0x34 -> "ä"). Ohne verfuegbare Layout-Abfrage
(Nicht-Windows) bleibt es bei der US-Naeherung aus _SPECIAL_KEYS."""
return _display_key_names().get(keycode, f"0x{keycode:02X}")
def hid_key_choices():
"""Sortierte [(keycode, name), ...] fuer Dropdown-Auswahl beim Editieren."""
return sorted(_SPECIAL_KEYS.items())
return [(code, hid_key_name(code)) for code in sorted(_SPECIAL_KEYS)]
def consumer_choices():
@ -130,15 +339,28 @@ def consumer_choices():
return sorted(_CONSUMER_NAMES.items())
_KEY_CODE_BY_NAME = {name: code for code, name in _SPECIAL_KEYS.items()}
_CONSUMER_ID_BY_NAME = {name: cid for cid, name in _CONSUMER_NAMES.items()}
def _key_code_by_name():
"""Namen -> Keycode, Layoutnamen haben Vorrang vor den US-Namen.
Beide Schreibweisen bleiben gueltig, damit aeltere Aufrufe nicht brechen.
Bei einer echten Kollision (deutsch: "Z" ist US-Position 0x1D *und*
Layoutname von 0x1C) gewinnt bewusst das Layout: wer "Z" sagt, will die
Taste, die auf dieser Tastatur ein Z tippt -- alles andere waere genau
der Fehler, der hier gerade behoben wurde."""
by_name = {name: code for code, name in _SPECIAL_KEYS.items()}
by_name.update({name: code for code, name in _display_key_names().items()})
return by_name
def hid_key_code_for_name(name):
"""z.B. 'S' -> 0x16. Wirft ValueError mit Vorschlaegen bei unbekanntem Namen."""
if name not in _KEY_CODE_BY_NAME:
raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(_KEY_CODE_BY_NAME)}")
return _KEY_CODE_BY_NAME[name]
by_name = _key_code_by_name()
if name not in by_name:
raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(by_name)}")
return by_name[name]
def consumer_id_for_name(name):
@ -163,7 +385,7 @@ def macro_step_label(step):
"""step: {'keycode','modifier'} -> z.B. 'Strg+S'. Reine Keycode/Modifier-Variante
von hid_key_label() (dort steckt beides in einem 16-Bit data-Feld, hier getrennt)."""
mods = [name for bit, name in MODIFIER_BITS if step["modifier"] & bit]
key = _SPECIAL_KEYS.get(step["keycode"], f"0x{step['keycode']:02X}")
key = hid_key_name(step["keycode"])
return "+".join(mods + [key]) if mods else key
@ -173,12 +395,25 @@ def macro_slot_label(steps):
return "".join(macro_step_label(s) for s in steps)
def macro_slot_choices(macros):
"""["Slot 0 — Strg+C → Strg+V", ...] fuer die Slot-Auswahl im
Bearbeiten-Dialog: alle 32 Slots samt Inhalt auf einen Blick, statt sich
per Spinbox durch die Tabelle zu klicken."""
return [f"Slot {slot}{macro_slot_label(macros[slot] if slot < len(macros) else [])}"
for slot in range(MACRO_SLOTS)]
def macro_slot_from_choice(choice):
"""Umkehrung von macro_slot_choices(): "Slot 7 — ..." -> 7."""
return int(choice.split("")[0].strip().split()[-1])
def hid_key_label(data):
"""data = keycode | (modifier << 8) -> z.B. 'Strg+S'"""
keycode = data & 0xFF
modifier = (data >> 8) & 0xFF
mods = [name for bit, name in MODIFIER_BITS if modifier & bit]
key = _SPECIAL_KEYS.get(keycode, f"0x{keycode:02X}")
key = hid_key_name(keycode)
return "+".join(mods + [key]) if mods else key
@ -186,8 +421,15 @@ def consumer_label(data):
return _CONSUMER_NAMES.get(data, f"Consumer 0x{data:04X}")
def action_label(action):
"""Menschenlesbarer Text für eine DeviceAction {type, data}."""
def action_label(action, macros=None):
"""Menschenlesbarer Text für eine DeviceAction {type, data}.
macros: optional die globale 32-Slot-Makrotabelle (siehe
versapad_combined). Ist sie da, zeigt ein Makro die tatsaechliche
Tastenfolge statt nur der Slot-Nummer -- ohne die sagt "Makro (Slot 7)"
im Hauptfenster nichts darueber aus, was die Taste eigentlich tut.
Ohne macros (z.B. bei den Einzel-JSONs aus CONFIG_PATHS, die gar keine
Makro-Schritte enthalten) bleibt es beim Slot-Text."""
t = action.get("type")
d = action.get("data", 0)
if t == "None":
@ -197,6 +439,8 @@ def action_label(action):
if t == "HidConsumer":
return consumer_label(d)
if t == "Macro":
if macros is not None and 0 <= d < len(macros):
return f"Makro {d}: {macro_slot_label(macros[d])}"
return f"Makro (Slot {d})"
if t == "ProfileSwitch":
if d in (0xFFFF, 0x00FF):
@ -220,20 +464,23 @@ def button_grid_position(index):
return col, row
def annotate_profile(cfg):
def annotate_profile(cfg, macros=None):
"""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."""
oder aus dem kombinierten Programmiermodus-State kommt.
macros wird an action_label() durchgereicht, damit Makro-Belegungen
ihre echte Tastenfolge zeigen statt nur der Slot-Nummer."""
for b in cfg["buttons"]:
b["label"] = action_label(b["action"])
b["label"] = action_label(b["action"], macros)
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_label"] = action_label(e["sw"], macros)
e["sw_note"] = e["sw"].get("note", "")
e["cw_label"] = action_label(e["cw"])
e["cw_label"] = action_label(e["cw"], macros)
e["cw_note"] = e["cw"].get("note", "")
e["ccw_label"] = action_label(e["ccw"])
e["ccw_label"] = action_label(e["ccw"], macros)
e["ccw_note"] = e["ccw"].get("note", "")
return cfg

152
versapad_keylayout.py Normal file
View file

@ -0,0 +1,152 @@
"""
Abfragen ans AKTIVE Windows-Tastaturlayout: physische Tastenposition
(Scan-Code) und der Name, den diese Taste auf dem aktuellen Layout traegt.
Warum ueberhaupt WinAPI, wo dieses Projekt sonst konsequent darauf
verzichtet: HID-Keycodes bezeichnen **physische Tastenpositionen** (das
Board sendet eine Position, erst Windows macht daraus ein Zeichen). Aus
einem Tk-Event kommen aber nur `keysym` und Virtual-Key-Code -- beides ist
bereits durch das Layout gefiltert. Auf deutschem Layout landete deshalb
jedes Y auf der Z-Taste des Boards und umgekehrt, und AeOeUe/#/+ waren gar
nicht erfassbar. Die Position ist ohne `MapVirtualKeyW` schlicht nicht zu
bekommen.
Abgrenzung zu den Domaenenregeln in AGENTS.md: verboten sind dort
**Fenstermanipulation** (`SetWindowLongW`/`ShowWindow` aufs eigene Fenster)
und **globale Tastaturhooks** (`SetWindowsHookEx`) -- genau die Aufrufe, die
in einem anderen Projekt AV-Fehlalarme ausgeloest haben. Hier passiert
nichts davon: `MapVirtualKeyW` und `GetKeyNameTextW` sind passive,
lesende Layout-Abfragen ohne Fenster-, Prozess- oder Eingabezugriff. Die
offizielle VersaGUI (C#) benutzt `GetKeyNameText()` fuer denselben Zweck.
Alles hier ist optional: auf Nicht-Windows oder wenn `user32` nicht laedt,
bleibt AVAILABLE False und alle Funktionen liefern None -- `versapad_data`
faellt dann auf seine US-Layout-Naeherung zurueck.
"""
import sys
# Scan-Code (PS/2 Set 1, wie MapVirtualKeyW ihn liefert) -> HID Usage Page
# 0x07. Diese Tabelle ist layoutunabhaengig und damit der eigentliche Kern:
# eine physische Taste hat immer denselben Scan-Code und denselben HID-Code,
# egal welches Zeichen das Layout daraufschreibt.
SCANCODE_TO_HID = {
0x01: 0x29, # Esc
0x02: 0x1E, 0x03: 0x1F, 0x04: 0x20, 0x05: 0x21, 0x06: 0x22,
0x07: 0x23, 0x08: 0x24, 0x09: 0x25, 0x0A: 0x26, 0x0B: 0x27, # 1-9, 0
0x0C: 0x2D, 0x0D: 0x2E, # US -/=, deutsch ss/Akut
0x0E: 0x2A, 0x0F: 0x2B, # Backspace, Tab
0x10: 0x14, 0x11: 0x1A, 0x12: 0x08, 0x13: 0x15, 0x14: 0x17, # Q W E R T
0x15: 0x1C, 0x16: 0x18, 0x17: 0x0C, 0x18: 0x12, 0x19: 0x13, # US Y U I O P
0x1A: 0x2F, 0x1B: 0x30, # US [/], deutsch Ue/+
0x1C: 0x28, # Enter
0x1E: 0x04, 0x1F: 0x16, 0x20: 0x07, 0x21: 0x09, 0x22: 0x0A, # A S D F G
0x23: 0x0B, 0x24: 0x0D, 0x25: 0x0E, 0x26: 0x0F, # H J K L
0x27: 0x33, 0x28: 0x34, # US ;/', deutsch Oe/Ae
0x29: 0x35, # US Backtick, deutsch Zirkumflex
0x2B: 0x31, # US Backslash, deutsch #
0x2C: 0x1D, 0x2D: 0x1B, 0x2E: 0x06, 0x2F: 0x19, 0x30: 0x05, # US Z X C V B
0x31: 0x11, 0x32: 0x10, # N M
0x33: 0x36, 0x34: 0x37, # Komma, Punkt (in beiden Layouts gleich)
0x35: 0x38, # US /, deutsch -
0x37: 0x55, # Num *
0x39: 0x2C, # Leertaste
0x3A: 0x39, # Caps
0x45: 0x53, 0x46: 0x47, # NumLock, Rollen
0x47: 0x5F, 0x48: 0x60, 0x49: 0x61, 0x4A: 0x56, # Num 7 8 9 -
0x4B: 0x5C, 0x4C: 0x5D, 0x4D: 0x5E, 0x4E: 0x57, # Num 4 5 6 +
0x4F: 0x59, 0x50: 0x5A, 0x51: 0x5B, # Num 1 2 3
0x52: 0x62, 0x53: 0x63, # Num 0 .
0x56: 0x64, # ISO-Zusatztaste (deutsch <>|)
0x57: 0x44, 0x58: 0x45, # F11, F12
}
for _i in range(10):
SCANCODE_TO_HID[0x3B + _i] = 0x3A + _i # F1-F10
HID_TO_SCANCODE = {hid: scan for scan, hid in SCANCODE_TO_HID.items()}
# Nur fuer diese HID-Codes lohnt der Layout-Name: es sind die Tasten, deren
# Beschriftung sich zwischen Layouts unterscheidet (Buchstaben, Ziffern,
# Satzzeichen). Fuer Enter/F5/Entf/Numpad bleiben die gepflegten deutschen
# Namen aus versapad_data._SPECIAL_KEYS besser als das, was Windows liefert
# ("EINGABE", "4 (ZEHNERTASTATUR)").
CHARACTER_HID_CODES = (
frozenset(range(0x04, 0x1E)) # A-Z (US-Positionen)
| frozenset(range(0x1E, 0x28)) # 1-9, 0
| frozenset(range(0x2D, 0x39)) # Satzzeichen
| frozenset({0x64}) # ISO-Zusatztaste
)
MAPVK_VK_TO_VSC_EX = 4
AVAILABLE = False
_user32 = None
if sys.platform == "win32":
try:
import ctypes
from ctypes import wintypes
_user32 = ctypes.WinDLL("user32", use_last_error=True)
_user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT]
_user32.MapVirtualKeyW.restype = wintypes.UINT
_user32.GetKeyNameTextW.argtypes = [wintypes.LONG, wintypes.LPWSTR, ctypes.c_int]
_user32.GetKeyNameTextW.restype = ctypes.c_int
AVAILABLE = True
except (ImportError, OSError, AttributeError):
_user32 = None # z.B. exotische Python-Builds ohne ctypes
def scancode_for_vk(vk):
"""Virtual-Key-Code -> Scan-Code der physischen Taste, oder None.
MAPVK_VK_TO_VSC_EX setzt fuer manche Tasten ein 0xE0-Praefix ins High-Byte
(erweiterte Tasten), fuer die Pfeiltasten hier aber gemessen NICHT -- die
liefern denselben Scan-Code wie ihre Numpad-Zwillinge. Genau deshalb
laeuft die Aufloesung in versapad_data zuerst ueber die eindeutigen
Tk-keysyms und benutzt diesen Weg nur fuer Zeichentasten."""
if not AVAILABLE:
return None
scan = _user32.MapVirtualKeyW(vk, MAPVK_VK_TO_VSC_EX)
return scan or None
def hid_for_vk(vk):
"""Virtual-Key-Code -> HID-Keycode ueber die physische Tastenposition.
Das ist der eigentliche Fix gegen vertauschtes Y/Z und nicht erfassbares
AeOeUe/#/+: der Umweg ueber das Zeichen entfaellt komplett."""
scan = scancode_for_vk(vk)
if scan is None:
return None
if (scan >> 8) == 0xE0:
return None # erweiterte Taste -- kommt hier nicht vor, siehe oben
return SCANCODE_TO_HID.get(scan & 0xFF)
def key_name(hid_code):
"""HID-Keycode -> Beschriftung dieser Taste auf dem aktiven Layout
(deutsch: HID 0x1C -> "Z", 0x34 -> "ä"), oder None wenn nicht ermittelbar.
Achtung: das Ergebnis gilt fuer das Layout, das beim Aufruf aktiv ist.
Ein Layoutwechsel zur Laufzeit wird nicht bemerkt -- fuer ein Tool, das
eine Tastatur konfiguriert, ist das vertretbar (der Cache in
versapad_data haelt entsprechend auch nur eine Fassung)."""
if not AVAILABLE:
return None
scan = HID_TO_SCANCODE.get(hid_code)
if scan is None:
return None
buffer = ctypes.create_unicode_buffer(64)
# lParam-Layout von GetKeyNameTextW: Bits 16-23 Scan-Code,
# Bit 24 "erweiterte Taste".
written = _user32.GetKeyNameTextW((scan & 0xFF) << 16, buffer, 64)
if not written:
return None
name = buffer.value.strip()
if not name:
return None
# Windows liefert Mehrzeichen-Namen in Grossbuchstaben ("AKUT",
# "ZIRKUMFLEX") -- als Dropdown-Eintrag zwischen "A" und "ä" liest sich
# Titelschreibung deutlich ruhiger.
if len(name) > 1 and name == name.upper():
return name.capitalize()
return name

View file

@ -61,7 +61,11 @@ def _profile_switch_data(target):
def _describe_action(action):
return {"type": action["type"], "data": action["data"], "label": vp.action_label(action),
"""label zeigt bei Makros die echte Tastenfolge statt nur der
Slot-Nummer (gleiche Darstellung wie im Hauptfenster) -- die Makrotabelle
liegt im selben State, also kein Grund, hier weniger zu verraten."""
return {"type": action["type"], "data": action["data"],
"label": vp.action_label(action, _cfg().get("macros")),
"note": action.get("note", "")}