Compare commits

...

7 Commits

21 changed files with 1485 additions and 594 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 -183
View File
@@ -1,205 +1,138 @@
# VersaGUI # VersaGUI
Windows-Tray-App zur Konfiguration und Steuerung des VersaPad v2. Windows-Tray-App zur Konfiguration des VersaPad v2.
Geschrieben in **C# / .NET 7 / WinForms**. 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 ## Voraussetzungen
- Windows 10/11 - Windows 10/11
- [.NET 7 SDK](https://dotnet.microsoft.com/download/dotnet/7.0) (zum Bauen) - .NET 7 SDK mit Windows-Desktop-Unterstützung
- VersaMCU-Firmware auf dem Board geflasht - geflashte `VersaMCU`-Firmware
- Board per USB verbunden (erscheint als CDC COM-Port, kein Treiber nötig) - Board per USB als Composite Device (Keyboard-HID, Consumer-HID und CDC)
verbunden
## Starten ## Starten
```bash ```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 ```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 Die vollständige Liste steht in
2. **Rechtsklick** auf das Tray-Icon → **Konfiguration...** [`doc/07_known_limitations.md`](doc/07_known_limitations.md).
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
--- ## 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 | - [Dokumentationsindex](doc/INDEX.md)
|---|-------------|--------| - [Datenlayout](doc/02_device_config.md)
| 1.1 | App läuft als **Windows Tray-Anwendung** ohne sichtbares Hauptfenster | ✅ | - [Bekannte Einschränkungen](doc/07_known_limitations.md)
| 1.2 | Automatische Board-Erkennung per **WMI / VID+PID** (`0x239A / 0x0042`) | ✅ | - [../VersaMCU/doc/07_serial_protocol.md](../VersaMCU/doc/07_serial_protocol.md)
| 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 |
| 9.4 | **Profile**: mehrere Tastenbelegungen speichern und wechseln | 🔲 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)
```
### Kommunikationsprotokoll
Das vollständige Protokoll (alle Command- und Event-IDs, NVM-Layout, Paketformat) ist in [VersaMCU/README.md](../VersaMCU/README.md#serial-protokoll-8-bytes-fixed) dokumentiert.
### 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 |
+24 -11
View File
@@ -16,10 +16,11 @@
|---|---| |---|---|
| `TrayApp` | `ApplicationContext`; hält Tray-Icon, öffnet ConfigForm, verarbeitet Board-Events | | `TrayApp` | `ApplicationContext`; hält Tray-Icon, öffnet ConfigForm, verarbeitet Board-Events |
| `SerialManager` | Verbindungsverwaltung, WMI-Erkennung, Lese-Thread, Sende-Methoden | | `SerialManager` | Verbindungsverwaltung, WMI-Erkennung, Lese-Thread, Sende-Methoden |
| `ConfigForm` | Hauptfenster (Grid + Encoder-Panel + Footer); öffnet ActionDialog | | `ConfigForm` | Hauptfenster (Profilauswahl, Grid, Encoder-Panel und Footer); öffnet ActionDialog |
| `ActionDialog` | Modaler Dialog zum Bearbeiten einer Aktion + LED-Einstellungen | | `ActionDialog` | Modaler Dialog zum Bearbeiten einer Aktion + LED-Einstellungen |
| `DeviceConfig` | C#-Spiegel von `SDeviceConfig`; Serialisierung/Deserialisierung (223 B) | | `DeviceConfig` | C#-Spiegel von `SDeviceConfig`; Validierung und Serialisierung (740 B) |
| `MacroTable` | C#-Spiegel von `SMacroTable`; Serialisierung/Deserialisierung (256 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` | | `ConfigJson` | JSON-Import/Export für `DeviceConfig` |
| `Protocol` | Konstanten für alle Command/Event-IDs (spiegelt `usb_serial.h`) | | `Protocol` | Konstanten für alle Command/Event-IDs (spiegelt `usb_serial.h`) |
@@ -29,9 +30,9 @@
Board → SerialManager (ReadLoop, BG-Thread) Board → SerialManager (ReadLoop, BG-Thread)
→ SynchronizationContext.Post (→ UI-Thread) → SynchronizationContext.Post (→ UI-Thread)
→ TrayApp.OnPacket() → TrayApp.OnPacket()
├── Config-Dump: _rxConfigBuf aufbauen → DeviceConfig.FromBytes() ├── Config-Dump vollständig sammeln → DeviceConfig.FromBytes()
├── Makro-Dump: _rxMacroBuf aufbauen → MacroTable.FromBytes() ├── danach Makro-Dump vollständig sammeln → MacroTable.FromBytes()
└── HOST_COMMAND-Events: (TODO: Aktion ausführen) └── HOST_COMMAND-Events mit 16-Bit-Command-ID empfangen
Benutzer → ConfigForm → ActionDialog Benutzer → ConfigForm → ActionDialog
→ DeviceConfig / MacroTable (in-memory ändern) → DeviceConfig / MacroTable (in-memory ändern)
@@ -45,7 +46,7 @@ Benutzer → ConfigForm → ActionDialog
UI-Thread : TrayApp, ConfigForm, ActionDialog, alle WinForms-Controls UI-Thread : TrayApp, ConfigForm, ActionDialog, alle WinForms-Controls
BG-Thread : SerialManager.ReadLoop() blockiert auf ReadByte() BG-Thread : SerialManager.ReadLoop() blockiert auf ReadByte()
Timer-Thread : SerialManager._reconnectTimer → TryConnect() alle 3 s Timer-Thread : SerialManager._reconnectTimer → TryConnect() alle 3 s
Sende-Task : ConfigForm.OnSave() → Task.Run() (blockiert ~400 ms für Transfer) 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. Alle Board-Events werden per `SynchronizationContext.Post` auf den UI-Thread gepostet. Controls dürfen nie vom BG-Thread angefasst werden.
@@ -55,7 +56,8 @@ Alle Board-Events werden per `SynchronizationContext.Post` auf den UI-Thread gep
``` ```
Start → Timer feuert → TryConnect() → WMI-Suche (VID 0x239A / PID 0x0042) Start → Timer feuert → TryConnect() → WMI-Suche (VID 0x239A / PID 0x0042)
→ SerialPort öffnen (DtrEnable=true VOR Open()!) → SerialPort öffnen (DtrEnable=true VOR Open()!)
→ 200 ms warten → ReadLoop starten → Connected-Event → RequestConfig() + RequestMacros() → 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 Disconnect → ReadLoop bricht ab → Disconnected-Event → 5 s Backoff → Timer läuft weiter
``` ```
@@ -64,6 +66,17 @@ Disconnect → ReadLoop bricht ab → Disconnected-Event → 5 s Backoff → Tim
- **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. - **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`. - **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()` muss exakt 223 B in derselben Reihenfolge wie `SDeviceConfig` (packed C++) erzeugen. Jede Änderung am Firmware-Layout muss hier gespiegelt werden. - **Packed-kompatible Serialisierung**: `DeviceConfig.ToBytes()` erzeugt exakt
- **Config-Version**: `DeviceConfig.Version == 2`. `FromBytes()` prüft Magic + Version + CRC; schlägt einer fehl → Methode gibt `false` zurück, Config bleibt unverändert. 740 B in derselben Reihenfolge wie `SDeviceConfig`; `MacroTable` exakt 512 B.
- **Debug-Log**: `versapad_rx.txt` in `%TEMP%` vor Release-Nutzung entfernen (TODO). - **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).
+38 -11
View File
@@ -8,6 +8,7 @@
- Verbindungsaufbau und automatischer Reconnect - Verbindungsaufbau und automatischer Reconnect
- Hintergrund-Lese-Thread mit Paket-Assembler - Hintergrund-Lese-Thread mit Paket-Assembler
- Sende-Methoden für alle Protokoll-Operationen - Sende-Methoden für alle Protokoll-Operationen
- ACK/NACK-Synchronisation zwischen ReadLoop-Thread und `SendConfig`/`SendMacros`-Task
## COM-Port-Erkennung ## COM-Port-Erkennung
@@ -52,20 +53,46 @@ Bei IOException:
else: break → Disconnect 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 ## Sende-Methoden
| Methode | Funktion | | Methode | Rückgabe | Funktion |
|---|---| |---|---|---|
| `Send(pkt)` | Rohe 8-Byte-Übertragung (fire-and-forget, ignoriert Sendefehler) | | `Send(pkt)` | bool | Rohe, unter Write-Lock atomare 8-Byte-Übertragung |
| `SetLedOverride(keyId, r, g, b)` | CMD 0x01 | | `SetLedOverride(keyId, r, g, b)` | void | CMD 0x01 |
| `ClearLedOverride(keyId)` | CMD 0x02 | | `ClearLedOverride(keyId)` | void | CMD 0x02 |
| `SetLedBase(keyId, r, g, b)` | CMD 0x03 | | `SetLedBase(keyId, r, g, b)` | void | CMD 0x03 |
| `RequestConfig()` | CMD 0x13 Board antwortet mit Config-Dump | | `RequestConfig()` | void | CMD 0x13 Board antwortet mit Config-Dump |
| `RequestMacros()` | CMD 0x23 Board antwortet mit Makro-Dump | | `RequestMacros()` | void | CMD 0x23 Board antwortet mit Makro-Dump |
| `SendConfig(cfg)` | BEGIN(0x10) → 38×DATA(0x11) → COMMIT(0x12), 5 ms zwischen Chunks | | `SendConfig(cfg)` | bool | BEGIN(0x10) → 124×DATA(0x11) → COMMIT(0x12), 5 ms zwischen Chunks, wartet auf ACK/NACK |
| `SendMacros(macros)` | BEGIN(0x20) → 43×DATA(0x21) → COMMIT(0x22), 5 ms zwischen Chunks | | `SendMacros(macros)` | bool | BEGIN(0x20) → 86×DATA(0x21) → COMMIT(0x22), 5 ms zwischen Chunks, wartet auf ACK/NACK |
`SendConfig` und `SendMacros` blockieren ~400 ms → werden in `Task.Run()` aus `ConfigForm.OnSave()` aufgerufen. 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 ## Events
+132 -75
View File
@@ -1,104 +1,161 @@
# DeviceConfig & MacroTable # DeviceConfig und MacroTable
**Datei:** `DeviceConfig.cs` Datei:
## Überblick - `src/DeviceConfig.cs`
C#-Spiegel der Firmware-Structs. Muss byte-kompatibel mit `SDeviceConfig` (nvm_config.h) und `SMacroTable` (macro_config.h) sein. ## 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 ## DeviceConfig
### Felder ### Globaler Stand
| Feld | Typ | Inhalt | - Magic `0x56503203`
|---|---|---| - Version `3`
| `MxActions[20]` | `DeviceAction[]` | Aktionen für MX-Buttons 019 | - Groesse `740` Byte
| `EncActions[4,3]` | `DeviceAction[,]` | Encoder [03][SW=0/CW=1/CCW=2] | - 3 Profile
| `LedBase[20]` | `Color[]` | RGB-Basis-LED-Farbe je Button |
| `LedAnim[20]` | `LedAnimType[]` | Animation je Button |
| `LedPeriod[20]` | `ushort[]` | Animationsperiode in ms |
### Serialisierungs-Layout (ToBytes / FromBytes, 223 B) ### Wichtige Felder
``` | Feld | Bedeutung |
Offset 0 4B Magic 0x56503202 (little-endian) |---|---|
Offset 4 1B Version = 2 | `ActiveProfileIndex` | aktives Profil 0..2 |
Offset 5 2B CRC16-CCITT über Bytes 7222 (little-endian) | `GlobalBrightness` | globale LED-Helligkeit |
Offset 7 60B MxActions[20] je 3B: type(1) + data_lo(1) + data_hi(1) | `Profiles[3]` | komplette Profil-Daten |
Offset 67 36B EncActions[4][3] je 3B | `EncSensitivity[4]` | im Vertrag enthalten; Firmware nutzt den Wert derzeit nicht |
Offset103 20B LedBase[i].R
Offset123 20B LedBase[i].G Fuer die GUI gibt es zusaetzlich Komfortzugriffe auf das aktive Profil:
Offset143 20B LedBase[i].B
Offset163 20B LedAnim[i] als byte - `MxActions[20]`
Offset183 40B LedPeriod[i] als uint16 little-endian - `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
``` ```
### CRC16-CCITT Jedes Profil belegt 236 Byte:
Polynom `0x1021`, Init `0xFFFF`, über Bytes 7222 (nach dem CRC-Feld selbst). Muss identisch mit Firmware-Implementierung sein. `DeviceConfig.Crc16()` ist statisch und direkt testbar. ```text
20 x mx_actions
### Defaults (entspricht Firmware-Defaults) 12 x enc_actions
20 x led_r
- Alle Aktionen: `None` 20 x led_g
- LEDs: warm-weiß (R=80, G=40, B=0) 20 x led_b
- Animation: `ColorCycle` (Regenbogen), Period 4000 ms 20 x led_brightness
20 x led_anim
--- 20 x led_period_ms
## DeviceAction
```csharp
public enum ActionType : byte { None=0, HidKey=1, HidConsumer=2, HostCommand=3, Macro=4 }
public class DeviceAction {
public ActionType Type { get; set; }
public ushort Data { get; set; }
// HidKey: Low-Byte = HID Keycode, High-Byte = Modifier
// HidConsumer: Consumer Usage ID
// HostCommand: Command-ID
// Macro: Slot-Index 031
}
``` ```
`DeviceAction.Display` gibt einen lesbaren String zurück (z.B. `"Strg+C"`, `"Play/Pause"`, `"Makro 3"`). ### 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 ## MacroTable
```csharp ### Stand
public class MacroTable {
public const int Slots = 32; - 32 Slots
public const int MaxSteps = 4; - 8 Steps pro Slot
public MacroStep[,] Steps { get; } // [slot][step] - 512 Byte gesamt
}
public class MacroStep {
public byte Keycode { get; set; } // 0 = leer
public byte Modifier { get; set; }
}
```
### Slot-Konvention ### Slot-Konvention
| Slots | Verwendung | | Slots | Bedeutung |
|---|---| |---|---|
| 019 | MX-Button `mxIdx` (`MacroTable.SlotForMx(mxIdx)`) | | `0..19` | MX-Buttons |
| 2031 | Encoder: `20 + enc * 3 + actIdx` (`MacroTable.SlotForEncoder(enc, actIdx)`) | | `20..31` | Encoder-Aktionen |
### Serialisierung (256 B) ### Serialisierung
32 Slots × 4 Steps × 2 B = 256 B. Keycode zuerst, dann Modifier. Kein Magic/CRC (Board akzeptiert jeden Inhalt). Ein Step besteht aus:
--- - `keycode`
- `modifier`
## LedAnimType `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.
```csharp Es gibt kein Magic und keine CRC fuer die Makrotabelle.
public enum LedAnimType : byte { Beim Transfer prüft die GUI trotzdem exakte Größe und alle HID-Keycodes; die
Static=0, Blink=1, Pulse=2, ColorCycle=5 Firmware prüft zusätzlich die vollständige Chunkmenge.
}
```
Werte entsprechen `LEDAnim` in der Firmware. `FADE_IN` (3) und `FADE_OUT` (4) existieren in der Firmware aber nicht in der GUI (nicht konfigurierbar, nur `COLOR_FADE` intern). ## 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.
+25 -16
View File
@@ -7,7 +7,8 @@
- App-Lebenszyklus als `ApplicationContext` (kein Hauptfenster) - App-Lebenszyklus als `ApplicationContext` (kein Hauptfenster)
- Tray-Icon mit Verbindungsstatus und Kontextmenü - Tray-Icon mit Verbindungsstatus und Kontextmenü
- Empfang und Routing aller Board-Events (via `SerialManager.PacketReceived`) - Empfang und Routing aller Board-Events (via `SerialManager.PacketReceived`)
- Config/Makro-Dump-Empfang (chunked, via `_rxConfigBuf` / `_rxMacroBuf`) - sequenzieller Config-/Makro-Dump-Empfang über `ChunkTransferBuffer`
- ACK/NACK-Weiterleitung an SerialManager
- Öffnen des `ConfigForm` (nur eine Instanz gleichzeitig) - Öffnen des `ConfigForm` (nur eine Instanz gleichzeitig)
## Tray-Menü ## Tray-Menü
@@ -30,18 +31,20 @@ Icon und Tooltip spiegeln den Verbindungsstatus:
| Event-ID | Aktion | | Event-ID | Aktion |
|---|---| |---|---|
| `EvtConfigBegin` | `_rxConfigBuf = new byte[223]` | | `EvtConfigBegin/Data/End` | Chunkzahl, Indizes und Vollständigkeit prüfen; danach `DeviceConfig.FromBytes()` |
| `EvtConfigData` | Chunk in `_rxConfigBuf` eintragen (`KeyId * 6` = Byte-Offset) | | `EvtConfigAck` | `serial.SignalConfigAck()` — gibt SendConfig()-Thread frei |
| `EvtConfigEnd` | `DeviceConfig.FromBytes()``ConfigForm.RefreshAll()` | | `EvtConfigNack` | `serial.SignalConfigNack()` — gibt SendConfig()-Thread frei (Fehler) |
| `EvtMacroBegin` | `_rxMacroBuf = new byte[256]` | | `EvtMacroBegin/Data/End` | vollständigen 512-Byte-Dump prüfen; danach `MacroTable.FromBytes()` |
| `EvtMacroData` | Chunk in `_rxMacroBuf` eintragen | | `EvtMacroAck` | `serial.SignalMacroAck()` — gibt SendMacros()-Thread frei |
| `EvtMacroEnd` | `MacroTable.FromBytes()` | | `EvtMacroNack` | `serial.SignalMacroNack()` — gibt SendMacros()-Thread frei (Fehler) |
| `EvtPong` | MessageBox "Ping OK" | | `EvtPong` | MessageBox "Ping OK" |
| `EvtConfigAck` | MessageBox "Config gespeichert" | | `EvtKeyDown/Up` | Host-Command-ID in Byte 2/3 empfangen |
| `EvtConfigNack` | MessageBox "Config FEHLER" | | `EvtEncCw/Ccw` | Host-Command-ID und Encoderrichtung empfangen |
| `EvtMacroAck` | MessageBox "Makros gespeichert" |
| `EvtKeyDown` | TODO: HOST_COMMAND-Aktion ausführen | Diese vier Host-Events sendet die Firmware nur für Actions vom Typ
| `EvtEncCw/Ccw` | TODO: Encoder HOST_COMMAND | `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 ## Verbindungslebenszyklus
@@ -49,14 +52,20 @@ Icon und Tooltip spiegeln den Verbindungsstatus:
OnConnected(): OnConnected():
Icon + Text + Menü-Item aktualisieren Icon + Text + Menü-Item aktualisieren
serial.RequestConfig() → Config-Dump vom Board serial.RequestConfig() → Config-Dump vom Board
serial.RequestMacros() → Makro-Dump vom Board nach gültigem Config-End:
serial.RequestMacros() → Makro-Dump vom Board
OnDisconnected(): OnDisconnected():
Icon + Text + Menü-Item aktualisieren Icon + Text + Menü-Item aktualisieren
``` ```
## TODOs in dieser Klasse 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).
- HOST_COMMAND-Ausführung: `EvtKeyDown` empfangen → Aktion aus Config laden → URL/Programm starten ## Offene Punkte
- sichere Zuordnung von Command-IDs zu explizit erlaubten Desktop-Aktionen
- Eigenes Tray-Icon statt `SystemIcons.Application` - Eigenes Tray-Icon statt `SystemIcons.Application`
- Debug-Log (`versapad_rx.txt`) entfernen
+19 -5
View File
@@ -4,7 +4,11 @@
## Verantwortung ## Verantwortung
Hauptkonfigurationsfenster: zeigt alle 20 MX-Buttons und 4 Encoder, öffnet `ActionDialog` bei Klick, speichert Config + Makros auf das Board. 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 ## Layout
@@ -45,15 +49,23 @@ Entspricht `key_id - 5` in der Firmware. Im TableLayoutPanel: Spalte=col, Zeile=
```csharp ```csharp
Task.Run(() => { Task.Run(() => {
_serial.SendConfig(_config); // ~300 ms bool cfgOk = _serial.SendConfig(_config); // wartet auf ACK/NACK
Thread.Sleep(50); bool macroOk = _serial.SendMacros(_macros);
_serial.SendMacros(_macros); // ~250 ms
InvokeOnUi(() => { /* Button-Text + Enabled zurücksetzen */ }); InvokeOnUi(() => { /* Button-Text + Enabled zurücksetzen */ });
}); });
``` ```
Save-Button wird während der Übertragung deaktiviert, Text wechselt zu "Wird gesendet...". Save-Button wird während der Übertragung deaktiviert, Text wechselt zu "Wird gesendet...".
Save-Button ist nur aktiviert wenn Board verbunden (`_serial.IsConnected`). 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 ## Import / Export
@@ -64,7 +76,9 @@ Fehler (IO, JSON-Parse, falsche Version) werden per `MessageBox` angezeigt.
## RefreshAll ## RefreshAll
Wird von `TrayApp` nach erfolgreicher Config vom Board aufgerufen (über `_configForm?.RefreshAll()`). Aktualisiert alle 20 MX-Buttons und 12 Encoder-Buttons ohne Dialog. 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) ## Extensions (in derselben Datei)
+38 -3
View File
@@ -22,13 +22,37 @@ public ushort ResultPeriod { get; } // Periode in ms
| HID Tastatur | `_hidKeyPanel` | Capture-Button + Strg/Shift/Alt/Win-Checkboxen | | HID Tastatur | `_hidKeyPanel` | Capture-Button + Strg/Shift/Alt/Win-Checkboxen |
| HID Consumer | `_consumerPanel` | Dropdown mit 12 Medien-Aktionen | | HID Consumer | `_consumerPanel` | Dropdown mit 12 Medien-Aktionen |
| Host Command | `_cmdPanel` | TextBox für numerische Command-ID | | Host Command | `_cmdPanel` | TextBox für numerische Command-ID |
| Makro | `_macroPanel` | 4 Step-Buttons + je Strg/Shift/Alt-Checkboxen | | 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 | | Keine | — | Alle Panels ausgeblendet |
LED-Panels (`_colorPanel`, `_animPanel`) erscheinen zusätzlich wenn `showColor=true` (nur MX-Buttons, nicht Encoder). 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. `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) ## Tasten-Capture (HID-Modus)
1. Benutzer klickt "Taste drücken..." 1. Benutzer klickt "Taste drücken..."
@@ -41,7 +65,10 @@ WinForms behandelt Pfeil- und Enter-Tasten als "Dialog Keys" in `ProcessDialogKe
## Makro-Capture ## Makro-Capture
Jeder der 4 Steps hat einen eigenen Capture-Button. `_captureStep` (03, -1 = inaktiv) zeigt welcher Step gerade aufnimmt. Capture-Logik identisch mit HID-Modus, schreibt in `_stepKeycodes[captureStep]`. 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) ## Schlüssellookup (layout-unabhängig)
@@ -86,6 +113,14 @@ Scan-Code → HID Usage via s_scanToHid[sc]
Nur wenn `showColor=true` (MX-Buttons). Enthält: Nur wenn `showColor=true` (MX-Buttons). Enthält:
- **Farbpicker**: `ColorDialog``_colorBtn.BackColor` - **Farbpicker**: `ColorDialog``_colorBtn.BackColor`
- **Animations-Dropdown**: Statisch / Blinken / Pulsieren / Regenbogen - **Animations-Dropdown**: Statisch / Blinken / Pulsieren / Regenbogen
- **Periode-Dropdown**: Presets von "Sehr langsam (8 s)" bis "Sehr schnell (250 ms)" - **Periode-Dropdown**: 500 ms, 1 s, 2 s oder 4 s
Bei "Regenbogen" wird der Farbpicker ausgeblendet (Farbe irrelevant). 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).
+20 -3
View File
@@ -8,12 +8,20 @@ Menschenlesbares JSON mit `System.Text.Json` (`WriteIndented=true`, Enums als St
```json ```json
{ {
"version": 2, "version": 3,
"profile": 0,
"buttons": [ "buttons": [
{ {
"index": 0, "index": 0,
"action": { "type": "HidKey", "data": 260 }, "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
}
}, },
... ...
], ],
@@ -31,12 +39,16 @@ Menschenlesbares JSON mit `System.Text.Json` (`WriteIndented=true`, Enums als St
## Serialisierung ## Serialisierung
`ConfigJson.Serialize(cfg)` → JSON-String. Exportiert alle 20 Buttons + 4 Encoder vollständig. `ConfigJson.Serialize(cfg)` → JSON-String. Exportiert alle 20 Buttons und vier
Encoder des aktuell aktiven Profils vollständig, einschließlich
Per-LED-Helligkeit.
## Deserialisierung ## Deserialisierung
`ConfigJson.Deserialize(json, cfg)`: `ConfigJson.Deserialize(json, cfg)`:
- Prüft `version` wirft `InvalidDataException` bei Mismatch - 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`) - Schreibt in bestehendes `DeviceConfig`-Objekt (kein `new`)
- Fehlende `buttons`/`encoders`-Arrays werden ignoriert (partial import möglich) - Fehlende `buttons`/`encoders`-Arrays werden ignoriert (partial import möglich)
- Ungültige `index`-Werte werden übersprungen - Ungültige `index`-Werte werden übersprungen
@@ -44,5 +56,10 @@ Menschenlesbares JSON mit `System.Text.Json` (`WriteIndented=true`, Enums als St
## Anmerkungen ## Anmerkungen
- `MacroTable` wird **nicht** exportiert (kein JSON-Format für Makros definiert) - `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)`) - `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 - 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.
+23 -19
View File
@@ -1,25 +1,29 @@
# VersaGUI Dokumentations-Index # VersaGUI - Dokumentationsindex
Jede Datei deckt eine GUI-Komponente ab. Für Claude: die relevante(n) Dateien zu Beginn einer Aufgabe lesen statt die gesamten Quelldateien zu scannen. 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 | | Datei | Inhalt |
|---|---| |---|---|
| [00_architecture.md](00_architecture.md) | Threading-Modell, Datenfluss, Verbindungslebenszyklus, globale Constraints (DTR, IOException, Packed-Layout) | | [00_architecture.md](00_architecture.md) | Threading-Modell, Datenfluss, Verbindungslebenszyklus |
| [01_serial_manager.md](01_serial_manager.md) | WMI-Erkennung, TryConnect, ReadLoop, Sende-Methoden, Reconnect-Backoff | | [01_serial_manager.md](01_serial_manager.md) | Port-Erkennung, Connect, ReadLoop, Sende-Pfad |
| [02_device_config.md](02_device_config.md) | DeviceConfig + MacroTable: Felder, Byte-Layout (223/256 B), CRC16, LedAnimType | | [02_device_config.md](02_device_config.md) | `DeviceConfig`, `MacroTable`, aktuelles Byte-Layout, CRC |
| [03_tray_app.md](03_tray_app.md) | ApplicationContext, Tray-Icon, Board-Event-Routing, Config/Makro-Dump-Empfang, TODOs | | [03_tray_app.md](03_tray_app.md) | TrayApp, Packet-Routing, Dump-Empfang, UI-Zustand |
| [04_config_form.md](04_config_form.md) | Grid-Layout, mx_idx-Formel, RefreshMxButton, OnSave (Task.Run), Import/Export | | [04_config_form.md](04_config_form.md) | Grid, Speichern, Import/Export |
| [05_action_dialog.md](05_action_dialog.md) | Panels je Typ, ProcessCmdKey-Capture, layout-unabhängiger Scan-Code-Lookup, Consumer-Liste | | [05_action_dialog.md](05_action_dialog.md) | Key-Capture, Action-Auswahl, LED-Einstellungen |
| [06_config_json.md](06_config_json.md) | JSON-Format, Serialize/Deserialize, Einschränkungen | | [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 |
## Schnell-Referenz: Was steht wo? ## Schnellreferenz
- **DtrEnable-Problem** → [00_architecture.md](00_architecture.md), [01_serial_manager.md](01_serial_manager.md) - aktuelles Config-Layout `740` Byte -> [02_device_config.md](02_device_config.md)
- **IOException ≠ Disconnect (.NET 7)** → [00_architecture.md](00_architecture.md), [01_serial_manager.md](01_serial_manager.md) - aktuelle Makrotabelle `512` Byte -> [02_device_config.md](02_device_config.md)
- **Byte-Layout der 223-Byte-Config** → [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)
- **Warum ProcessCmdKey statt OnKeyDown?** → [05_action_dialog.md](05_action_dialog.md) - Makro-Slot-Konvention -> [02_device_config.md](02_device_config.md)
- **Warum Scan-Codes statt VK-Codes (Umlaut-Problem)?** → [05_action_dialog.md](05_action_dialog.md) - Host-Command-Status -> [03_tray_app.md](03_tray_app.md)
- **mx_idx ↔ key_id-Umrechnung** → [04_config_form.md](04_config_form.md) - bekannte Einschränkungen -> [07_known_limitations.md](07_known_limitations.md)
- **Makro-Slot-Konvention** → [02_device_config.md](02_device_config.md) - automatisierte Binär-/Transferverträge ->
- **HOST_COMMAND noch nicht implementiert** → [03_tray_app.md](03_tray_app.md) [`../tests/VersaGUI.ContractTests/`](../tests/VersaGUI.ContractTests/)
- **Task.Run beim Speichern** → [04_config_form.md](04_config_form.md)
+52 -19
View File
@@ -5,9 +5,12 @@
// HID Tastatur → Capture-Button (Klicken + Taste drücken) + Modifier-Checkboxen // HID Tastatur → Capture-Button (Klicken + Taste drücken) + Modifier-Checkboxen
// HID Consumer → Dropdown mit benannten Medien-/Lautstärke-Aktionen // HID Consumer → Dropdown mit benannten Medien-/Lautstärke-Aktionen
// Host Command → Zahlen-Eingabe (Command-ID) // Host Command → Zahlen-Eingabe (Command-ID)
// Makro → acht HID-Key-Steps
// Profilwechsel → Zielprofil oder zyklisch nächstes Profil
// Keine Aktion → nichts // 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; namespace VersaGUI;
@@ -35,6 +38,10 @@ public class ActionDialog : Form
private readonly Panel _cmdPanel; private readonly Panel _cmdPanel;
private readonly TextBox _cmdBox; private readonly TextBox _cmdBox;
// Profil wechseln
private readonly Panel _profilePanel;
private readonly ComboBox _profileCombo;
// LED-Farbe // LED-Farbe
private readonly Panel _colorPanel; private readonly Panel _colorPanel;
private readonly Button _colorBtn; private readonly Button _colorBtn;
@@ -50,11 +57,11 @@ public class ActionDialog : Form
private readonly MacroTable _macros; private readonly MacroTable _macros;
private readonly int _macroSlot; private readonly int _macroSlot;
// Je Step: [CaptureBtn, CheckCtrl, CheckShift, CheckAlt] // Je Step: [CaptureBtn, CheckCtrl, CheckShift, CheckAlt]
private readonly Button[] _stepBtns = new Button[4]; private readonly Button[] _stepBtns = new Button[8];
private readonly CheckBox[] _stepCtrl = new CheckBox[4]; private readonly CheckBox[] _stepCtrl = new CheckBox[8];
private readonly CheckBox[] _stepShift = new CheckBox[4]; private readonly CheckBox[] _stepShift = new CheckBox[8];
private readonly CheckBox[] _stepAlt = new CheckBox[4]; private readonly CheckBox[] _stepAlt = new CheckBox[8];
private readonly byte[] _stepKeycodes = new byte[4]; private readonly byte[] _stepKeycodes = new byte[8];
private int _captureStep = -1; // -1 = nicht im Capture-Modus private int _captureStep = -1; // -1 = nicht im Capture-Modus
// Capture-Zustand // Capture-Zustand
@@ -220,7 +227,7 @@ public class ActionDialog : Form
_showColor = showColor; _showColor = showColor;
// Bestehende Makro-Steps aus der Tabelle laden // 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; _stepKeycodes[i] = _macros.Steps[_macroSlot, i].Keycode;
// Formular-Grundeinstellungen // Formular-Grundeinstellungen
@@ -240,7 +247,7 @@ public class ActionDialog : Form
DropDownStyle = ComboBoxStyle.DropDownList, DropDownStyle = ComboBoxStyle.DropDownList,
}; };
_typeCombo.Items.AddRange(new object[] _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.SelectedIndex = (int)action.Type;
_typeCombo.SelectedIndexChanged += OnTypeChanged; _typeCombo.SelectedIndexChanged += OnTypeChanged;
@@ -320,6 +327,24 @@ public class ActionDialog : Form
}; };
_cmdPanel.Controls.AddRange(new Control[] { cmdLabel, _cmdBox }); _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 ──────────────────────────────────────────────────────── // ── Farb-Panel ────────────────────────────────────────────────────────
_colorPanel = new Panel _colorPanel = new Panel
{ {
@@ -378,12 +403,12 @@ public class ActionDialog : Form
_animPanel.Controls.AddRange(new Control[] _animPanel.Controls.AddRange(new Control[]
{ animLabel, _animCombo, periodLabel, _periodCombo }); { animLabel, _animCombo, periodLabel, _periodCombo });
// ── Makro-Panel (4 Steps) ───────────────────────────────────────────── // ── Makro-Panel (8 Steps) ─────────────────────────────────────────────
_macroPanel = new Panel { Location = new Point(12, 48), Size = new Size(396, 4 * 30 + 22) }; _macroPanel = new Panel { Location = new Point(12, 48), Size = new Size(396, 8 * 30 + 22) };
var macroHint = new Label 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), Location = new Point(0, 0),
Size = new Size(396, 28), Size = new Size(396, 28),
ForeColor = SystemColors.GrayText, ForeColor = SystemColors.GrayText,
@@ -391,7 +416,7 @@ public class ActionDialog : Form
}; };
_macroPanel.Controls.Add(macroHint); _macroPanel.Controls.Add(macroHint);
for (int step = 0; step < 4; step++) for (int step = 0; step < 8; step++)
{ {
int s = step; int s = step;
int y2 = 30 + step * 30; int y2 = 30 + step * 30;
@@ -454,7 +479,7 @@ public class ActionDialog : Form
Controls.AddRange(new Control[] Controls.AddRange(new Control[]
{ {
typeLabel, _typeCombo, typeLabel, _typeCombo,
_hidKeyPanel, _consumerPanel, _cmdPanel, _macroPanel, _hidKeyPanel, _consumerPanel, _cmdPanel, _profilePanel, _macroPanel,
_colorPanel, _animPanel, _colorPanel, _animPanel,
_okBtn, _cancelBtn, _okBtn, _cancelBtn,
}); });
@@ -481,6 +506,7 @@ public class ActionDialog : Form
_hidKeyPanel.Visible = t == ActionType.HidKey; _hidKeyPanel.Visible = t == ActionType.HidKey;
_consumerPanel.Visible = t == ActionType.HidConsumer; _consumerPanel.Visible = t == ActionType.HidConsumer;
_cmdPanel.Visible = t == ActionType.HostCommand; _cmdPanel.Visible = t == ActionType.HostCommand;
_profilePanel.Visible = t == ActionType.ProfileSwitch;
_macroPanel.Visible = t == ActionType.Macro; _macroPanel.Visible = t == ActionType.Macro;
_colorPanel.Visible = _showColor; _colorPanel.Visible = _showColor;
_animPanel.Visible = _showColor; _animPanel.Visible = _showColor;
@@ -496,11 +522,12 @@ public class ActionDialog : Form
int contentH = t switch int contentH = t switch
{ {
ActionType.HidKey => 110, ActionType.HidKey => 110,
ActionType.HidConsumer => 30, ActionType.HidConsumer => 30,
ActionType.HostCommand => 30, ActionType.HostCommand => 30,
ActionType.Macro => 4 * 30 + 22, ActionType.ProfileSwitch => 30,
_ => 0, ActionType.Macro => 8 * 30 + 22,
_ => 0,
}; };
int ledY = 48 + contentH + 10; int ledY = 48 + contentH + 10;
@@ -710,12 +737,18 @@ public class ActionDialog : Form
return; return;
} }
break; 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) if (type == ActionType.Macro)
{ {
// Steps in die Makro-Tabelle schreiben // Steps in die Makro-Tabelle schreiben
for (int i = 0; i < 4; i++) for (int i = 0; i < 8; i++)
{ {
byte mod = 0; byte mod = 0;
if (_stepCtrl[i].Checked) mod |= 0x01; 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;
}
}
+60 -12
View File
@@ -2,6 +2,7 @@
// Hauptfenster der VersaPad-Konfiguration. // Hauptfenster der VersaPad-Konfiguration.
// //
// Layout: // Layout:
// Profil-Auswahl (Profil 13)
// ┌── Tasten (4 × 5 Grid) ──────────────────────────────────────────────┐ // ┌── Tasten (4 × 5 Grid) ──────────────────────────────────────────────┐
// │ Jede Taste = Button mit Hintergrundfarbe (= LED-Base) und │ // │ Jede Taste = Button mit Hintergrundfarbe (= LED-Base) und │
// │ Aktionsbeschriftung. Klick öffnet ActionDialog. │ // │ Aktionsbeschriftung. Klick öffnet ActionDialog. │
@@ -9,11 +10,10 @@
// ┌── Encoder ─────────────────────────────────────────────────────────┐ // ┌── Encoder ─────────────────────────────────────────────────────────┐
// │ 4 Zeilen à [SW][CW][CCW] ohne LED-Farbe │ // │ 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. // "Auf Board speichern" sendet Config und Makros in 6-Byte-Chunks über CDC.
// Firmware-Seite (WRITE_CONFIG) ist noch TODO → Schaltfläche ist // Die Schaltfläche ist deaktiviert, wenn das Board nicht verbunden ist.
// deaktiviert wenn das Board nicht verbunden ist.
namespace VersaGUI; namespace VersaGUI;
@@ -26,6 +26,7 @@ public class ConfigForm : Form
private readonly Button[] _btnGrid = new Button[20]; private readonly Button[] _btnGrid = new Button[20];
private readonly Button[,] _encBtns = new Button[4, 3]; private readonly Button[,] _encBtns = new Button[4, 3];
private Button _saveBtn = null!; private Button _saveBtn = null!;
private ComboBox _profileCombo = null!;
public ConfigForm(DeviceConfig config, MacroTable macros, SerialManager serial) public ConfigForm(DeviceConfig config, MacroTable macros, SerialManager serial)
{ {
@@ -40,6 +41,7 @@ public class ConfigForm : Form
ClientSize = new Size(520, 480); ClientSize = new Size(520, 480);
int y = 12; int y = 12;
y = BuildProfileBar(y);
y = BuildButtonGrid(y); y = BuildButtonGrid(y);
y = BuildEncoderPanel(y); y = BuildEncoderPanel(y);
BuildFooter(y); BuildFooter(y);
@@ -50,6 +52,35 @@ public class ConfigForm : Form
_saveBtn.Enabled = _serial.IsConnected; _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) ─────────────────────────────────── // ── Tasten-Grid (4 Spalten × 5 Zeilen) ───────────────────────────────────
private int BuildButtonGrid(int startY) private int BuildButtonGrid(int startY)
@@ -257,16 +288,28 @@ public class ConfigForm : Form
_saveBtn.Enabled = false; _saveBtn.Enabled = false;
_saveBtn.Text = "Wird gesendet..."; _saveBtn.Text = "Wird gesendet...";
// SendConfig + SendMacros blockieren ~400ms → Background-Thread // SendConfig + SendMacros warten auf ACK/NACK → Background-Thread
Task.Run(() => Task.Run(() =>
{ {
_serial.SendConfig(_config); bool cfgOk = _serial.SendConfig(_config); // wartet intern auf CONFIG_ACK/NACK
Thread.Sleep(50); // kurze Pause zwischen Config- und Makro-Transfer bool macroOk = _serial.SendMacros(_macros); // wartet intern auf MACRO_ACK/NACK
_serial.SendMacros(_macros);
InvokeOnUi(() => InvokeOnUi(() =>
{ {
_saveBtn.Text = "Auf Board speichern"; _saveBtn.Text = "Auf Board speichern";
_saveBtn.Enabled = _serial.IsConnected; _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) // Alle Buttons neu zeichnen (z.B. nach Config-Laden vom Board)
public void RefreshAll() public void RefreshAll()
{ {
if (_profileCombo.SelectedIndex != _config.ActiveProfileIndex)
_profileCombo.SelectedIndex = _config.ActiveProfileIndex;
for (int i = 0; i < 20; i++) RefreshMxButton(i); for (int i = 0; i < 20; i++) RefreshMxButton(i);
for (int enc = 0; enc < 4; enc++) for (int enc = 0; enc < 4; enc++)
for (int act = 0; act < 3; act++) for (int act = 0; act < 3; act++)
@@ -390,9 +436,11 @@ internal static class Extensions
public static string TypeDescription(this DeviceAction a) => a.Type switch public static string TypeDescription(this DeviceAction a) => a.Type switch
{ {
ActionType.HidKey => "HID Tastatur-Keycode", ActionType.HidKey => "HID Tastatur-Keycode",
ActionType.HidConsumer => "HID Consumer Control (Media/Volume)", ActionType.HidConsumer => "HID Consumer Control (Media/Volume)",
ActionType.HostCommand => "Host Command App führt aus", ActionType.HostCommand => "Host Command-ID für Desktop-Mapping",
_ => "Keine Aktion", ActionType.Macro => "Makro-Sequenz",
ActionType.ProfileSwitch => "Profil wechseln",
_ => "Keine Aktion",
}; };
} }
+72 -18
View File
@@ -1,12 +1,14 @@
// ConfigJson.cs // ConfigJson.cs
// JSON-Import/Export für DeviceConfig. // JSON-Import/Export für DeviceConfig.
// //
// Format (menschenlesbar, kommentiert mit Feldnamen): // Format (menschenlesbar):
// { // {
// "version": 2, // "version": 3,
// "profile": 0,
// "buttons": [ // "buttons": [
// { "index": 0, "action": { "type": "HidKey", "data": 260 }, // { "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": [ // "encoders": [
@@ -17,6 +19,8 @@
// ... // ...
// ] // ]
// } // }
//
// Import/Export bezieht sich immer auf das aktive Profil.
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
@@ -37,6 +41,7 @@ internal static class ConfigJson
var doc = new ConfigJsonDoc var doc = new ConfigJsonDoc
{ {
Version = DeviceConfig.Version, Version = DeviceConfig.Version,
Profile = cfg.ActiveProfileIndex,
Buttons = new ButtonEntry[20], Buttons = new ButtonEntry[20],
Encoders = new EncoderEntry[4], Encoders = new EncoderEntry[4],
}; };
@@ -49,11 +54,12 @@ internal static class ConfigJson
Action = ActionToJson(cfg.MxActions[i]), Action = ActionToJson(cfg.MxActions[i]),
Led = new LedEntry Led = new LedEntry
{ {
R = cfg.LedBase[i].R, R = cfg.LedBase[i].R,
G = cfg.LedBase[i].G, G = cfg.LedBase[i].G,
B = cfg.LedBase[i].B, B = cfg.LedBase[i].B,
Anim = cfg.LedAnim[i], Brightness = cfg.LedBrightness[i],
PeriodMs = cfg.LedPeriod[i], Anim = cfg.LedAnim[i],
PeriodMs = cfg.LedPeriod[i],
}, },
}; };
} }
@@ -80,6 +86,11 @@ internal static class ConfigJson
if (doc.Version != DeviceConfig.Version) if (doc.Version != DeviceConfig.Version)
throw new InvalidDataException( throw new InvalidDataException(
$"Config-Version {doc.Version} wird nicht unterstützt (erwartet {DeviceConfig.Version})."); $"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) if (doc.Buttons != null)
{ {
@@ -89,9 +100,10 @@ internal static class ConfigJson
ActionFromJson(b.Action, cfg.MxActions[b.Index]); ActionFromJson(b.Action, cfg.MxActions[b.Index]);
if (b.Led != null) if (b.Led != null)
{ {
cfg.LedBase[b.Index] = Color.FromArgb(b.Led.R, b.Led.G, b.Led.B); cfg.LedBase[b.Index] = Color.FromArgb(b.Led.R, b.Led.G, b.Led.B);
cfg.LedAnim[b.Index] = b.Led.Anim; cfg.LedBrightness[b.Index] = b.Led.Brightness;
cfg.LedPeriod[b.Index] = b.Led.PeriodMs; cfg.LedAnim[b.Index] = b.Led.Anim;
cfg.LedPeriod[b.Index] = b.Led.PeriodMs;
} }
} }
} }
@@ -120,12 +132,53 @@ internal static class ConfigJson
dst.Data = src.Data; 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 ───────────────────────────────────────────────────── // ── JSON-Datenklassen ─────────────────────────────────────────────────────
private class ConfigJsonDoc private class ConfigJsonDoc
{ {
[JsonPropertyName("version")] public byte Version { get; set; } [JsonPropertyName("version")] public byte Version { get; set; }
[JsonPropertyName("buttons")] public ButtonEntry[]? Buttons { get; set; } [JsonPropertyName("profile")] public byte Profile { get; set; }
[JsonPropertyName("buttons")] public ButtonEntry[]? Buttons { get; set; }
[JsonPropertyName("encoders")] public EncoderEntry[]? Encoders { get; set; } [JsonPropertyName("encoders")] public EncoderEntry[]? Encoders { get; set; }
} }
@@ -152,10 +205,11 @@ internal static class ConfigJson
private class LedEntry private class LedEntry
{ {
[JsonPropertyName("r")] public byte R { get; set; } [JsonPropertyName("r")] public byte R { get; set; }
[JsonPropertyName("g")] public byte G { get; set; } [JsonPropertyName("g")] public byte G { get; set; }
[JsonPropertyName("b")] public byte B { get; set; } [JsonPropertyName("b")] public byte B { get; set; }
[JsonPropertyName("anim")] public LedAnimType Anim { get; set; } [JsonPropertyName("brightness")] public byte Brightness { get; set; } = 255;
[JsonPropertyName("period_ms")] public ushort PeriodMs { get; set; } [JsonPropertyName("anim")] public LedAnimType Anim { get; set; }
[JsonPropertyName("period_ms")] public ushort PeriodMs { get; set; }
} }
} }
+288 -94
View File
@@ -1,17 +1,32 @@
// DeviceConfig.cs // 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): // Serialisiertes Config-Layout (740 B, packed):
// Offset 0 4B Magic 0x56503202 // ── Globaler Header (32B) ──────────────────────────────────────────────────
// Offset 4 1B Version 2 // Offset 0 4B Magic 0x56503203
// Offset 5 2B CRC16 (CCITT über Bytes 7222) // Offset 4 1B Version = 3
// Offset 7 60B mx_actions[20] je 3B: type(1) + data_lo(1) + data_hi(1) // Offset 5 2B CRC16-CCITT über Bytes 7739
// Offset 67 36B enc_actions[4][3] je 3B // Offset 7 1B ActiveProfile (02)
// Offset103 20B led_r[20] // Offset 8 1B GlobalBrightness (0255)
// Offset123 20B led_g[20] // Offset 9 4B EncSensitivity[4]
// Offset143 20B led_b[20] // Offset 13 19B Reserve
// Offset163 20B led_anim[20] je 1B (LedAnimType) // ── Profil 0 (236B, ab Offset 32) ─────────────────────────────────────────
// Offset183 40B led_period_ms[20] je 2B (uint16, little-endian) // ── 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; using System.Drawing;
@@ -19,11 +34,12 @@ namespace VersaGUI;
public enum ActionType : byte public enum ActionType : byte
{ {
None = 0, None = 0,
HidKey = 1, // data = Keycode (Low-Byte) + Modifier (High-Byte) HidKey = 1, // data = Keycode (Low-Byte) + Modifier (High-Byte)
HidConsumer = 2, // data = HID Consumer Usage ID 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) 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) // 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 }; 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 class MacroTable
{ {
public const int Slots = 32; public const int Slots = 32;
public const int MaxSteps = 4; public const int MaxSteps = 8;
public const int Size = Slots * MaxSteps * 2;
// [slot][step] // [slot][step]
public MacroStep[,] Steps { get; } = new MacroStep[Slots, MaxSteps]; public MacroStep[,] Steps { get; } = new MacroStep[Slots, MaxSteps];
@@ -53,13 +70,17 @@ public class MacroTable
} }
// Slot-Index für MX-Buttons und Encoder-Aktionen // Slot-Index für MX-Buttons und Encoder-Aktionen
public static int SlotForMx(int mxIdx) => mxIdx; // 019 public static int SlotForMx(int mxIdx) => mxIdx;
public static int SlotForEncoder(int enc, int actIdx) => 20 + enc * 3 + actIdx; // 2031 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() 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; int pos = 0;
for (int s = 0; s < Slots; s++) for (int s = 0; s < Slots; s++)
for (int i = 0; i < MaxSteps; i++) for (int i = 0; i < MaxSteps; i++)
@@ -70,9 +91,11 @@ public class MacroTable
return buf; 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; int pos = 0;
for (int s = 0; s < Slots; s++) for (int s = 0; s < Slots; s++)
for (int i = 0; i < MaxSteps; i++) for (int i = 0; i < MaxSteps; i++)
@@ -80,16 +103,37 @@ public class MacroTable
Steps[s, i].Keycode = buf[pos++]; Steps[s, i].Keycode = buf[pos++];
Steps[s, i].Modifier = 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 public enum LedAnimType : byte
{ {
Static = 0, Static = 0,
Blink = 1, Blink = 1,
Pulse = 2, Pulse = 2,
ColorCycle = 5, // Regenbogen (entspricht LEDAnim::COLOR_CYCLE in Firmware) FadeIn = 3,
FadeOut = 4,
ColorCycle = 5,
ColorFade = 6,
} }
public class DeviceAction public class DeviceAction
@@ -130,6 +174,10 @@ public class DeviceAction
} }
if (Type == ActionType.Macro) if (Type == ActionType.Macro)
return $"Makro {Data}"; return $"Makro {Data}";
if (Type == ActionType.ProfileSwitch)
return Data is 0x00FF or 0xFFFF
? "→ Nächstes Profil"
: $"→ Profil {Data + 1}";
return $"CMD {Data}"; return $"CMD {Data}";
} }
} }
@@ -152,31 +200,19 @@ public class DeviceAction
public DeviceAction Clone() => new() { Type = Type, Data = Data }; 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; } = public DeviceAction[] MxActions { get; } =
Enumerable.Range(0, 20).Select(_ => new DeviceAction()).ToArray(); Enumerable.Range(0, 20).Select(_ => new DeviceAction()).ToArray();
public DeviceAction[,] EncActions { get; } = InitEncActions(); 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();
public Color[] LedBase { get; } = public byte[] LedBrightness { get; } = Enumerable.Repeat((byte)255, 20).ToArray();
Enumerable.Repeat(Color.FromArgb(80, 40, 0), 20).ToArray(); public LedAnimType[] LedAnim { get; } = Enumerable.Repeat(LedAnimType.ColorCycle, 20).ToArray();
public ushort[] LedPeriod { get; } = Enumerable.Repeat((ushort)4000, 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() private static DeviceAction[,] InitEncActions()
{ {
@@ -187,79 +223,43 @@ public class DeviceConfig
return a; return a;
} }
// ── Serialisierung ──────────────────────────────────────────────────────── // Serialisiert ein Profil in 236 Bytes
public void ToBytes(byte[] buf, int offset)
public byte[] ToBytes()
{ {
const int size = 223; int pos = offset;
var buf = new byte[size]; foreach (var a in MxActions) WriteAction(buf, ref pos, a);
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);
for (int e = 0; e < 4; e++) for (int e = 0; e < 4; e++)
for (int i = 0; i < 3; i++) for (int i = 0; i < 3; i++) WriteAction(buf, ref pos, EncActions[e, 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].R;
for (int i = 0; i < 20; i++) buf[pos++] = LedBase[i].G; 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++] = 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)LedAnim[i];
for (int i = 0; i < 20; i++) for (int i = 0; i < 20; i++)
{ {
buf[pos++] = (byte)(LedPeriod[i] & 0xFF); buf[pos++] = (byte)(LedPeriod[i] & 0xFF);
buf[pos++] = (byte)(LedPeriod[i] >> 8); 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; int pos = offset;
for (int i = 0; i < 20; i++) ReadAction(buf, ref pos, MxActions[i]);
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;
for (int i = 0; i < 20; i++) ReadAction(buf, ref pos, MxActions[i]);
for (int e = 0; e < 4; e++) for (int e = 0; e < 4; e++)
for (int i = 0; i < 3; i++) for (int i = 0; i < 3; i++) ReadAction(buf, ref pos, EncActions[e, 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]); for (int i = 0; i < 20; i++) LedBase[i] = Color.FromArgb(buf[pos + i], buf[pos + 20 + i], buf[pos + 40 + i]);
pos += 60; 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++) LedAnim[i] = (LedAnimType)buf[pos++];
for (int i = 0; i < 20; i++) for (int i = 0; i < 20; i++)
{ {
LedPeriod[i] = (ushort)(buf[pos] | (buf[pos + 1] << 8)); LedPeriod[i] = (ushort)(buf[pos] | (buf[pos + 1] << 8));
pos += 2; pos += 2;
} }
return true;
} }
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static void WriteAction(byte[] buf, ref int pos, DeviceAction a) private static void WriteAction(byte[] buf, ref int pos, DeviceAction a)
{ {
buf[pos++] = (byte)a.Type; buf[pos++] = (byte)a.Type;
@@ -273,6 +273,200 @@ public class DeviceConfig
a.Data = (ushort)(buf[pos] | (buf[pos + 1] << 8)); a.Data = (ushort)(buf[pos] | (buf[pos + 1] << 8));
pos += 2; 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) private static void WriteU32(byte[] buf, ref int pos, uint v)
{ {
+13 -9
View File
@@ -5,16 +5,18 @@
// Alle Pakete: 8 Bytes fest // Alle Pakete: 8 Bytes fest
// [0] Command/Event-ID // [0] Command/Event-ID
// [1] key_id (Button 024 oder Encoder 03) // [1] key_id (Button 024 oder Encoder 03)
// [2] r / Daten-Byte A // [2..7] kommandospezifische Nutzdaten
// [3] g / Daten-Byte B
// [4] b
// [5..7] reserviert (0x00)
namespace VersaGUI; namespace VersaGUI;
public static class Protocol public static class Protocol
{ {
public const int PacketSize = 8; 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) ──────────────────────────────────── // ── Commands: App → Board (0x010x7F) ────────────────────────────────────
public const byte CmdSetLedOverride = 0x01; // key_id, r, g, b public const byte CmdSetLedOverride = 0x01; // key_id, r, g, b
@@ -25,7 +27,6 @@ public static class Protocol
// CmdConfigBegin: Data[1] = Anzahl Chunks // CmdConfigBegin: Data[1] = Anzahl Chunks
// CmdConfigData: Data[1] = Chunk-Index (0-based), Data[2..7] = 6 Bytes Nutzdaten // CmdConfigData: Data[1] = Chunk-Index (0-based), Data[2..7] = 6 Bytes Nutzdaten
// CmdConfigCommit: Board schreibt empfangene Daten in NVM // 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 CmdPing = 0x05; // Board antwortet mit EvtPong
public const byte CmdConfigBegin = 0x10; public const byte CmdConfigBegin = 0x10;
public const byte CmdConfigData = 0x11; 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 public const byte CmdMacroRead = 0x23; // Board sendet aktuelle Makro-Tabelle zurück
// ── Events: Board → App (0x810xFF) ────────────────────────────────────── // ── Events: Board → App (0x810xFF) ──────────────────────────────────────
public const byte EvtKeyDown = 0x81; // key_id → HOST_COMMAND-Button gedrückt // Host-Events übertragen die Command-ID little-endian in Data[2..3].
public const byte EvtKeyUp = 0x82; // key_id → HOST_COMMAND-Button losgelassen public const byte EvtKeyDown = 0x81; // Host-Action: key_id → gedrückt
public const byte EvtEncCw = 0x83; // key_id → Encoder Schritt CW public const byte EvtKeyUp = 0x82; // Host-Action: key_id → losgelassen
public const byte EvtEncCcw = 0x84; // key_id → Encoder Schritt CCW 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 EvtPong = 0x85; // Antwort auf CmdPing
public const byte EvtConfigAck = 0x90; // Config erfolgreich in NVM geschrieben public const byte EvtConfigAck = 0x90; // Config erfolgreich in NVM geschrieben
public const byte EvtConfigNack = 0x91; // Config CRC/Magic ungültig 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 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 EvtMacroData = 0x97; // Makro-Chunk (Data[1] = Index, Data[2..7] = 6B)
public const byte EvtMacroEnd = 0x98; // Makro-Dump vollständig 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. // Ein 8-Byte-Paket mit benannten Accessoren.
@@ -63,6 +66,7 @@ public class SerialPacket
public byte R => Data[2]; public byte R => Data[2];
public byte G => Data[3]; public byte G => Data[3];
public byte B => Data[4]; public byte B => Data[4];
public ushort DataU16 => (ushort)(Data[2] | (Data[3] << 8));
// Leeres Paket (für eingehende Daten) // Leeres Paket (für eingehende Daten)
public SerialPacket() { } public SerialPacket() { }
+179 -72
View File
@@ -7,7 +7,7 @@
// //
// Threading: // Threading:
// - _readThread: Hintergrund-Thread, liest eingehende Bytes und baut Pakete zusammen. // - _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), // - Alle Events werden auf dem UI-Thread gefeuert (via SynchronizationContext),
// damit TrayApp direkt NotifyIcon und Menü aktualisieren kann. // damit TrayApp direkt NotifyIcon und Menü aktualisieren kann.
// //
@@ -36,8 +36,18 @@ public class SerialManager : IDisposable
private System.Threading.Timer? _reconnectTimer; private System.Threading.Timer? _reconnectTimer;
private readonly SynchronizationContext _ui; private readonly SynchronizationContext _ui;
private readonly object _connectLock = new(); // Verhindert parallele TryConnect()-Aufrufe 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 _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; public bool IsConnected => _port?.IsOpen == true;
@@ -61,34 +71,43 @@ public class SerialManager : IDisposable
if (!Monitor.TryEnter(_connectLock)) return; if (!Monitor.TryEnter(_connectLock)) return;
try try
{ {
if (IsConnected || _waitingAfterDisconnect) return; if (_disposed || IsConnected || _waitingAfterDisconnect) return;
string? portName = FindPort(); string? portName = FindPort();
if (portName is null) return; if (portName is null) return;
var port = new SerialPort(portName, 115200) SerialPort? port = null;
try
{ {
ReadTimeout = 500, port = new SerialPort(portName, 115200)
WriteTimeout = 500, {
DtrEnable = true, // Muss VOR Open() gesetzt werden SAMD21 prüft DTR ReadTimeout = 500,
// in SerialUSB-bool für usb_serial_send(). Default ist WriteTimeout = 500,
// false, was alle Board→PC-Antworten still verwirft. DtrEnable = true,
// Vor Open() gesetzt verursacht es keinen Zustandswechsel };
// und triggert keinen CDC-Disconnect auf dem Board.
};
port.Open(); port.Open();
// Kurz warten bis USB/CDC sich nach der Verbindungsherstellung stabilisiert // Kurz warten bis USB/CDC sich nach der Verbindungsherstellung stabilisiert
Thread.Sleep(200); Thread.Sleep(200);
_port = port; _port = port;
// Lesethread starten // Lesethread starten
_readThread = new Thread(ReadLoop) { IsBackground = true, Name = "VersaPad-Read" }; _readThread = new Thread(ReadLoop)
_readThread.Start(); {
IsBackground = true,
Name = "VersaPad-Read",
};
_readThread.Start();
_ui.Post(_ => Connected?.Invoke(this, EventArgs.Empty), null); _ui.Post(_ => Connected?.Invoke(this, EventArgs.Empty), null);
port = null; // Besitz liegt ab hier bei _port/ReadLoop.
}
finally
{
port?.Dispose();
}
} }
catch catch
{ {
@@ -103,10 +122,16 @@ public class SerialManager : IDisposable
public void Disconnect() public void Disconnect()
{ {
var port = _port; SerialPort? port = Interlocked.Exchange(ref _port, null);
_port = null;
try { port?.Close(); } catch { } try { port?.Close(); } catch { }
port?.Dispose(); port?.Dispose();
_configAckOk = false;
_macroAckOk = false;
_configAckExpected = false;
_macroAckExpected = false;
ReleaseGate(_configAckGate);
ReleaseGate(_macroAckGate);
} }
// ── COM-Port-Erkennung per WMI ──────────────────────────────────────────── // ── COM-Port-Erkennung per WMI ────────────────────────────────────────────
@@ -195,11 +220,22 @@ public class SerialManager : IDisposable
// ── Senden ─────────────────────────────────────────────────────────────── // ── Senden ───────────────────────────────────────────────────────────────
public void Send(SerialPacket pkt) public bool Send(SerialPacket pkt)
{ {
if (!IsConnected) return; lock (_writeLock)
try { _port!.Write(pkt.Data, 0, Protocol.PacketSize); } {
catch { /* Sendefehler ignorieren ReadLoop erkennt Disconnect */ } SerialPort? port = _port;
if (port?.IsOpen != true) return false;
try
{
port.Write(pkt.Data, 0, Protocol.PacketSize);
return true;
}
catch
{
return false;
}
}
} }
// ── Hilfsmethoden ──────────────────────────────────────────────────────── // ── Hilfsmethoden ────────────────────────────────────────────────────────
@@ -221,64 +257,135 @@ public class SerialManager : IDisposable
public void RequestMacros() public void RequestMacros()
=> Send(new SerialPacket(Protocol.CmdMacroRead)); => Send(new SerialPacket(Protocol.CmdMacroRead));
// Makro-Tabelle (256 Bytes) in 6-Byte-Chunks senden. // Wird von TrayApp aufgerufen wenn das Board CONFIG_ACK/NACK oder MACRO_ACK/NACK sendet.
public void SendMacros(MacroTable macros) // Gibt den wartenden SendConfig()/SendMacros()-Thread frei.
public void SignalConfigAck()
{ {
byte[] data = macros.ToBytes(); if (!_configAckExpected) return;
const int payload = 6; _configAckOk = true;
int chunks = (data.Length + payload - 1) / payload; // 43 ReleaseGate(_configAckGate);
}
Send(new SerialPacket(Protocol.CmdMacroBegin, (byte)chunks)); public void SignalConfigNack()
Thread.Sleep(10); {
if (!_configAckExpected) return;
_configAckOk = false;
ReleaseGate(_configAckGate);
}
for (int i = 0; i < chunks; i++) 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)
{ {
var pkt = new SerialPacket(); if (!macros.IsValid() || !IsConnected) return false;
pkt.Data[0] = Protocol.CmdMacroData; byte[] data = macros.ToBytes();
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); ResetGate(_macroAckGate);
Send(new SerialPacket(Protocol.CmdMacroCommit)); _macroAckOk = false;
_macroAckExpected = false;
if (!Send(new SerialPacket(
Protocol.CmdMacroBegin, (byte)Protocol.MacroChunks)))
return false;
Thread.Sleep(10);
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 * Protocol.PayloadSize;
int count = Math.Min(Protocol.PayloadSize, data.Length - offset);
Buffer.BlockCopy(data, offset, pkt.Data, 2, count);
if (!Send(pkt)) return false;
Thread.Sleep(5);
}
Thread.Sleep(10);
_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. // Config in 6-Byte-Chunks an das Board senden.
// Protokoll: BEGIN → n×DATA → COMMIT // Protokoll: BEGIN → n×DATA → COMMIT
// Board schreibt nach COMMIT in den NVM (Firmware-Seite noch TODO). // Gibt true zurück wenn das Board CONFIG_ACK gesendet hat.
public void SendConfig(DeviceConfig config) public bool SendConfig(DeviceConfig config)
{ {
byte[] data = config.ToBytes(); lock (_transferLock)
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");
Send(new SerialPacket(Protocol.CmdConfigBegin, (byte)chunks));
Thread.Sleep(10);
for (int i = 0; i < chunks; i++)
{ {
var pkt = new SerialPacket(); if (!config.TryValidate(out _) || !IsConnected) return false;
pkt.Data[0] = Protocol.CmdConfigData; byte[] data = config.ToBytes();
pkt.Data[1] = (byte)i;
int offset = i * payload; ResetGate(_configAckGate);
int count = Math.Min(payload, data.Length - offset); _configAckOk = false;
Buffer.BlockCopy(data, offset, pkt.Data, 2, count); _configAckExpected = false;
Send(pkt);
Thread.Sleep(5); // Firmware-Loop Zeit geben den Puffer zu leeren if (!Send(new SerialPacket(
Protocol.CmdConfigBegin, (byte)Protocol.ConfigChunks)))
return false;
Thread.Sleep(10);
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 * Protocol.PayloadSize;
int count = Math.Min(Protocol.PayloadSize, data.Length - offset);
Buffer.BlockCopy(data, offset, pkt.Data, 2, count);
if (!Send(pkt)) return false;
Thread.Sleep(5);
}
Thread.Sleep(10);
_configAckExpected = true;
if (!Send(new SerialPacket(Protocol.CmdConfigCommit)))
{
_configAckExpected = false;
return false;
}
bool gateAcquired = _configAckGate.Wait(3000);
_configAckExpected = false;
return gateAcquired && _configAckOk;
} }
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));
} }
// ── IDisposable ─────────────────────────────────────────────────────────── // ── IDisposable ───────────────────────────────────────────────────────────
+62 -44
View File
@@ -10,8 +10,8 @@
// Beenden // Beenden
// //
// Board-Events: // Board-Events:
// KEY_DOWN / ENC_CW / ENC_CCW mit Aktion HOST_COMMAND werden hier empfangen. // KEY_DOWN/KEY_UP/ENC_CW/ENC_CCW enthalten die konfigurierte Host-Command-ID.
// Aktuell: Benachrichtigung anzeigen (HOST_COMMAND-Ausführung folgt später). // Eine konkrete Host-Aktionsausführung ist bewusst noch nicht implementiert.
namespace VersaGUI; namespace VersaGUI;
@@ -25,9 +25,12 @@ public class TrayApp : ApplicationContext
private ToolStripMenuItem _statusItem = null!; private ToolStripMenuItem _statusItem = null!;
private ConfigForm? _configForm; private ConfigForm? _configForm;
// Empfangspuffer für eingehende Dumps vom Board private readonly ChunkTransferBuffer _configRx =
private byte[]? _rxConfigBuf; new(Protocol.ConfigSize, Protocol.ConfigChunks);
private byte[]? _rxMacroBuf; private readonly ChunkTransferBuffer _macroRx =
new(Protocol.MacroSize, Protocol.MacroChunks);
private int _configReadAttempts;
private int _macroReadAttempts;
public TrayApp() public TrayApp()
{ {
@@ -92,9 +95,11 @@ public class TrayApp : ApplicationContext
_tray.Icon = SystemIcons.Information; _tray.Icon = SystemIcons.Information;
_statusItem.Text = "● Verbunden"; _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.RequestConfig();
_serial.RequestMacros();
} }
private void OnDisconnected(object? sender, EventArgs e) private void OnDisconnected(object? sender, EventArgs e)
@@ -102,24 +107,20 @@ public class TrayApp : ApplicationContext
_tray.Text = "VersaPad nicht verbunden"; _tray.Text = "VersaPad nicht verbunden";
_tray.Icon = SystemIcons.Application; _tray.Icon = SystemIcons.Application;
_statusItem.Text = "○ Nicht verbunden"; _statusItem.Text = "○ Nicht verbunden";
_configRx.Reset();
_macroRx.Reset();
} }
private void OnPacket(object? sender, SerialPacket pkt) 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) switch (pkt.Command)
{ {
case Protocol.EvtKeyDown: case Protocol.EvtKeyDown:
// TODO: HOST_COMMAND-Aktion aus Config laden und ausführen case Protocol.EvtKeyUp:
break;
case Protocol.EvtEncCw: case Protocol.EvtEncCw:
case Protocol.EvtEncCcw: 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; break;
case Protocol.EvtPong: case Protocol.EvtPong:
@@ -128,69 +129,86 @@ public class TrayApp : ApplicationContext
break; break;
case Protocol.EvtConfigAck: case Protocol.EvtConfigAck:
MessageBox.Show("Config erfolgreich gespeichert!", _serial.SignalConfigAck(); // SendConfig()-Thread freigeben
"VersaPad", MessageBoxButtons.OK, MessageBoxIcon.Information);
break; break;
case Protocol.EvtConfigNack: case Protocol.EvtConfigNack:
MessageBox.Show("Config FEHLER: CRC/Magic ungültig.\nÜbertragung fehlgeschlagen.", _serial.SignalConfigNack(); // SendConfig()-Thread freigeben (Fehler)
"VersaPad", MessageBoxButtons.OK, MessageBoxIcon.Error);
break; break;
// ── Config-Dump vom Board ───────────────────────────────────────── // ── Config-Dump vom Board ─────────────────────────────────────────
case Protocol.EvtConfigBegin: case Protocol.EvtConfigBegin:
_rxConfigBuf = new byte[223]; // sizeof(SDeviceConfig) = 223 (Version 2) _configRx.Begin(pkt.KeyId);
break; break;
case Protocol.EvtConfigData: case Protocol.EvtConfigData:
if (_rxConfigBuf != null) _configRx.Add(pkt);
{
int offset = pkt.KeyId * 6;
for (int i = 0; i < 6; i++)
if (offset + i < _rxConfigBuf.Length)
_rxConfigBuf[offset + i] = pkt.Data[2 + i];
}
break; break;
case Protocol.EvtConfigEnd: case Protocol.EvtConfigEnd:
if (_rxConfigBuf != null) if (_configRx.TryComplete(pkt.KeyId, out byte[] configData) &&
_config.FromBytes(configData))
{ {
_config.FromBytes(_rxConfigBuf);
_rxConfigBuf = null;
_configForm?.RefreshAll(); _configForm?.RefreshAll();
_macroReadAttempts = 1;
_serial.RequestMacros();
}
else if (_configReadAttempts < 2 && _serial.IsConnected)
{
_configReadAttempts++;
_serial.RequestConfig();
}
else
{
ShowTransferError("Config");
} }
break; break;
// ── Makro-Dump vom Board ────────────────────────────────────────── // ── Makro-Dump vom Board ──────────────────────────────────────────
case Protocol.EvtMacroAck: case Protocol.EvtMacroAck:
MessageBox.Show("Makros erfolgreich gespeichert!", _serial.SignalMacroAck(); // SendMacros()-Thread freigeben
"VersaPad", MessageBoxButtons.OK, MessageBoxIcon.Information); break;
case Protocol.EvtMacroNack:
_serial.SignalMacroNack(); // SendMacros()-Thread freigeben (Fehler)
break; break;
case Protocol.EvtMacroBegin: case Protocol.EvtMacroBegin:
_rxMacroBuf = new byte[256]; _macroRx.Begin(pkt.KeyId);
break; break;
case Protocol.EvtMacroData: case Protocol.EvtMacroData:
if (_rxMacroBuf != null) _macroRx.Add(pkt);
{
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; break;
case Protocol.EvtMacroEnd: case Protocol.EvtMacroEnd:
if (_rxMacroBuf != null) if (_macroRx.TryComplete(pkt.KeyId, out byte[] macroData) &&
_macros.FromBytes(macroData))
{ {
_macros.FromBytes(_rxMacroBuf); _configForm?.RefreshAll();
_rxMacroBuf = null; }
else if (_macroReadAttempts < 2 && _serial.IsConnected)
{
_macroReadAttempts++;
_serial.RequestMacros();
}
else
{
ShowTransferError("Makro-Tabelle");
} }
break; 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 ───────────────────────────────────────────────────────────── // ── Aufräumen ─────────────────────────────────────────────────────────────
protected override void Dispose(bool disposing) 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>