From 2eb43826ce6994ef5ac15845fadf15047568007f Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 29 Mar 2026 22:18:49 +0200 Subject: [PATCH] Added Macro functionality, udpated readme --- README.md | 124 +++++++++- src/ActionDialog.cs | 542 +++++++++++++++++++++++++++++++++---------- src/ConfigForm.cs | 116 +++++++-- src/ConfigJson.cs | 161 +++++++++++++ src/DeviceConfig.cs | 127 ++++++++-- src/Protocol.cs | 8 + src/SerialManager.cs | 30 +++ src/TrayApp.cs | 41 +++- 8 files changed, 975 insertions(+), 174 deletions(-) create mode 100644 src/ConfigJson.cs diff --git a/README.md b/README.md index e396ba8..dc377f6 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,97 @@ Die App erscheint als Icon in der Windows-Taskleiste (System Tray). Kein Hauptfe 4. **Auf Board speichern** – überträgt Config in den NVM des Boards 5. Beim nächsten Verbinden wird die Config automatisch vom Board geladen +--- + +## Funktionsumfang (Anforderungskatalog) + +### 1 Verbindung & Geräteerkennung + +| # | Anforderung | Status | +|---|-------------|--------| +| 1.1 | App läuft als **Windows Tray-Anwendung** ohne sichtbares Hauptfenster | ✅ | +| 1.2 | Automatische Board-Erkennung per **WMI / VID+PID** (`0x239A / 0x0042`) | ✅ | +| 1.3 | **Automatischer Reconnect** alle 3 s; 5 s Backoff nach Verbindungsverlust | ✅ | +| 1.4 | Verbindungsstatus im Tray-Icon (Symbol + Tooltip) sichtbar | ✅ | +| 1.5 | Beim Verbinden wird die gespeicherte Config **automatisch vom Board gelesen** | ✅ | +| 1.6 | Beim Verbinden wird die **Makro-Tabelle automatisch vom Board gelesen** | ✅ | + +### 2 Tastenbelegung – HID Tastatur + +| # | Anforderung | Status | +|---|-------------|--------| +| 2.1 | Taste durch **Drücken erfassen** (kein manuelles HID-ID-Eingeben) | ✅ | +| 2.2 | Modifier-Kombination: **Strg / Shift / Alt / Win** einzeln oder kombiniert | ✅ | +| 2.3 | **Pfeiltasten, Enter, Escape, F1–F12, Numpad** erfassbar | ✅ | +| 2.4 | **Layout-unabhängige Erfassung** via PS/2-Scan-Code → HID-Usage; Ö/Ä/Ü auf QWERTZ korrekt | ✅ | +| 2.5 | Taste-Name wird laut **aktivem Windows-Layout** angezeigt (`GetKeyNameText`) | ✅ | +| 2.6 | Board führt Tastendruck als **USB-HID-Tastatureingabe** aus (funktioniert ohne laufende App) | ✅ | + +### 3 Tastenbelegung – HID Consumer / Medientasten + +| # | Anforderung | Status | +|---|-------------|--------| +| 3.1 | Auswahl per **Dropdown** mit Klartext-Namen | ✅ | +| 3.2 | Unterstützte Aktionen: Play/Pause, Nächster/Vorheriger Titel, Stop, Lauter/Leiser, Mute, Taschenrechner, Browser Zurück/Vor | ✅ | + +### 4 Tastenbelegung – Makros + +| # | Anforderung | Status | +|---|-------------|--------| +| 4.1 | Bis zu **4 Schritte** pro Makro (je 1 Taste + Modifier) | ✅ | +| 4.2 | Jeder Schritt per **Taste drücken** erfassen, inkl. Sondertasten | ✅ | +| 4.3 | Leere Schritte werden übersprungen (kürzere Makros möglich) | ✅ | +| 4.4 | **32 Makro-Slots** – je ein Slot pro MX-Button (0–19) und Encoder-Aktion (20–31) | ✅ | +| 4.5 | Makro-Tabelle wird **separat** vom Board gelesen und geschrieben (NVM Row 1) | ✅ | +| 4.6 | Board führt Makro-Schritte mit 10 ms Key-Down + 20 ms Pause **ohne laufende App** aus | ✅ | + +### 5 Encoder-Belegung + +| # | Anforderung | Status | +|---|-------------|--------| +| 5.1 | **4 Encoder**, je 3 Aktionen: SW (Drücken), CW (Rechts), CCW (Links) | ✅ | +| 5.2 | Gleiche Aktionstypen wie Tasten (HID Key, Consumer, Makro, Host Command) | ✅ | + +### 6 LED-Konfiguration + +| # | Anforderung | Status | +|---|-------------|--------| +| 6.1 | **Basis-LED-Farbe** pro MX-Button via Farbpicker (RGB) | ✅ | +| 6.2 | **Animationsmodus** pro Button wählbar: Statisch, Blinken, Pulsieren, Regenbogen | ✅ | +| 6.3 | **Animations-Tempo**: Schnell (0,5 s) / Mittel (1 s) / Langsam (2 s) / Sehr langsam (4 s) | ✅ | +| 6.4 | Regenbogen-Modus: Board berechnet Hue-Sweep lokal, gleichmäßige Phasenverteilung | ✅ | +| 6.5 | LED-Config und Aktionstyp im **gleichen Dialog** bearbeitbar | ✅ | + +### 7 Konfiguration speichern & laden + +| # | Anforderung | Status | +|---|-------------|--------| +| 7.1 | Config und Makros werden gleichzeitig per **„Auf Board speichern"** übertragen | ✅ | +| 7.2 | Board validiert Config mit **CRC16-CCITT + Magic + Version**; NACK bei Fehler | ✅ | +| 7.3 | Config bleibt nach Stromverlust im **NVM** erhalten (kein Flash-Verschleiß bei nur Lesen) | ✅ | +| 7.4 | **JSON-Export** der gesamten Config (Aktionen + LED) in Datei | ✅ | +| 7.5 | **JSON-Import** mit Versionscheck; ungültige Dateien werden abgelehnt | ✅ | + +### 8 Verbindungsprotokoll + +| # | Anforderung | Status | +|---|-------------|--------| +| 8.1 | **8-Byte-Festlängen-Pakete** über CDC Serial (kein Treiber nötig) | ✅ | +| 8.2 | **Ping/Pong** zur manuellen Verbindungsdiagnose im Konfigurations-Fenster | ✅ | +| 8.3 | Config-Transfer: BEGIN → n×DATA(6 Byte) → COMMIT → ACK/NACK | ✅ | +| 8.4 | Makro-Transfer: gleiche Struktur, 43 Chunks à 6 Byte (256 Byte Tabelle) | ✅ | +| 8.5 | Debug-Logging aller RX-Pakete in `%TEMP%\versapad_rx.txt` | ✅ | + +### 9 Nicht implementiert / Roadmap + +| # | Anforderung | Status | +|---|-------------|--------| +| 9.1 | **Host Command**: URL/Programm öffnen wenn Board-Taste gedrückt | 🔲 TODO | +| 9.2 | Eigenes **Tray-Icon** (aktuell: Windows-Standard-Icon) | 🔲 TODO | +| 9.3 | **Fader/Potentiometer**-Unterstützung (3× ADC-Achsen auf Board vorhanden) | 🔲 TODO | + +--- + ## Projekt-Struktur ``` @@ -44,10 +135,11 @@ VersaGUI/ ├── Program.cs – Einstiegspunkt, startet TrayApp ├── TrayApp.cs – ApplicationContext: Tray-Icon, Menü, Paket-Routing ├── ConfigForm.cs – Konfigurations-Fenster (4×5 Grid + Encoder-Panel) -├── ActionDialog.cs – Dialog: Taste erfassen / Consumer auswählen / Host-Command -├── DeviceConfig.cs – C#-Spiegel von SDeviceConfig (163B Serialisierung, CRC16) +├── ActionDialog.cs – Dialog: Taste erfassen / Consumer / Makro / LED-Animation +├── DeviceConfig.cs – C#-Spiegel von SDeviceConfig (223 B, Version 2) + MacroTable (256 B) +├── ConfigJson.cs – JSON-Export/Import der DeviceConfig ├── Protocol.cs – Protokoll-Konstanten (Commands + Events) -└── SerialManager.cs – COM-Port-Erkennung, Read-Loop, Config senden/empfangen +└── SerialManager.cs – COM-Port-Erkennung, Read-Loop, Config + Makros senden/empfangen ``` ## Architektur @@ -71,22 +163,32 @@ TrayApp ### DeviceConfig / Serialisierung -`DeviceConfig.ToBytes()` erzeugt den exakt 163-Byte-Puffer der `SDeviceConfig`-Struct in der Firmware entspricht (little-endian, packed). `CRC16-CCITT` (Poly 0x1021, Init 0xFFFF) über Bytes 7–162 – identisch zur Firmware-Implementierung. +`DeviceConfig.ToBytes()` erzeugt den exakt **223-Byte**-Puffer der `SDeviceConfig`-Struct (Version 2, little-endian, packed). `CRC16-CCITT` (Poly 0x1021, Init 0xFFFF) über Bytes 7–222 – identisch zur Firmware-Implementierung. + +`MacroTable.ToBytes()` erzeugt **256 Byte** (32 Slots × 4 Steps × 2 Byte). Config-Übertragung Board→PC (Lesen): ``` -PC sendet: CMD_CONFIG_READ (0x13) -Board sendet: EVT_CONFIG_BEGIN (Chunk-Anzahl) - EVT_CONFIG_DATA × 28 (je 6 Nutzbytes) +PC sendet: CMD_CONFIG_READ (0x13) +Board sendet: EVT_CONFIG_BEGIN (Chunk-Anzahl = 38) + EVT_CONFIG_DATA × 38 (je 6 Nutzbytes) EVT_CONFIG_END ``` Config-Übertragung PC→Board (Schreiben): ``` -PC sendet: CMD_CONFIG_BEGIN (Chunk-Anzahl) - CMD_CONFIG_DATA × 28 - CMD_CONFIG_COMMIT -Board sendet: EVT_CONFIG_ACK oder EVT_CONFIG_NACK +PC sendet: CMD_CONFIG_BEGIN (0x10, Chunk-Anzahl) + CMD_CONFIG_DATA × 38 + CMD_CONFIG_COMMIT (0x12) +Board sendet: EVT_CONFIG_ACK (0x90) oder EVT_CONFIG_NACK (0x91) +``` + +Makro-Übertragung (analog, separate Kommandos 0x20–0x23): +``` +PC sendet: CMD_MACRO_BEGIN (0x20, Chunk-Anzahl = 43) + CMD_MACRO_DATA × 43 + CMD_MACRO_COMMIT (0x22) +Board sendet: EVT_MACRO_ACK (0x95) ``` ### Bekannte .NET-Eigenheiten diff --git a/src/ActionDialog.cs b/src/ActionDialog.cs index 6f1d882..a1b6432 100644 --- a/src/ActionDialog.cs +++ b/src/ActionDialog.cs @@ -7,7 +7,7 @@ // Host Command → Zahlen-Eingabe (Command-ID) // Keine Aktion → nichts // -// Optional (nur MX-Buttons): LED-Basisfarbe +// Optional (nur MX-Buttons): LED-Basisfarbe + LED-Animation + Geschwindigkeit namespace VersaGUI; @@ -16,6 +16,8 @@ public class ActionDialog : Form // ── Ergebnis ────────────────────────────────────────────────────────────── public DeviceAction ResultAction { get; private set; } public Color ResultColor { get; private set; } + public LedAnimType ResultAnim { get; private set; } + public ushort ResultPeriod { get; private set; } // ── Controls ────────────────────────────────────────────────────────────── private readonly ComboBox _typeCombo; @@ -38,86 +40,132 @@ public class ActionDialog : Form private readonly Button _colorBtn; private Color _color; + // LED-Animation + private readonly Panel _animPanel; + private readonly ComboBox _animCombo; + private readonly ComboBox _periodCombo; + + // Makro + private readonly Panel _macroPanel; + private readonly MacroTable _macros; + private readonly int _macroSlot; + // Je Step: [CaptureBtn, CheckCtrl, CheckShift, CheckAlt] + private readonly Button[] _stepBtns = new Button[4]; + private readonly CheckBox[] _stepCtrl = new CheckBox[4]; + private readonly CheckBox[] _stepShift = new CheckBox[4]; + private readonly CheckBox[] _stepAlt = new CheckBox[4]; + private readonly byte[] _stepKeycodes = new byte[4]; + private int _captureStep = -1; // -1 = nicht im Capture-Modus + // Capture-Zustand private bool _capturing; private byte _hidKeycode; // 0 = nicht belegt + // Layout + private readonly bool _showColor; + private Button _okBtn = null!; + private Button _cancelBtn = null!; + // ── Lookup-Tabellen ─────────────────────────────────────────────────────── - // Windows VK → HID Keyboard Usage (USB HID Usage Table 0x07) - private static readonly Dictionary s_vkToHid = new() + // P/Invoke: Windows-API für layout-unabhängige Scan-Code-Konvertierung + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern uint MapVirtualKey(uint uCode, uint uMapType); + + [System.Runtime.InteropServices.DllImport("user32.dll", + CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + private static extern int GetKeyNameText(int lParam, + System.Text.StringBuilder lpString, int nSize); + + // Scan-Code (PS/2 Set 1) → HID Usage (physische Tastenposition, Layout-unabhängig). + // Korrekt für QWERTZ, AZERTY und andere Layouts: Ö = Scan 0x27 → HID 0x33 (;-Position) + private static readonly Dictionary s_scanToHid = new() { - // Buchstaben - [Keys.A]=0x04,[Keys.B]=0x05,[Keys.C]=0x06,[Keys.D]=0x07, - [Keys.E]=0x08,[Keys.F]=0x09,[Keys.G]=0x0A,[Keys.H]=0x0B, - [Keys.I]=0x0C,[Keys.J]=0x0D,[Keys.K]=0x0E,[Keys.L]=0x0F, - [Keys.M]=0x10,[Keys.N]=0x11,[Keys.O]=0x12,[Keys.P]=0x13, - [Keys.Q]=0x14,[Keys.R]=0x15,[Keys.S]=0x16,[Keys.T]=0x17, - [Keys.U]=0x18,[Keys.V]=0x19,[Keys.W]=0x1A,[Keys.X]=0x1B, - [Keys.Y]=0x1C,[Keys.Z]=0x1D, - // Ziffernreihe - [Keys.D1]=0x1E,[Keys.D2]=0x1F,[Keys.D3]=0x20,[Keys.D4]=0x21, - [Keys.D5]=0x22,[Keys.D6]=0x23,[Keys.D7]=0x24,[Keys.D8]=0x25, - [Keys.D9]=0x26,[Keys.D0]=0x27, - // Steuerung - [Keys.Return]=0x28,[Keys.Escape]=0x29,[Keys.Back]=0x2A, - [Keys.Tab]=0x2B, [Keys.Space]=0x2C, [Keys.Delete]=0x4C, - [Keys.Insert]=0x49,[Keys.Home]=0x4A, [Keys.PageUp]=0x4B, - [Keys.End]=0x4D, [Keys.PageDown]=0x4E, - [Keys.Right]=0x4F,[Keys.Left]=0x50, [Keys.Down]=0x51,[Keys.Up]=0x52, - [Keys.CapsLock]=0x39, - [Keys.PrintScreen]=0x46,[Keys.Scroll]=0x47,[Keys.Pause]=0x48, - [Keys.NumLock]=0x53, - // F-Tasten - [Keys.F1]=0x3A,[Keys.F2]=0x3B,[Keys.F3]=0x3C,[Keys.F4]=0x3D, - [Keys.F5]=0x3E,[Keys.F6]=0x3F,[Keys.F7]=0x40,[Keys.F8]=0x41, - [Keys.F9]=0x42,[Keys.F10]=0x43,[Keys.F11]=0x44,[Keys.F12]=0x45, - // Sonderzeichen (QWERTZ-kompatibel, basiert auf US-HID-Positionen) - [Keys.OemMinus]=0x2D, [Keys.Oemplus]=0x2E, - [Keys.OemOpenBrackets]=0x2F, [Keys.OemCloseBrackets]=0x30, - [Keys.OemPipe]=0x31, [Keys.OemSemicolon]=0x33, - [Keys.OemQuotes]=0x34, [Keys.Oemtilde]=0x35, - [Keys.Oemcomma]=0x36, [Keys.OemPeriod]=0x37, - [Keys.OemQuestion]=0x38, - // Numpad - [Keys.NumPad1]=0x59,[Keys.NumPad2]=0x5A,[Keys.NumPad3]=0x5B, - [Keys.NumPad4]=0x5C,[Keys.NumPad5]=0x5D,[Keys.NumPad6]=0x5E, - [Keys.NumPad7]=0x5F,[Keys.NumPad8]=0x60,[Keys.NumPad9]=0x61, - [Keys.NumPad0]=0x62,[Keys.Decimal]=0x63, - [Keys.Multiply]=0x55,[Keys.Add]=0x57, - [Keys.Subtract]=0x56,[Keys.Divide]=0x54, + [0x01]=0x29, // Escape + [0x02]=0x1E,[0x03]=0x1F,[0x04]=0x20,[0x05]=0x21,[0x06]=0x22, + [0x07]=0x23,[0x08]=0x24,[0x09]=0x25,[0x0A]=0x26,[0x0B]=0x27, // 1–0 + [0x0C]=0x2D,[0x0D]=0x2E, // -/ß =/´ + [0x0E]=0x2A, // Backspace + [0x0F]=0x2B, // 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, // Z/Y U I O P + [0x1A]=0x2F,[0x1B]=0x30, // Ü + + [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,[0x29]=0x35, // Ö Ä ^ + [0x2B]=0x31, // # + [0x2C]=0x1D,[0x2D]=0x1B,[0x2E]=0x06,[0x2F]=0x19,[0x30]=0x05, // Y/Z X C V B + [0x31]=0x11,[0x32]=0x10,[0x33]=0x36,[0x34]=0x37,[0x35]=0x38, // N M , . - + [0x37]=0x55, // Numpad * + [0x39]=0x2C, // Space + [0x3A]=0x39, // CapsLock + [0x3B]=0x3A,[0x3C]=0x3B,[0x3D]=0x3C,[0x3E]=0x3D,[0x3F]=0x3E, // F1–F5 + [0x40]=0x3F,[0x41]=0x40,[0x42]=0x41,[0x43]=0x42,[0x44]=0x43, // F6–F10 + [0x45]=0x53,[0x46]=0x47, // NumLock ScrollLock + [0x47]=0x5F,[0x48]=0x60,[0x49]=0x61,[0x4A]=0x56, // Num7 8 9 - + [0x4B]=0x5C,[0x4C]=0x5D,[0x4D]=0x5E,[0x4E]=0x57, // Num4 5 6 + + [0x4F]=0x59,[0x50]=0x5A,[0x51]=0x5B,[0x52]=0x62,[0x53]=0x63, // Num1 2 3 0 . + [0x56]=0x64, // Non-US \ (< > auf QWERTZ) + [0x57]=0x44,[0x58]=0x45, // F11 F12 }; - // HID-Code → lesbarer Name (für Capture-Button-Beschriftung) - private static readonly Dictionary s_hidNames = new() + // HID → Scan-Code (umgekehrte Tabelle, für Anzeigenamen) + private static readonly Dictionary s_hidToScan = + s_scanToHid.ToDictionary(kv => kv.Value, kv => kv.Key); + + // Direkte VK→HID-Zuordnung nur für erweiterte Navigationstasten. + // MapVirtualKey gibt für diese den Numpad-Scan-Code zurück (falsch). + private static readonly Dictionary s_extVkToHid = new() { - [0x04]="A",[0x05]="B",[0x06]="C",[0x07]="D",[0x08]="E",[0x09]="F", - [0x0A]="G",[0x0B]="H",[0x0C]="I",[0x0D]="J",[0x0E]="K",[0x0F]="L", - [0x10]="M",[0x11]="N",[0x12]="O",[0x13]="P",[0x14]="Q",[0x15]="R", - [0x16]="S",[0x17]="T",[0x18]="U",[0x19]="V",[0x1A]="W",[0x1B]="X", - [0x1C]="Y",[0x1D]="Z", - [0x1E]="1",[0x1F]="2",[0x20]="3",[0x21]="4",[0x22]="5", - [0x23]="6",[0x24]="7",[0x25]="8",[0x26]="9",[0x27]="0", - [0x28]="Enter", [0x29]="Escape", [0x2A]="Backspace", - [0x2B]="Tab", [0x2C]="Leertaste", [0x2D]="-", - [0x2E]="=", [0x2F]="[", [0x30]="]", - [0x31]="\\", [0x33]=";", [0x34]="'", - [0x35]="`", [0x36]=",", [0x37]=".", - [0x38]="/", [0x39]="CapsLock", - [0x3A]="F1", [0x3B]="F2", [0x3C]="F3", [0x3D]="F4", - [0x3E]="F5", [0x3F]="F6", [0x40]="F7", [0x41]="F8", - [0x42]="F9", [0x43]="F10",[0x44]="F11",[0x45]="F12", - [0x46]="Druck", [0x47]="Rollen", [0x48]="Pause", - [0x49]="Einfg", [0x4A]="Pos1", [0x4B]="Bild Auf", - [0x4C]="Entf", [0x4D]="Ende", [0x4E]="Bild Ab", - [0x4F]="Rechts", [0x50]="Links", [0x51]="Runter", [0x52]="Hoch", - [0x53]="NumLock",[0x54]="Num/", [0x55]="Num*", - [0x56]="Num-", [0x57]="Num+", - [0x59]="Num1", [0x5A]="Num2", [0x5B]="Num3", [0x5C]="Num4", - [0x5D]="Num5", [0x5E]="Num6", [0x5F]="Num7", [0x60]="Num8", - [0x61]="Num9", [0x62]="Num0", [0x63]="Num.", + [Keys.Up]=0x52, [Keys.Down]=0x51, [Keys.Left]=0x50, + [Keys.Right]=0x4F, [Keys.Home]=0x4A, [Keys.End]=0x4D, + [Keys.PageUp]=0x4B, [Keys.PageDown]=0x4E, [Keys.Insert]=0x49, + [Keys.Delete]=0x4C, [Keys.Pause]=0x48, [Keys.Scroll]=0x47, + [Keys.PrintScreen]=0x46, }; + // Windows VK → HID via Scan-Code (layout-unabhängig). + // Ö auf QWERTZ → Scan 0x27 → HID 0x33 (;-Position auf US) → Board sendet HID 0x33 + // → Zielrechner mit QWERTZ-Layout erzeugt korrekt Ö. + private static byte VkToHid(Keys vk) + { + if (s_extVkToHid.TryGetValue(vk, out byte hExt)) return hExt; + byte sc = (byte)MapVirtualKey((uint)(vk & Keys.KeyCode), 0u /* VK→SC */); + return s_scanToHid.TryGetValue(sc, out byte hid) ? hid : (byte)0; + } + + // HID-Code → lesbarer Tastenname laut aktivem Windows-Tastaturlayout. + // Gibt auf QWERTZ "Ö" für HID 0x33, auf US-Layout ";" zurück. + private static string HidKeyName(byte hid) + { + // Sondertasten ohne Scan-Code-Eintrag + var special = (Dictionary)new Dictionary + { + [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", + }; + if (special.TryGetValue(hid, out string? name)) return name; + + // Für Zeichen-Tasten: GetKeyNameText gibt den lokalisierten Namen zurück + if (!s_hidToScan.TryGetValue(hid, out byte sc)) return $"0x{hid:X2}"; + int lParam = sc << 16; + var sb = new System.Text.StringBuilder(32); + GetKeyNameText(lParam, sb, sb.Capacity); + return sb.Length > 0 ? sb.ToString() : $"0x{hid:X2}"; + } + // HID Consumer Usage IDs (Usage Page 0x0C) private static readonly (ushort Usage, string Name)[] s_consumer = { @@ -137,11 +185,43 @@ public class ActionDialog : Form // ── Konstruktor ─────────────────────────────────────────────────────────── - public ActionDialog(DeviceAction action, Color ledColor, bool showColor = true) + // Animations-ComboBox-Einträge: (Anzeigename, LedAnimType, Farbe sinnvoll?) + private static readonly (string Name, LedAnimType Type, bool ShowColor)[] s_anims = + { + ("Statisch", LedAnimType.Static, true), + ("Blinken", LedAnimType.Blink, true), + ("Pulsieren", LedAnimType.Pulse, true), + ("Regenbogen", LedAnimType.ColorCycle, false), + }; + + // Geschwindigkeits-Presets: (Anzeigename, Millisekunden) + private static readonly (string Name, ushort Ms)[] s_periods = + { + ("Schnell (0.5s)", 500), + ("Mittel (1s)", 1000), + ("Langsam (2s)", 2000), + ("Sehr langsam (4s)", 4000), + }; + + public ActionDialog(DeviceAction action, Color ledColor, + LedAnimType ledAnim = LedAnimType.ColorCycle, + ushort ledPeriod = 4000, + MacroTable? macros = null, + int macroSlot = 0, + bool showColor = true) { ResultAction = action.Clone(); ResultColor = ledColor; + ResultAnim = ledAnim; + ResultPeriod = ledPeriod; _color = ledColor; + _macros = macros ?? new MacroTable(); + _macroSlot = macroSlot; + _showColor = showColor; + + // Bestehende Makro-Steps aus der Tabelle laden + for (int i = 0; i < 4; i++) + _stepKeycodes[i] = _macros.Steps[_macroSlot, i].Keycode; // Formular-Grundeinstellungen Text = "Aktion bearbeiten"; @@ -156,20 +236,20 @@ public class ActionDialog : Form _typeCombo = new ComboBox { Location = new Point(80, 12), - Width = 208, + Width = 316, DropDownStyle = ComboBoxStyle.DropDownList, }; _typeCombo.Items.AddRange(new object[] - { "Keine Aktion", "HID Tastatur", "HID Consumer", "Host Command" }); + { "Keine Aktion", "HID Tastatur", "HID Consumer", "Host Command", "Makro" }); _typeCombo.SelectedIndex = (int)action.Type; _typeCombo.SelectedIndexChanged += OnTypeChanged; // ── HID-Tastatur-Panel ──────────────────────────────────────────────── - _hidKeyPanel = new Panel { Location = new Point(12, 48), Size = new Size(280, 110) }; + _hidKeyPanel = new Panel { Location = new Point(12, 48), Size = new Size(396, 110) }; _captureBtn = new Button { - Size = new Size(280, 48), + Size = new Size(396, 48), Location = new Point(0, 0), FlatStyle = FlatStyle.Flat, Font = new Font(Font.FontFamily, 12, FontStyle.Regular), @@ -213,11 +293,11 @@ public class ActionDialog : Form RefreshCaptureBtn(); // erst nach Checkbox-Initialisierung aufrufen // ── Consumer-Panel ──────────────────────────────────────────────────── - _consumerPanel = new Panel { Location = new Point(12, 48), Size = new Size(280, 30) }; + _consumerPanel = new Panel { Location = new Point(12, 48), Size = new Size(396, 30) }; _consumerCombo = new ComboBox { Location = new Point(0, 0), - Width = 280, + Width = 396, DropDownStyle = ComboBoxStyle.DropDownList, }; foreach (var (_, name) in s_consumer) @@ -230,12 +310,12 @@ public class ActionDialog : Form _consumerPanel.Controls.Add(_consumerCombo); // ── Host-Command-Panel ──────────────────────────────────────────────── - _cmdPanel = new Panel { Location = new Point(12, 48), Size = new Size(280, 30) }; + _cmdPanel = new Panel { Location = new Point(12, 48), Size = new Size(396, 30) }; var cmdLabel = new Label { Text = "Command-ID:", Location = new Point(0, 8), AutoSize = true }; _cmdBox = new TextBox { Location = new Point(90, 4), - Width = 190, + Width = 306, Text = action.Type == ActionType.HostCommand ? $"{action.Data}" : "0", }; _cmdPanel.Controls.AddRange(new Control[] { cmdLabel, _cmdBox }); @@ -244,14 +324,14 @@ public class ActionDialog : Form _colorPanel = new Panel { Location = new Point(12, 168), - Size = new Size(280, 30), + Size = new Size(396, 30), Visible = showColor, }; var colorLabel = new Label { Text = "LED-Farbe:", Location = new Point(0, 8), AutoSize = true }; _colorBtn = new Button { Location = new Point(80, 2), - Size = new Size(200, 26), + Size = new Size(316, 26), BackColor = ledColor, FlatStyle = FlatStyle.Flat, Text = string.Empty, @@ -260,37 +340,126 @@ public class ActionDialog : Form _colorBtn.Click += OnColorClick; _colorPanel.Controls.AddRange(new Control[] { colorLabel, _colorBtn }); - // ── Buttons Fußzeile ────────────────────────────────────────────────── - int footerY = showColor ? 208 : 168; - var okBtn = new Button + // ── Animation-Panel ─────────────────────────────────────────────────── + _animPanel = new Panel + { + Location = new Point(12, 208), + Size = new Size(396, 58), + Visible = showColor, + }; + + var animLabel = new Label { Text = "Animation:", Location = new Point(0, 8), AutoSize = true }; + _animCombo = new ComboBox + { + Location = new Point(80, 4), + Width = 316, + DropDownStyle = ComboBoxStyle.DropDownList, + }; + foreach (var (name, _, _) in s_anims) + _animCombo.Items.Add(name); + + int animIdx = Array.FindIndex(s_anims, a => a.Type == ledAnim); + _animCombo.SelectedIndex = animIdx >= 0 ? animIdx : 0; + _animCombo.SelectedIndexChanged += OnAnimChanged; + + var periodLabel = new Label { Text = "Tempo:", Location = new Point(0, 36), AutoSize = true }; + _periodCombo = new ComboBox + { + Location = new Point(80, 32), + Width = 316, + DropDownStyle = ComboBoxStyle.DropDownList, + }; + foreach (var (name, _) in s_periods) + _periodCombo.Items.Add(name); + + int periodIdx = Array.FindIndex(s_periods, p => p.Ms == ledPeriod); + _periodCombo.SelectedIndex = periodIdx >= 0 ? periodIdx : 3; // Default: sehr langsam + + _animPanel.Controls.AddRange(new Control[] + { animLabel, _animCombo, periodLabel, _periodCombo }); + + // ── Makro-Panel (4 Steps) ───────────────────────────────────────────── + _macroPanel = new Panel { Location = new Point(12, 48), Size = new Size(396, 4 * 30 + 22) }; + + var macroHint = new Label + { + Text = "Klicke einen Step, dann Taste drücken. Leere Steps werden übersprungen.", + Location = new Point(0, 0), + Size = new Size(396, 28), + ForeColor = SystemColors.GrayText, + Font = new Font(Font.FontFamily, 8), + }; + _macroPanel.Controls.Add(macroHint); + + for (int step = 0; step < 4; step++) + { + int s = step; + int y2 = 30 + step * 30; + + var stepLabel = new Label + { + Text = $"Step {step + 1}:", + Location = new Point(0, y2 + 6), + AutoSize = true, + }; + + _stepBtns[step] = new Button + { + Location = new Point(50, y2), + Size = new Size(160, 24), + FlatStyle = FlatStyle.Flat, + Cursor = Cursors.Hand, + }; + _stepBtns[step].FlatAppearance.BorderColor = Color.DarkGray; + _stepBtns[step].Click += (_, _) => StartMacroCapture(s); + + _stepCtrl[step] = new CheckBox { Text = "Strg", Location = new Point(218, y2 + 4), AutoSize = true }; + _stepShift[step] = new CheckBox { Text = "Shift", Location = new Point(268, y2 + 4), AutoSize = true }; + _stepAlt[step] = new CheckBox { Text = "Alt", Location = new Point(326, y2 + 4), AutoSize = true }; + + // Bestehende Werte aus Makro-Tabelle laden + _stepCtrl[step].Checked = (_macros.Steps[_macroSlot, step].Modifier & 0x01) != 0; + _stepShift[step].Checked = (_macros.Steps[_macroSlot, step].Modifier & 0x02) != 0; + _stepAlt[step].Checked = (_macros.Steps[_macroSlot, step].Modifier & 0x04) != 0; + + _stepCtrl[step].CheckedChanged += (_, _) => RefreshStepBtn(s); + _stepShift[step].CheckedChanged += (_, _) => RefreshStepBtn(s); + _stepAlt[step].CheckedChanged += (_, _) => RefreshStepBtn(s); + + _macroPanel.Controls.AddRange(new Control[] + { stepLabel, _stepBtns[step], _stepCtrl[step], _stepShift[step], _stepAlt[step] }); + + RefreshStepBtn(step); + } + + // ── Buttons Fußzeile ───────────────────────────────────────────────── + _okBtn = new Button { Text = "OK", DialogResult = DialogResult.OK, - Location = new Point(112, footerY), Width = 80, }; - okBtn.Click += OnOk; + _okBtn.Click += OnOk; - var cancelBtn = new Button + _cancelBtn = new Button { Text = "Abbrechen", DialogResult = DialogResult.Cancel, - Location = new Point(200, footerY), Width = 92, }; - AcceptButton = okBtn; - CancelButton = cancelBtn; - ClientSize = new Size(304, footerY + 44); + AcceptButton = _okBtn; + CancelButton = _cancelBtn; Controls.AddRange(new Control[] { typeLabel, _typeCombo, - _hidKeyPanel, _consumerPanel, _cmdPanel, _colorPanel, - okBtn, cancelBtn, + _hidKeyPanel, _consumerPanel, _cmdPanel, _macroPanel, + _colorPanel, _animPanel, + _okBtn, _cancelBtn, }); - UpdatePanelVisibility(); + UpdatePanelVisibility(); // setzt Visibility + ruft UpdateLayout auf } // ── Panel-Sichtbarkeit ──────────────────────────────────────────────────── @@ -301,12 +470,62 @@ public class ActionDialog : Form UpdatePanelVisibility(); } + private void OnAnimChanged(object? sender, EventArgs e) + { + UpdateAnimVisibility(); + } + private void UpdatePanelVisibility() { var t = (ActionType)_typeCombo.SelectedIndex; _hidKeyPanel.Visible = t == ActionType.HidKey; _consumerPanel.Visible = t == ActionType.HidConsumer; _cmdPanel.Visible = t == ActionType.HostCommand; + _macroPanel.Visible = t == ActionType.Macro; + _colorPanel.Visible = _showColor; + _animPanel.Visible = _showColor; + UpdateAnimVisibility(); + UpdateLayout(); + } + + // Positioniert LED-Panels dynamisch unterhalb des aktiven Typ-Panels + // und passt die Formulargröße an. + private void UpdateLayout() + { + var t = (ActionType)_typeCombo.SelectedIndex; + + int contentH = t switch + { + ActionType.HidKey => 110, + ActionType.HidConsumer => 30, + ActionType.HostCommand => 30, + ActionType.Macro => 4 * 30 + 22, + _ => 0, + }; + + int ledY = 48 + contentH + 10; + int footerY = ledY; + + if (_showColor) + { + _colorPanel.Location = new Point(12, ledY); + _animPanel.Location = new Point(12, ledY + 38); + footerY = ledY + 38 + 62; + } + + _okBtn.Location = new Point(224, footerY); + _cancelBtn.Location = new Point(312, footerY); + ClientSize = new Size(420, footerY + 44); + } + + private void UpdateAnimVisibility() + { + if (_animPanel.Visible && _animCombo.SelectedIndex >= 0) + { + var (_, animType, showColorFlag) = s_anims[_animCombo.SelectedIndex]; + _colorBtn.Enabled = showColorFlag; + _periodCombo.Enabled = animType != LedAnimType.Static; + } } // ── Tasten-Erfassung ────────────────────────────────────────────────────── @@ -317,6 +536,40 @@ public class ActionDialog : Form else StartCapture(); } + // ── Makro-Step-Capture ──────────────────────────────────────────────────── + + private void StartMacroCapture(int step) + { + _captureStep = step; + _stepBtns[step].Text = "Taste drücken..."; + _stepBtns[step].BackColor = Color.FromArgb(255, 220, 80); + _stepBtns[step].ForeColor = Color.Black; + } + + private void RefreshStepBtn(int step) + { + if (_captureStep == step) return; // gerade im Capture-Modus + byte kc = _stepKeycodes[step]; + if (kc == 0) + { + _stepBtns[step].Text = "— (leer)"; + _stepBtns[step].BackColor = SystemColors.Control; + _stepBtns[step].ForeColor = SystemColors.GrayText; + } + else + { + string name = HidKeyName(kc); + var parts = new List(); + if (_stepCtrl[step].Checked) parts.Add("Strg"); + if (_stepShift[step].Checked) parts.Add("Shift"); + if (_stepAlt[step].Checked) parts.Add("Alt"); + parts.Add(name); + _stepBtns[step].Text = string.Join("+", parts); + _stepBtns[step].BackColor = SystemColors.Control; + _stepBtns[step].ForeColor = SystemColors.ControlText; + } + } + private void StartCapture() { _capturing = true; @@ -334,14 +587,14 @@ public class ActionDialog : Form private void RefreshCaptureBtn() { - if (_hidKeycode != 0 && s_hidNames.TryGetValue(_hidKeycode, out string? kn)) + if (_hidKeycode != 0) { var parts = new List(); if (_chkCtrl.Checked) parts.Add("Strg"); if (_chkShift.Checked) parts.Add("Shift"); if (_chkAlt.Checked) parts.Add("Alt"); if (_chkWin.Checked) parts.Add("Win"); - parts.Add(kn); + parts.Add(HidKeyName(_hidKeycode)); _captureBtn.Text = string.Join(" + ", parts); } else @@ -353,41 +606,65 @@ public class ActionDialog : Form _captureBtn.FlatAppearance.BorderColor = Color.DarkGray; } - // Tasten werden form-weit abgefangen (KeyPreview = true) - protected override void OnKeyDown(KeyEventArgs e) + // ProcessCmdKey wird vor jeder Dialog-Verarbeitung aufgerufen (Enter, Pfeiltasten, + // Tab usw. werden normalerweise als "Dialog-Tasten" verschluckt, bevor OnKeyDown + // feuert). Daher hier die Capture-Logik – so kommen auch ↑↓←→ und Enter an. + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { - if (!_capturing) { base.OnKeyDown(e); return; } + const int WM_KEYDOWN = 0x0100; + const int WM_SYSKEYDOWN = 0x0104; + if (msg.Msg != WM_KEYDOWN && msg.Msg != WM_SYSKEYDOWN) + return base.ProcessCmdKey(ref msg, keyData); + + if (!_capturing && _captureStep < 0) + return base.ProcessCmdKey(ref msg, keyData); + + Keys vk = keyData & Keys.KeyCode; + bool ctrl = (keyData & Keys.Control) != 0; + bool shift = (keyData & Keys.Shift) != 0; + bool alt = (keyData & Keys.Alt) != 0; // Reine Modifier-Tasten ignorieren – auf echte Taste warten - if (e.KeyCode is Keys.ControlKey or Keys.LControlKey or Keys.RControlKey - or Keys.ShiftKey or Keys.LShiftKey or Keys.RShiftKey - or Keys.Menu or Keys.LMenu or Keys.RMenu - or Keys.LWin or Keys.RWin or Keys.Apps) + if (vk is Keys.ControlKey or Keys.LControlKey or Keys.RControlKey + or Keys.ShiftKey or Keys.LShiftKey or Keys.RShiftKey + or Keys.Menu or Keys.LMenu or Keys.RMenu + or Keys.LWin or Keys.RWin or Keys.Apps) + return true; // schlucken, aber Capture läuft weiter + + // ── Makro-Step-Capture ──────────────────────────────────────────────── + if (_captureStep >= 0) { - e.Handled = true; - return; + int s = _captureStep; + _captureStep = -1; + if (vk == Keys.Escape) + { + _stepKeycodes[s] = 0; + } + else + { + _stepKeycodes[s] = VkToHid(vk); + _stepCtrl[s].Checked = ctrl; + _stepShift[s].Checked = shift; + _stepAlt[s].Checked = alt; + } + RefreshStepBtn(s); + return true; } - // Escape bricht Erfassung ab (ohne Wert zu ändern) - if (e.KeyCode == Keys.Escape) + // ── HID-Tastatur-Capture ────────────────────────────────────────────── + if (vk == Keys.Escape) { StopCapture(); - e.Handled = true; - e.SuppressKeyPress = true; - return; } - - // Modifikatoren übernehmen - _chkCtrl.Checked = e.Control; - _chkShift.Checked = e.Shift; - _chkAlt.Checked = e.Alt; - - // VK → HID-Code - _hidKeycode = s_vkToHid.TryGetValue(e.KeyCode, out byte hid) ? hid : (byte)0; - - StopCapture(); - e.Handled = true; - e.SuppressKeyPress = true; + else + { + _chkCtrl.Checked = ctrl; + _chkShift.Checked = shift; + _chkAlt.Checked = alt; + _hidKeycode = VkToHid(vk); + StopCapture(); + } + return true; } // ── Farbe ───────────────────────────────────────────────────────────────── @@ -435,7 +712,30 @@ public class ActionDialog : Form break; } + if (type == ActionType.Macro) + { + // Steps in die Makro-Tabelle schreiben + for (int i = 0; i < 4; i++) + { + byte mod = 0; + if (_stepCtrl[i].Checked) mod |= 0x01; + if (_stepShift[i].Checked) mod |= 0x02; + if (_stepAlt[i].Checked) mod |= 0x04; + _macros.Steps[_macroSlot, i].Keycode = _stepKeycodes[i]; + _macros.Steps[_macroSlot, i].Modifier = mod; + } + data = (ushort)_macroSlot; + } + ResultAction = new DeviceAction { Type = type, Data = data }; ResultColor = _color; + + if (_animPanel.Visible && _animCombo.SelectedIndex >= 0) + { + ResultAnim = s_anims[_animCombo.SelectedIndex].Type; + ResultPeriod = _periodCombo.SelectedIndex >= 0 + ? s_periods[_periodCombo.SelectedIndex].Ms + : (ushort)4000; + } } } diff --git a/src/ConfigForm.cs b/src/ConfigForm.cs index 155a41f..3f3967d 100644 --- a/src/ConfigForm.cs +++ b/src/ConfigForm.cs @@ -20,19 +20,17 @@ namespace VersaGUI; public class ConfigForm : Form { private readonly DeviceConfig _config; + private readonly MacroTable _macros; private readonly SerialManager _serial; - // Grid-Buttons für die 20 MX-Buttons (Index = mx_idx = key_id - 5) private readonly Button[] _btnGrid = new Button[20]; - - // Encoder-Buttons [enc][SW/CW/CCW] private readonly Button[,] _encBtns = new Button[4, 3]; - private Button _saveBtn = null!; - public ConfigForm(DeviceConfig config, SerialManager serial) + public ConfigForm(DeviceConfig config, MacroTable macros, SerialManager serial) { _config = config; + _macros = macros; _serial = serial; Text = "VersaPad – Konfiguration"; @@ -182,6 +180,22 @@ public class ConfigForm : Form }; pingBtn.Click += (_, _) => _serial.Send(new SerialPacket(Protocol.CmdPing)); + var exportBtn = new Button + { + Text = "Exportieren", + Location = new Point(256, startY), + Size = new Size(80, 30), + }; + exportBtn.Click += OnExport; + + var importBtn = new Button + { + Text = "Importieren", + Location = new Point(340, startY), + Size = new Size(80, 30), + }; + importBtn.Click += OnImport; + var closeBtn = new Button { Text = "Schließen", @@ -190,7 +204,7 @@ public class ConfigForm : Form }; closeBtn.Click += (_, _) => Close(); - Controls.AddRange(new Control[] { _saveBtn, pingBtn, closeBtn }); + Controls.AddRange(new Control[] { _saveBtn, pingBtn, exportBtn, importBtn, closeBtn }); // Fensterhöhe anpassen ClientSize = new Size(ClientSize.Width, startY + 50); @@ -202,15 +216,21 @@ public class ConfigForm : Form { if (sender is not Button btn || btn.Tag is not int mxIdx) return; + int slot = MacroTable.SlotForMx(mxIdx); using var dlg = new ActionDialog( _config.MxActions[mxIdx], _config.LedBase[mxIdx], + _config.LedAnim[mxIdx], + _config.LedPeriod[mxIdx], + _macros, slot, showColor: true); if (dlg.ShowDialog(this) != DialogResult.OK) return; - _config.MxActions[mxIdx] = dlg.ResultAction; - _config.LedBase[mxIdx] = dlg.ResultColor; + _config.MxActions[mxIdx] = dlg.ResultAction; + _config.LedBase[mxIdx] = dlg.ResultColor; + _config.LedAnim[mxIdx] = dlg.ResultAnim; + _config.LedPeriod[mxIdx] = dlg.ResultPeriod; RefreshMxButton(mxIdx); } @@ -218,9 +238,12 @@ public class ConfigForm : Form { if (sender is not Button btn || btn.Tag is not (int enc, int act)) return; + int slot = MacroTable.SlotForEncoder(enc, act); using var dlg = new ActionDialog( _config.EncActions[enc, act], Color.Black, + LedAnimType.Static, 0, + _macros, slot, showColor: false); if (dlg.ShowDialog(this) != DialogResult.OK) return; @@ -234,15 +257,16 @@ public class ConfigForm : Form _saveBtn.Enabled = false; _saveBtn.Text = "Wird gesendet..."; - // SendConfig blockiert ~180ms (Delays zwischen Paketen) → Background-Thread + // SendConfig + SendMacros blockieren ~400ms → Background-Thread Task.Run(() => { _serial.SendConfig(_config); + Thread.Sleep(50); // kurze Pause zwischen Config- und Makro-Transfer + _serial.SendMacros(_macros); InvokeOnUi(() => { _saveBtn.Text = "Auf Board speichern"; _saveBtn.Enabled = _serial.IsConnected; - // Ergebnis kommt als ACK/NACK-Event vom Board → TrayApp zeigt Balloon }); }); } @@ -263,11 +287,28 @@ public class ConfigForm : Form var btn = _btnGrid[mxIdx]; var action = _config.MxActions[mxIdx]; var color = _config.LedBase[mxIdx]; + var anim = _config.LedAnim[mxIdx]; - btn.BackColor = color; - // Textfarbe kontrastreich zur Hintergrundfarbe wählen - btn.ForeColor = Luminance(color) > 128 ? Color.Black : Color.White; - btn.Text = action.Display; + // Regenbogen: Hintergrund als Verlauf andeuten (dunkles Grau als Neutral) + if (anim == LedAnimType.ColorCycle) + { + btn.BackColor = Color.FromArgb(40, 40, 40); + btn.ForeColor = Color.White; + } + else + { + btn.BackColor = color; + btn.ForeColor = Luminance(color) > 128 ? Color.Black : Color.White; + } + + string animName = anim switch + { + LedAnimType.Blink => " [Blinken]", + LedAnimType.Pulse => " [Pulsieren]", + LedAnimType.ColorCycle => " [Regenbogen]", + _ => "", + }; + btn.Text = action.Display + animName; btn.ToolTipText(action.TypeDescription()); } @@ -282,6 +323,53 @@ public class ConfigForm : Form private static int Luminance(Color c) => (c.R * 299 + c.G * 587 + c.B * 114) / 1000; + // ── Import / Export ─────────────────────────────────────────────────────── + + private void OnExport(object? sender, EventArgs e) + { + using var dlg = new SaveFileDialog + { + Title = "Konfiguration exportieren", + Filter = "VersaPad Config (*.json)|*.json", + DefaultExt = "json", + FileName = "versapad_config.json", + }; + if (dlg.ShowDialog(this) != DialogResult.OK) return; + + try + { + string json = ConfigJson.Serialize(_config); + File.WriteAllText(dlg.FileName, json, System.Text.Encoding.UTF8); + } + catch (Exception ex) + { + MessageBox.Show($"Export fehlgeschlagen:\n{ex.Message}", + "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void OnImport(object? sender, EventArgs e) + { + using var dlg = new OpenFileDialog + { + Title = "Konfiguration importieren", + Filter = "VersaPad Config (*.json)|*.json", + }; + if (dlg.ShowDialog(this) != DialogResult.OK) return; + + try + { + string json = File.ReadAllText(dlg.FileName, System.Text.Encoding.UTF8); + ConfigJson.Deserialize(json, _config); + RefreshAll(); + } + catch (Exception ex) + { + MessageBox.Show($"Import fehlgeschlagen:\n{ex.Message}", + "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + private void InvokeOnUi(Action a) { if (InvokeRequired) Invoke(a); diff --git a/src/ConfigJson.cs b/src/ConfigJson.cs new file mode 100644 index 0000000..fea8024 --- /dev/null +++ b/src/ConfigJson.cs @@ -0,0 +1,161 @@ +// ConfigJson.cs +// JSON-Import/Export für DeviceConfig. +// +// Format (menschenlesbar, kommentiert mit Feldnamen): +// { +// "version": 2, +// "buttons": [ +// { "index": 0, "action": { "type": "HidKey", "data": 260 }, +// "led": { "r": 80, "g": 40, "b": 0, "anim": "ColorCycle", "period_ms": 4000 } }, +// ... +// ], +// "encoders": [ +// { "index": 0, +// "sw": { "type": "None", "data": 0 }, +// "cw": { "type": "None", "data": 0 }, +// "ccw": { "type": "None", "data": 0 } }, +// ... +// ] +// } + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Drawing; + +namespace VersaGUI; + +internal static class ConfigJson +{ + private static readonly JsonSerializerOptions s_opts = new() + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() }, + }; + + public static string Serialize(DeviceConfig cfg) + { + var doc = new ConfigJsonDoc + { + Version = DeviceConfig.Version, + Buttons = new ButtonEntry[20], + Encoders = new EncoderEntry[4], + }; + + for (int i = 0; i < 20; i++) + { + doc.Buttons[i] = new ButtonEntry + { + Index = i, + Action = ActionToJson(cfg.MxActions[i]), + Led = new LedEntry + { + R = cfg.LedBase[i].R, + G = cfg.LedBase[i].G, + B = cfg.LedBase[i].B, + Anim = cfg.LedAnim[i], + PeriodMs = cfg.LedPeriod[i], + }, + }; + } + + for (int e = 0; e < 4; e++) + { + doc.Encoders[e] = new EncoderEntry + { + Index = e, + Sw = ActionToJson(cfg.EncActions[e, DeviceConfig.EncSw]), + Cw = ActionToJson(cfg.EncActions[e, DeviceConfig.EncCw]), + Ccw = ActionToJson(cfg.EncActions[e, DeviceConfig.EncCcw]), + }; + } + + return JsonSerializer.Serialize(doc, s_opts); + } + + public static void Deserialize(string json, DeviceConfig cfg) + { + var doc = JsonSerializer.Deserialize(json, s_opts) + ?? throw new InvalidDataException("Ungültiges JSON-Format."); + + if (doc.Version != DeviceConfig.Version) + throw new InvalidDataException( + $"Config-Version {doc.Version} wird nicht unterstützt (erwartet {DeviceConfig.Version})."); + + if (doc.Buttons != null) + { + foreach (var b in doc.Buttons) + { + if (b.Index < 0 || b.Index >= 20) continue; + ActionFromJson(b.Action, cfg.MxActions[b.Index]); + if (b.Led != null) + { + cfg.LedBase[b.Index] = Color.FromArgb(b.Led.R, b.Led.G, b.Led.B); + cfg.LedAnim[b.Index] = b.Led.Anim; + cfg.LedPeriod[b.Index] = b.Led.PeriodMs; + } + } + } + + if (doc.Encoders != null) + { + foreach (var enc in doc.Encoders) + { + if (enc.Index < 0 || enc.Index >= 4) continue; + ActionFromJson(enc.Sw, cfg.EncActions[enc.Index, DeviceConfig.EncSw]); + ActionFromJson(enc.Cw, cfg.EncActions[enc.Index, DeviceConfig.EncCw]); + ActionFromJson(enc.Ccw, cfg.EncActions[enc.Index, DeviceConfig.EncCcw]); + } + } + } + + // ── Hilfsmethoden ───────────────────────────────────────────────────────── + + private static ActionEntry ActionToJson(DeviceAction a) + => new() { Type = a.Type, Data = a.Data }; + + private static void ActionFromJson(ActionEntry? src, DeviceAction dst) + { + if (src == null) return; + dst.Type = src.Type; + dst.Data = src.Data; + } + + // ── JSON-Datenklassen ───────────────────────────────────────────────────── + + private class ConfigJsonDoc + { + [JsonPropertyName("version")] public byte Version { get; set; } + [JsonPropertyName("buttons")] public ButtonEntry[]? Buttons { get; set; } + [JsonPropertyName("encoders")] public EncoderEntry[]? Encoders { get; set; } + } + + private class ButtonEntry + { + [JsonPropertyName("index")] public int Index { get; set; } + [JsonPropertyName("action")] public ActionEntry? Action { get; set; } + [JsonPropertyName("led")] public LedEntry? Led { get; set; } + } + + private class EncoderEntry + { + [JsonPropertyName("index")] public int Index { get; set; } + [JsonPropertyName("sw")] public ActionEntry? Sw { get; set; } + [JsonPropertyName("cw")] public ActionEntry? Cw { get; set; } + [JsonPropertyName("ccw")] public ActionEntry? Ccw { get; set; } + } + + private class ActionEntry + { + [JsonPropertyName("type")] public ActionType Type { get; set; } + [JsonPropertyName("data")] public ushort Data { get; set; } + } + + private class LedEntry + { + [JsonPropertyName("r")] public byte R { get; set; } + [JsonPropertyName("g")] public byte G { get; set; } + [JsonPropertyName("b")] public byte B { get; set; } + [JsonPropertyName("anim")] public LedAnimType Anim { get; set; } + [JsonPropertyName("period_ms")] public ushort PeriodMs { get; set; } + } +} diff --git a/src/DeviceConfig.cs b/src/DeviceConfig.cs index 7e69ca4..10b9312 100644 --- a/src/DeviceConfig.cs +++ b/src/DeviceConfig.cs @@ -1,15 +1,17 @@ // DeviceConfig.cs // C#-Spiegel der SDeviceConfig-Struktur aus der Firmware (nvm_config.h). // -// Serialisiertes Layout (packed, 163 Bytes): +// Serialisiertes Layout (packed, 223 Bytes): // Offset 0 4B Magic 0x56503202 -// Offset 4 1B Version 1 -// Offset 5 2B CRC16 (CCITT über Bytes 7–162) +// Offset 4 1B Version 2 +// Offset 5 2B CRC16 (CCITT über Bytes 7–222) // Offset 7 60B mx_actions[20] je 3B: type(1) + data_lo(1) + data_hi(1) // Offset 67 36B enc_actions[4][3] je 3B // Offset103 20B led_r[20] // Offset123 20B led_g[20] // Offset143 20B led_b[20] +// Offset163 20B led_anim[20] je 1B (LedAnimType) +// Offset183 40B led_period_ms[20] je 2B (uint16, little-endian) using System.Drawing; @@ -21,6 +23,73 @@ public enum ActionType : byte HidKey = 1, // data = Keycode (Low-Byte) + Modifier (High-Byte) HidConsumer = 2, // data = HID Consumer Usage ID HostCommand = 3, // data = Command-ID → App führt aus + Macro = 4, // data = Makro-Slot-Index (0–31) +} + +// Ein Step in einem Makro (keycode=0 → leerer/letzter Step) +public class MacroStep +{ + public byte Keycode { get; set; } = 0; + public byte Modifier { get; set; } = 0; + + public bool IsEmpty => Keycode == 0; + public MacroStep Clone() => new() { Keycode = Keycode, Modifier = Modifier }; +} + +// Makro-Tabelle: 32 Slots × 4 Steps (spiegelt SMacroTable aus macro_config.h) +public class MacroTable +{ + public const int Slots = 32; + public const int MaxSteps = 4; + + // [slot][step] + public MacroStep[,] Steps { get; } = new MacroStep[Slots, MaxSteps]; + + public MacroTable() + { + for (int s = 0; s < Slots; s++) + for (int i = 0; i < MaxSteps; i++) + Steps[s, i] = new MacroStep(); + } + + // Slot-Index für MX-Buttons und Encoder-Aktionen + public static int SlotForMx(int mxIdx) => mxIdx; // 0–19 + public static int SlotForEncoder(int enc, int actIdx) => 20 + enc * 3 + actIdx; // 20–31 + + // Serialisierung: 32 × 4 × 2 = 256 Bytes + public byte[] ToBytes() + { + var buf = new byte[256]; + int pos = 0; + for (int s = 0; s < Slots; s++) + for (int i = 0; i < MaxSteps; i++) + { + buf[pos++] = Steps[s, i].Keycode; + buf[pos++] = Steps[s, i].Modifier; + } + return buf; + } + + public void FromBytes(byte[] buf) + { + if (buf.Length < 256) return; + int pos = 0; + for (int s = 0; s < Slots; s++) + for (int i = 0; i < MaxSteps; i++) + { + Steps[s, i].Keycode = buf[pos++]; + Steps[s, i].Modifier = buf[pos++]; + } + } +} + +// Spiegelt LEDAnim aus CButton.h – nur die in der GUI konfigurierbaren Werte +public enum LedAnimType : byte +{ + Static = 0, + Blink = 1, + Pulse = 2, + ColorCycle = 5, // Regenbogen (entspricht LEDAnim::COLOR_CYCLE in Firmware) } public class DeviceAction @@ -28,7 +97,6 @@ public class DeviceAction public ActionType Type { get; set; } = ActionType.None; public ushort Data { get; set; } = 0; - // Kurzanzeige für Buttons und Tooltips public string Display { get @@ -60,6 +128,8 @@ public class DeviceAction _ => $"C:{Data:X3}", }; } + if (Type == ActionType.Macro) + return $"Makro {Data}"; return $"CMD {Data}"; } } @@ -85,24 +155,29 @@ public class DeviceAction public class DeviceConfig { public const uint Magic = 0x56503202; - public const byte Version = 1; + public const byte Version = 2; - // Encoder-Aktions-Indizes (Spalte in enc_actions[enc, idx]) public const int EncSw = 0; public const int EncCw = 1; public const int EncCcw = 2; - // 20 MX-Button-Aktionen (key_id 5–24, mx_idx = key_id - 5) public DeviceAction[] MxActions { get; } = Enumerable.Range(0, 20).Select(_ => new DeviceAction()).ToArray(); - // 4 Encoder × 3 Aktionen [enc][SW/CW/CCW] public DeviceAction[,] EncActions { get; } = InitEncActions(); - // Base-LED-Farben für die 20 MX-Buttons (Default: warm-weiß wie Firmware) + // Base-LED-Farben (Default: warm-weiß wie Firmware) public Color[] LedBase { get; } = Enumerable.Repeat(Color.FromArgb(80, 40, 0), 20).ToArray(); + // LED-Animationen (Default: Regenbogen) + public LedAnimType[] LedAnim { get; } = + Enumerable.Repeat(LedAnimType.ColorCycle, 20).ToArray(); + + // Animationsperiode in ms (Default: 4000ms) + public ushort[] LedPeriod { get; } = + Enumerable.Repeat((ushort)4000, 20).ToArray(); + private static DeviceAction[,] InitEncActions() { var a = new DeviceAction[4, 3]; @@ -114,36 +189,36 @@ public class DeviceConfig // ── Serialisierung ──────────────────────────────────────────────────────── - // Erzeugt den 163-Byte-Puffer der 1:1 SDeviceConfig entspricht. public byte[] ToBytes() { - const int size = 163; + const int size = 223; var buf = new byte[size]; int pos = 0; - // Magic (little-endian) WriteU32(buf, ref pos, Magic); - // Version buf[pos++] = Version; - // CRC-Platzhalter (wird am Ende eingetragen) int crcOffset = pos; pos += 2; - // mx_actions[20] foreach (var a in MxActions) WriteAction(buf, ref pos, a); - // enc_actions[4][3] for (int e = 0; e < 4; e++) for (int i = 0; i < 3; i++) WriteAction(buf, ref pos, EncActions[e, i]); - // led_r / led_g / led_b for (int i = 0; i < 20; i++) buf[pos++] = LedBase[i].R; for (int i = 0; i < 20; i++) buf[pos++] = LedBase[i].G; for (int i = 0; i < 20; i++) buf[pos++] = LedBase[i].B; - // CRC über alles ab mx_actions (Offset 7) + for (int i = 0; i < 20; i++) buf[pos++] = (byte)LedAnim[i]; + + for (int i = 0; i < 20; i++) + { + buf[pos++] = (byte)(LedPeriod[i] & 0xFF); + buf[pos++] = (byte)(LedPeriod[i] >> 8); + } + ushort crc = Crc16(buf, 7, size - 7); buf[crcOffset] = (byte)(crc & 0xFF); buf[crcOffset + 1] = (byte)(crc >> 8); @@ -151,18 +226,16 @@ public class DeviceConfig return buf; } - // Lädt Config aus einem 163-Byte-Puffer (empfangen vom Board). - // Gibt false zurück wenn Magic/Version/CRC ungültig. public bool FromBytes(byte[] buf) { - if (buf.Length < 163) return false; + if (buf.Length < 223) return false; uint magic = ReadU32(buf, 0); if (magic != Magic) return false; if (buf[4] != Version) return false; ushort storedCrc = (ushort)(buf[5] | (buf[6] << 8)); - ushort computedCrc = Crc16(buf, 7, 163 - 7); + ushort computedCrc = Crc16(buf, 7, 223 - 7); if (storedCrc != computedCrc) return false; int pos = 7; @@ -172,6 +245,15 @@ public class DeviceConfig ReadAction(buf, ref pos, EncActions[e, i]); for (int i = 0; i < 20; i++) LedBase[i] = Color.FromArgb(buf[pos + i], buf[pos + 20 + i], buf[pos + 40 + i]); + pos += 60; + + for (int i = 0; i < 20; i++) LedAnim[i] = (LedAnimType)buf[pos++]; + + for (int i = 0; i < 20; i++) + { + LedPeriod[i] = (ushort)(buf[pos] | (buf[pos + 1] << 8)); + pos += 2; + } return true; } @@ -203,7 +285,6 @@ public class DeviceConfig private static uint ReadU32(byte[] buf, int pos) => (uint)(buf[pos] | (buf[pos+1] << 8) | (buf[pos+2] << 16) | (buf[pos+3] << 24)); - // CRC16-CCITT (Poly 0x1021, Init 0xFFFF) – identisch zur Firmware public static ushort Crc16(byte[] data, int offset, int length) { ushort crc = 0xFFFF; diff --git a/src/Protocol.cs b/src/Protocol.cs index 224d358..f3355d3 100644 --- a/src/Protocol.cs +++ b/src/Protocol.cs @@ -31,6 +31,10 @@ public static class Protocol public const byte CmdConfigData = 0x11; public const byte CmdConfigCommit = 0x12; public const byte CmdConfigRead = 0x13; // Board sendet aktuelle NVM-Config zurück + public const byte CmdMacroBegin = 0x20; // Makro-Tabelle übertragen: Data[1] = Chunks + public const byte CmdMacroData = 0x21; // Makro-Chunk: Data[1] = Index, Data[2..7] = 6B + public const byte CmdMacroCommit = 0x22; // NVM schreiben + public const byte CmdMacroRead = 0x23; // Board sendet aktuelle Makro-Tabelle zurück // ── Events: Board → App (0x81–0xFF) ────────────────────────────────────── public const byte EvtKeyDown = 0x81; // key_id → HOST_COMMAND-Button gedrückt @@ -43,6 +47,10 @@ public static class Protocol public const byte EvtConfigBegin = 0x92; // Beginn Config-Dump (Data[1] = Chunks) public const byte EvtConfigData = 0x93; // Config-Chunk (Data[1] = Index, Data[2..7] = 6B) public const byte EvtConfigEnd = 0x94; // Config-Dump vollständig + public const byte EvtMacroAck = 0x95; // Makro-Tabelle erfolgreich gespeichert + public const byte EvtMacroBegin = 0x96; // Beginn Makro-Dump (Data[1] = Chunks) + public const byte EvtMacroData = 0x97; // Makro-Chunk (Data[1] = Index, Data[2..7] = 6B) + public const byte EvtMacroEnd = 0x98; // Makro-Dump vollständig } // Ein 8-Byte-Paket mit benannten Accessoren. diff --git a/src/SerialManager.cs b/src/SerialManager.cs index 24fafbf..c323d74 100644 --- a/src/SerialManager.cs +++ b/src/SerialManager.cs @@ -217,6 +217,36 @@ public class SerialManager : IDisposable public void RequestConfig() => Send(new SerialPacket(Protocol.CmdConfigRead)); + // Makro-Tabelle vom Board anfordern + public void RequestMacros() + => Send(new SerialPacket(Protocol.CmdMacroRead)); + + // Makro-Tabelle (256 Bytes) in 6-Byte-Chunks senden. + public void SendMacros(MacroTable macros) + { + byte[] data = macros.ToBytes(); + const int payload = 6; + int chunks = (data.Length + payload - 1) / payload; // 43 + + Send(new SerialPacket(Protocol.CmdMacroBegin, (byte)chunks)); + Thread.Sleep(10); + + for (int i = 0; i < chunks; i++) + { + var pkt = new SerialPacket(); + pkt.Data[0] = Protocol.CmdMacroData; + pkt.Data[1] = (byte)i; + int offset = i * payload; + int count = Math.Min(payload, data.Length - offset); + Buffer.BlockCopy(data, offset, pkt.Data, 2, count); + Send(pkt); + Thread.Sleep(5); + } + + Thread.Sleep(10); + Send(new SerialPacket(Protocol.CmdMacroCommit)); + } + // Config in 6-Byte-Chunks an das Board senden. // Protokoll: BEGIN → n×DATA → COMMIT // Board schreibt nach COMMIT in den NVM (Firmware-Seite noch TODO). diff --git a/src/TrayApp.cs b/src/TrayApp.cs index 75d1f2b..ec440aa 100644 --- a/src/TrayApp.cs +++ b/src/TrayApp.cs @@ -20,12 +20,14 @@ public class TrayApp : ApplicationContext private readonly NotifyIcon _tray; private readonly SerialManager _serial; private readonly DeviceConfig _config = new(); + private readonly MacroTable _macros = new(); private ToolStripMenuItem _statusItem = null!; private ConfigForm? _configForm; - // Empfangspuffer für eingehenden Config-Dump vom Board + // Empfangspuffer für eingehende Dumps vom Board private byte[]? _rxConfigBuf; + private byte[]? _rxMacroBuf; public TrayApp() { @@ -78,7 +80,7 @@ public class TrayApp : ApplicationContext _configForm.BringToFront(); return; } - _configForm = new ConfigForm(_config, _serial); + _configForm = new ConfigForm(_config, _macros, _serial); _configForm.Show(); } @@ -90,8 +92,9 @@ public class TrayApp : ApplicationContext _tray.Icon = SystemIcons.Information; _statusItem.Text = "● Verbunden"; - // Config sofort vom Board laden + // Config und Makro-Tabelle vom Board laden _serial.RequestConfig(); + _serial.RequestMacros(); } private void OnDisconnected(object? sender, EventArgs e) @@ -136,7 +139,7 @@ public class TrayApp : ApplicationContext // ── Config-Dump vom Board ───────────────────────────────────────── case Protocol.EvtConfigBegin: - _rxConfigBuf = new byte[163]; + _rxConfigBuf = new byte[223]; // sizeof(SDeviceConfig) = 223 (Version 2) break; case Protocol.EvtConfigData: @@ -154,7 +157,35 @@ public class TrayApp : ApplicationContext { _config.FromBytes(_rxConfigBuf); _rxConfigBuf = null; - _configForm?.RefreshAll(); // offenes Fenster sofort aktualisieren + _configForm?.RefreshAll(); + } + break; + + // ── Makro-Dump vom Board ────────────────────────────────────────── + case Protocol.EvtMacroAck: + MessageBox.Show("Makros erfolgreich gespeichert!", + "VersaPad", MessageBoxButtons.OK, MessageBoxIcon.Information); + break; + + case Protocol.EvtMacroBegin: + _rxMacroBuf = new byte[256]; + break; + + case Protocol.EvtMacroData: + if (_rxMacroBuf != null) + { + int offset = pkt.KeyId * 6; + for (int i = 0; i < 6; i++) + if (offset + i < _rxMacroBuf.Length) + _rxMacroBuf[offset + i] = pkt.Data[2 + i]; + } + break; + + case Protocol.EvtMacroEnd: + if (_rxMacroBuf != null) + { + _macros.FromBytes(_rxMacroBuf); + _rxMacroBuf = null; } break; }