VersaGUI-py/versapad_data.py
Julian Appel 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

501 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Liest die 3 VersaPad-Config-JSONs (Desktop) und decodiert sie zu
lesbaren Strukturen. Referenz: VersaGUI/src/ActionDialog.cs, ConfigJson.cs
(s_consumer-Tabelle, HidKeyName()-Sonderzeichen, Modifier-Bits).
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"),
}
# index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge)
GRID_COLS = 4
GRID_ROWS = 5
MODIFIER_BITS = [
(0x01, "Strg"),
(0x02, "Shift"),
(0x04, "Alt"),
(0x08, "Win"),
]
# Verbatim aus ActionDialog.cs HidKeyName() -- Sondertasten ohne Scan-Code-Eintrag
_SPECIAL_KEYS = {
0x28: "Enter", 0x29: "Escape", 0x2A: "Backspace", 0x2B: "Tab",
0x2C: "Leer", 0x39: "Caps", 0x46: "Druck", 0x47: "Rollen",
0x48: "Pause", 0x49: "Einfg", 0x4A: "Pos1", 0x4B: "Bild↑",
0x4C: "Entf", 0x4D: "Ende", 0x4E: "Bild↓",
0x4F: "", 0x50: "", 0x51: "", 0x52: "",
0x53: "NumLock", 0x54: "Num/", 0x55: "Num*", 0x56: "Num-",
0x57: "Num+", 0x58: "NumEnter",
0x59: "Num1", 0x5A: "Num2", 0x5B: "Num3", 0x5C: "Num4",
0x5D: "Num5", 0x5E: "Num6", 0x5F: "Num7", 0x60: "Num8",
0x61: "Num9", 0x62: "Num0", 0x63: "Num.",
0x3A: "F1", 0x3B: "F2", 0x3C: "F3", 0x3D: "F4", 0x3E: "F5",
0x3F: "F6", 0x40: "F7", 0x41: "F8", 0x42: "F9", 0x43: "F10",
0x44: "F11", 0x45: "F12",
}
# HID Usage Page 0x07 (Keyboard), Zeichentasten -- US-Layout-Beschriftung als
# lesbare Näherung (die reale GUI zeigt das aktive Windows-Layout via
# GetKeyNameText, das können wir hier ohne WinAPI-Call nicht nachbilden).
for _i in range(26):
_SPECIAL_KEYS[0x04 + _i] = chr(ord("A") + _i)
for _i in range(9):
_SPECIAL_KEYS[0x1E + _i] = str(_i + 1)
_SPECIAL_KEYS[0x27] = "0"
_SPECIAL_KEYS.update({
0x2D: "-", 0x2E: "=", 0x2F: "[", 0x30: "]", 0x31: "\\",
0x32: "#", 0x33: ";", 0x34: "'", 0x35: "`", 0x36: ",",
0x37: ".", 0x38: "/", 0x64: "\\ (Non-US)", 0x65: "Menu",
})
# Verbatim aus ActionDialog.cs s_consumer -- HID Consumer Usage IDs (Usage Page 0x0C)
_CONSUMER_NAMES = {
0x00CD: "Play / Pause",
0x00B5: "Nächster Titel",
0x00B6: "Vorheriger Titel",
0x00B7: "Stop",
0x00E9: "Lauter",
0x00EA: "Leiser",
0x00E2: "Stummschalten",
0x0192: "Taschenrechner",
0x0223: "Browser Startseite",
0x0224: "Browser Zurück",
0x0225: "Browser Vor",
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",
"Pulse": "≈ pulsierend",
"FadeIn": "↗ fade-in",
"FadeOut": "↘ fade-out",
"ColorCycle": "⟳ regenbogen",
"ColorFade": "⟳ farbwechsel",
}
_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 [(code, hid_key_name(code)) for code in sorted(_SPECIAL_KEYS)]
def consumer_choices():
"""Sortierte [(usage_id, name), ...] fuer Dropdown-Auswahl beim Editieren."""
return sorted(_CONSUMER_NAMES.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."""
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):
"""z.B. 'Play / Pause' -> 0xCD. Wirft ValueError mit Vorschlaegen bei unbekanntem Namen."""
if name not in _CONSUMER_ID_BY_NAME:
raise ValueError(f"Unbekannte Medienaktion {name!r}. Gueltige Namen: {sorted(_CONSUMER_ID_BY_NAME)}")
return _CONSUMER_ID_BY_NAME[name]
def modifier_bits_for_names(names):
"""['Strg','Shift'] -> 0x03. Wirft ValueError bei unbekanntem Namen."""
valid = {name: bit for bit, name in MODIFIER_BITS}
bits = 0
for name in names:
if name not in valid:
raise ValueError(f"Unbekannter Modifier {name!r}. Gueltig: {sorted(valid)}")
bits |= valid[name]
return bits
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 = hid_key_name(step["keycode"])
return "+".join(mods + [key]) if mods else key
def macro_slot_label(steps):
if not steps:
return "(leer)"
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 = hid_key_name(keycode)
return "+".join(mods + [key]) if mods else key
def consumer_label(data):
return _CONSUMER_NAMES.get(data, f"Consumer 0x{data:04X}")
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":
return ""
if t == "HidKey":
return hid_key_label(d)
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):
return "Profilwechsel → nächstes"
return f"Profilwechsel → Profil {d}"
return f"{t} ({d})"
def led_css(led):
return f"rgb({led['r']},{led['g']},{led['b']})"
def led_css_hex(led):
"""Tk-Farben brauchen #RRGGBB statt rgb(...)."""
return f"#{led['r']:02x}{led['g']:02x}{led['b']:02x}"
def button_grid_position(index):
"""index -> (col, row), Reihe 0 = oben"""
col, row = divmod(index, GRID_ROWS)
return col, row
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"], 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"], 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
def load_profile(profile):
path = CONFIG_PATHS[profile]
with open(path, "r", encoding="utf-8") as f:
cfg = json.load(f)
return annotate_profile(cfg)
def load_all_profiles():
return {p: load_profile(p) for p in CONFIG_PATHS}
def config_mtimes():
"""Für Auto-Reload: liefert {profile: mtime} der 3 JSON-Dateien."""
return {p: os.path.getmtime(path) for p, path in CONFIG_PATHS.items() if os.path.exists(path)}