Compare commits

..

28 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
517f12c960 Enclosure Case für das Versa Board V.40
Ein Case als Fusion *.f3d Datei inkl. Halter für 3D Connexion Space Mouse. Separate Ständer im gleichen Design mit magnetischer Arretierung.
2026-08-17 19:39:30 +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
cjjohn
9464cafaab Document today's config-path, window-chrome and tooling gotchas
AGENTS.md gains the rules the day's debugging produced: never put the
config in the install dir (the build script wipes it, silently costing
all local-only fields), never branch app_dir() on sys.frozen (two
diverging configs), the consequences that come with a frameless window,
and why the resize grip must be placed rather than packed. Also records
that Claude's Bash and PowerShell tools can see different files under
AppData, which made "the notes are in the file" and "the app shows no
notes" both true at once -- with the diagnostic-module approach that
finally settled it.

README: config now lives in %APPDATA%\VersaPadViewer, plus the frameless
window and notes in the feature list.
2026-08-15 12:19:39 +02:00
cjjohn
91bac353b4 Keep user config out of the install dir; frameless window with working resize
Three related fixes after the notes feature landed:

Config location: build_and_deploy.ps1 wipes its target directory before
every deploy, and app_dir() had just been pointed at that same directory
-- so every rebuild silently deleted the user's config and the app
rebuilt it empty from the board, losing all notes. Config now lives in
%APPDATA%\VersaPadViewer (roaming), separate from the install dir, and
the build script additionally rescues any versapad_config*.json it finds
in the target so legacy installs survive an upgrade.

Window chrome: hide the title bar (plain Tk overrideredirect, no ctypes
window manipulation) and move the mode checkboxes up next to the title,
which reclaims two full rows of header height that the taller note cards
had eaten. Removing the title bar also removes the resize borders, so
add a grip -- anchored with place() to the window corner rather than
packed after the content, which would push it out of view exactly when
the window is too small and the grip is needed. Default geometry grown
to fit the taller cards, and empty encoder note lines are no longer
rendered at all.

Note truncation: the note area was a fixed 34px and cut longer notes
mid-word; it now takes the remaining card height.
2026-08-15 12:12:33 +02:00
cjjohn
4b5da69174 Add free-text notes per button/encoder action
Lets you record what a binding actually does (e.g. "Save in Fusion
360") alongside the auto-generated label ("Strg+S"). Notes live in a
new "note" field on every action dict, purely local like profile
names -- the firmware struct has no room for strings, and
pack_config/unpack_config already only touch type/data so the extra
key round-trips harmlessly.

Two things had to be handled carefully: changing a button's key/type
must not wipe its note (all set_button_*/set_encoder_* setters and the
edit dialog now carry the previous note forward), and re-reading from
the board must not erase notes either, since the firmware doesn't know
about them -- versapad_combined.merge_notes() restores them onto the
freshly-fetched state by button/encoder index.

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

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

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

(cherry picked from commit 82c3fd0056)
2026-08-14 23:51:34 +02:00
b4ad7698ed Move config storage next to the install and auto-create it if missing
versapad_combined.DEFAULT_PATH was hardcoded to this one machine's OneDrive
desktop, which made the tool unusable anywhere else. versapad_data.app_dir()
now resolves to the running .exe's own folder when frozen, or the project
directory when run from source, and DEFAULT_PATH hangs off that instead.

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

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

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

(cherry picked from commit 5d14bdd826)
2026-08-14 23:50:46 +02:00
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
cjjohn
8ba524c07b Release COM port after each versapad MCP tool call
get_board_status()/load_from_board()/write_to_board() kept the serial
link open indefinitely after use (VersaPadLink._ensure_open() never
closes on its own). Any session that called one of these left the COM
port locked for VersaGUI/desktop_viewer/server.py's board fallback
until the MCP server process was killed. Close the link in a finally
block after each call instead.
2026-08-09 18:23:33 +02:00
cjjohn
540ce5b1eb Fix crash when desktop config JSON is deleted: self-heal from board
server.py and desktop_viewer.py read-only mode required the desktop
config JSONs to exist and crashed/showed "Config-Datei fehlt" if one
was deleted, even though the board already holds the config durably
in NVM. Add versapad_combined.fetch_from_board()/load_or_fetch(): a
missing combined JSON is now transparently rebuilt from the board via
serial and cached back to disk, falling back to the legacy per-profile
JSONs (or a clear error) only when the board is unreachable.
2026-08-08 17:06:53 +02:00
cjjohn
8a2a73c68d Fix Programmiermodus default-seed bug wiping other profiles
_on_toggle_editing() seeded self.combined from default_combined() on first
activation, which reads the stale per-profile JSONs for all 3 profiles
instead of the current versapad_config_all.json. Writing to board while
only editing one profile silently reverted the other two. Now prefers
loading the current combined file, falling back to defaults only if it
doesn't exist.

Also documents the READ_STATUS polling change and the jappel PR workflow
constraint in AGENTS.md.
2026-08-07 20:50:52 +02:00
17 changed files with 2665 additions and 361 deletions

4
.gitignore vendored
View file

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

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"]
}
}
}

373
AGENTS.md
View file

