Compare commits

...

9 Commits

21 changed files with 1892 additions and 446 deletions
+71
View File
@@ -0,0 +1,71 @@
# Arbeitsanweisungen für Coding-Agents
Diese Datei gilt für das gesamte VersaGUI-Repository.
## Ziel und Nachbar-Repository
VersaGUI ist die Windows-/WinForms-Konfigurationsanwendung für VersaPad v2.
Sie läuft auf .NET 7 und kommuniziert über feste 8-Byte-CDC-Pakete mit der
Firmware im benachbarten, eigenständigen Repository `../VersaMCU`.
`DelphiGUI` gehört nicht zum gepflegten Scope und darf bei Änderungen nicht
als Referenz oder Ziel verwendet werden.
## Vor Änderungen lesen
1. `README.md`
2. `doc/INDEX.md`
3. `doc/00_architecture.md`
4. `doc/07_known_limitations.md`
5. bei Protokoll-/Layoutänderungen zusätzlich:
- `src/Protocol.cs`
- `src/DeviceConfig.cs`
- `../VersaMCU/AGENTS.md`
- `../VersaMCU/doc/06_nvm_config.md`
- `../VersaMCU/doc/07_serial_protocol.md`
## Gemeinsame Binärverträge
- `DeviceConfig` muss exakt `SDeviceConfig` entsprechen: Config v3, 740 Byte.
- `MacroTable` muss exakt `SMacroTable` entsprechen: 512 Byte.
- Enum-Werte, Packing, Offsets, CRC-Bereich, Chunkgrößen und USB-IDs sind
gemeinsame Verträge mit VersaMCU.
- Änderungen daran in beiden Repositories implementieren, dokumentieren,
bauen und getrennt committen.
- Host-Command-IDs niemals ungeprüft als Shellkommando ausführen. Eine spätere
Host-Aktionsfunktion benötigt ein explizites sicheres Mapping.
- Das Binärmodell akzeptiert alle Firmware-Animationen `0..6`; der aktuelle
`ActionDialog` bietet davon nur Static, Blink, Pulse und ColorCycle an.
Bestehende, im Dialog nicht angebotene Werte nicht stillschweigend
überschreiben.
## Threading und Transfers
- WinForms-Controls ausschließlich auf dem UI-Thread anfassen.
- `SerialManager.ReadLoop()` läuft im Hintergrund und postet Events über den
`SynchronizationContext`.
- SerialPort-Pakete müssen unter dem Write-Lock vollständig geschrieben werden.
- Config-/Makrotransfers nicht parallel ausführen.
- Dumps und Uploads nur akzeptieren, wenn Chunkzahl, Indizes und
Vollständigkeit stimmen.
- DTR muss vor `SerialPort.Open()` aktiviert sein.
## Verifikation
Mindestens:
```bash
dotnet build src/VersaGUI.csproj --no-restore
dotnet run --project tests/VersaGUI.ContractTests/VersaGUI.ContractTests.csproj
git diff --check
```
Bei gemeinsamen Vertragsänderungen zusätzlich:
```bash
pio run -d ../VersaMCU -e versapad
```
Die Contract-Tests prüfen Config-/Makrogrößen, CRC, Feldvalidierung,
Chunkvollständigkeit und Host-Command-Payloads. Sie ersetzen keinen Test mit
angeschlossenem Board.
+116 -178
View File
@@ -1,200 +1,138 @@
# VersaGUI
Windows-Tray-App zur Konfiguration und Steuerung des VersaPad v2.
Geschrieben in **C# / .NET 7 / WinForms**.
Windows-Tray-App zur Konfiguration des VersaPad v2.
Geschrieben in C# mit WinForms.
Diese GUI und das benachbarte Repository `../VersaMCU` sind die gemeinsam
gepflegten Referenzen für Protokoll und Datenlayout. Andere GUI-Ports gehören
nicht zum aktuellen Scope.
## Voraussetzungen
- Windows 10/11
- [.NET 7 SDK](https://dotnet.microsoft.com/download/dotnet/7.0) (zum Bauen)
- VersaMCU-Firmware auf dem Board geflasht
- Board per USB verbunden (erscheint als CDC COM-Port, kein Treiber nötig)
- .NET 7 SDK mit Windows-Desktop-Unterstützung
- geflashte `VersaMCU`-Firmware
- Board per USB als Composite Device (Keyboard-HID, Consumer-HID und CDC)
verbunden
## Starten
```bash
dotnet run
dotnet run --project src/VersaGUI.csproj
```
Oder als Release-Build:
## Was die App macht
- erkennt das Board automatisch per VID/PID
- oeffnet den CDC-COM-Port mit aktivem DTR
- liest beim Verbinden zuerst die Config und danach die Makros
- validiert Chunkzahl, eindeutige Indizes, Vollständigkeit, CRC und Feldwerte
- zeigt MX-Buttons und Encoder-Aktionen an
- bearbeitet alle drei Profile über die Profilauswahl
- schreibt Config und Makros getrennt, aber in einem UI-Vorgang auf das Board
- sendet einen Protokoll-Ping und zeigt die Antwort an
## Aktueller Datenstand
### DeviceConfig
- Magic `0x56503203`
- Version `3`
- `740` Byte
- 3 Profile
- globale Helligkeit
- per-LED-Helligkeit
Globale Helligkeit und Encoder-Sensitivität werden bytegenau erhalten, besitzen
aber derzeit keine eigenen Bedienelemente. Per-LED-Helligkeit kann über JSON
gesetzt werden.
### MacroTable
- 32 Slots
- 8 Steps pro Slot
- `512` Byte
## Uebertragungsformat
Es wird dasselbe 8-Byte-Protokoll wie in der Firmware verwendet.
Aktuelle Chunk-Zahlen:
- Config: `124`
- Makros: `86`
Speicherablauf:
```text
CONFIG_BEGIN
CONFIG_DATA x124
CONFIG_COMMIT
wait for ACK/NACK
MACRO_BEGIN
MACRO_DATA x86
MACRO_COMMIT
wait for ACK/NACK
```
## GUI-Funktionen
- HID-Key-Zuweisung inklusive Modifier
- Consumer-Keys
- Host-Command-IDs konfigurieren und empfangen; Desktop-Aktionen werden noch
nicht ausgeführt
- Makros mit bis zu 8 Steps
- Profilwechsel als ActionType
- LED-Farbe, Animation und Periode pro MX-Button
- JSON Import/Export
## Projektstruktur
```text
VersaGUI/
|-- AGENTS.md
|-- doc/
|-- tests/VersaGUI.ContractTests/
`-- src/
|-- Program.cs
|-- TrayApp.cs
|-- SerialManager.cs
|-- ChunkTransferBuffer.cs
|-- DeviceConfig.cs
|-- ConfigForm.cs
|-- ActionDialog.cs
|-- ConfigJson.cs
`-- Protocol.cs
```
## Build und Vertragsprüfungen
```bash
dotnet publish -c Release -r win-x64 --self-contained
dotnet build src/VersaGUI.csproj --no-restore
dotnet run --project tests/VersaGUI.ContractTests/VersaGUI.ContractTests.csproj
```
Die App erscheint als Icon in der Windows-Taskleiste (System Tray). Kein Hauptfenster.
## Grenzen / Status
## Bedienung
- die App ist Windows-only
- Host-Command-Events enthalten Command-ID, Key-/Encoder-ID und Richtung; ein
sicheres Mapping auf konkrete Desktop-Aktionen ist noch nicht implementiert
- der ActionDialog bietet aktuell vier der sieben vom Binärformat unterstützten
LED-Animationen an
- ein vollständig ausbleibendes Dump-Ende besitzt noch keinen Empfangs-Timeout
1. **Board verbinden** App erkennt das Board automatisch per VID/PID (`0x239A / 0x0042`) via WMI
2. **Rechtsklick** auf das Tray-Icon → **Konfiguration...**
3. Taste/Encoder anklicken → Aktion auswählen:
- **HID Tastatur**: Großen Button klicken, dann gewünschte Taste drücken (Strg+C, F5, …)
- **HID Consumer**: Dropdown Play/Pause, Lautstärke, etc.
- **Host Command**: Numerische Command-ID (zukünftig: URL/Programm)
- **LED-Farbe**: Farbpicker für die Idle-LED des Buttons
4. **Auf Board speichern** überträgt Config in den NVM des Boards
5. Beim nächsten Verbinden wird die Config automatisch vom Board geladen
Die vollständige Liste steht in
[`doc/07_known_limitations.md`](doc/07_known_limitations.md).
---
## Einstieg für Coding-LLMs
## Funktionsumfang (Anforderungskatalog)
Repository-Anweisungen, gemeinsame Binärverträge und Verifikation stehen in
[`AGENTS.md`](AGENTS.md).
### 1 Verbindung & Geräteerkennung
## Weiterführende Doku
| # | 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
```
VersaGUI/
├── VersaGUI.csproj .NET 7 WinForms Projekt
├── 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 / 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 + Makros senden/empfangen
```
## Architektur
```
TrayApp
├── SerialManager Hintergrund-Thread: COM-Port-Verbindung + Leseloop
│ ├── Connected-Event → Config vom Board anfordern (CMD_CONFIG_READ)
│ └── PacketReceived → TrayApp.OnPacket()
├── DeviceConfig In-Memory-Modell der Board-Config
└── ConfigForm (optional) Öffnet sich auf Benutzeranfrage
└── ActionDialog Modaler Dialog pro Taste/Encoder
```
### SerialManager
- Erkennt das Board per **WMI** (`Win32_PnPEntity`, VID/PID-Filter) → COM-Port-Name
- Lese-Loop im Hintergrund-Thread: sammelt 8-Byte-Pakete, feuert `PacketReceived`-Event auf dem UI-Thread
- **Reconnect-Timer**: versucht alle 3 Sekunden neu zu verbinden; nach Disconnect 5 Sekunden Backoff
- Wichtig: `DtrEnable = true` muss **vor** `Open()` gesetzt werden der SAMD21 prüft die DTR-Leitung in `if (!SerialUSB)` und verwirft sonst alle ausgehenden Pakete
### DeviceConfig / Serialisierung
`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 = 38)
EVT_CONFIG_DATA × 38 (je 6 Nutzbytes)
EVT_CONFIG_END
```
Config-Übertragung PC→Board (Schreiben):
```
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
| Problem | Lösung |
|---|---|
| `.NET 7 SerialPort.ReadByte()` wirft `IOException` statt `TimeoutException` auf Timeout | `catch (IOException)` → nur als Fehler behandeln wenn Port nicht mehr offen ist |
| `DtrEnable = false` (Standard) → Board sendet nichts | `DtrEnable = true` im SerialPort-Konstruktor setzen, **vor** `Open()` |
| DTR-Zustandswechsel nach `Open()` löst CDC-Disconnect auf dem Board aus | DTR vor `Open()` setzen → kein Zustandswechsel |
- [Dokumentationsindex](doc/INDEX.md)
- [Datenlayout](doc/02_device_config.md)
- [Bekannte Einschränkungen](doc/07_known_limitations.md)
- [../VersaMCU/doc/07_serial_protocol.md](../VersaMCU/doc/07_serial_protocol.md)
+82
View File
@@ -0,0 +1,82 @@
# VersaGUI Architektur-Übersicht
## Technologie-Stack
| Merkmal | Wert |
|---|---|
| Sprache | C# / .NET 7 |
| UI-Framework | WinForms (`[STAThread]`) |
| Einstiegspunkt | `Program.cs``Application.Run(new TrayApp())` |
| Laufzeitmodell | `ApplicationContext` (kein `Form` als Hauptfenster) |
| Threading | UI-Thread + 1 Hintergrund-Lese-Thread + Timer-Thread |
## Komponentenübersicht
| Datei | Verantwortung |
|---|---|
| `TrayApp` | `ApplicationContext`; hält Tray-Icon, öffnet ConfigForm, verarbeitet Board-Events |
| `SerialManager` | Verbindungsverwaltung, WMI-Erkennung, Lese-Thread, Sende-Methoden |
| `ConfigForm` | Hauptfenster (Profilauswahl, Grid, Encoder-Panel und Footer); öffnet ActionDialog |
| `ActionDialog` | Modaler Dialog zum Bearbeiten einer Aktion + LED-Einstellungen |
| `DeviceConfig` | C#-Spiegel von `SDeviceConfig`; Validierung und Serialisierung (740 B) |
| `MacroTable` | C#-Spiegel von `SMacroTable`; Validierung und Serialisierung (512 B) |
| `ChunkTransferBuffer` | prüft Dump-Chunkzahl, Indizes und Vollständigkeit |
| `ConfigJson` | JSON-Import/Export für `DeviceConfig` |
| `Protocol` | Konstanten für alle Command/Event-IDs (spiegelt `usb_serial.h`) |
## Datenfluss
```
Board → SerialManager (ReadLoop, BG-Thread)
→ SynchronizationContext.Post (→ UI-Thread)
→ TrayApp.OnPacket()
├── Config-Dump vollständig sammeln → DeviceConfig.FromBytes()
├── danach Makro-Dump vollständig sammeln → MacroTable.FromBytes()
└── HOST_COMMAND-Events mit 16-Bit-Command-ID empfangen
Benutzer → ConfigForm → ActionDialog
→ DeviceConfig / MacroTable (in-memory ändern)
→ SerialManager.SendConfig() + SendMacros() (BG-Task)
→ Board (chunked, 6 B/Paket)
```
## Threading-Modell
```
UI-Thread : TrayApp, ConfigForm, ActionDialog, alle WinForms-Controls
BG-Thread : SerialManager.ReadLoop() blockiert auf ReadByte()
Timer-Thread : SerialManager._reconnectTimer → TryConnect() alle 3 s
Sende-Task : ConfigForm.OnSave() → Task.Run() (blockiert bis ACK/NACK oder Timeout)
```
Alle Board-Events werden per `SynchronizationContext.Post` auf den UI-Thread gepostet. Controls dürfen nie vom BG-Thread angefasst werden.
## Verbindungslebenszyklus
```
Start → Timer feuert → TryConnect() → WMI-Suche (VID 0x239A / PID 0x0042)
→ SerialPort öffnen (DtrEnable=true VOR Open()!)
→ 200 ms warten → ReadLoop starten → Connected-Event
→ RequestConfig() → vollständig validieren → RequestMacros()
Disconnect → ReadLoop bricht ab → Disconnected-Event → 5 s Backoff → Timer läuft weiter
```
## Invarianten / Constraints
- **DtrEnable=true muss VOR `Open()` gesetzt werden**: SAMD21 prüft DTR für `usb_serial_send()`. Der Default-Wert false würde alle Board→PC-Antworten still verwerfen.
- **IOException ≠ Disconnect**: .NET 7 wirft `IOException` statt `TimeoutException` bei `ReadByte()`-Timeout. Nur als echten Fehler behandeln wenn `_port.IsOpen == false`.
- **Packed-kompatible Serialisierung**: `DeviceConfig.ToBytes()` erzeugt exakt
740 B in derselben Reihenfolge wie `SDeviceConfig`; `MacroTable` exakt 512 B.
- **Config-Version**: `DeviceConfig.Version == 3`. `FromBytes()` prüft Magic,
Version, CRC, Profilindex, Actions, LED-Enums und kritische Perioden, bevor
Objektzustand geändert wird.
- **Transfer-Synchronisation**: Config- und Makro-Uploads laufen unter einem
gemeinsamen Transfer-Lock; einzelne Pakete unter einem Write-Lock.
- **Host-Commands**: Pakete sind vollständig definiert, die Ausführung einer
Command-ID als Desktop-Aktion ist bewusst noch nicht implementiert.
- **UI-Modell-Grenze**: Das Binärmodell kann alle sieben Firmware-Animationen
lesen und schreiben. Der ActionDialog bietet aktuell vier davon zur Auswahl.
Bewusst offene Punkte stehen gesammelt in
[`07_known_limitations.md`](07_known_limitations.md).
+103
View File
@@ -0,0 +1,103 @@
# SerialManager
**Datei:** `SerialManager.cs`
## Verantwortung
- COM-Port-Erkennung per WMI (VID/PID)
- Verbindungsaufbau und automatischer Reconnect
- Hintergrund-Lese-Thread mit Paket-Assembler
- Sende-Methoden für alle Protokoll-Operationen
- ACK/NACK-Synchronisation zwischen ReadLoop-Thread und `SendConfig`/`SendMacros`-Task
## COM-Port-Erkennung
```csharp
VidPid = "VID_239A&PID_0042" // muss mit platformio.ini übereinstimmen
SELECT Name, DeviceID FROM Win32_PnPEntity WHERE DeviceID LIKE '%VID_239A&PID_0042%'
// Name enthält z.B. "USB Serial Device (COM3)" → extrahiert "COM3"
```
Erkennt das Board auch bei wechselnder COM-Nummer. Funktioniert auch nach USB-Reinitialisierung (Kabel abziehen/stecken nach SWD-Flash).
## Verbindungsaufbau (TryConnect)
```
Monitor.TryEnter(_connectLock) nur ein gleichzeitiger Versuch
FindPort() per WMI
SerialPort erstellen:
ReadTimeout = 500 ms
WriteTimeout = 500 ms
DtrEnable = true ← VOR Open() setzen! (SAMD21 CDC-Constraint)
port.Open()
Thread.Sleep(200) CDC stabilisieren
ReadLoop-Thread starten
Connected-Event → UI-Thread
```
**Reconnect-Timer**: alle 3 s, Start nach 500 ms. 5 s Backoff (`_waitingAfterDisconnect`) nach Verbindungsverlust damit Board vollständig re-enumerieren kann.
## ReadLoop (Hintergrund-Thread)
```
while (port.IsOpen):
b = port.ReadByte() blockiert 500 ms (ReadTimeout)
buf[bufFill++] = b
if bufFill < 8: continue
→ SerialPacket fertig → SynchronizationContext.Post → PacketReceived auf UI-Thread
bufFill = 0
Bei IOException:
if port.IsOpen: ignorieren ← .NET 7: IOException = normaler Timeout (kein Fehler!)
else: break → Disconnect
```
## ACK/NACK-Synchronisation
`SendConfig` und `SendMacros` blockieren nach COMMIT auf ein `SemaphoreSlim`-Gate bis das Board antwortet:
```
_configAckGate / _macroAckGate SemaphoreSlim(0, 1)
_configAckOk / _macroAckOk volatile bool (true = ACK, false = NACK)
```
| Methode | Aufruf durch | Wirkung |
|---|---|---|
| `SignalConfigAck()` | TrayApp bei EvtConfigAck | `_configAckOk = true`, Gate freigeben |
| `SignalConfigNack()` | TrayApp bei EvtConfigNack | `_configAckOk = false`, Gate freigeben |
| `SignalMacroAck()` | TrayApp bei EvtMacroAck | `_macroAckOk = true`, Gate freigeben |
| `SignalMacroNack()` | TrayApp bei EvtMacroNack | `_macroAckOk = false`, Gate freigeben |
`SendConfig()` und `SendMacros()` geben `bool` zurück (`true` = ACK erhalten, `false` = NACK oder Timeout nach 3 s).
## Sende-Methoden
| Methode | Rückgabe | Funktion |
|---|---|---|
| `Send(pkt)` | bool | Rohe, unter Write-Lock atomare 8-Byte-Übertragung |
| `SetLedOverride(keyId, r, g, b)` | void | CMD 0x01 |
| `ClearLedOverride(keyId)` | void | CMD 0x02 |
| `SetLedBase(keyId, r, g, b)` | void | CMD 0x03 |
| `RequestConfig()` | void | CMD 0x13 Board antwortet mit Config-Dump |
| `RequestMacros()` | void | CMD 0x23 Board antwortet mit Makro-Dump |
| `SendConfig(cfg)` | bool | BEGIN(0x10) → 124×DATA(0x11) → COMMIT(0x12), 5 ms zwischen Chunks, wartet auf ACK/NACK |
| `SendMacros(macros)` | bool | BEGIN(0x20) → 86×DATA(0x21) → COMMIT(0x22), 5 ms zwischen Chunks, wartet auf ACK/NACK |
Die reine Chunk-Pausierung beträgt nominal etwa 640 ms für Config und 450 ms
für Makros, jeweils zuzüglich Port-, NVM- und ACK-Zeit. Nach jedem COMMIT wird
höchstens 3 s auf ACK/NACK gewartet. Deshalb laufen beide Methoden aus
`ConfigForm.OnSave()` in `Task.Run()` und niemals auf dem UI-Thread.
Vor `BEGIN` wird das zugehörige ACK-Gate geleert. Beide Blob-Sender teilen
einen Transfer-Lock, prüfen jeden `Send()`-Rückgabewert und brechen bei
Disconnect sofort ab. Ein Disconnect gibt wartende ACK-Gates mit Fehlerstatus
frei.
## Events
| Event | Gefeuert wenn |
|---|---|
| `Connected` | Verbindung erfolgreich hergestellt (auf UI-Thread) |
| `Disconnected` | Verbindung verloren (auf UI-Thread) |
| `PacketReceived` | Vollständiges 8-Byte-Paket empfangen (auf UI-Thread) |
+161
View File
@@ -0,0 +1,161 @@
# DeviceConfig und MacroTable
Datei:
- `src/DeviceConfig.cs`
## Zweck
Die C#-Klassen spiegeln das aktuelle Firmware-Layout bytegenau.
Sie muessen deshalb synchron zu `VersaMCU/src/config/nvm_config.h` und `macro_config.h` bleiben.
## DeviceConfig
### Globaler Stand
- Magic `0x56503203`
- Version `3`
- Groesse `740` Byte
- 3 Profile
### Wichtige Felder
| Feld | Bedeutung |
|---|---|
| `ActiveProfileIndex` | aktives Profil 0..2 |
| `GlobalBrightness` | globale LED-Helligkeit |
| `Profiles[3]` | komplette Profil-Daten |
| `EncSensitivity[4]` | im Vertrag enthalten; Firmware nutzt den Wert derzeit nicht |
Fuer die GUI gibt es zusaetzlich Komfortzugriffe auf das aktive Profil:
- `MxActions[20]`
- `EncActions[4,3]`
- `LedBase[20]`
- `LedAnim[20]`
- `LedPeriod[20]`
### Layout
```text
Offset 0 4B magic
Offset 4 1B version
Offset 5 2B crc
Offset 7 1B active_profile
Offset 8 1B global_brightness
Offset 9 4B enc_sensitivity[4]
Offset 13 19B reserve
Offset 32 profile 0
Offset 268 profile 1
Offset 504 profile 2
```
Jedes Profil belegt 236 Byte:
```text
20 x mx_actions
12 x enc_actions
20 x led_r
20 x led_g
20 x led_b
20 x led_brightness
20 x led_anim
20 x led_period_ms
```
### Action-Daten
| Typ | Datenbereich |
|---|---|
| `None` | Daten werden ignoriert |
| `HidKey` | Low-Byte Keycode `0x00..0x65`, High-Byte Modifier |
| `HidConsumer` | Usage `0x0000..0x03FF` |
| `HostCommand` | frei definierte Command-ID `0x0000..0xFFFF` |
| `Macro` | Slot `0..31` |
| `ProfileSwitch` | Profil `0..2`; `0x00FF` oder `0xFFFF` bedeuten „nächstes Profil“ |
### LED-Animationen im Binärformat
| Wert | Enum |
|---|---|
| `0` | `Static` |
| `1` | `Blink` |
| `2` | `Pulse` |
| `3` | `FadeIn` |
| `4` | `FadeOut` |
| `5` | `ColorCycle` |
| `6` | `ColorFade` |
`DeviceConfig` akzeptiert alle Werte `0..6`. Der aktuelle ActionDialog bietet
nur `Static`, `Blink`, `Pulse` und `ColorCycle` an.
## CRC
CRC16-CCITT:
- Polynom `0x1021`
- Init `0xFFFF`
- Bereich `7..739`
Die CRC muss exakt zur Firmware passen.
## Defaults
Firmware-Defaults:
- alle Actions `None`
- `GlobalBrightness = 255`
- `enc_sensitivity = 1`
- Base-Farbe `R=80, G=40, B=0`
- Animation `ColorCycle`
- Periode `4000 ms`
Sichtbarer Effekt auf dem Board:
- alle MX-LEDs laufen im Regenbogenmodus
## MacroTable
### Stand
- 32 Slots
- 8 Steps pro Slot
- 512 Byte gesamt
### Slot-Konvention
| Slots | Bedeutung |
|---|---|
| `0..19` | MX-Buttons |
| `20..31` | Encoder-Aktionen |
### Serialisierung
Ein Step besteht aus:
- `keycode`
- `modifier`
`keycode == 0` beendet die Ausführung des Slots. Belegte Steps nach der ersten
Lücke werden daher zwar serialisiert, von der Firmware aber nicht ausgeführt.
Es gibt kein Magic und keine CRC fuer die Makrotabelle.
Beim Transfer prüft die GUI trotzdem exakte Größe und alle HID-Keycodes; die
Firmware prüft zusätzlich die vollständige Chunkmenge.
## Validierung
`ToBytes()` verweigert ungültige Objektzustände. `FromBytes()` verändert das
bestehende Objekt erst, nachdem Größe, Magic, Version, CRC, aktives Profil,
Action-Werte, LED-Enums und die Mindestperiode von 2 ms für `Pulse` geprüft
wurden. Für die Makrotabelle gelten exakte 512 Byte und Keycodes bis `0x65`.
## Wichtig fuer Aenderungen
Wenn sich Firmware-Layout, Magic, Version, Profilzahl oder Makrogroesse aendern, muessen mindestens diese Stellen zusammen angepasst werden:
- `VersaMCU`
- `VersaGUI`
Beide sind eigenständige Git-Repositories und werden getrennt gebaut und
committed. Andere GUI-Ports gehören nicht zum gepflegten Scope.
+71
View File
@@ -0,0 +1,71 @@
# TrayApp
**Datei:** `TrayApp.cs`
## Verantwortung
- App-Lebenszyklus als `ApplicationContext` (kein Hauptfenster)
- Tray-Icon mit Verbindungsstatus und Kontextmenü
- Empfang und Routing aller Board-Events (via `SerialManager.PacketReceived`)
- sequenzieller Config-/Makro-Dump-Empfang über `ChunkTransferBuffer`
- ACK/NACK-Weiterleitung an SerialManager
- Öffnen des `ConfigForm` (nur eine Instanz gleichzeitig)
## Tray-Menü
```
[●/○] VersaPad verbunden / nicht verbunden (disabled, nur Anzeige)
──────────────────────────────────────────────
Konfiguration... → OpenConfigForm()
──────────────────────────────────────────────
Beenden → Application.Exit()
```
Icon und Tooltip spiegeln den Verbindungsstatus:
- Verbunden: `SystemIcons.Information`, Text "VersaPad verbunden"
- Getrennt: `SystemIcons.Application`, Text "VersaPad nicht verbunden"
(TODO: eigenes Icon einbinden)
## Board-Event-Handling (OnPacket)
| Event-ID | Aktion |
|---|---|
| `EvtConfigBegin/Data/End` | Chunkzahl, Indizes und Vollständigkeit prüfen; danach `DeviceConfig.FromBytes()` |
| `EvtConfigAck` | `serial.SignalConfigAck()` — gibt SendConfig()-Thread frei |
| `EvtConfigNack` | `serial.SignalConfigNack()` — gibt SendConfig()-Thread frei (Fehler) |
| `EvtMacroBegin/Data/End` | vollständigen 512-Byte-Dump prüfen; danach `MacroTable.FromBytes()` |
| `EvtMacroAck` | `serial.SignalMacroAck()` — gibt SendMacros()-Thread frei |
| `EvtMacroNack` | `serial.SignalMacroNack()` — gibt SendMacros()-Thread frei (Fehler) |
| `EvtPong` | MessageBox "Ping OK" |
| `EvtKeyDown/Up` | Host-Command-ID in Byte 2/3 empfangen |
| `EvtEncCw/Ccw` | Host-Command-ID und Encoderrichtung empfangen |
Diese vier Host-Events sendet die Firmware nur für Actions vom Typ
`HostCommand`. `TrayApp` dekodiert die 16-Bit-ID, führt sie aber noch nicht aus.
ACK/NACK-Events zeigen keine eigene MessageBox mehr — das Ergebnis wird nach Abschluss beider Transfers gebündelt in `ConfigForm.OnSave()` angezeigt.
## Verbindungslebenszyklus
```
OnConnected():
Icon + Text + Menü-Item aktualisieren
serial.RequestConfig() → Config-Dump vom Board
nach gültigem Config-End:
serial.RequestMacros() → Makro-Dump vom Board
OnDisconnected():
Icon + Text + Menü-Item aktualisieren
```
Ein mit `END` abgeschlossener, aber unvollständiger oder ungültiger Dump wird
einmal wiederholt. Schlägt auch der zweite Versuch fehl, zeigt das Tray-Icon
eine Warnung. Bleibt `END` vollständig aus, gibt es derzeit keinen
Empfangs-Timeout; siehe
[`07_known_limitations.md`](07_known_limitations.md).
## Offene Punkte
- sichere Zuordnung von Command-IDs zu explizit erlaubten Desktop-Aktionen
- Eigenes Tray-Icon statt `SystemIcons.Application`
+89
View File
@@ -0,0 +1,89 @@
# ConfigForm
**Datei:** `ConfigForm.cs`
## Verantwortung
Hauptkonfigurationsfenster: wählt eines von drei Profilen, zeigt dessen 20
MX-Buttons und 4 Encoder, öffnet `ActionDialog` bei Klick und speichert Config
plus Makros auf das Board.
Oberhalb des Tasten-Grids befindet sich die Profilauswahl für Profil 1 bis 3.
## Layout
```
┌── Tasten (GroupBox) ──────────────────────────────────────────┐
│ TableLayoutPanel 4 Spalten × 5 Zeilen │
│ Jede Zelle = Button mit LED-Hintergrundfarbe + Aktionstext │
└───────────────────────────────────────────────────────────────┘
┌── Encoder (GroupBox) ─────────────────────────────────────────┐
│ Header: SW / CW / CCW │
│ 4 Zeilen × 3 Buttons (ENC 03) │
└───────────────────────────────────────────────────────────────┘
[Auf Board speichern] [Ping] [Exportieren] [Importieren] [Schließen]
```
`FixedSingle`-Border, `StartPosition = CenterScreen`, kein Maximieren.
## MX-Button-Darstellung (RefreshMxButton)
| Animation | Hintergrund | Textfarbe |
|---|---|---|
| ColorCycle | `(40, 40, 40)` dunkelgrau | Weiß |
| Andere | `LedBase[mxIdx]` | Schwarz/Weiß nach Luminanz |
Text = `action.Display + animName` (z.B. `"Strg+C [Blinken]"`).
Luminanz-Formel für Textfarben-Kontrast: `(R*299 + G*587 + B*114) / 1000`
## mx_idx ↔ Physisches Layout
```
mx_idx = col * 5 + row (col=0..3, row=0..4)
```
Entspricht `key_id - 5` in der Firmware. Im TableLayoutPanel: Spalte=col, Zeile=row.
## Speichern (OnSave)
```csharp
Task.Run(() => {
bool cfgOk = _serial.SendConfig(_config); // wartet auf ACK/NACK
bool macroOk = _serial.SendMacros(_macros);
InvokeOnUi(() => { /* Button-Text + Enabled zurücksetzen */ });
});
```
Save-Button wird während der Übertragung deaktiviert, Text wechselt zu "Wird gesendet...".
Save-Button ist nur aktiviert wenn Board verbunden (`_serial.IsConnected`).
Config und Makros werden sequenziell gesendet; ein gemeinsamer Transfer-Lock
verhindert parallele Blob-Transfers.
## Profile
Die Profilauswahl setzt `ActiveProfileIndex` und schaltet alle Komfortzugriffe
von `DeviceConfig` auf das gewählte Profil um. `RefreshAll()` synchronisiert
die Auswahl nach Board-Reload oder JSON-Import und zeichnet anschließend die
20 MX- und 12 Encoder-Schaltflächen neu.
## Import / Export
- **Export**: `ConfigJson.Serialize(_config)``SaveFileDialog``.json`-Datei
- **Import**: `OpenFileDialog` → Datei lesen → `ConfigJson.Deserialize()``RefreshAll()`
Fehler (IO, JSON-Parse, falsche Version) werden per `MessageBox` angezeigt.
## RefreshAll
Wird von `TrayApp` nach erfolgreicher Config oder Makrotabelle vom Board sowie
nach JSON-Import aufgerufen. Aktualisiert Profilauswahl, alle 20 MX-Buttons und
12 Encoder-Buttons ohne Dialog.
## Extensions (in derselben Datei)
```csharp
// Statischer ToolTip für alle Controls
public static void ToolTipText(this Control ctrl, string text)
public static string TypeDescription(this DeviceAction a)
```
+126
View File
@@ -0,0 +1,126 @@
# ActionDialog
**Datei:** `ActionDialog.cs`
## Verantwortung
Modaler Dialog zum Bearbeiten einer einzelnen Aktion (MX-Button oder Encoder-Slot) inkl. optionaler LED-Einstellungen.
## Ergebnis-Properties
```csharp
public DeviceAction ResultAction { get; } // Typ + Keycode/Usage/etc.
public Color ResultColor { get; } // LED-Basisfarbe
public LedAnimType ResultAnim { get; } // Animation
public ushort ResultPeriod { get; } // Periode in ms
```
## Panels (je nach Typ ein Panel sichtbar)
| Typ | Panel | Inhalt |
|---|---|---|
| HID Tastatur | `_hidKeyPanel` | Capture-Button + Strg/Shift/Alt/Win-Checkboxen |
| HID Consumer | `_consumerPanel` | Dropdown mit 12 Medien-Aktionen |
| Host Command | `_cmdPanel` | TextBox für numerische Command-ID |
| Makro | `_macroPanel` | 8 Step-Buttons + je Strg/Shift/Alt-Checkboxen |
| Profil wechseln | `_profilePanel` | Dropdown: "Nächstes Profil (Zyklus)" / "Profil 1" / "Profil 2" / "Profil 3" |
| Keine | — | Alle Panels ausgeblendet |
LED-Panels (`_colorPanel`, `_animPanel`) erscheinen zusätzlich wenn `showColor=true` (nur MX-Buttons, nicht Encoder).
`UpdateLayout()` repositioniert LED-Panels und passt `ClientSize` dynamisch an wenn der Typ gewechselt wird.
## Profil-Panel
```csharp
// Items in _profileCombo:
// Index 0: "Nächstes Profil (Zyklus)" → Data = 0xFFFF
// Index 1: "Profil 1" → Data = 0
// Index 2: "Profil 2" → Data = 1
// Index 3: "Profil 3" → Data = 2
// Initialbelegung: beide von der Firmware akzeptierten Zykluswerte erkennen
_profileCombo.SelectedIndex =
action.Data is 0x00FF or 0xFFFF ? 0 : action.Data + 1;
// In OnOk():
data = _profileCombo.SelectedIndex == 0
? (ushort)0xFFFF
: (ushort)(_profileCombo.SelectedIndex - 1);
```
`SAction.data` ist ein 16-Bit-Feld. Die GUI schreibt für „nächstes Profil“
kanonisch `0xFFFF`; Firmware und Deserialisierung akzeptieren zusätzlich den
älteren Wert `0x00FF`.
## Tasten-Capture (HID-Modus)
1. Benutzer klickt "Taste drücken..."
2. `_capturing = true`
3. Nächste Taste in `ProcessCmdKey()``VkToHid()``_hidKeycode` setzen
4. Capture-Button zeigt Tastenname (`HidKeyName()`)
**Warum `ProcessCmdKey` statt `OnKeyDown`?**
WinForms behandelt Pfeil- und Enter-Tasten als "Dialog Keys" in `ProcessDialogKey()` — sie erreichen `OnKeyDown` nie. `ProcessCmdKey` wird vor `ProcessDialogKey` aufgerufen und kann diese Tasten abfangen.
## Makro-Capture
Jeder der 8 Steps hat einen eigenen Capture-Button. `_captureStep` (07,
-1 = inaktiv) zeigt, welcher Step gerade aufnimmt. Capture-Logik identisch mit
HID-Modus, schreibt in `_stepKeycodes[captureStep]`. Der erste leere Step
beendet die Makrosequenz; deshalb dürfen belegte Steps keine Lücke enthalten.
## Schlüssellookup (layout-unabhängig)
**Problem**: Windows VK-Codes sind tastaturlayout-abhängig. `Keys.OemQuotes` auf QWERTZ → Ö, auf US → `'`. Würde falsche HID-Codes liefern.
**Lösung**: PS/2 Scan-Codes sind layout-unabhängig (physische Tastenposition).
```
VK → Scan-Code via MapVirtualKey(vk, MAPVK_VK_TO_VSC)
Scan-Code → HID Usage via s_scanToHid[sc]
```
`s_scanToHid`: vollständige PS/2-Set-1 → HID-Tabelle (~60 Einträge).
**Ausnahme**: Erweiterte Navigationstasten (Pfeiltasten, Pos1, Ende, …) geben via `MapVirtualKey` den Numpad-Scan-Code zurück. Für diese gibt es eine direkte `Keys → HID`-Tabelle (`s_extVkToHid`).
**Anzeige**: `HidKeyName(hid)` gibt den lokalisierten Tastennamen zurück:
- Sondertasten: fest eingetragene deutsche Namen ("Esc", "→", "F5", …)
- Zeichentasten: `GetKeyNameText(scanCode << 16, ...)` → Windows gibt den Namen laut aktivem Layout zurück ("Ö" auf QWERTZ, ";" auf US)
## Consumer-Aktionen
12 HID Consumer Usage IDs als feste Liste:
| Usage | Name |
|---|---|
| 0x00CD | Play / Pause |
| 0x00B5 | Nächster Titel |
| 0x00B6 | Vorheriger Titel |
| 0x00B7 | Stop |
| 0x00E9 | Lauter |
| 0x00EA | Leiser |
| 0x00E2 | Stummschalten |
| 0x0192 | Taschenrechner |
| 0x0223 | Browser Startseite |
| 0x0224 | Browser Zurück |
| 0x0225 | Browser Vor |
| 0x00B0 | Aufnahme |
## LED-Einstellungen
Nur wenn `showColor=true` (MX-Buttons). Enthält:
- **Farbpicker**: `ColorDialog``_colorBtn.BackColor`
- **Animations-Dropdown**: Statisch / Blinken / Pulsieren / Regenbogen
- **Periode-Dropdown**: 500 ms, 1 s, 2 s oder 4 s
Bei "Regenbogen" wird der Farbpicker ausgeblendet (Farbe irrelevant).
Das Binärmodell kennt außerdem `FadeIn`, `FadeOut` und `ColorFade`; diese drei
Werte sind im Dialog noch nicht auswählbar. Per-LED-Helligkeit wird im
Binärformat und JSON erhalten, besitzt hier aber ebenfalls kein Bedienelement.
Beim Öffnen einer nicht angebotenen Animation fällt die Auswahl derzeit auf
`Static` zurück. Unbekannte, aber formal gültige Consumer-Usages fallen auf
„Play / Pause“ zurück. Details stehen in
[`07_known_limitations.md`](07_known_limitations.md).
+65
View File
@@ -0,0 +1,65 @@
# ConfigJson (Import / Export)
**Datei:** `ConfigJson.cs`
## Format
Menschenlesbares JSON mit `System.Text.Json` (`WriteIndented=true`, Enums als Strings via `JsonStringEnumConverter`).
```json
{
"version": 3,
"profile": 0,
"buttons": [
{
"index": 0,
"action": { "type": "HidKey", "data": 260 },
"led": {
"r": 80,
"g": 40,
"b": 0,
"brightness": 255,
"anim": "ColorCycle",
"period_ms": 4000
}
},
...
],
"encoders": [
{
"index": 0,
"sw": { "type": "None", "data": 0 },
"cw": { "type": "None", "data": 0 },
"ccw": { "type": "None", "data": 0 }
},
...
]
}
```
## Serialisierung
`ConfigJson.Serialize(cfg)` → JSON-String. Exportiert alle 20 Buttons und vier
Encoder des aktuell aktiven Profils vollständig, einschließlich
Per-LED-Helligkeit.
## Deserialisierung
`ConfigJson.Deserialize(json, cfg)`:
- Prüft `version` wirft `InvalidDataException` bei Mismatch
- prüft und übernimmt `profile` im Bereich `0..2`
- validiert Actions, LED-Enums und die Mindestperiode für `Pulse`
- Schreibt in bestehendes `DeviceConfig`-Objekt (kein `new`)
- Fehlende `buttons`/`encoders`-Arrays werden ignoriert (partial import möglich)
- Ungültige `index`-Werte werden übersprungen
## Anmerkungen
- `MacroTable` wird **nicht** exportiert (kein JSON-Format für Makros definiert)
- globale Helligkeit und Encoder-Sensitivität werden nicht exportiert
- `profile` wählt das Zielprofil aus und macht es gleichzeitig zum aktiven
Profil; die beiden anderen Profile bleiben unverändert
- `data` enthält den `ushort`-Wert direkt (für HidKey z.B. `Keycode | (Modifier << 8)`)
- Die Datei ist kein Binärformat und kann manuell bearbeitet werden
- Ein ungültiger Wert wird vor dem Anwenden mit `InvalidDataException`
abgelehnt
+69
View File
@@ -0,0 +1,69 @@
# Bekannte Einschränkungen
Diese Seite trennt bewusst offene Punkte von bereits implementierten
Verträgen. Sie ist vor Änderungen an Verbindungslogik, ActionDialog oder
Binärformat zu lesen.
## Host-Commands
Firmware und GUI übertragen für `HostCommand`:
- Key- beziehungsweise Encoder-ID
- Richtung über die Event-ID
- 16-Bit-Command-ID in Byte 2/3
`TrayApp` führt die Command-ID noch nicht als Desktop-Aktion aus. Eine spätere
Implementierung benötigt ein explizites Allowlist-Mapping; beliebige
Shellbefehle aus Config-Daten sind nicht zulässig.
## LED- und globale Einstellungen
Das Binärformat und `DeviceConfig` unterstützen sieben Animationen:
`Static`, `Blink`, `Pulse`, `FadeIn`, `FadeOut`, `ColorCycle` und `ColorFade`.
Der `ActionDialog` bietet aktuell nur die ersten drei sowie `ColorCycle` an.
Beim Öffnen einer nicht angebotenen Animation fällt die Auswahl auf `Static`
zurück und würde beim Bestätigen entsprechend gespeichert.
Per-LED-Helligkeit wird binär und im JSON erhalten, besitzt aber kein
Bedienelement. Globale Helligkeit und Encoder-Sensitivität werden binär
erhalten, sind weder im Hauptfenster editierbar noch Teil des JSON-Exports.
Die Firmware verwendet Encoder-Sensitivität derzeit ebenfalls nicht.
## ActionDialog
- Die Consumer-Liste enthält zwölf vordefinierte Usages. Andere formal gültige
Usages bis `0x03FF` können aus dem Board gelesen werden, fallen beim Öffnen
des Dialogs aber auf „Play / Pause“ zurück.
- Das Periodenfeld bietet nur 500, 1000, 2000 und 4000 ms. Andere geladene
Perioden fallen im Dialog auf 4000 ms zurück.
- Ein Makro endet am ersten Step mit `keycode == 0`; Lücken vor späteren
belegten Steps werden nicht ausgeführt.
## Transfers und Verbindung
- Das CDC-Protokoll besitzt kein Byte-Framing, keine Paket-CRC und keine
Transfer-ID. Verliert der Stream ein Byte, kann die 8-Byte-Grenze bis zum
Reconnect verschoben bleiben.
- Chunkzahl, eindeutige Indizes, Vollständigkeit und END-Metadaten werden
geprüft. Bleibt ein Dump jedoch ohne `END`, existiert noch kein
Empfangs-Timeout oder Watchdog; die automatische Wiederholung startet nur
nach einem als ungültig erkannten `END`.
- `RequestConfig()` und `RequestMacros()` melden einen unmittelbar
fehlgeschlagenen `Send()` nicht an `TrayApp`.
- Während `SendConfig()` und `SendMacros()` ist nur die Speichern-Schaltfläche
deaktiviert. Andere Editoraktionen werden nicht global gesperrt.
## Testabdeckung
Die Console-Contract-Tests prüfen:
- Configgröße, CRC und Roundtrip
- ausgewählte ungültige Configfelder
- Makrogröße und HID-Keycode-Grenze
- Chunkvollständigkeit und doppelte Chunks
- 16-Bit-Host-Command-Payload
Nicht automatisiert geprüft werden WinForms-Interaktionen, WMI-Porterkennung,
echte SerialPort-Fehler, Reconnects, NVM-Schreibzeiten, HID-Ausgabe und
Hardwareverhalten. Dafür ist weiterhin ein Test mit angeschlossenem VersaPad
erforderlich.
+29
View File
@@ -0,0 +1,29 @@
# VersaGUI - Dokumentationsindex
Die Dateien hier beschreiben die aktuelle C#-Referenz-GUI fuer Config v3 und 32x8 Makros.
Repository-weite LLM-/Agent-Richtlinien stehen in
[`../AGENTS.md`](../AGENTS.md). Gemeinsame Firmwareverträge müssen zusätzlich
gegen `../../VersaMCU` geprüft werden.
| Datei | Inhalt |
|---|---|
| [00_architecture.md](00_architecture.md) | Threading-Modell, Datenfluss, Verbindungslebenszyklus |
| [01_serial_manager.md](01_serial_manager.md) | Port-Erkennung, Connect, ReadLoop, Sende-Pfad |
| [02_device_config.md](02_device_config.md) | `DeviceConfig`, `MacroTable`, aktuelles Byte-Layout, CRC |
| [03_tray_app.md](03_tray_app.md) | TrayApp, Packet-Routing, Dump-Empfang, UI-Zustand |
| [04_config_form.md](04_config_form.md) | Grid, Speichern, Import/Export |
| [05_action_dialog.md](05_action_dialog.md) | Key-Capture, Action-Auswahl, LED-Einstellungen |
| [06_config_json.md](06_config_json.md) | JSON-Format und Grenzen |
| [07_known_limitations.md](07_known_limitations.md) | Bewusst offene GUI-, Protokoll- und Testgrenzen |
## Schnellreferenz
- aktuelles Config-Layout `740` Byte -> [02_device_config.md](02_device_config.md)
- aktuelle Makrotabelle `512` Byte -> [02_device_config.md](02_device_config.md)
- DTR und Connect-Pfad -> [00_architecture.md](00_architecture.md), [01_serial_manager.md](01_serial_manager.md)
- Makro-Slot-Konvention -> [02_device_config.md](02_device_config.md)
- Host-Command-Status -> [03_tray_app.md](03_tray_app.md)
- bekannte Einschränkungen -> [07_known_limitations.md](07_known_limitations.md)
- automatisierte Binär-/Transferverträge ->
[`../tests/VersaGUI.ContractTests/`](../tests/VersaGUI.ContractTests/)
+48 -15
View File
@@ -5,9 +5,12 @@
// HID Tastatur → Capture-Button (Klicken + Taste drücken) + Modifier-Checkboxen
// HID Consumer → Dropdown mit benannten Medien-/Lautstärke-Aktionen
// Host Command → Zahlen-Eingabe (Command-ID)
// Makro → acht HID-Key-Steps
// Profilwechsel → Zielprofil oder zyklisch nächstes Profil
// Keine Aktion → nichts
//
// Optional (nur MX-Buttons): LED-Basisfarbe + LED-Animation + Geschwindigkeit
// Optional (nur MX-Buttons): LED-Basisfarbe + vier auswählbare Animationen +
// Geschwindigkeit. Das Binärmodell akzeptiert zusätzlich drei Animationen.
namespace VersaGUI;
@@ -35,6 +38,10 @@ public class ActionDialog : Form
private readonly Panel _cmdPanel;
private readonly TextBox _cmdBox;
// Profil wechseln
private readonly Panel _profilePanel;
private readonly ComboBox _profileCombo;
// LED-Farbe
private readonly Panel _colorPanel;
private readonly Button _colorBtn;
@@ -50,11 +57,11 @@ public class ActionDialog : Form
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 readonly Button[] _stepBtns = new Button[8];
private readonly CheckBox[] _stepCtrl = new CheckBox[8];
private readonly CheckBox[] _stepShift = new CheckBox[8];
private readonly CheckBox[] _stepAlt = new CheckBox[8];
private readonly byte[] _stepKeycodes = new byte[8];
private int _captureStep = -1; // -1 = nicht im Capture-Modus
// Capture-Zustand
@@ -220,7 +227,7 @@ public class ActionDialog : Form
_showColor = showColor;
// Bestehende Makro-Steps aus der Tabelle laden
for (int i = 0; i < 4; i++)
for (int i = 0; i < 8; i++)
_stepKeycodes[i] = _macros.Steps[_macroSlot, i].Keycode;
// Formular-Grundeinstellungen
@@ -240,7 +247,7 @@ public class ActionDialog : Form
DropDownStyle = ComboBoxStyle.DropDownList,
};
_typeCombo.Items.AddRange(new object[]
{ "Keine Aktion", "HID Tastatur", "HID Consumer", "Host Command", "Makro" });
{ "Keine Aktion", "HID Tastatur", "HID Consumer", "Host Command", "Makro", "Profil wechseln" });
_typeCombo.SelectedIndex = (int)action.Type;
_typeCombo.SelectedIndexChanged += OnTypeChanged;
@@ -320,6 +327,24 @@ public class ActionDialog : Form
};
_cmdPanel.Controls.AddRange(new Control[] { cmdLabel, _cmdBox });
// ── Profil-Panel ──────────────────────────────────────────────────────
_profilePanel = new Panel { Location = new Point(12, 48), Size = new Size(396, 30) };
var profileLabel = new Label { Text = "Ziel-Profil:", Location = new Point(0, 8), AutoSize = true };
_profileCombo = new ComboBox
{
Location = new Point(90, 4),
Width = 200,
DropDownStyle = ComboBoxStyle.DropDownList,
};
_profileCombo.Items.AddRange(new object[]
{ "Nächstes Profil (Zyklus)", "Profil 1", "Profil 2", "Profil 3" });
_profileCombo.SelectedIndex = action.Type == ActionType.ProfileSwitch
? (action.Data is 0x00FF or 0xFFFF
? 0
: Math.Clamp((int)action.Data + 1, 1, 3))
: 0;
_profilePanel.Controls.AddRange(new Control[] { profileLabel, _profileCombo });
// ── Farb-Panel ────────────────────────────────────────────────────────
_colorPanel = new Panel
{
@@ -378,12 +403,12 @@ public class ActionDialog : Form
_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) };
// ── Makro-Panel (8 Steps) ─────────────────────────────────────────────
_macroPanel = new Panel { Location = new Point(12, 48), Size = new Size(396, 8 * 30 + 22) };
var macroHint = new Label
{
Text = "Klicke einen Step, dann Taste drücken. Leere Steps werden übersprungen.",
Text = "Klicke einen Step, dann Taste drücken. Der erste leere Step beendet das Makro.",
Location = new Point(0, 0),
Size = new Size(396, 28),
ForeColor = SystemColors.GrayText,
@@ -391,7 +416,7 @@ public class ActionDialog : Form
};
_macroPanel.Controls.Add(macroHint);
for (int step = 0; step < 4; step++)
for (int step = 0; step < 8; step++)
{
int s = step;
int y2 = 30 + step * 30;
@@ -454,7 +479,7 @@ public class ActionDialog : Form
Controls.AddRange(new Control[]
{
typeLabel, _typeCombo,
_hidKeyPanel, _consumerPanel, _cmdPanel, _macroPanel,
_hidKeyPanel, _consumerPanel, _cmdPanel, _profilePanel, _macroPanel,
_colorPanel, _animPanel,
_okBtn, _cancelBtn,
});
@@ -481,6 +506,7 @@ public class ActionDialog : Form
_hidKeyPanel.Visible = t == ActionType.HidKey;
_consumerPanel.Visible = t == ActionType.HidConsumer;
_cmdPanel.Visible = t == ActionType.HostCommand;
_profilePanel.Visible = t == ActionType.ProfileSwitch;
_macroPanel.Visible = t == ActionType.Macro;
_colorPanel.Visible = _showColor;
_animPanel.Visible = _showColor;
@@ -499,7 +525,8 @@ public class ActionDialog : Form
ActionType.HidKey => 110,
ActionType.HidConsumer => 30,
ActionType.HostCommand => 30,
ActionType.Macro => 4 * 30 + 22,
ActionType.ProfileSwitch => 30,
ActionType.Macro => 8 * 30 + 22,
_ => 0,
};
@@ -710,12 +737,18 @@ public class ActionDialog : Form
return;
}
break;
case ActionType.ProfileSwitch:
data = _profileCombo.SelectedIndex == 0
? (ushort)0xFFFF // Zyklus: Firmware rechnet (current+1)%3
: (ushort)(_profileCombo.SelectedIndex - 1); // Profil 0/1/2
break;
}
if (type == ActionType.Macro)
{
// Steps in die Makro-Tabelle schreiben
for (int i = 0; i < 4; i++)
for (int i = 0; i < 8; i++)
{
byte mod = 0;
if (_stepCtrl[i].Checked) mod |= 0x01;
+73
View File
@@ -0,0 +1,73 @@
namespace VersaGUI;
/// <summary>
/// Sammelt einen Board→GUI-Dump und akzeptiert ihn nur, wenn Chunkzahl,
/// Indizes, Vollständigkeit und END-Metadaten exakt zum erwarteten Blob passen.
/// </summary>
public sealed class ChunkTransferBuffer
{
private readonly int _size;
private readonly int _expectedChunks;
private byte[]? _buffer;
private bool[]? _received;
private bool _valid;
public ChunkTransferBuffer(int size, int expectedChunks)
{
_size = size;
_expectedChunks = expectedChunks;
}
public bool Begin(byte announcedChunks)
{
_buffer = new byte[_size];
_received = new bool[_expectedChunks];
_valid = announcedChunks == _expectedChunks;
return _valid;
}
public bool Add(SerialPacket packet)
{
if (!_valid || _buffer is null || _received is null)
return false;
int chunk = packet.KeyId;
int offset = chunk * Protocol.PayloadSize;
if (chunk >= _expectedChunks || _received[chunk] || offset >= _size)
{
_valid = false;
return false;
}
int count = Math.Min(Protocol.PayloadSize, _size - offset);
Buffer.BlockCopy(packet.Data, 2, _buffer, offset, count);
_received[chunk] = true;
return true;
}
public bool TryComplete(byte announcedChunks, out byte[] data)
{
data = Array.Empty<byte>();
bool complete =
_valid &&
announcedChunks == _expectedChunks &&
_buffer is not null &&
_received is not null &&
_received.All(received => received);
if (complete)
data = _buffer!;
Reset();
return complete;
}
public void Reset()
{
_buffer = null;
_received = null;
_valid = false;
}
}
+57 -9
View File
@@ -2,6 +2,7 @@
// Hauptfenster der VersaPad-Konfiguration.
//
// Layout:
// Profil-Auswahl (Profil 13)
// ┌── Tasten (4 × 5 Grid) ──────────────────────────────────────────────┐
// │ Jede Taste = Button mit Hintergrundfarbe (= LED-Base) und │
// │ Aktionsbeschriftung. Klick öffnet ActionDialog. │
@@ -9,11 +10,10 @@
// ┌── Encoder ─────────────────────────────────────────────────────────┐
// │ 4 Zeilen à [SW][CW][CCW] ohne LED-Farbe │
// └────────────────────────────────────────────────────────────────────┘
// [Auf Board speichern] [Schließen]
// [Auf Board speichern] [Ping] [Exportieren] [Importieren] [Schließen]
//
// "Auf Board speichern" sendet die Config in 6-Byte-Chunks über Serial.
// Firmware-Seite (WRITE_CONFIG) ist noch TODO → Schaltfläche ist
// deaktiviert wenn das Board nicht verbunden ist.
// "Auf Board speichern" sendet Config und Makros in 6-Byte-Chunks über CDC.
// Die Schaltfläche ist deaktiviert, wenn das Board nicht verbunden ist.
namespace VersaGUI;
@@ -26,6 +26,7 @@ public class ConfigForm : Form
private readonly Button[] _btnGrid = new Button[20];
private readonly Button[,] _encBtns = new Button[4, 3];
private Button _saveBtn = null!;
private ComboBox _profileCombo = null!;
public ConfigForm(DeviceConfig config, MacroTable macros, SerialManager serial)
{
@@ -40,6 +41,7 @@ public class ConfigForm : Form
ClientSize = new Size(520, 480);
int y = 12;
y = BuildProfileBar(y);
y = BuildButtonGrid(y);
y = BuildEncoderPanel(y);
BuildFooter(y);
@@ -50,6 +52,35 @@ public class ConfigForm : Form
_saveBtn.Enabled = _serial.IsConnected;
}
// ── Profil-Leiste ─────────────────────────────────────────────────────────
private int BuildProfileBar(int startY)
{
var label = new Label
{
Text = "Profil:",
Location = new Point(12, startY + 4),
AutoSize = true,
};
_profileCombo = new ComboBox
{
Location = new Point(60, startY),
Width = 160,
DropDownStyle = ComboBoxStyle.DropDownList,
};
_profileCombo.Items.AddRange(new object[] { "Profil 1", "Profil 2", "Profil 3" });
_profileCombo.SelectedIndex = _config.ActiveProfileIndex;
_profileCombo.SelectedIndexChanged += (_, _) =>
{
_config.ActiveProfileIndex = (byte)_profileCombo.SelectedIndex;
RefreshAll();
};
Controls.AddRange(new Control[] { label, _profileCombo });
return startY + 34;
}
// ── Tasten-Grid (4 Spalten × 5 Zeilen) ───────────────────────────────────
private int BuildButtonGrid(int startY)
@@ -257,16 +288,28 @@ public class ConfigForm : Form
_saveBtn.Enabled = false;
_saveBtn.Text = "Wird gesendet...";
// SendConfig + SendMacros blockieren ~400ms → Background-Thread
// SendConfig + SendMacros warten auf ACK/NACK → Background-Thread
Task.Run(() =>
{
_serial.SendConfig(_config);
Thread.Sleep(50); // kurze Pause zwischen Config- und Makro-Transfer
_serial.SendMacros(_macros);
bool cfgOk = _serial.SendConfig(_config); // wartet intern auf CONFIG_ACK/NACK
bool macroOk = _serial.SendMacros(_macros); // wartet intern auf MACRO_ACK/NACK
InvokeOnUi(() =>
{
_saveBtn.Text = "Auf Board speichern";
_saveBtn.Enabled = _serial.IsConnected;
if (cfgOk && macroOk)
{
MessageBox.Show("Konfiguration erfolgreich gespeichert.",
"VersaPad", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
else
{
string detail = (!cfgOk && !macroOk) ? "Config und Makros"
: !cfgOk ? "Config"
: "Makros";
MessageBox.Show($"Speichern fehlgeschlagen ({detail}).\nBoard hat NACK gesendet oder nicht geantwortet.",
"VersaPad Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
});
});
}
@@ -276,6 +319,9 @@ public class ConfigForm : Form
// Alle Buttons neu zeichnen (z.B. nach Config-Laden vom Board)
public void RefreshAll()
{
if (_profileCombo.SelectedIndex != _config.ActiveProfileIndex)
_profileCombo.SelectedIndex = _config.ActiveProfileIndex;
for (int i = 0; i < 20; i++) RefreshMxButton(i);
for (int enc = 0; enc < 4; enc++)
for (int act = 0; act < 3; act++)
@@ -392,7 +438,9 @@ internal static class Extensions
{
ActionType.HidKey => "HID Tastatur-Keycode",
ActionType.HidConsumer => "HID Consumer Control (Media/Volume)",
ActionType.HostCommand => "Host Command App führt aus",
ActionType.HostCommand => "Host Command-ID für Desktop-Mapping",
ActionType.Macro => "Makro-Sequenz",
ActionType.ProfileSwitch => "Profil wechseln",
_ => "Keine Aktion",
};
}
+57 -3
View File
@@ -1,12 +1,14 @@
// ConfigJson.cs
// JSON-Import/Export für DeviceConfig.
//
// Format (menschenlesbar, kommentiert mit Feldnamen):
// Format (menschenlesbar):
// {
// "version": 2,
// "version": 3,
// "profile": 0,
// "buttons": [
// { "index": 0, "action": { "type": "HidKey", "data": 260 },
// "led": { "r": 80, "g": 40, "b": 0, "anim": "ColorCycle", "period_ms": 4000 } },
// "led": { "r": 80, "g": 40, "b": 0, "brightness": 255,
// "anim": "ColorCycle", "period_ms": 4000 } },
// ...
// ],
// "encoders": [
@@ -17,6 +19,8 @@
// ...
// ]
// }
//
// Import/Export bezieht sich immer auf das aktive Profil.
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -37,6 +41,7 @@ internal static class ConfigJson
var doc = new ConfigJsonDoc
{
Version = DeviceConfig.Version,
Profile = cfg.ActiveProfileIndex,
Buttons = new ButtonEntry[20],
Encoders = new EncoderEntry[4],
};
@@ -52,6 +57,7 @@ internal static class ConfigJson
R = cfg.LedBase[i].R,
G = cfg.LedBase[i].G,
B = cfg.LedBase[i].B,
Brightness = cfg.LedBrightness[i],
Anim = cfg.LedAnim[i],
PeriodMs = cfg.LedPeriod[i],
},
@@ -80,6 +86,11 @@ internal static class ConfigJson
if (doc.Version != DeviceConfig.Version)
throw new InvalidDataException(
$"Config-Version {doc.Version} wird nicht unterstützt (erwartet {DeviceConfig.Version}).");
if (doc.Profile >= DeviceConfig.ProfileCount)
throw new InvalidDataException("Profilindex muss im Bereich 0..2 liegen.");
ValidateDocument(doc);
cfg.ActiveProfileIndex = doc.Profile;
if (doc.Buttons != null)
{
@@ -90,6 +101,7 @@ internal static class ConfigJson
if (b.Led != null)
{
cfg.LedBase[b.Index] = Color.FromArgb(b.Led.R, b.Led.G, b.Led.B);
cfg.LedBrightness[b.Index] = b.Led.Brightness;
cfg.LedAnim[b.Index] = b.Led.Anim;
cfg.LedPeriod[b.Index] = b.Led.PeriodMs;
}
@@ -120,11 +132,52 @@ internal static class ConfigJson
dst.Data = src.Data;
}
private static void ValidateDocument(ConfigJsonDoc doc)
{
if (doc.Buttons != null)
{
foreach (ButtonEntry button in doc.Buttons)
{
ValidateAction(button.Action);
if (button.Led == null) continue;
byte anim = (byte)button.Led.Anim;
if (anim > (byte)LedAnimType.ColorFade)
throw new InvalidDataException(
$"Button {button.Index}: unbekannte LED-Animation {anim}.");
if (button.Led.Anim == LedAnimType.Pulse &&
button.Led.PeriodMs < 2)
{
throw new InvalidDataException(
$"Button {button.Index}: Pulse benötigt mindestens 2 ms.");
}
}
}
if (doc.Encoders != null)
{
foreach (EncoderEntry encoder in doc.Encoders)
{
ValidateAction(encoder.Sw);
ValidateAction(encoder.Cw);
ValidateAction(encoder.Ccw);
}
}
}
private static void ValidateAction(ActionEntry? action)
{
if (action != null && !DeviceConfig.ActionIsValid(action.Type, action.Data))
throw new InvalidDataException(
$"Ungültige Action {action.Type} mit Datenwert {action.Data}.");
}
// ── JSON-Datenklassen ─────────────────────────────────────────────────────
private class ConfigJsonDoc
{
[JsonPropertyName("version")] public byte Version { get; set; }
[JsonPropertyName("profile")] public byte Profile { get; set; }
[JsonPropertyName("buttons")] public ButtonEntry[]? Buttons { get; set; }
[JsonPropertyName("encoders")] public EncoderEntry[]? Encoders { get; set; }
}
@@ -155,6 +208,7 @@ internal static class ConfigJson
[JsonPropertyName("r")] public byte R { get; set; }
[JsonPropertyName("g")] public byte G { get; set; }
[JsonPropertyName("b")] public byte B { get; set; }
[JsonPropertyName("brightness")] public byte Brightness { get; set; } = 255;
[JsonPropertyName("anim")] public LedAnimType Anim { get; set; }
[JsonPropertyName("period_ms")] public ushort PeriodMs { get; set; }
}
+283 -89
View File
@@ -1,17 +1,32 @@
// DeviceConfig.cs
// C#-Spiegel der SDeviceConfig-Struktur aus der Firmware (nvm_config.h).
// C#-Spiegel der Firmware-Structs. Muss byte-kompatibel mit SDeviceConfig (nvm_config.h)
// und SMacroTable (macro_config.h) sein.
//
// Serialisiertes Layout (packed, 223 Bytes):
// Offset 0 4B Magic 0x56503202
// 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)
// Serialisiertes Config-Layout (740 B, packed):
// ── Globaler Header (32B) ──────────────────────────────────────────────────
// Offset 0 4B Magic 0x56503203
// Offset 4 1B Version = 3
// Offset 5 2B CRC16-CCITT über Bytes 7739
// Offset 7 1B ActiveProfile (02)
// Offset 8 1B GlobalBrightness (0255)
// Offset 9 4B EncSensitivity[4]
// Offset 13 19B Reserve
// ── Profil 0 (236B, ab Offset 32) ─────────────────────────────────────────
// ── Profil 1 (236B, ab Offset 268) ────────────────────────────────────────
// ── Profil 2 (236B, ab Offset 504) ────────────────────────────────────────
//
// Pro Profil (236B):
// Offset 0 60B MxActions[20] je 3B: type(1) + data_lo(1) + data_hi(1)
// Offset 60 36B EncActions[4][3] je 3B
// Offset 96 20B LedBase[i].R
// Offset116 20B LedBase[i].G
// Offset136 20B LedBase[i].B
// Offset156 20B LedBrightness[i] per-LED 0255
// Offset176 20B LedAnim[i]
// Offset196 40B LedPeriod[i] uint16 little-endian
//
// MacroTable-Layout (512 B):
// 32 Slots × 8 Steps × 2B = 512B
using System.Drawing;
@@ -22,8 +37,9 @@ public enum ActionType : byte
None = 0,
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
HostCommand = 3, // data = Command-ID für ein sicheres App-Mapping
Macro = 4, // data = Makro-Slot-Index (031)
ProfileSwitch = 5, // data = Profil 02 oder 0x00FF/0xFFFF für nächstes Profil
}
// Ein Step in einem Makro (keycode=0 → leerer/letzter Step)
@@ -36,11 +52,12 @@ public class MacroStep
public MacroStep Clone() => new() { Keycode = Keycode, Modifier = Modifier };
}
// Makro-Tabelle: 32 Slots × 4 Steps (spiegelt SMacroTable aus macro_config.h)
// Makro-Tabelle: 32 Slots × 8 Steps (spiegelt SMacroTable aus macro_config.h)
public class MacroTable
{
public const int Slots = 32;
public const int MaxSteps = 4;
public const int MaxSteps = 8;
public const int Size = Slots * MaxSteps * 2;
// [slot][step]
public MacroStep[,] Steps { get; } = new MacroStep[Slots, MaxSteps];
@@ -53,13 +70,17 @@ public class MacroTable
}
// 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
public static int SlotForMx(int mxIdx) => mxIdx;
public static int SlotForEncoder(int enc, int actIdx) => 20 + enc * 3 + actIdx;
// Serialisierung: 32 × 4 × 2 = 256 Bytes
// Serialisierung: 32 × 8 × 2 = 512 Bytes
public byte[] ToBytes()
{
var buf = new byte[256];
if (!IsValid())
throw new InvalidOperationException(
"Makrotabelle enthält einen nicht unterstützten HID-Keycode.");
var buf = new byte[Size];
int pos = 0;
for (int s = 0; s < Slots; s++)
for (int i = 0; i < MaxSteps; i++)
@@ -70,9 +91,11 @@ public class MacroTable
return buf;
}
public void FromBytes(byte[] buf)
public bool FromBytes(byte[] buf)
{
if (buf.Length < 256) return;
if (buf.Length != Size || !SerializedDataIsValid(buf))
return false;
int pos = 0;
for (int s = 0; s < Slots; s++)
for (int i = 0; i < MaxSteps; i++)
@@ -80,16 +103,37 @@ public class MacroTable
Steps[s, i].Keycode = buf[pos++];
Steps[s, i].Modifier = buf[pos++];
}
return true;
}
public bool IsValid()
{
for (int slot = 0; slot < Slots; slot++)
for (int step = 0; step < MaxSteps; step++)
if (Steps[slot, step].Keycode > 0x65)
return false;
return true;
}
private static bool SerializedDataIsValid(byte[] buf)
{
for (int pos = 0; pos < buf.Length; pos += 2)
if (buf[pos] > 0x65)
return false;
return true;
}
}
// Spiegelt LEDAnim aus CButton.h nur die in der GUI konfigurierbaren Werte
// Spiegelt LEDAnim aus CButton.h
public enum LedAnimType : byte
{
Static = 0,
Blink = 1,
Pulse = 2,
ColorCycle = 5, // Regenbogen (entspricht LEDAnim::COLOR_CYCLE in Firmware)
FadeIn = 3,
FadeOut = 4,
ColorCycle = 5,
ColorFade = 6,
}
public class DeviceAction
@@ -130,6 +174,10 @@ public class DeviceAction
}
if (Type == ActionType.Macro)
return $"Makro {Data}";
if (Type == ActionType.ProfileSwitch)
return Data is 0x00FF or 0xFFFF
? "→ Nächstes Profil"
: $"→ Profil {Data + 1}";
return $"CMD {Data}";
}
}
@@ -152,31 +200,19 @@ public class DeviceAction
public DeviceAction Clone() => new() { Type = Type, Data = Data };
}
public class DeviceConfig
// ── Pro-Profil-Daten (spiegelt SDeviceProfile) ────────────────────────────────
public class DeviceProfile
{
public const uint Magic = 0x56503202;
public const byte Version = 2;
public const int EncSw = 0;
public const int EncCw = 1;
public const int EncCcw = 2;
public DeviceAction[] MxActions { get; } =
Enumerable.Range(0, 20).Select(_ => new DeviceAction()).ToArray();
public DeviceAction[,] EncActions { get; } = InitEncActions();
// 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();
public Color[] LedBase { get; } = Enumerable.Repeat(Color.FromArgb(80, 40, 0), 20).ToArray();
public byte[] LedBrightness { get; } = Enumerable.Repeat((byte)255, 20).ToArray();
public LedAnimType[] LedAnim { get; } = Enumerable.Repeat(LedAnimType.ColorCycle, 20).ToArray();
public ushort[] LedPeriod { get; } = Enumerable.Repeat((ushort)4000, 20).ToArray();
private static DeviceAction[,] InitEncActions()
{
@@ -187,79 +223,43 @@ public class DeviceConfig
return a;
}
// ── Serialisierung ────────────────────────────────────────────────────────
public byte[] ToBytes()
// Serialisiert ein Profil in 236 Bytes
public void ToBytes(byte[] buf, int offset)
{
const int size = 223;
var buf = new byte[size];
int pos = 0;
WriteU32(buf, ref pos, Magic);
buf[pos++] = Version;
int crcOffset = pos;
pos += 2;
foreach (var a in MxActions)
WriteAction(buf, ref pos, a);
int pos = offset;
foreach (var a in MxActions) WriteAction(buf, ref pos, a);
for (int e = 0; e < 4; e++)
for (int i = 0; i < 3; i++)
WriteAction(buf, ref pos, EncActions[e, i]);
for (int i = 0; i < 3; i++) WriteAction(buf, ref pos, EncActions[e, i]);
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;
for (int i = 0; i < 20; i++) buf[pos++] = LedBrightness[i];
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);
return buf;
}
public bool FromBytes(byte[] buf)
// Deserialisiert ein Profil aus 236 Bytes
public void FromBytes(byte[] buf, int offset)
{
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, 223 - 7);
if (storedCrc != computedCrc) return false;
int pos = 7;
int pos = offset;
for (int i = 0; i < 20; i++) ReadAction(buf, ref pos, MxActions[i]);
for (int e = 0; e < 4; e++)
for (int i = 0; i < 3; i++)
ReadAction(buf, ref pos, EncActions[e, i]);
for (int i = 0; i < 3; i++) 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++) LedBrightness[i] = buf[pos++];
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;
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static void WriteAction(byte[] buf, ref int pos, DeviceAction a)
{
buf[pos++] = (byte)a.Type;
@@ -273,6 +273,200 @@ public class DeviceConfig
a.Data = (ushort)(buf[pos] | (buf[pos + 1] << 8));
pos += 2;
}
}
// ── Gesamt-Config (spiegelt SDeviceConfig) ────────────────────────────────────
public class DeviceConfig
{
public const uint Magic = 0x56503203;
public const byte Version = 3;
public const int Size = 740;
public const int ProfileCount = 3;
public const int ProfileSize = 236;
public const int EncSw = 0;
public const int EncCw = 1;
public const int EncCcw = 2;
// Globale Felder
public byte ActiveProfileIndex { get; set; } = 0;
public byte GlobalBrightness { get; set; } = 255;
public byte[] EncSensitivity { get; } = new byte[] { 1, 1, 1, 1 };
// 3 Profile
public DeviceProfile[] Profiles { get; } =
Enumerable.Range(0, 3).Select(_ => new DeviceProfile()).ToArray();
// ── Shortcuts auf aktives Profil (ConfigForm braucht keine Änderung) ──────
public DeviceAction[] MxActions => Profiles[ActiveProfileIndex].MxActions;
public DeviceAction[,] EncActions => Profiles[ActiveProfileIndex].EncActions;
public Color[] LedBase => Profiles[ActiveProfileIndex].LedBase;
public byte[] LedBrightness => Profiles[ActiveProfileIndex].LedBrightness;
public LedAnimType[] LedAnim => Profiles[ActiveProfileIndex].LedAnim;
public ushort[] LedPeriod => Profiles[ActiveProfileIndex].LedPeriod;
// ── Serialisierung ────────────────────────────────────────────────────────
public byte[] ToBytes()
{
if (!TryValidate(out string reason))
throw new InvalidOperationException($"Ungültige DeviceConfig: {reason}");
var buf = new byte[Size];
int pos = 0;
WriteU32(buf, ref pos, Magic);
buf[pos++] = Version;
int crcOffset = pos;
pos += 2; // CRC-Platzhalter
buf[pos++] = ActiveProfileIndex;
buf[pos++] = GlobalBrightness;
for (int i = 0; i < 4; i++) buf[pos++] = EncSensitivity[i];
pos += 19; // Reserve (bleibt 0)
// 3 Profile à 236B
for (int p = 0; p < 3; p++)
Profiles[p].ToBytes(buf, pos + p * 236);
pos += 3 * 236;
ushort crc = Crc16(buf, 7, Size - 7);
buf[crcOffset] = (byte)(crc & 0xFF);
buf[crcOffset + 1] = (byte)(crc >> 8);
return buf;
}
public bool FromBytes(byte[] buf)
{
if (buf.Length != Size) 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, Size - 7);
if (storedCrc != computedCrc) return false;
if (!SerializedFieldsAreValid(buf)) return false;
int pos = 7;
ActiveProfileIndex = buf[pos++];
GlobalBrightness = buf[pos++];
for (int i = 0; i < 4; i++) EncSensitivity[i] = buf[pos++];
pos += 19; // Reserve überspringen
for (int p = 0; p < 3; p++)
Profiles[p].FromBytes(buf, pos + p * 236);
return true;
}
public bool TryValidate(out string reason)
{
if (ActiveProfileIndex >= ProfileCount)
{
reason = "aktives Profil liegt nicht im Bereich 0..2";
return false;
}
for (int profile = 0; profile < ProfileCount; profile++)
{
DeviceProfile current = Profiles[profile];
foreach (DeviceAction action in current.MxActions)
{
if (!ActionIsValid(action.Type, action.Data))
{
reason = $"ungültige MX-Action in Profil {profile}";
return false;
}
}
for (int enc = 0; enc < 4; enc++)
{
for (int actionIndex = 0; actionIndex < 3; actionIndex++)
{
DeviceAction action = current.EncActions[enc, actionIndex];
if (!ActionIsValid(action.Type, action.Data))
{
reason = $"ungültige Encoder-Action in Profil {profile}";
return false;
}
}
}
for (int led = 0; led < 20; led++)
{
byte anim = (byte)current.LedAnim[led];
if (anim > (byte)LedAnimType.ColorFade)
{
reason = $"ungültige LED-Animation in Profil {profile}";
return false;
}
if (current.LedAnim[led] == LedAnimType.Pulse &&
current.LedPeriod[led] < 2)
{
reason = $"Pulse-Periode unter 2 ms in Profil {profile}";
return false;
}
}
}
reason = "";
return true;
}
internal static bool ActionIsValid(ActionType type, ushort data)
{
return type switch
{
ActionType.None => true,
ActionType.HidKey => (byte)(data & 0xFF) <= 0x65,
ActionType.HidConsumer => data <= 0x03FF,
ActionType.HostCommand => true,
ActionType.Macro => data < MacroTable.Slots,
ActionType.ProfileSwitch => data <= 2 || data is 0x00FF or 0xFFFF,
_ => false,
};
}
private static bool SerializedFieldsAreValid(byte[] buf)
{
if (buf[7] >= ProfileCount) return false;
for (int profile = 0; profile < ProfileCount; profile++)
{
int profileOffset = 32 + profile * ProfileSize;
int actionPos = profileOffset;
for (int action = 0; action < 32; action++)
{
ActionType type = (ActionType)buf[actionPos++];
ushort data = (ushort)(buf[actionPos] | (buf[actionPos + 1] << 8));
actionPos += 2;
if (!ActionIsValid(type, data)) return false;
}
int animOffset = profileOffset + 176;
int periodOffset = profileOffset + 196;
for (int led = 0; led < 20; led++)
{
byte anim = buf[animOffset + led];
ushort period = (ushort)(
buf[periodOffset + led * 2] |
(buf[periodOffset + led * 2 + 1] << 8));
if (anim > (byte)LedAnimType.ColorFade) return false;
if (anim == (byte)LedAnimType.Pulse && period < 2) return false;
}
}
return true;
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static void WriteU32(byte[] buf, ref int pos, uint v)
{
+13 -9
View File
@@ -5,16 +5,18 @@
// Alle Pakete: 8 Bytes fest
// [0] Command/Event-ID
// [1] key_id (Button 024 oder Encoder 03)
// [2] r / Daten-Byte A
// [3] g / Daten-Byte B
// [4] b
// [5..7] reserviert (0x00)
// [2..7] kommandospezifische Nutzdaten
namespace VersaGUI;
public static class Protocol
{
public const int PacketSize = 8;
public const int PayloadSize = 6;
public const int ConfigSize = 740;
public const int ConfigChunks = (ConfigSize + PayloadSize - 1) / PayloadSize;
public const int MacroSize = 512;
public const int MacroChunks = (MacroSize + PayloadSize - 1) / PayloadSize;
// ── Commands: App → Board (0x010x7F) ────────────────────────────────────
public const byte CmdSetLedOverride = 0x01; // key_id, r, g, b
@@ -25,7 +27,6 @@ public static class Protocol
// CmdConfigBegin: Data[1] = Anzahl Chunks
// CmdConfigData: Data[1] = Chunk-Index (0-based), Data[2..7] = 6 Bytes Nutzdaten
// CmdConfigCommit: Board schreibt empfangene Daten in NVM
// Firmware-Seite ist noch TODO Kommandos sind hier schon definiert.
public const byte CmdPing = 0x05; // Board antwortet mit EvtPong
public const byte CmdConfigBegin = 0x10;
public const byte CmdConfigData = 0x11;
@@ -37,10 +38,11 @@ public static class Protocol
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
public const byte EvtKeyUp = 0x82; // key_id → HOST_COMMAND-Button losgelassen
public const byte EvtEncCw = 0x83; // key_id → Encoder Schritt CW
public const byte EvtEncCcw = 0x84; // key_id → Encoder Schritt CCW
// Host-Events übertragen die Command-ID little-endian in Data[2..3].
public const byte EvtKeyDown = 0x81; // Host-Action: key_id → gedrückt
public const byte EvtKeyUp = 0x82; // Host-Action: key_id → losgelassen
public const byte EvtEncCw = 0x83; // Host-Action: enc_id → Schritt CW
public const byte EvtEncCcw = 0x84; // Host-Action: enc_id → Schritt CCW
public const byte EvtPong = 0x85; // Antwort auf CmdPing
public const byte EvtConfigAck = 0x90; // Config erfolgreich in NVM geschrieben
public const byte EvtConfigNack = 0x91; // Config CRC/Magic ungültig
@@ -51,6 +53,7 @@ public static class Protocol
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
public const byte EvtMacroNack = 0x99; // Makro-Tabelle: NVM-Fehler
}
// Ein 8-Byte-Paket mit benannten Accessoren.
@@ -63,6 +66,7 @@ public class SerialPacket
public byte R => Data[2];
public byte G => Data[3];
public byte B => Data[4];
public ushort DataU16 => (ushort)(Data[2] | (Data[3] << 8));
// Leeres Paket (für eingehende Daten)
public SerialPacket() { }
+150 -43
View File
@@ -7,7 +7,7 @@
//
// Threading:
// - _readThread: Hintergrund-Thread, liest eingehende Bytes und baut Pakete zusammen.
// - _reconnectTimer: System.Threading.Timer, versucht alle 2s neu zu verbinden.
// - _reconnectTimer: System.Threading.Timer, versucht alle 3s neu zu verbinden.
// - Alle Events werden auf dem UI-Thread gefeuert (via SynchronizationContext),
// damit TrayApp direkt NotifyIcon und Menü aktualisieren kann.
//
@@ -36,8 +36,18 @@ public class SerialManager : IDisposable
private System.Threading.Timer? _reconnectTimer;
private readonly SynchronizationContext _ui;
private readonly object _connectLock = new(); // Verhindert parallele TryConnect()-Aufrufe
private readonly object _writeLock = new(); // Ein Paket atomar schreiben
private readonly object _transferLock = new(); // Keine parallelen Blob-Transfers
private bool _disposed;
private bool _waitingAfterDisconnect; // Backoff nach Trennung aktiv
private volatile bool _waitingAfterDisconnect; // Backoff nach Trennung aktiv
// Synchronisation: Board-ACK/NACK abwarten bevor nächster Transfer startet
private readonly SemaphoreSlim _configAckGate = new(0, 1);
private readonly SemaphoreSlim _macroAckGate = new(0, 1);
private volatile bool _configAckOk;
private volatile bool _macroAckOk;
private volatile bool _configAckExpected;
private volatile bool _macroAckExpected;
public bool IsConnected => _port?.IsOpen == true;
@@ -61,20 +71,19 @@ public class SerialManager : IDisposable
if (!Monitor.TryEnter(_connectLock)) return;
try
{
if (IsConnected || _waitingAfterDisconnect) return;
if (_disposed || IsConnected || _waitingAfterDisconnect) return;
string? portName = FindPort();
if (portName is null) return;
var port = new SerialPort(portName, 115200)
SerialPort? port = null;
try
{
port = new SerialPort(portName, 115200)
{
ReadTimeout = 500,
WriteTimeout = 500,
DtrEnable = true, // Muss VOR Open() gesetzt werden SAMD21 prüft DTR
// in SerialUSB-bool für usb_serial_send(). Default ist
// false, was alle Board→PC-Antworten still verwirft.
// Vor Open() gesetzt verursacht es keinen Zustandswechsel
// und triggert keinen CDC-Disconnect auf dem Board.
DtrEnable = true,
};
port.Open();
@@ -85,10 +94,20 @@ public class SerialManager : IDisposable
_port = port;
// Lesethread starten
_readThread = new Thread(ReadLoop) { IsBackground = true, Name = "VersaPad-Read" };
_readThread = new Thread(ReadLoop)
{
IsBackground = true,
Name = "VersaPad-Read",
};
_readThread.Start();
_ui.Post(_ => Connected?.Invoke(this, EventArgs.Empty), null);
port = null; // Besitz liegt ab hier bei _port/ReadLoop.
}
finally
{
port?.Dispose();
}
}
catch
{
@@ -103,10 +122,16 @@ public class SerialManager : IDisposable
public void Disconnect()
{
var port = _port;
_port = null;
SerialPort? port = Interlocked.Exchange(ref _port, null);
try { port?.Close(); } catch { }
port?.Dispose();
_configAckOk = false;
_macroAckOk = false;
_configAckExpected = false;
_macroAckExpected = false;
ReleaseGate(_configAckGate);
ReleaseGate(_macroAckGate);
}
// ── COM-Port-Erkennung per WMI ────────────────────────────────────────────
@@ -195,11 +220,22 @@ public class SerialManager : IDisposable
// ── Senden ───────────────────────────────────────────────────────────────
public void Send(SerialPacket pkt)
public bool Send(SerialPacket pkt)
{
if (!IsConnected) return;
try { _port!.Write(pkt.Data, 0, Protocol.PacketSize); }
catch { /* Sendefehler ignorieren ReadLoop erkennt Disconnect */ }
lock (_writeLock)
{
SerialPort? port = _port;
if (port?.IsOpen != true) return false;
try
{
port.Write(pkt.Data, 0, Protocol.PacketSize);
return true;
}
catch
{
return false;
}
}
}
// ── Hilfsmethoden ────────────────────────────────────────────────────────
@@ -221,64 +257,135 @@ public class SerialManager : IDisposable
public void RequestMacros()
=> Send(new SerialPacket(Protocol.CmdMacroRead));
// Makro-Tabelle (256 Bytes) in 6-Byte-Chunks senden.
public void SendMacros(MacroTable macros)
// Wird von TrayApp aufgerufen wenn das Board CONFIG_ACK/NACK oder MACRO_ACK/NACK sendet.
// Gibt den wartenden SendConfig()/SendMacros()-Thread frei.
public void SignalConfigAck()
{
byte[] data = macros.ToBytes();
const int payload = 6;
int chunks = (data.Length + payload - 1) / payload; // 43
if (!_configAckExpected) return;
_configAckOk = true;
ReleaseGate(_configAckGate);
}
Send(new SerialPacket(Protocol.CmdMacroBegin, (byte)chunks));
public void SignalConfigNack()
{
if (!_configAckExpected) return;
_configAckOk = false;
ReleaseGate(_configAckGate);
}
public void SignalMacroAck()
{
if (!_macroAckExpected) return;
_macroAckOk = true;
ReleaseGate(_macroAckGate);
}
public void SignalMacroNack()
{
if (!_macroAckExpected) return;
_macroAckOk = false;
ReleaseGate(_macroAckGate);
}
private static void ReleaseGate(SemaphoreSlim gate)
{
if (gate.CurrentCount != 0) return;
try { gate.Release(); }
catch (SemaphoreFullException) { }
}
private static void ResetGate(SemaphoreSlim gate)
{
while (gate.Wait(0)) { }
}
// Makro-Tabelle (512 Bytes) in 6-Byte-Chunks senden.
// Gibt true zurück wenn das Board MACRO_ACK gesendet hat.
public bool SendMacros(MacroTable macros)
{
lock (_transferLock)
{
if (!macros.IsValid() || !IsConnected) return false;
byte[] data = macros.ToBytes();
ResetGate(_macroAckGate);
_macroAckOk = false;
_macroAckExpected = false;
if (!Send(new SerialPacket(
Protocol.CmdMacroBegin, (byte)Protocol.MacroChunks)))
return false;
Thread.Sleep(10);
for (int i = 0; i < chunks; i++)
for (int i = 0; i < Protocol.MacroChunks; 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);
int offset = i * Protocol.PayloadSize;
int count = Math.Min(Protocol.PayloadSize, data.Length - offset);
Buffer.BlockCopy(data, offset, pkt.Data, 2, count);
Send(pkt);
if (!Send(pkt)) return false;
Thread.Sleep(5);
}
Thread.Sleep(10);
Send(new SerialPacket(Protocol.CmdMacroCommit));
_macroAckExpected = true;
if (!Send(new SerialPacket(Protocol.CmdMacroCommit)))
{
_macroAckExpected = false;
return false;
}
bool gateAcquired = _macroAckGate.Wait(3000);
_macroAckExpected = false;
return gateAcquired && _macroAckOk;
}
}
// 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).
public void SendConfig(DeviceConfig config)
// Gibt true zurück wenn das Board CONFIG_ACK gesendet hat.
public bool SendConfig(DeviceConfig config)
{
lock (_transferLock)
{
if (!config.TryValidate(out _) || !IsConnected) return false;
byte[] data = config.ToBytes();
const int payload = 6; // Nutzbytes pro Paket (8 - 2 Header-Bytes)
int chunks = (data.Length + payload - 1) / payload;
File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"),
$"{DateTime.Now:HH:mm:ss.fff} TX ConfigBegin chunks={chunks} dataLen={data.Length}\n");
ResetGate(_configAckGate);
_configAckOk = false;
_configAckExpected = false;
Send(new SerialPacket(Protocol.CmdConfigBegin, (byte)chunks));
if (!Send(new SerialPacket(
Protocol.CmdConfigBegin, (byte)Protocol.ConfigChunks)))
return false;
Thread.Sleep(10);
for (int i = 0; i < chunks; i++)
for (int i = 0; i < Protocol.ConfigChunks; i++)
{
var pkt = new SerialPacket();
pkt.Data[0] = Protocol.CmdConfigData;
pkt.Data[1] = (byte)i;
int offset = i * payload;
int count = Math.Min(payload, data.Length - offset);
int offset = i * Protocol.PayloadSize;
int count = Math.Min(Protocol.PayloadSize, data.Length - offset);
Buffer.BlockCopy(data, offset, pkt.Data, 2, count);
Send(pkt);
Thread.Sleep(5); // Firmware-Loop Zeit geben den Puffer zu leeren
if (!Send(pkt)) return false;
Thread.Sleep(5);
}
File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"),
$"{DateTime.Now:HH:mm:ss.fff} TX ConfigCommit\n");
Thread.Sleep(10);
Send(new SerialPacket(Protocol.CmdConfigCommit));
_configAckExpected = true;
if (!Send(new SerialPacket(Protocol.CmdConfigCommit)))
{
_configAckExpected = false;
return false;
}
bool gateAcquired = _configAckGate.Wait(3000);
_configAckExpected = false;
return gateAcquired && _configAckOk;
}
}
// ── IDisposable ───────────────────────────────────────────────────────────
+62 -44
View File
@@ -10,8 +10,8 @@
// Beenden
//
// Board-Events:
// KEY_DOWN / ENC_CW / ENC_CCW mit Aktion HOST_COMMAND werden hier empfangen.
// Aktuell: Benachrichtigung anzeigen (HOST_COMMAND-Ausführung folgt später).
// KEY_DOWN/KEY_UP/ENC_CW/ENC_CCW enthalten die konfigurierte Host-Command-ID.
// Eine konkrete Host-Aktionsausführung ist bewusst noch nicht implementiert.
namespace VersaGUI;
@@ -25,9 +25,12 @@ public class TrayApp : ApplicationContext
private ToolStripMenuItem _statusItem = null!;
private ConfigForm? _configForm;
// Empfangspuffer für eingehende Dumps vom Board
private byte[]? _rxConfigBuf;
private byte[]? _rxMacroBuf;
private readonly ChunkTransferBuffer _configRx =
new(Protocol.ConfigSize, Protocol.ConfigChunks);
private readonly ChunkTransferBuffer _macroRx =
new(Protocol.MacroSize, Protocol.MacroChunks);
private int _configReadAttempts;
private int _macroReadAttempts;
public TrayApp()
{
@@ -92,9 +95,11 @@ public class TrayApp : ApplicationContext
_tray.Icon = SystemIcons.Information;
_statusItem.Text = "● Verbunden";
// Config und Makro-Tabelle vom Board laden
// Dumps bewusst sequenziell anfordern, damit sich zwei lange
// Board→Host-Transfers nicht gegenseitig überholen können.
_configReadAttempts = 1;
_macroReadAttempts = 0;
_serial.RequestConfig();
_serial.RequestMacros();
}
private void OnDisconnected(object? sender, EventArgs e)
@@ -102,24 +107,20 @@ public class TrayApp : ApplicationContext
_tray.Text = "VersaPad nicht verbunden";
_tray.Icon = SystemIcons.Application;
_statusItem.Text = "○ Nicht verbunden";
_configRx.Reset();
_macroRx.Reset();
}
private void OnPacket(object? sender, SerialPacket pkt)
{
// Debug: alle empfangenen Pakete loggen
File.AppendAllText(
Path.Combine(Path.GetTempPath(), "versapad_rx.txt"),
$"{DateTime.Now:HH:mm:ss.fff} RX cmd=0x{pkt.Command:X2} key={pkt.KeyId}\n");
switch (pkt.Command)
{
case Protocol.EvtKeyDown:
// TODO: HOST_COMMAND-Aktion aus Config laden und ausführen
break;
case Protocol.EvtKeyUp:
case Protocol.EvtEncCw:
case Protocol.EvtEncCcw:
// TODO: Encoder HOST_COMMAND
// Command-ID steht jetzt direkt in pkt.DataU16. Die sichere
// Zuordnung zu konkreten Host-Aktionen folgt separat.
break;
case Protocol.EvtPong:
@@ -128,69 +129,86 @@ public class TrayApp : ApplicationContext
break;
case Protocol.EvtConfigAck:
MessageBox.Show("Config erfolgreich gespeichert!",
"VersaPad", MessageBoxButtons.OK, MessageBoxIcon.Information);
_serial.SignalConfigAck(); // SendConfig()-Thread freigeben
break;
case Protocol.EvtConfigNack:
MessageBox.Show("Config FEHLER: CRC/Magic ungültig.\nÜbertragung fehlgeschlagen.",
"VersaPad", MessageBoxButtons.OK, MessageBoxIcon.Error);
_serial.SignalConfigNack(); // SendConfig()-Thread freigeben (Fehler)
break;
// ── Config-Dump vom Board ─────────────────────────────────────────
case Protocol.EvtConfigBegin:
_rxConfigBuf = new byte[223]; // sizeof(SDeviceConfig) = 223 (Version 2)
_configRx.Begin(pkt.KeyId);
break;
case Protocol.EvtConfigData:
if (_rxConfigBuf != null)
{
int offset = pkt.KeyId * 6;
for (int i = 0; i < 6; i++)
if (offset + i < _rxConfigBuf.Length)
_rxConfigBuf[offset + i] = pkt.Data[2 + i];
}
_configRx.Add(pkt);
break;
case Protocol.EvtConfigEnd:
if (_rxConfigBuf != null)
if (_configRx.TryComplete(pkt.KeyId, out byte[] configData) &&
_config.FromBytes(configData))
{
_config.FromBytes(_rxConfigBuf);
_rxConfigBuf = null;
_configForm?.RefreshAll();
_macroReadAttempts = 1;
_serial.RequestMacros();
}
else if (_configReadAttempts < 2 && _serial.IsConnected)
{
_configReadAttempts++;
_serial.RequestConfig();
}
else
{
ShowTransferError("Config");
}
break;
// ── Makro-Dump vom Board ──────────────────────────────────────────
case Protocol.EvtMacroAck:
MessageBox.Show("Makros erfolgreich gespeichert!",
"VersaPad", MessageBoxButtons.OK, MessageBoxIcon.Information);
_serial.SignalMacroAck(); // SendMacros()-Thread freigeben
break;
case Protocol.EvtMacroNack:
_serial.SignalMacroNack(); // SendMacros()-Thread freigeben (Fehler)
break;
case Protocol.EvtMacroBegin:
_rxMacroBuf = new byte[256];
_macroRx.Begin(pkt.KeyId);
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];
}
_macroRx.Add(pkt);
break;
case Protocol.EvtMacroEnd:
if (_rxMacroBuf != null)
if (_macroRx.TryComplete(pkt.KeyId, out byte[] macroData) &&
_macros.FromBytes(macroData))
{
_macros.FromBytes(_rxMacroBuf);
_rxMacroBuf = null;
_configForm?.RefreshAll();
}
else if (_macroReadAttempts < 2 && _serial.IsConnected)
{
_macroReadAttempts++;
_serial.RequestMacros();
}
else
{
ShowTransferError("Makro-Tabelle");
}
break;
}
}
private void ShowTransferError(string name)
{
_tray.BalloonTipTitle = "VersaPad Übertragungsfehler";
_tray.BalloonTipText = $"{name} konnte nicht vollständig gelesen werden.";
_tray.BalloonTipIcon = ToolTipIcon.Warning;
_tray.ShowBalloonTip(4000);
}
// ── Aufräumen ─────────────────────────────────────────────────────────────
protected override void Dispose(bool disposing)
+97
View File
@@ -0,0 +1,97 @@
using VersaGUI;
static void Assert(bool condition, string message)
{
if (!condition) throw new InvalidOperationException(message);
}
static void TestDeviceConfigRoundTrip()
{
var source = new DeviceConfig();
source.Profiles[2].MxActions[4].Type = ActionType.HostCommand;
source.Profiles[2].MxActions[4].Data = 0xBEEF;
source.ActiveProfileIndex = 2;
byte[] data = source.ToBytes();
Assert(data.Length == Protocol.ConfigSize, "Configgröße weicht ab.");
ushort storedCrc = (ushort)(data[5] | (data[6] << 8));
Assert(
storedCrc == DeviceConfig.Crc16(data, 7, data.Length - 7),
"Config-CRC weicht ab.");
var target = new DeviceConfig();
Assert(target.FromBytes(data), "Gültige Config wurde abgelehnt.");
Assert(target.ActiveProfileIndex == 2, "Aktives Profil ging verloren.");
Assert(
target.Profiles[2].MxActions[4].Data == 0xBEEF,
"Host-Command-ID ging verloren.");
}
static void TestInvalidConfigIsRejected()
{
byte[] data = new DeviceConfig().ToBytes();
data[7] = 3;
ushort crc = DeviceConfig.Crc16(data, 7, data.Length - 7);
data[5] = (byte)crc;
data[6] = (byte)(crc >> 8);
Assert(!new DeviceConfig().FromBytes(data), "Ungültiger Profilindex wurde akzeptiert.");
var invalidPulse = new DeviceConfig();
invalidPulse.Profiles[0].LedAnim[0] = LedAnimType.Pulse;
invalidPulse.Profiles[0].LedPeriod[0] = 1;
Assert(!invalidPulse.TryValidate(out _), "Unsichere Pulse-Periode wurde akzeptiert.");
}
static void TestMacroValidation()
{
var macros = new MacroTable();
macros.Steps[31, 7].Keycode = 0x65;
byte[] data = macros.ToBytes();
Assert(data.Length == Protocol.MacroSize, "Makrogröße weicht ab.");
var copy = new MacroTable();
Assert(copy.FromBytes(data), "Gültige Makrotabelle wurde abgelehnt.");
data[0] = 0x66;
Assert(!copy.FromBytes(data), "Ungültiger HID-Keycode wurde akzeptiert.");
}
static void TestChunkValidation()
{
var receiver = new ChunkTransferBuffer(13, 3);
Assert(receiver.Begin(3), "Gültige Chunkzahl wurde abgelehnt.");
for (byte chunk = 0; chunk < 3; chunk++)
{
var packet = new SerialPacket();
packet.Data[1] = chunk;
for (int i = 0; i < Protocol.PayloadSize; i++)
packet.Data[2 + i] = (byte)(chunk * Protocol.PayloadSize + i);
Assert(receiver.Add(packet), $"Chunk {chunk} wurde abgelehnt.");
}
Assert(receiver.TryComplete(3, out byte[] result), "Vollständiger Dump wurde abgelehnt.");
Assert(result.Length == 13 && result[12] == 12, "Chunkdaten wurden falsch zusammengesetzt.");
Assert(receiver.Begin(3), "Zweiter Transfer konnte nicht starten.");
var duplicate = new SerialPacket();
Assert(receiver.Add(duplicate), "Erster Chunk wurde abgelehnt.");
Assert(!receiver.Add(duplicate), "Doppelter Chunk wurde akzeptiert.");
Assert(!receiver.TryComplete(3, out _), "Ungültiger Dump wurde abgeschlossen.");
}
static void TestHostCommandPayload()
{
var packet = new SerialPacket(Protocol.EvtEncCw, 2, 0x34, 0x12);
Assert(packet.DataU16 == 0x1234, "16-Bit-Command-ID wurde falsch dekodiert.");
}
TestDeviceConfigRoundTrip();
TestInvalidConfigIsRejected();
TestMacroValidation();
TestChunkValidation();
TestHostCommandPayload();
Console.WriteLine("VersaGUI contract tests passed.");
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\VersaGUI.csproj" />
</ItemGroup>
</Project>