Added Macro functionality, udpated readme

This commit is contained in:
Julian Appel 2026-03-29 22:18:49 +02:00
parent 747bec985d
commit 2eb43826ce
8 changed files with 975 additions and 174 deletions

122
README.md
View File

@ -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, F1F12, 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 (019) und Encoder-Aktion (2031) | ✅ |
| 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 7162 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 7222 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)
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 0x200x23):
```
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

View File

@ -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,85 +40,131 @@ 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<Keys, byte> 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<byte, byte> 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, // 10
[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, // F1F5
[0x40]=0x3F,[0x41]=0x40,[0x42]=0x41,[0x43]=0x42,[0x44]=0x43, // F6F10
[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<byte, string> s_hidNames = new()
// HID → Scan-Code (umgekehrte Tabelle, für Anzeigenamen)
private static readonly Dictionary<byte, byte> 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<Keys, byte> 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+",
[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<byte, string>)new Dictionary<byte, string>
{
[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<string>();
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<string>();
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
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;
else
{
_chkCtrl.Checked = ctrl;
_chkShift.Checked = shift;
_chkAlt.Checked = alt;
_hidKeycode = VkToHid(vk);
StopCapture();
e.Handled = true;
e.SuppressKeyPress = true;
}
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;
}
}
}

View File

@ -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.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];
// 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;
// Textfarbe kontrastreich zur Hintergrundfarbe wählen
btn.ForeColor = Luminance(color) > 128 ? Color.Black : Color.White;
btn.Text = action.Display;
}
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);

161
src/ConfigJson.cs Normal file
View File

@ -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<ConfigJsonDoc>(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; }
}
}

View File

@ -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 7162)
// Offset 4 1B Version 2
// Offset 5 2B CRC16 (CCITT über Bytes 7222)
// 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 (031)
}
// 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; // 019
public static int SlotForEncoder(int enc, int actIdx) => 20 + enc * 3 + actIdx; // 2031
// 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 524, 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;

View File

@ -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 (0x810xFF) ──────────────────────────────────────
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.

View File

@ -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).

View File

@ -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;
}