@ -16,6 +16,17 @@ 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.
`CONFIG_PATHS` bleibt bewusst auf dem OneDrive-Desktop hartkodiert (Lese-
Interop mit einem JSON-Export der offiziellen VersaGUI, kein von diesem
Tool geschriebenes Format, siehe dort).
- `server.py` — stdlib `http.server`, generiert HTML pro Request neu,
Profil-Wechsel über `?profile=0|1|2`, Auto-Reload alle 4s. Rein lesend,
kein Programmiermodus (bewusst einfach gehalten).
@ -29,33 +40,75 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
Makros per 8-Byte-Paket-Protokoll, Board-Identifikation per VID/PID
`239A:0042`. Schreiben ist sicher im Sinne von "kann NVM nicht zerlegen"
— Firmware prüft Magic/CRC/Keycode-Bereich vor jedem Save, antwortet
sonst nur mit NACK.
sonst nur mit NACK. `read_active_profile()` (für Live-Sync-Polling)
nutzt seit 2026-08-07 `CMD_READ_STATUS`/`EVT_STATUS` (0x06/0x86, ein
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 (Root Cause + Fix in
VersaMCU-Commit "Add lightweight READ_STATUS command..."). **Braucht
entsprechend neue Firmware auf dem Board** — mit altem `versapad`-Firmwarestand
liefert `READ_STATUS` schlicht Timeout, kein Absturz.
- `versapad_combined.py` — Ein-Datei-Format (alle 3 Profile + Makros +
**nur lokal gespeicherte** Profilnamen), Default-Pfad
`~\OneDrive\Desktop\versapad_config_all.json`. Passt zum Wire-Protokoll:
`CONFIG_BEGIN/COMMIT` überträgt ohnehin immer den kompletten 740B-Block.
`versapad_data.app_dir()\versapad_config_all.json` (neben der
Installation, nicht mehr hartkodiert auf einer bestimmten Maschine).
Passt zum Wire-Protokoll: `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer
den kompletten 740B-Block. `load_or_fetch()` legt bei fehlender Datei
automatisch eine neue an — erst Versuch per Serial vom Board, sonst als
leere `default_combined()` (kein Board noetig fuer die Erstbenutzung).
`read_profile_names()` liest nur die Namen (kein Board-Zugriff, fuer
Tab-Beschriftungen im Nur-Lese-Modus, siehe Installierbarkeit-Notiz).
- `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
@ -74,6 +127,18 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
(`profile_names`) — die Firmware-Structs haben keinen Platz für einen
String (Header exakt 32B, jedes Profil exakt 236B, alles verplant). Sie
landen nie aufs Board, egal welcher Schreibpfad benutzt wird.
- **Notizen** (freier Text je Button/Encoder-Aktion, seit 2026-08-15) sind
aus demselben Grund rein lokal: leben im `"note"`-Feld JEDER Action
(`{"type","data","note"}`), nicht in einer separaten Struktur. `to_binary`/
`pack_config` ignorieren das Feld beim Schreiben (liest nur type/data),
`from_binary` liefert frisch vom Board immer `note=""` (Board kennt keine
Notizen) — `versapad_combined.merge_notes(neu, alt)` kopiert bestehende
Notizen nach jedem `load_from_board()`/`fetch_from_board()` zurück, sonst
gingen sie bei jedem Board-Refresh verloren. Action-Typ wechseln
(`set_button_key` etc.) darf die Notiz NICHT loeschen (baut die neue
Action ueber `_replace_action()`/`dlg.action["note"]` mit der alten Notiz),
nur `set_button_note()`/`set_encoder_note()`/das Notiz-Feld im
Programmiermodus-Dialog aendern sie gezielt.
- Der COM-Port ist exklusiv. Live-Sync und Programmiermodus schalten sich
gegenseitig aus (ein `VersaPadLink` kann nicht von zwei Konsumenten
gleichzeitig genutzt werden); VersaGUI läuft als Tray-App dauerhaft im
@ -86,12 +151,226 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
vorhanden, und fällt sonst auf die klassischen `versapad_config{1,2,3}.json`
zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine
Bearbeitung "verschwunden".
- 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).
- **Config-Pfad darf NIE im Installationsverzeichnis liegen (2026-08-15,
Datenverlust-Bug):** `build_and_deploy.ps1` räumt sein Zielverzeichnis vor
jedem Deploy komplett ab (`Remove-Item $localDir -Recurse -Force`). Nachdem
`app_dir()` am selben Tag auf genau dieses Verzeichnis gezeigt hatte, hat
**jeder Rebuild die Nutzer-Config mitgelöscht**; die App hat sie danach
kommentarlos leer vom Board neu aufgebaut (`load_or_fetch()`
`fetch_from_board()`), wodurch alle Notizen weg waren. Besonders tückisch:
Board-Bindings und LEDs sahen danach völlig normal aus, nur die rein
lokalen Felder (Notizen, Profilnamen) fehlten -- der Schaden ist also
unsichtbar, wenn man nur aufs Grid schaut. Fix: `app_dir()` liefert jetzt
`%APPDATA%\VersaPadViewer` (Roaming), getrennt vom Installationsordner in
`%LOCALAPPDATA%`; zusätzlich rettet `build_and_deploy.ps1` vorgefundene
`versapad_config*.json` aus dem Zielverzeichnis über den Deploy hinweg
(für Altinstallationen). **Regel für künftige Änderungen: Programm- und
Datenverzeichnis nie zusammenlegen, egal wie praktisch "alles an einem
Ort" klingt.**
- **Nur EIN Config-Pfad, unabhängig vom Startweg (2026-08-15):** `app_dir()`
hat bewusst KEINEN `sys.frozen`-Zweig mehr. Vorher sah die gebaute `.exe`
eine andere Datei als der aus dem Quellcode gestartete MCP-Server, was zu
zwei auseinanderlaufenden Configs führte (die `.exe` zeigte ein anderes
Profil 0 als der MCP-Server meldete). Wer hier wieder nach Startweg
unterscheidet, baut denselben Bug erneut ein.
- **Installierbarkeit verbessert 2026-08-14:** Drei Probleme beim
Weitergeben an andere Leute behoben. (1) `build_and_deploy.ps1` starb bei
fehlenden Paketen (pyinstaller/pystray/pillow) kommentarlos, v.a. bei
Doppelklick im Explorer, weil das Fenster sich sofort schließt. Fix:
`requirements.txt` (alle Pakete an einer Stelle, README und Skript nutzen
dieselbe Datei), Skript installiert sie selbst per `pip install -r`,
läuft komplett in try/catch, prüft `$LASTEXITCODE` nach jedem nativen
Aufruf, und pausiert am Ende (Erfolg wie Fehler) auf Tastendruck --
`-NoPause` für CI/Automation. (2) `versapad_combined.DEFAULT_PATH` war
hartkodiert auf `~\OneDrive\Desktop` einer bestimmten Maschine -- für
andere Nutzer unbrauchbar. Fix: `versapad_data.app_dir()` (neue
Funktion) liefert bei der gebauten `.exe` deren Installationsordner
(`sys.executable`-Verzeichnis), sonst den Projektordner (`__file__`-
Verzeichnis) -- `DEFAULT_PATH` hängt jetzt daran, landet also immer neben
der laufenden Installation. **Diese app_dir()-Variante ist seit
2026-08-15 überholt und war aktiv schädlich, siehe oben "Config-Pfad
darf nie im Installationsverzeichnis liegen".**
`versapad_data.CONFIG_PATHS` (Lese-Interop
mit der C#-VersaGUI) bleibt bewusst auf dem Desktop, siehe oben. (3) Fehlte
die Config UND war kein Board erreichbar, blockierte das Tool mit einer
Fehlermeldung statt zu starten. Fix: `load_or_fetch()` legt jetzt bei
Board-Fehlschlag eine leere `default_combined()` an statt `RuntimeError`
zu werfen -- Erstbenutzung ganz ohne vorhandene Config oder Board
funktioniert jetzt. (4) Profilnamen waren zusätzlich hartkodiert in
`versapad_data.PROFILE_NAMES` (Dict, jetzt entfernt, ersetzt durch
`NUM_PROFILES = 3`) und liefen der JSON-`profile_names` parallel --
Programmiermodus zeigte umbenannte Profile, Lesemodus/Browser-Ansicht
weiterhin die alten Namen. Fix: `server.py` und `desktop_viewer.py` lesen
Namen jetzt immer aus der kombinierten JSON (`combined["profile_names"]`
bzw. `versapad_combined.read_profile_names()`), eine einzige Quelle der
Wahrheit für alle Frontends.
- **Herkunft der drei Punkte oben + Tab-Umbenennen-Fix:** 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
eine ungefangene `FileNotFoundError` bzw. "Config-Datei fehlt"-Anzeige,
obwohl das Board die Config laengst dauerhaft im NVM haelt. Fix: neue
`versapad_combined.fetch_from_board()`/`load_or_fetch()` -- fehlt die
Kombi-JSON, wird sie automatisch per Serial vom Board neu aufgebaut und
als Cache gespeichert (self-healing), nur bei unerreichbarem Board (Port
belegt/kein Board) bleibt der Fallback auf die alten Einzel-JSONs bzw.
eine Klartext-Fehlermeldung. In `desktop_viewer.py` nur versucht, wenn
Live-Sync aus ist (sonst haelt der Serial-Hintergrundthread den
COM-Port -- zwei gleichzeitige Zugriffe auf denselben `self.ser` waeren
eine Race Condition). Die Desktop-JSONs sind damit reiner Lesecache, kein
Pflegeaufwand mehr fuers Board-Backup.
- **Bug behoben 2026-08-07:** `_on_toggle_editing()` initialisierte
`self.combined` beim ersten Aktivieren des Programmiermodus mit
`vcomb.default_combined()` — das seedet ALLE 3 Profile aus den alten
Einzel-JSONs `versapad_config{1,2,3}.json`, nicht aus der aktuellen
`versapad_config_all.json` oder vom Board. Ein Klick auf "Zum Board
übertragen" hat dadurch beim Testen alle 3 Profile auf einen veralteten
Stand zurückgesetzt, obwohl nur ein Profil-Tab sichtbar bearbeitet wurde —
der Schaden an den anderen beiden Profilen blieb unbemerkt, bis explizit
jedes Profil einzeln gegengelesen wurde. Fix: lädt jetzt zuerst
`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.
- **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.
- **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
@ -136,6 +415,28 @@ Byte-Identität), nicht nur gegen Beispieldaten. Vor jedem Schreibvorgang
aufs Board: erst mit unveränderten Daten testen (Identity-Write), bevor
echte Änderungen geschrieben werden.
**Änderungen an der Config immer aus der Sicht prüfen, die die laufende App
hat (2026-08-15 gelernt):** Claudes Bash- und PowerShell-Werkzeuge sehen
unter `C:\Users\chris\AppData\...` teilweise *unterschiedliche* Dateien —
derselbe Pfad lieferte gleichzeitig 29826 Bytes (Bash/MCP-Server) und
28455 Bytes (PowerShell/`.exe`). Konkret heißt das: **Config-Schreibvorgänge
über den versapad-MCP-Server (`save_local()`) erreichen die installierte
`.exe` nicht zuverlässig.** Das hat eine Fehlersuche über viele Runden
verschleppt, weil "Notizen sind in der Datei" (Bash) und "App zeigt keine
Notizen" gleichzeitig stimmten. Vorgehen:
- Config-Dateien, die die installierte App lesen soll, über **PowerShell**
schreiben/prüfen (`Get-Item`, dort ausgeführtes `python`), nicht über Bash.
- Bei "Änderung wirkt nicht"-Symptomen als Erstes Dateigröße/mtime aus
*beiden* Sichten vergleichen, bevor Code-Ursachen gesucht werden.
- `Z:\Git\...` (SMB-Share) ist von diesem Effekt nicht betroffen — Quellcode
und Build verhalten sich normal.
Verlässlich zum Ziel führt bei solchen Widersprüchen ein temporäres
Diagnose-Modul, das im gebauten Bundle mitläuft und `app_dir()`,
`DEFAULT_PATH`, `os.stat()` und den tatsächlich geladenen JSON-Inhalt in eine
Datei schreibt — damit war die Ursache in einem Durchlauf sichtbar, nachdem
Vermutungen mehrfach danebenlagen.
## Naming
Code-Identifier englisch (Python-Konvention: `pack_config`, `read_macros`,
@ -148,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
@ -168,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
@ -189,3 +500,13 @@ Committen: prägnante Commit-Message je abgeschlossenem, verifiziertem
Arbeitspaket. Nach jedem Push: alle bekannten Remotes prüfen (`origin` auf
GitHub, `jappel` auf git.jappel.io) — beide müssen synchron bleiben, siehe
Speicher-Notiz "Multi-Remote-Repos synchron halten".
**PR-Erstellung auf git.jappel.io per API/curl mit Access-Token wird vom
Bash-Classifier geblockt** (Auto-Mode, gilt auch für `git credential fill`),
siehe Speicher-Notiz "Bash-Klassifikator blockt Credential/Auth-Schreibzugriffe".
Branch pushen geht (nutzt den Git-eigenen Credential-Helper, kein Token im
Klartext im Bash-Aufruf), den fertigen PR muss der User über den von Forgejo
nach dem Push ausgegebenen Compare-Link selbst anlegen (oder Claude einen
Token geben, der dann NICHT wiederverwendbar im Bash-Aufruf landen darf,
sondern nur für den einen `curl`-Call — auch das kann der Classifier trotzdem
blocken, dann bleibt nur der manuelle Link).

139
README.md
View file

@ -11,10 +11,14 @@ Board-Schreibzugriff) und der MCP-Server sind funktionsfähig und gegen ein
echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original,
inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft
manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum
Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die
Standard-Dateipfade für die Config-JSONs sind aktuell für eine bestimmte
Windows-Maschine hartkodiert (`versapad_data.CONFIG_PATHS`,
`versapad_combined.DEFAULT_PATH`) — für einen anderen Rechner dort anpassen.
Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene
Config-Datei (`versapad_config_all.json`) liegt automatisch unter
`%APPDATA%\VersaPadViewer\` (siehe „Aufbau" unten) und wird bei Bedarf
automatisch neu angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale*
Lese-Interop mit den JSON-Exports der offiziellen VersaGUI
(`versapad_data.CONFIG_PATHS`) ist noch für eine bestimmte Windows-Maschine
hartkodiert (OneDrive-Desktop) — für einen anderen Rechner dort anpassen,
falls gewünscht.
## Features
@ -26,26 +30,63 @@ Windows-Maschine hartkodiert (`versapad_data.CONFIG_PATHS`,
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,
bleibt beim Tastenwechsel und beim "Vom Board laden" erhalten
- **MCP-Server** — lässt eine KI (Claude o.ä.) die Belegung direkt per
Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten)
- **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie
die offizielle VersaGUI
- **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
- Python 3.11 oder neuer
- Pakete: `pyserial` (Board-Kommunikation), `pystray` + `pillow`
(Tray-Icon/Desktop-App), optional `mcp` (nur für den MCP-Server)
- Alle Pakete stehen in `requirements.txt` (`pyserial` fürs Board,
`pystray` + `pillow` fürs Tray-Icon/Desktop-Fenster, `mcp` für den
MCP-Server, `pyinstaller` fürs `.exe`-Bauen):
```bash
pip install pyserial pystray pillow mcp
pip install -r requirements.txt
```
Für den Browser-Modus (`server.py`) reicht die Python-Standardbibliothek —
keine zusätzlichen Pakete nötig.
keine zusätzlichen Pakete nötig. `build_and_deploy.ps1` installiert
`requirements.txt` beim Bauen automatisch selbst — ein manuelles
`pip install` vorher ist dafür nicht nötig.
## Starten
@ -65,8 +106,15 @@ Maschine/Python-Version unterschiedlich). Selbst bauen:
.\build_and_deploy.ps1
```
Das Skript baut mit PyInstaller (`--onedir --windowed`, eigenes Icon) und
kopiert das Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`.
Das Skript installiert/aktualisiert selbst alle nötigen Pakete aus
`requirements.txt` (kein manuelles `pip install` vorher nötig), baut dann
mit PyInstaller (`--onedir --windowed`, eigenes Icon) und kopiert das
Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`.
Bricht ein Schritt ab (fehlendes Python, PyInstaller-Fehler, ...), zeigt das
Skript eine klare Fehlermeldung und wartet auf einen Tastendruck, statt sich
bei Doppelklick im Explorer kommentarlos zu schließen (`-NoPause`
unterdrückt das für automatisierte Aufrufe/CI).
**Wichtig:** Sowohl Bauen als auch Ausführen müssen auf einem lokalen
Laufwerk passieren — von einem Netzlaufwerk (SMB-Share) aus scheitert
PyInstaller beim Bauen (Pfadlängen-Problem mit Tcl/Tk-Zeitzonendaten) und
@ -75,13 +123,12 @@ das Nachladen der Bundle-DLLs von einem Netzwerkpfad, ohne jede
Fehlermeldung). `build_and_deploy.ps1` kopiert den Quellcode deshalb
automatisch zuerst nach `%TEMP%` und baut nur dort.
Voraussetzung: `pip install pyinstaller` zusätzlich zu den obigen Paketen.
## Aufbau
| 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 |
@ -94,19 +141,38 @@ Das Binärformat (`versapad_protocol.py`) 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).
Config-Dateien liegen standardmäßig auf dem Desktop
(`versapad_config1/2/3.json` für den reinen Lesemodus,
`versapad_config_all.json` für den Programmiermodus — Pfade sind aktuell
hartkodiert für eine bestimmte Windows-Maschine, siehe `CONFIG_PATHS` in
`versapad_data.py` bzw. `DEFAULT_PATH` in `versapad_combined.py`).
Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile +
Makros + lokale Profilnamen + Notizen, siehe `versapad_combined.py`) liegt
unter `%APPDATA%\VersaPadViewer\` (`versapad_data.app_dir()`), also
**getrennt vom Installationsordner** und unabhängig davon, ob die `.exe`
oder der Quellcode gestartet wurde. Beides ist Absicht: das Build-Skript
räumt sein Zielverzeichnis vor jedem Deploy komplett ab (läge die Config
dort, würde jeder Rebuild sie löschen), und ein vom Startweg abhängiger
Pfad hatte zu zwei auseinanderlaufenden Configs geführt. Fehlt die Datei
(z.B. frische Installation), wird sie automatisch angelegt — per Serial vom
Board, falls eins angeschlossen ist, sonst als leere Default-Config.
Profilnamen (`profile_names`) stehen direkt in dieser Datei und lassen sich
per Doppelklick auf einen Tab (in jedem Modus — Nur-Lesen, Live-Sync oder
Programmiermodus) oder `rename_profile()` (MCP) ändern. Notizen ebenso —
im Bearbeiten-Dialog des Programmiermodus oder per
`set_button_note()`/`set_encoder_note()` (MCP).
Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool
geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export
der offiziellen VersaGUI (C#/.NET) — Pfad aktuell hartkodiert auf den
OneDrive-Desktop einer bestimmten Windows-Maschine, siehe `CONFIG_PATHS` in
`versapad_data.py`.
## MCP-Server
`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`/
@ -123,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

BIN
VP-Board Enclosure.f3d Normal file

Binary file not shown.

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")
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")
selectcolor=BG, activebackground=BG2, activeforeground=TEXT,
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)
@ -124,7 +362,9 @@ class MacroStepsDialog(_ModalDialog):
class ActionEditDialog(_ModalDialog):
"""Ergebnis in self.action / self.led nach run()==True. led bleibt None
wenn led_in None war (Encoder -- keine eigene Farbe)."""
wenn led_in None war (Encoder -- keine eigene Farbe). self.action enthaelt
immer ein "note"-Feld (freie Notiz, was die Aktion tut -- rein lokal wie
Profilnamen, geht nie aufs Board)."""
def __init__(self, parent, title, action, led, macros):
super().__init__(parent, title)
@ -134,21 +374,30 @@ class ActionEditDialog(_ModalDialog):
self.led = None
row = 0
note_row = tk.Frame(self, bg=BG2)
note_row.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(12, 4)); row += 1
tk.Label(note_row, text="Notiz:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
self._note_var = tk.StringVar(value=action.get("note", ""))
tk.Entry(note_row, textvariable=self._note_var, width=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(
row=row, column=0, sticky="w", padx=12, pady=(12, 4)); row += 1
row=row, column=0, sticky="w", padx=12, pady=(4, 4)); row += 1
self._type_var = tk.StringVar(value=action["type"])
type_frame = tk.Frame(self, bg=BG2)
type_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12); row += 1
for value, label in TYPE_CHOICES:
tk.Radiobutton(type_frame, text=label, variable=self._type_var, value=value,
command=self._on_type_change, bg=BG2, fg=TEXT, selectcolor=BG,
activebackground=BG2, activeforeground=TEXT,
font=("Segoe UI", 9)).pack(side="left", padx=(0, 8))
command=self._on_type_change, bg=BG2, fg=TEXT, selectcolor=BG,
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
@ -165,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
@ -197,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:
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")
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)
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")
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.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)
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))
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")
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")
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.Spinbox(anim_row, from_=2, to=10000, increment=100, textvariable=self._period_var,
width=7).pack(side="left", padx=6)
return row + 1
def _hex(self):
r, g, b = self._led_color
return f"#{r:02x}{g:02x}{b:02x}"
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")
result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe", parent=self)
if result and result[0]:
r, g, b = (int(c) for c in result[0])
self._led_color = (r, g, b)
self._swatch.configure(bg=self._hex())
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():
@ -243,83 +513,109 @@ 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,
width=20, state="readonly").pack(side="left", padx=6)
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")
ttk.Combobox(row1, textvariable=self._profile_switch_var,
values=[n for n, _ in PROFILE_SWITCH_CHOICES],
width=22, state="readonly").pack(side="left", padx=6)
values=[n for n, _ in PROFILE_SWITCH_CHOICES],
width=22, state="readonly").pack(side="left", padx=6)
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}
action["note"] = self._note_var.get()
self.action = action
if self._led is not None:
r, g, b = self._led_color

View file

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

View file

@ -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
@ -54,7 +55,11 @@ OK_GREEN = "#3ecf6e"
WARN_RED = "#e0895a"
POLL_MS = 1500
CARD_W, CARD_H = 150, 84
CARD_W, CARD_H = 150, 114
# 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
@ -97,9 +102,11 @@ Buttons setzen (profile 0-2, index 0-19):
set_button_profile_switch(profile, index, target)
set_button_none(profile, index)
set_button_led(profile, index, r, g, b, anim, period_ms)
set_button_note(profile, index, note) freie Notiz, was der Button tut
Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'):
set_encoder_key / _consumer / _macro / _profile_switch / _none(...)
set_encoder_note(profile, index, field, note)
Makro:
set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...]
@ -122,13 +129,23 @@ class VersaPadViewer(tk.Tk):
super().__init__()
self.title("VersaPad Steuermatrix")
self.configure(bg=BG)
self.geometry("760x760")
# Hoehe muss Kopfzeile + Tabs + 5 Kartenreihen + Encoder + Fusszeile
# fassen -- seit die Karten eine Notizzeile haben (CARD_H 84 -> 114)
# reichten die alten 760px nicht mehr: der Encoder-Bereich lag
# unterhalb des Fensterrands. Wer CARD_H aendert, muss hier mit.
self.geometry("790x960")
self.profile = 0
self._mtimes = {}
self.combined = None # kombinierter Programmiermodus-State, erst bei Bedarf befuellt
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()
@ -149,55 +166,57 @@ class VersaPadViewer(tk.Tk):
)
self._tray_icon.run_detached()
header = tk.Frame(self, bg=BG)
header.pack(fill="x", padx=20, pady=(18, 4))
tk.Label(header, text="VersaPad Steuermatrix", bg=BG, fg=TEXT,
font=("Segoe UI", 15, "bold")).pack(anchor="w")
self.header_sub = tk.Label(header, text="pollt Config-JSONs alle 1.5s", bg=BG,
fg=TEXT_DIM, font=("Segoe UI", 9))
self.header_sub.pack(anchor="w")
# 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)
info_btn = tk.Label(header, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 14),
cursor="hand2")
info_btn.place(relx=1.0, x=0, y=-4, anchor="ne")
self.toggles_row = header = tk.Frame(self, bg=BG)
header.pack(fill="x", padx=20, pady=(10, 6))
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)
info_btn.pack(side="right", padx=(0, 8))
info_btn.bind("<Button-1>", lambda e: self._show_mcp_info())
info_btn.bind("<Enter>", lambda e: info_btn.configure(fg=ACCENT))
info_btn.bind("<Leave>", lambda e: info_btn.configure(fg=TEXT_DIM))
self.tabs = tk.Frame(self, bg=BG)
self.tabs.pack(fill="x", padx=20, pady=(12, 10))
self.tab_buttons = {}
for p in sorted(vp.PROFILE_NAMES):
btn = tk.Label(self.tabs, text=vp.PROFILE_NAMES[p], bg=CARD_BG, fg=TEXT,
font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2")
btn.pack(side="left", padx=(0, 8))
btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True))
btn.bind("<Double-Button-1>", lambda e, prof=p: self._rename_tab(prof))
self.tab_buttons[p] = btn
toggles_row = tk.Frame(self, bg=BG)
toggles_row.pack(fill="x", padx=20, pady=(0, 6))
# Modus-Umschalter direkt neben der Ueberschrift statt in eigener
# Zeile -- spart eine komplette Zeile Fensterhoehe.
self.sync_check = tk.Checkbutton(
toggles_row, text="Live-Sync mit Board", variable=self.live_sync,
header, text="Live-Sync", variable=self.live_sync,
command=self._on_toggle_sync, bg=BG, fg=TEXT, selectcolor=CARD_BG,
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold"))
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
self.sync_check.pack(side="left")
self.sync_status = tk.Label(toggles_row, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9))
self.sync_status.pack(side="left", padx=(8, 20))
self.sync_status = tk.Label(header, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
self.sync_status.pack(side="left", padx=(4, 14))
self.edit_check = tk.Checkbutton(
toggles_row, text="Programmiermodus", variable=self.editing,
header, text="Programmiermodus", variable=self.editing,
command=self._on_toggle_editing, bg=BG, fg=TEXT, selectcolor=CARD_BG,
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold"))
self.edit_check.pack(side="left", padx=(0, 20))
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
self.edit_check.pack(side="left", padx=(0, 14))
self.always_on_top = tk.BooleanVar(value=False)
self.topmost_check = tk.Checkbutton(
toggles_row, text="Immer im Vordergrund", variable=self.always_on_top,
header, text="Immer im Vordergrund", variable=self.always_on_top,
command=self._on_toggle_topmost, bg=BG, fg=TEXT, selectcolor=CARD_BG,
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold"))
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
self.topmost_check.pack(side="left")
# Schmale Toolbar-Zeile fuers Board-I/O -- nur sichtbar im
# Programmiermodus (siehe _on_toggle_editing), direkt unter den
# Checkboxen statt weit unten zwischen Tabs und Matrix.
self.prog_row = tk.Frame(self, bg=BG)
for text, cmd in (
("Vom Board laden", self._load_from_board),
@ -207,24 +226,49 @@ class VersaPadViewer(tk.Tk):
):
tk.Button(self.prog_row, text=text, command=cmd, bg=CARD_BG, fg=TEXT,
activebackground=ACCENT, activeforeground="#fff", relief="flat",
padx=10, pady=4, font=("Segoe UI", 9)).pack(side="left", padx=(0, 8))
self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9))
padx=8, pady=2, font=("Segoe UI", 8)).pack(side="left", padx=(0, 6))
self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
self.prog_status.pack(side="left", padx=(8, 0))
# 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
# Fensterkopf -- naeher an dem, was sie auswaehlen.
self.tabs = tk.Frame(self, bg=BG)
self.tabs.pack(fill="x", padx=20, pady=(8, 8))
self.tab_buttons = {}
for p in range(vp.NUM_PROFILES):
btn = tk.Label(self.tabs, text=f"Profil {p}", bg=CARD_BG, fg=TEXT,
font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2")
btn.pack(side="left", padx=(0, 8))
btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True))
btn.bind("<Double-Button-1>", lambda e, prof=p: self._rename_tab(prof))
self.tab_buttons[p] = btn
self.grid_frame = tk.Frame(self, bg=BG)
self.grid_frame.pack(padx=20, pady=(8, 0))
self.grid_frame.pack(padx=20, pady=(0, 0), anchor="w")
tk.Label(self, text="Encoder", bg=BG, fg=TEXT_DIM,
font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=20, pady=(20, 8))
self.enc_frame = tk.Frame(self, bg=BG)
self.enc_frame.pack(fill="x", padx=20)
self.footer = tk.Label(self, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
self.footer.pack(anchor="w", padx=20, pady=(20, 10))
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")
# 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)
@ -242,21 +286,49 @@ class VersaPadViewer(tk.Tk):
self._render()
def _update_tab_labels(self):
"""Profilnamen kommen immer aus der kombinierten JSON (nicht mehr
hartkodiert) -- im Programmiermodus aus dem In-Memory-State, sonst
per schlankem Datei-Read (kein Board-Zugriff, siehe
versapad_combined.profile_names())."""
if self.editing.get() and self.combined:
names = self.combined["profile_names"]
else:
names = vcomb.read_profile_names()
for p, btn in self.tab_buttons.items():
if self.editing.get() and self.combined:
btn.configure(text=self.combined["profile_names"][p])
else:
btn.configure(text=vp.PROFILE_NAMES[p])
btn.configure(text=names[p])
def _rename_tab(self, profile):
if not self.editing.get() or self.combined is None:
return
current = self.combined["profile_names"][profile]
"""Profilname per Doppelklick auf den Tab umbenennen -- geht in
jedem Modus (rein lokal, landet nie aufs Board, siehe Kritische
Domänenregeln). Im Programmiermodus wird der bereits geladene
In-Memory-State direkt bearbeitet + autosaved; sonst wird die
kombinierte Datei frisch gelesen/geschrieben (legt sie bei Bedarf
automatisch an, siehe versapad_combined.load_or_fetch())."""
if self.editing.get() and self.combined is not None:
combined = self.combined
else:
try:
combined = vcomb.load_or_fetch()
except (OSError, ValueError, KeyError) as e:
messagebox.showerror("Umbenennen fehlgeschlagen", str(e))
return
current = combined["profile_names"][profile]
name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):",
initialvalue=current, parent=self)
if name:
self.combined["profile_names"][profile] = name
self._update_tab_labels()
if not name:
return
combined["profile_names"][profile] = name
if combined is self.combined:
self._autosave_combined()
else:
try:
vcomb.save_file(combined, vcomb.DEFAULT_PATH)
except OSError as e:
messagebox.showerror("Umbenennen fehlgeschlagen", f"Speichern fehlgeschlagen: {e}")
return
self._update_tab_labels()
def _show_mcp_info(self):
win = tk.Toplevel(self)
@ -342,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()
@ -391,13 +457,17 @@ class VersaPadViewer(tk.Tk):
self._on_toggle_sync()
self.sync_check.configure(state="disabled")
if self.combined is None:
self.combined = vcomb.default_combined()
self.prog_row.pack(fill="x", padx=20, pady=(0, 14), after=self.tabs)
self.header_sub.configure(text="Programmiermodus · Zelle anklicken zum Bearbeiten")
if os.path.exists(vcomb.DEFAULT_PATH):
try:
self.combined = vcomb.load_file(vcomb.DEFAULT_PATH)
except (OSError, ValueError, KeyError):
self.combined = vcomb.default_combined()
else:
self.combined = vcomb.default_combined()
self.prog_row.pack(fill="x", padx=20, pady=(0, 6), after=self.toggles_row)
else:
self.sync_check.configure(state="normal")
self.prog_row.pack_forget()
self.header_sub.configure(text="pollt Config-JSONs alle 1.5s")
self._update_tab_labels()
self._render()
@ -426,7 +496,9 @@ class VersaPadViewer(tk.Tk):
macro_slots = vproto.unpack_macros(raw_macros)
names = self.combined["profile_names"] if self.combined else None
self.combined = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names)
previous = self.combined
self.combined = vcomb.merge_notes(
vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous)
self.profile = self.combined["active_profile"]
self._status("vom Board geladen", True)
self._update_tab_labels()
@ -513,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):
@ -520,22 +769,36 @@ class VersaPadViewer(tk.Tk):
Bevorzugt die kombinierte Datei (versapad_config_all.json), falls
vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen
sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen.
Faellt zurueck auf die klassischen versapad_config{1,2,3}.json, wenn
es noch keine kombinierte Datei gibt."""
if os.path.exists(vcomb.DEFAULT_PATH):
Fehlt sie (z.B. versehentlich geloescht, oder frische Installation
ganz ohne Config) und ist Live-Sync gerade aus (COM-Port frei), wird
sie automatisch angelegt -- per Serial vom Board, oder als leere
Default-Config, wenn auch kein Board erreichbar ist (siehe
versapad_combined.load_or_fetch()) -- das Tool ist damit auch ganz
ohne vorhandene Config sofort benutzbar, kein Datei-Handling von
Hand mehr noetig. Nur wenn Live-Sync gerade an ist (haelt den
COM-Port) und noch keine Datei existiert, weicht es zuletzt auf die
klassischen versapad_config{1,2,3}.json (Export der offiziellen
VersaGUI) aus."""
if os.path.exists(vcomb.DEFAULT_PATH) or not self.live_sync.get():
try:
data = vcomb.load_file(vcomb.DEFAULT_PATH)
data = vcomb.load_or_fetch(link=self._link)
raw = data["profiles"][self.profile]
cfg = vp.annotate_profile({
"buttons": [dict(b) for b in raw["buttons"]],
"encoders": [dict(e) for e in raw["encoders"]],
})
}, data.get("macros"))
return cfg, f"Quelle: {vcomb.DEFAULT_PATH}"
except (KeyError, IndexError, ValueError):
pass # kaputte/unvollstaendige Datei -- auf Einzel-JSONs ausweichen
return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}"
pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen
try:
return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}"
except FileNotFoundError as e:
hint = (" (Live-Sync ist an -- COM-Port belegt, zum automatischen "
"Neuladen vom Board erst ausschalten)") if self.live_sync.get() else ""
raise FileNotFoundError(f"{e}{hint}") from e
def _render(self):
self._update_tab_labels()
for w in self.grid_frame.winfo_children():
w.destroy()
for w in self.enc_frame.winfo_children():
@ -549,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:
@ -591,19 +854,23 @@ class VersaPadViewer(tk.Tk):
tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT,
font=("Segoe UI", 10, "normal" if empty else "bold"),
wraplength=CARD_W - 20, justify="left", anchor="nw").place(
x=10, y=26, width=CARD_W - 20, height=36)
x=10, y=24, width=CARD_W - 20, height=28)
# Notiz bekommt den ganzen Rest der Karte (bis zur Animationszeile) --
# bei 34px wurden laengere Notizen mitten im Wort abgeschnitten.
tk.Label(card, text=btn["note"], bg=CARD_BG, fg=TEXT_DIM,
font=("Segoe UI", 8), wraplength=CARD_W - 20, justify="left", anchor="nw").place(
x=10, y=53, width=CARD_W - 20, height=CARD_H - 69)
anim = "" if empty else vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"])
tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM,
font=("Segoe UI", 7), anchor="sw").place(
x=10, y=CARD_H - 20, width=CARD_W - 20, height=14)
x=10, y=CARD_H - 16, width=CARD_W - 20, height=13)
if editable:
card.configure(cursor="hand2")
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,
@ -613,18 +880,31 @@ class VersaPadViewer(tk.Tk):
inner.pack(fill="both", expand=True, padx=10, pady=8)
tk.Label(inner, text=f"Encoder {enc['index']}", bg=CARD_BG, fg=TEXT_DIM,
font=("Segoe UI", 8, "bold")).pack(anchor="w", pady=(0, 4))
for key, field, val in (("Druck", "sw", enc["sw_label"]), ("CW", "cw", enc["cw_label"]),
("CCW", "ccw", enc["ccw_label"])):
for key, field, val, note in (("Druck", "sw", enc["sw_label"], enc["sw_note"]),
("CW", "cw", enc["cw_label"], enc["cw_note"]),
("CCW", "ccw", enc["ccw_label"], enc["ccw_note"])):
row = tk.Frame(inner, bg=CARD_BG)
row.pack(fill="x", pady=1)
tk.Label(row, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
tk.Label(row, text=val or "", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
row.pack(fill="x", pady=(1, 4))
top = tk.Frame(row, bg=CARD_BG)
top.pack(fill="x")
tk.Label(top, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
tk.Label(top, text=val or "", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
# Notizzeile nur anlegen, wenn es wirklich eine Notiz gibt --
# ein leeres Label belegt sonst pro Encoder-Aktion eine Zeile
# Hoehe (12x im Fenster) und schiebt die Fusszeile aus dem Bild.
note_label = None
if note:
note_label = tk.Label(row, text=note, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7),
wraplength=150, justify="left", anchor="w")
note_label.pack(fill="x")
if editable:
row.configure(cursor="hand2")
handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl)
row.bind("<Button-1>", handler)
for child in row.winfo_children():
child.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:
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 |

5
requirements.txt Normal file
View file

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

View file

@ -1,7 +1,10 @@
"""
VersaPad Viewer -- Browser-Variante.
Liest bei jedem Request live die 3 Config-JSONs vom Desktop und rendert
die Steuermatrix (4x5 Grid + 4 Encoder) je Profil als HTML.
Liest bei jedem Request die kombinierte Config-JSON vom Desktop und
rendert die Steuermatrix (4x5 Grid + 4 Encoder) je Profil als HTML. Fehlt
die JSON (z.B. geloescht), wird sie automatisch per Serial vom Board neu
aufgebaut und als neuer Cache gespeichert -- das Board ist die eigentliche
Quelle der Wahrheit, siehe versapad_combined.load_or_fetch().
Kein Build-Schritt, kein externes Framework -- nur stdlib.
Start: python server.py [--port 8765]
@ -12,6 +15,7 @@ import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
import versapad_combined as vcomb
import versapad_data as vp
PAGE_CSS = """
@ -34,7 +38,7 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
.grid {
display: grid;
grid-template-columns: repeat(4, 140px);
grid-template-rows: repeat(5, 76px);
grid-template-rows: repeat(5, 104px);
grid-auto-flow: column;
gap: 10px;
margin-bottom: 36px;
@ -49,15 +53,17 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
}
.cell .idx { position: absolute; top: 8px; right: 10px; font-size: 11px; color: #6a6d78; }
.cell .label { font-size: 14px; font-weight: 600; line-height: 1.25; word-break: break-word; }
.cell .note { font-size: 11px; color: #8a8d98; margin-top: 4px; word-break: break-word; }
.cell .anim { font-size: 11px; color: #8a8d98; margin-top: 2px; }
.cell.empty .label { color: #4a4d58; font-weight: 400; }
h2 { font-size: 15px; font-weight: 600; color: #c4c6cf; margin: 0 0 12px; }
.encoders { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 720px; }
.enc { background: #1e2129; border: 1px solid #2a2d37; border-radius: 10px; padding: 12px 14px; }
.enc .idx { font-size: 12px; color: #6a6d78; margin-bottom: 8px; }
.enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0; }
.enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0 0; }
.enc .row .k { color: #8a8d98; }
.enc .row .v { font-weight: 500; text-align: right; }
.enc .note { font-size: 11px; color: #6a6d78; padding-bottom: 6px; word-break: break-word; }
footer { margin-top: 40px; color: #6a6d78; font-size: 12px; }
"""
@ -69,28 +75,44 @@ def render_cell(btn):
grid_col, grid_row = btn["col"] + 1, btn["row"] + 1
style = f"grid-column:{grid_col}; grid-row:{grid_row};"
label = html.escape(btn["label"]) if btn["label"] else ""
note = html.escape(btn["note"]) if btn.get("note") else ""
return f"""<div class="{css_class}" style="{style}">
<div class="swatch" style="background:{vp.led_css(btn['led'])};"></div>
<div class="idx">#{btn['index']}</div>
<div class="label">{label}</div>
<div class="note">{note}</div>
<div class="anim">{"" if empty else anim}</div>
</div>"""
def _encoder_row(key, label, note):
label = html.escape(label) or ""
note_html = f'<div class="note">{html.escape(note)}</div>' if note else ""
return f"""<div class="row"><span class="k">{key}</span><span class="v">{label}</span></div>{note_html}"""
def render_encoder(enc):
rows = (
_encoder_row("Druck", enc["sw_label"], enc["sw_note"])
+ _encoder_row("CW", enc["cw_label"], enc["cw_note"])
+ _encoder_row("CCW", enc["ccw_label"], enc["ccw_note"])
)
return f"""<div class="enc">
<div class="idx">Encoder {enc['index']}</div>
<div class="row"><span class="k">Druck</span><span class="v">{html.escape(enc['sw_label']) or ''}</span></div>
<div class="row"><span class="k">CW</span><span class="v">{html.escape(enc['cw_label']) or ''}</span></div>
<div class="row"><span class="k">CCW</span><span class="v">{html.escape(enc['ccw_label']) or ''}</span></div>
{rows}
</div>"""
def render_page(profile):
cfg = vp.load_profile(profile)
combined = vcomb.load_or_fetch()
raw = combined["profiles"][profile]
cfg = vp.annotate_profile({
"buttons": [dict(b) for b in raw["buttons"]],
"encoders": [dict(e) for e in raw["encoders"]],
}, combined.get("macros"))
tabs = "".join(
f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(vp.PROFILE_NAMES[p])}</a>'
for p in sorted(vp.PROFILE_NAMES)
f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(combined["profile_names"][p])}</a>'
for p in range(vp.NUM_PROFILES)
)
cells = "".join(render_cell(b) for b in cfg["buttons"])
encoders = "".join(render_encoder(e) for e in cfg["encoders"])
@ -107,7 +129,7 @@ def render_page(profile):
<div class="grid">{cells}</div>
<h2>Encoder</h2>
<div class="encoders">{encoders}</div>
<footer>Quelle: {html.escape(vp.CONFIG_PATHS[profile])}</footer>
<footer>Quelle: {html.escape(vcomb.DEFAULT_PATH)}</footer>
</body></html>"""
@ -121,7 +143,7 @@ class Handler(BaseHTTPRequestHandler):
profile = int(query.get("profile", ["0"])[0])
except ValueError:
profile = 0
if profile not in vp.PROFILE_NAMES:
if not (0 <= profile < vp.NUM_PROFILES):
profile = 0
try:
body = render_page(profile).encode("utf-8")
@ -130,10 +152,10 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
except FileNotFoundError as e:
except (FileNotFoundError, RuntimeError) as e:
self.send_response(500)
self.end_headers()
self.wfile.write(f"Config-Datei fehlt: {e}".encode("utf-8"))
self.wfile.write(f"Config nicht verfuegbar: {e}".encode("utf-8"))
def main():

View file

@ -17,17 +17,18 @@ import os
import versapad_data as vp
import versapad_protocol as proto
import versapad_serial as vs
DEFAULT_PATH = os.path.expanduser(r"~\OneDrive\Desktop\versapad_config_all.json")
DEFAULT_PATH = os.path.join(vp.app_dir(), "versapad_config_all.json")
DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"]
def _empty_profile():
buttons = [{"index": i, "action": {"type": "None", "data": 0},
buttons = [{"index": i, "action": {"type": "None", "data": 0, "note": ""},
"led": {"r": 80, "g": 40, "b": 0, "brightness": 255,
"anim": "Static", "period_ms": 4000}} for i in range(20)]
none = {"type": "None", "data": 0}
none = {"type": "None", "data": 0, "note": ""}
encoders = [{"index": i, "sw": dict(none), "cw": dict(none), "ccw": dict(none)} for i in range(4)]
return {"buttons": buttons, "encoders": encoders}
@ -60,17 +61,54 @@ def default_combined():
def from_binary(config_dict, macro_slots, profile_names=None):
"""config_dict: Ergebnis von versapad_protocol.unpack_config().
macro_slots: Ergebnis von versapad_protocol.unpack_macros()."""
macro_slots: Ergebnis von versapad_protocol.unpack_macros(). Actions
kommen frisch vom Board ohne "note" (die Firmware kennt keine Notizen,
siehe merge_notes()) -- hier nur mit leerem Default versehen, damit das
Feld ueberall verlaesslich existiert."""
profiles = config_dict["profiles"]
for profile in profiles:
for b in profile["buttons"]:
b["action"].setdefault("note", "")
for e in profile["encoders"]:
for field in ("sw", "cw", "ccw"):
e[field].setdefault("note", "")
return {
"active_profile": config_dict["active_profile"],
"global_brightness": config_dict["global_brightness"],
"enc_sensitivity": config_dict["enc_sensitivity"],
"profile_names": profile_names or list(DEFAULT_NAMES),
"profiles": config_dict["profiles"],
"profiles": profiles,
"macros": macro_slots,
}
def merge_notes(combined, previous):
"""Kopiert Notizen (Button/Encoder-Aktion) aus einem vorherigen State in
einen frisch vom Board gelesenen State -- wie profile_names sind Notizen
rein lokal und wuerden bei jedem load_from_board()/fetch_from_board()
sonst verloren gehen, weil die Firmware sie nicht kennt. previous=None
(z.B. allererstes Laden) -> nichts zu tun, combined unveraendert
zurueckgegeben. Aendert combined in-place und gibt es zurueck."""
if not previous:
return combined
for p_idx, profile in enumerate(combined["profiles"]):
if p_idx >= len(previous["profiles"]):
continue
prev_profile = previous["profiles"][p_idx]
prev_buttons = {b["index"]: b for b in prev_profile.get("buttons", [])}
for b in profile["buttons"]:
prev = prev_buttons.get(b["index"])
if prev:
b["action"]["note"] = prev["action"].get("note", "")
prev_encoders = {e["index"]: e for e in prev_profile.get("encoders", [])}
for e in profile["encoders"]:
prev = prev_encoders.get(e["index"])
if prev:
for field in ("sw", "cw", "ccw"):
e[field]["note"] = prev[field].get("note", "")
return combined
def to_binary(combined):
"""-> (config_bytes[740], macro_bytes[512])"""
config_bytes = proto.pack_config({
@ -84,6 +122,7 @@ def to_binary(combined):
def save_file(combined, path=DEFAULT_PATH):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(combined, f, indent=2, ensure_ascii=False)
return path
@ -92,3 +131,70 @@ def save_file(combined, path=DEFAULT_PATH):
def load_file(path=DEFAULT_PATH):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def fetch_from_board(link=None, profile_names=None):
"""Liest Config+Makros direkt vom Board per Serial (~1-2s), das Board
ist die eigentliche Quelle der Wahrheit (write_to_board() speichert
dauerhaft im NVM -- die JSON-Dateien hier sind nur ein Lesecache).
link: bestehender VersaPadLink wiederverwenden (z.B. desktop_viewer's
self._link, damit nicht zwei Verbindungen um denselben COM-Port
konkurrieren) -- sonst wird eine eigene geoeffnet und wieder
geschlossen. Wirft RuntimeError mit Klartext-Ursache (last_error),
z.B. wenn der Port gerade von VersaGUI/einem anderen Viewer belegt ist."""
owns_link = link is None
if owns_link:
link = vs.VersaPadLink()
try:
raw_cfg = link.read_full_config()
if raw_cfg is None:
raise RuntimeError(f"Config vom Board laden fehlgeschlagen: {link.last_error}")
raw_macros = link.read_macros()
if raw_macros is None:
raise RuntimeError(f"Makros vom Board laden fehlgeschlagen: {link.last_error}")
cfg_dict = proto.unpack_config(raw_cfg)
if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]):
raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)")
macro_slots = proto.unpack_macros(raw_macros)
return from_binary(cfg_dict, macro_slots, profile_names=profile_names)
finally:
if owns_link:
link.close()
def load_or_fetch(path=DEFAULT_PATH, link=None, profile_names=None):
"""Bevorzugt die lokale Kombi-Datei. Fehlt sie (z.B. versehentlich
geloescht, oder frische Installation ohne jede Config), wird sie
automatisch neu angelegt -- zuerst per Serial-Versuch vom Board (das
behaelt die Config dauerhaft im NVM, die JSON ist nur ein Lesecache
dafuer), und falls auch das Board nicht erreichbar ist (nicht
verbunden, COM-Port belegt, frisch installiert ohne Board in Reichweite)
als leere Default-Config (vgl. default_combined()) -- damit ist das
Tool auch ganz ohne vorhandene Config sofort benutzbar, statt mit
einem Fehler zu blockieren."""
if os.path.exists(path):
return load_file(path)
try:
combined = fetch_from_board(link=link, profile_names=profile_names)
except RuntimeError:
combined = default_combined()
if profile_names:
combined["profile_names"] = profile_names
try:
save_file(combined, path)
except OSError:
pass # Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden
return combined
def read_profile_names(path=DEFAULT_PATH):
"""Nur die (lokalen) Profilnamen lesen, ohne die volle Config zu
brauchen -- fuer Tab-Beschriftungen im Nur-Lese-Modus. Greift bewusst
nicht aufs Board zu (kein COM-Port-Konflikt mit Live-Sync), faellt bei
fehlender/kaputter Datei auf DEFAULT_NAMES zurueck."""
if os.path.exists(path):
try:
return load_file(path).get("profile_names", list(DEFAULT_NAMES))
except (OSError, ValueError):
pass
return list(DEFAULT_NAMES)

View file

@ -8,18 +8,54 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen.
import json
import os
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"
def app_dir():
"""Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json,
siehe versapad_combined.DEFAULT_PATH). Programmatisch aus der Umgebung
abgeleitet, kein hartkodierter Pfad -- laeuft so auf jeder Maschine und
unter jedem Benutzer.
Bewusst NICHT vom Startweg abhaengig (kein `sys.frozen`-Zweig): die
gebaute .exe, der Start aus dem Quellcode und der MCP-Server muessen
dieselbe Datei sehen, sonst laufen zwei Configs auseinander und
Aenderungen aus dem einen Weg sind im anderen unsichtbar (genau das ist
am 2026-08-15 passiert -- .exe zeigte ein anderes Profil 0 als der
MCP-Server).
Bewusst auch NICHT das Installationsverzeichnis (%LOCALAPPDATA%\\
VersaPadViewer, wo die .exe liegt): `build_and_deploy.ps1` raeumt das
Zielverzeichnis vor jedem Deploy komplett ab (`Remove-Item -Recurse`) --
laege die Config dort, wuerde JEDER Rebuild die Nutzerdaten mitloeschen
(am 2026-08-15 genau so passiert, alle Notizen weg). Programm- und
Datenverzeichnis bleiben deshalb getrennt: Roaming-AppData fuer die
Config."""
base = (os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA")
or os.path.join(os.path.expanduser("~"), ".local", "share"))
return os.path.join(base, APP_NAME)
# CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind
# keine von diesem Tool geschriebenen Dateien, sondern ein Export der
# offiziellen (C#/.NET-)VersaGUI auf dieser einen Maschine (reine Lese-
# Interop, siehe README "Bekannte Einschraenkungen"). Fuer das eigentliche,
# von diesem Tool selbst gepflegte Format siehe versapad_combined.DEFAULT_PATH
# (liegt jetzt in app_dir(), nicht mehr hartkodiert auf dem Desktop).
CONFIG_PATHS = {
0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"),
1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"),
2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"),
}
PROFILE_NAMES = {
0: "Profil 0 Windows",
1: "Profil 1 Fusion 360",
2: "Profil 2 BricsCAD",
}
# index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge)
GRID_COLS = 4
GRID_ROWS = 5
@ -78,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",
@ -89,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():
@ -99,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):
@ -132,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
@ -142,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
@ -155,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":
@ -166,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):
@ -189,16 +464,24 @@ def button_grid_position(index):
return col, row
def annotate_profile(cfg):
"""Fuegt label/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus
einer Einzel-JSON oder aus dem kombinierten Programmiermodus-State kommt."""
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.
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["cw_label"] = action_label(e["cw"])
e["ccw_label"] = action_label(e["ccw"])
e["sw_label"] = action_label(e["sw"], macros)
e["sw_note"] = e["sw"].get("note", "")
e["cw_label"] = action_label(e["cw"], macros)
e["cw_note"] = e["cw"].get("note", "")
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,21 @@ 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", "")}
def _replace_action(old_action, new_type, new_data):
"""Baut eine neue Action mit neuem Typ/Daten, behaelt aber die Notiz vom
vorherigen Stand bei (rein lokal, unabhaengig davon was die Aktion tut --
ein Tastenwechsel soll die Beschreibung 'was der Button macht' nicht
loeschen). Zum Loeschen explizit set_button_note()/set_encoder_note()
mit leerem String."""
return {"type": new_type, "data": new_data, "note": old_action.get("note", "")}
# ── Lesen ────────────────────────────────────────────────────────────────────
@ -116,9 +130,15 @@ def get_macro(slot: int) -> dict:
def get_board_status() -> dict:
"""Prueft per Serial, ob das Board erreichbar ist und welches Profil dort
gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der
Tkinter-Viewer den COM-Port gerade halten."""
profile = _link.read_active_profile()
return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error}
Tkinter-Viewer den COM-Port gerade halten. Gibt den COM-Port danach
sofort wieder frei (kein dauerhaft offen gehaltener Serial-Handle --
sonst blockiert dieser Prozess andere Tools/Viewer mit "busy", bis er
beendet wird)."""
try:
profile = _link.read_active_profile()
return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error}
finally:
_link.close()
# ── Buttons (20 pro Profil, MX-Matrix) ───────────────────────────────────────
@ -132,7 +152,7 @@ def set_button_key(profile: int, index: int, key: str, modifiers: list[str] = []
btn = _find(_profile(profile)["buttons"], index)
keycode = vp.hid_key_code_for_name(key)
mod_bits = vp.modifier_bits_for_names(modifiers)
btn["action"] = {"type": "HidKey", "data": (mod_bits << 8) | keycode}
btn["action"] = _replace_action(btn["action"], "HidKey", (mod_bits << 8) | keycode)
return _describe_action(btn["action"])
@ -142,7 +162,7 @@ def set_button_consumer(profile: int, index: int, consumer: str) -> dict:
'Lauter', 'Leiser', 'Nächster Titel', 'Vorheriger Titel'."""
btn = _find(_profile(profile)["buttons"], index)
cid = vp.consumer_id_for_name(consumer)
btn["action"] = {"type": "HidConsumer", "data": cid}
btn["action"] = _replace_action(btn["action"], "HidConsumer", cid)
return _describe_action(btn["action"])
@ -151,7 +171,7 @@ def set_button_macro(profile: int, index: int, slot: int) -> dict:
"""Belegt einen MX-Button mit einem Makro-Slot (0-31). Die Schritte selbst
mit set_macro() befuellen."""
btn = _find(_profile(profile)["buttons"], index)
btn["action"] = {"type": "Macro", "data": slot}
btn["action"] = _replace_action(btn["action"], "Macro", slot)
return _describe_action(btn["action"])
@ -159,15 +179,27 @@ def set_button_macro(profile: int, index: int, slot: int) -> dict:
def set_button_profile_switch(profile: int, index: int, target) -> dict:
"""Belegt einen MX-Button mit Profilwechsel. target: 'next' (Zyklus) oder 0/1/2."""
btn = _find(_profile(profile)["buttons"], index)
btn["action"] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)}
btn["action"] = _replace_action(btn["action"], "ProfileSwitch", _profile_switch_data(target))
return _describe_action(btn["action"])
@mcp.tool()
def set_button_none(profile: int, index: int) -> dict:
"""Entfernt die Belegung eines MX-Buttons (Action = None)."""
"""Entfernt die Belegung eines MX-Buttons (Action = None). Notiz bleibt
erhalten -- zum Loeschen set_button_note(profile, index, "")."""
btn = _find(_profile(profile)["buttons"], index)
btn["action"] = {"type": "None", "data": 0}
btn["action"] = _replace_action(btn["action"], "None", 0)
return _describe_action(btn["action"])
@mcp.tool()
def set_button_note(profile: int, index: int, note: str) -> dict:
"""Setzt/aendert die freie Notiz eines MX-Buttons -- was der Button tut,
unabhaengig von der technischen Aktion (z.B. 'Speichern in Fusion 360').
Rein lokal, landet nie aufs Board (wie Profilnamen). Leerer String
loescht die Notiz."""
btn = _find(_profile(profile)["buttons"], index)
btn["action"]["note"] = note
return _describe_action(btn["action"])
@ -199,7 +231,7 @@ def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: l
enc, f = _encoder_field(profile, index, field)
keycode = vp.hid_key_code_for_name(key)
mod_bits = vp.modifier_bits_for_names(modifiers)
enc[f] = {"type": "HidKey", "data": (mod_bits << 8) | keycode}
enc[f] = _replace_action(enc[f], "HidKey", (mod_bits << 8) | keycode)
return _describe_action(enc[f])
@ -207,7 +239,7 @@ def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: l
def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) -> dict:
"""Belegt eine Encoder-Aktion mit einer Medientaste."""
enc, f = _encoder_field(profile, index, field)
enc[f] = {"type": "HidConsumer", "data": vp.consumer_id_for_name(consumer)}
enc[f] = _replace_action(enc[f], "HidConsumer", vp.consumer_id_for_name(consumer))
return _describe_action(enc[f])
@ -215,7 +247,7 @@ def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) ->
def set_encoder_macro(profile: int, index: int, field: str, slot: int) -> dict:
"""Belegt eine Encoder-Aktion mit einem Makro-Slot (0-31)."""
enc, f = _encoder_field(profile, index, field)
enc[f] = {"type": "Macro", "data": slot}
enc[f] = _replace_action(enc[f], "Macro", slot)
return _describe_action(enc[f])
@ -225,15 +257,26 @@ def set_encoder_profile_switch(profile: int, index: int, field: str, target) ->
Achtung: Encoder 0 'sw' ist normalerweise auf allen 3 Profilen der
Profilwechsel -- nicht ohne Ruecksprache mit dem User aendern."""
enc, f = _encoder_field(profile, index, field)
enc[f] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)}
enc[f] = _replace_action(enc[f], "ProfileSwitch", _profile_switch_data(target))
return _describe_action(enc[f])
@mcp.tool()
def set_encoder_none(profile: int, index: int, field: str) -> dict:
"""Entfernt eine Encoder-Belegung (Action = None)."""
"""Entfernt eine Encoder-Belegung (Action = None). Notiz bleibt erhalten
-- zum Loeschen set_encoder_note(profile, index, field, "")."""
enc, f = _encoder_field(profile, index, field)
enc[f] = {"type": "None", "data": 0}
enc[f] = _replace_action(enc[f], "None", 0)
return _describe_action(enc[f])
@mcp.tool()
def set_encoder_note(profile: int, index: int, field: str, note: str) -> dict:
"""Setzt/aendert die freie Notiz einer Encoder-Aktion (sw/cw/ccw) -- was
sie tut, unabhaengig von der technischen Aktion. Rein lokal, landet nie
aufs Board. Leerer String loescht die Notiz."""
enc, f = _encoder_field(profile, index, field)
enc[f]["note"] = note
return _describe_action(enc[f])
@ -297,24 +340,30 @@ def load_local(path: str = None) -> dict:
@mcp.tool()
def load_from_board() -> dict:
"""Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und
ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die
kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade
von VersaGUI/dem Tkinter-Viewer gehalten wird."""
raw_cfg = _link.read_full_config()
if raw_cfg is None:
raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}")
raw_macros = _link.read_macros()
if raw_macros is None:
raise RuntimeError(f"Makros laden fehlgeschlagen: {_link.last_error}")
ersetzt damit den In-Memory-State. Profilnamen UND Notizen bleiben
erhalten (kennt nur wir, nicht das Board). Schlaegt fehl, wenn der
COM-Port gerade von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den
COM-Port danach sofort wieder frei (siehe get_board_status())."""
try:
raw_cfg = _link.read_full_config()
if raw_cfg is None:
raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}")
raw_macros = _link.read_macros()
if raw_macros is None:
raise RuntimeError(f"Makros laden fehlgeschlagen: {_link.last_error}")
cfg_dict = vproto.unpack_config(raw_cfg)
if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]):
raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)")
macro_slots = vproto.unpack_macros(raw_macros)
cfg_dict = vproto.unpack_config(raw_cfg)
if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]):
raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)")
macro_slots = vproto.unpack_macros(raw_macros)
names = _cfg()["profile_names"]
_state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names)
return list_profiles()
names = _cfg()["profile_names"]
previous = _state["combined"]
_state["combined"] = vcomb.merge_notes(
vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous)
return list_profiles()
finally:
_link.close()
@mcp.tool()
@ -322,13 +371,17 @@ def write_to_board() -> dict:
"""Schreibt den kompletten In-Memory-State (alle 3 Profile + Makros) aufs
Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft
Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit
NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port."""
cfg_bytes, macro_bytes = vcomb.to_binary(_cfg())
if not _link.write_full_config(cfg_bytes):
raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}")
if not _link.write_macros(macro_bytes):
raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}")
return {"ok": True}
NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port.
Gibt den COM-Port danach sofort wieder frei (siehe get_board_status())."""
try:
cfg_bytes, macro_bytes = vcomb.to_binary(_cfg())
if not _link.write_full_config(cfg_bytes):
raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}")
if not _link.write_macros(macro_bytes):
raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}")
return {"ok": True}
finally:
_link.close()
if __name__ == "__main__":