Harden protocol transfers and config validation
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
# 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. 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
Windows-Tray-App zur Konfiguration des VersaPad v2.
|
Windows-Tray-App zur Konfiguration des VersaPad v2.
|
||||||
Geschrieben in C# mit WinForms.
|
Geschrieben in C# mit WinForms.
|
||||||
|
|
||||||
Hinweis:
|
Diese GUI und das benachbarte Repository `../VersaMCU` sind die gemeinsam
|
||||||
Diese GUI ist weiterhin die Referenzimplementierung fuer Protokoll und Datenlayout.
|
gepflegten Referenzen für Protokoll und Datenlayout. Andere GUI-Ports gehören
|
||||||
Parallel existiert mit `VersaGUIDelphi` ein Delphi/VCL-Port unter dem Namen `VersaGUI Delphi`.
|
nicht zum aktuellen Scope.
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ Parallel existiert mit `VersaGUIDelphi` ein Delphi/VCL-Port unter dem Namen `Ver
|
|||||||
## Starten
|
## Starten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet run
|
dotnet run --project src/VersaGUI.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
## Was die App macht
|
## Was die App macht
|
||||||
@@ -25,6 +25,7 @@ dotnet run
|
|||||||
- erkennt das Board automatisch per VID/PID
|
- erkennt das Board automatisch per VID/PID
|
||||||
- oeffnet den CDC-COM-Port mit aktivem DTR
|
- oeffnet den CDC-COM-Port mit aktivem DTR
|
||||||
- liest beim Verbinden zuerst die Config und danach die Makros
|
- 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
|
- zeigt MX-Buttons und Encoder-Aktionen an
|
||||||
- schreibt Config und Makros getrennt, aber in einem UI-Vorgang auf das Board
|
- schreibt Config und Makros getrennt, aber in einem UI-Vorgang auf das Board
|
||||||
|
|
||||||
@@ -82,6 +83,9 @@ wait for ACK/NACK
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
VersaGUI/
|
VersaGUI/
|
||||||
|
|-- AGENTS.md
|
||||||
|
|-- doc/
|
||||||
|
|-- tests/VersaGUI.ContractTests/
|
||||||
`-- src/
|
`-- src/
|
||||||
|-- Program.cs
|
|-- Program.cs
|
||||||
|-- TrayApp.cs
|
|-- TrayApp.cs
|
||||||
@@ -93,11 +97,23 @@ VersaGUI/
|
|||||||
`-- Protocol.cs
|
`-- Protocol.cs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Build und Vertragsprüfungen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/VersaGUI.csproj --no-restore
|
||||||
|
dotnet run --project tests/VersaGUI.ContractTests/VersaGUI.ContractTests.csproj
|
||||||
|
```
|
||||||
|
|
||||||
## Grenzen / Status
|
## Grenzen / Status
|
||||||
|
|
||||||
- Host-Commands sind protokollseitig vorhanden, aber appseitig noch nicht voll ausgebaut
|
|
||||||
- die App ist Windows-only
|
- die App ist Windows-only
|
||||||
- fuer eine alternative GUI ist `VersaGUIDelphi` der aktuelle Delphi/VCL-Port
|
- Host-Command-Events enthalten Command-ID, Key-/Encoder-ID und Richtung; ein
|
||||||
|
sicheres Mapping auf konkrete Desktop-Aktionen ist noch nicht implementiert
|
||||||
|
|
||||||
|
## Einstieg für Coding-LLMs
|
||||||
|
|
||||||
|
Repository-Anweisungen, gemeinsame Binärverträge und Verifikation stehen in
|
||||||
|
[`AGENTS.md`](AGENTS.md).
|
||||||
|
|
||||||
## Weiterfuehrende Doku
|
## Weiterfuehrende Doku
|
||||||
|
|
||||||
|
|||||||
+17
-9
@@ -18,8 +18,9 @@
|
|||||||
| `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 (Grid + Encoder-Panel + 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)
|
||||||
@@ -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,12 @@ 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.
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ _configAckOk / _macroAckOk – volatile bool (true = ACK, false = NACK)
|
|||||||
|
|
||||||
| Methode | Rückgabe | Funktion |
|
| Methode | Rückgabe | Funktion |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Send(pkt)` | void | Rohe 8-Byte-Übertragung (fire-and-forget) |
|
| `Send(pkt)` | bool | Rohe, unter Write-Lock atomare 8-Byte-Übertragung |
|
||||||
| `SetLedOverride(keyId, r, g, b)` | void | CMD 0x01 |
|
| `SetLedOverride(keyId, r, g, b)` | void | CMD 0x01 |
|
||||||
| `ClearLedOverride(keyId)` | void | CMD 0x02 |
|
| `ClearLedOverride(keyId)` | void | CMD 0x02 |
|
||||||
| `SetLedBase(keyId, r, g, b)` | void | CMD 0x03 |
|
| `SetLedBase(keyId, r, g, b)` | void | CMD 0x03 |
|
||||||
@@ -86,6 +86,11 @@ _configAckOk / _macroAckOk – volatile bool (true = ACK, false = NACK)
|
|||||||
|
|
||||||
`SendConfig` und `SendMacros` blockieren ~1,5 s (Chunks + NVM-Zeit) → werden in `Task.Run()` aus `ConfigForm.OnSave()` aufgerufen.
|
`SendConfig` und `SendMacros` blockieren ~1,5 s (Chunks + NVM-Zeit) → werden in `Task.Run()` aus `ConfigForm.OnSave()` aufgerufen.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
| Event | Gefeuert wenn |
|
| Event | Gefeuert wenn |
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ Ein Step besteht aus:
|
|||||||
- `modifier`
|
- `modifier`
|
||||||
|
|
||||||
Es gibt kein Magic und keine CRC fuer die Makrotabelle.
|
Es gibt kein Magic und keine CRC fuer die Makrotabelle.
|
||||||
|
Beim Transfer prüft die GUI trotzdem exakte Größe und alle HID-Keycodes; die
|
||||||
|
Firmware prüft zusätzlich die vollständige Chunkmenge.
|
||||||
|
|
||||||
## Wichtig fuer Aenderungen
|
## Wichtig fuer Aenderungen
|
||||||
|
|
||||||
@@ -117,4 +119,6 @@ Wenn sich Firmware-Layout, Magic, Version, Profilzahl oder Makrogroesse aendern,
|
|||||||
|
|
||||||
- `VersaMCU`
|
- `VersaMCU`
|
||||||
- `VersaGUI`
|
- `VersaGUI`
|
||||||
- `VersaGUIDelphi`
|
|
||||||
|
Beide sind eigenständige Git-Repositories und werden getrennt gebaut und
|
||||||
|
committed. Andere GUI-Ports gehören nicht zum gepflegten Scope.
|
||||||
|
|||||||
+12
-13
@@ -7,7 +7,7 @@
|
|||||||
- 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
|
- ACK/NACK-Weiterleitung an SerialManager
|
||||||
- Öffnen des `ConfigForm` (nur eine Instanz gleichzeitig)
|
- Öffnen des `ConfigForm` (nur eine Instanz gleichzeitig)
|
||||||
|
|
||||||
@@ -31,19 +31,15 @@ Icon und Tooltip spiegeln den Verbindungsstatus:
|
|||||||
|
|
||||||
| Event-ID | Aktion |
|
| Event-ID | Aktion |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `EvtConfigBegin` | `_rxConfigBuf = new byte[740]` |
|
| `EvtConfigBegin/Data/End` | Chunkzahl, Indizes und Vollständigkeit prüfen; danach `DeviceConfig.FromBytes()` |
|
||||||
| `EvtConfigData` | Chunk in `_rxConfigBuf` eintragen (`KeyId * 6` = Byte-Offset) |
|
|
||||||
| `EvtConfigEnd` | `DeviceConfig.FromBytes()` → `ConfigForm.RefreshAll()` |
|
|
||||||
| `EvtConfigAck` | `serial.SignalConfigAck()` — gibt SendConfig()-Thread frei |
|
| `EvtConfigAck` | `serial.SignalConfigAck()` — gibt SendConfig()-Thread frei |
|
||||||
| `EvtConfigNack` | `serial.SignalConfigNack()` — gibt SendConfig()-Thread frei (Fehler) |
|
| `EvtConfigNack` | `serial.SignalConfigNack()` — gibt SendConfig()-Thread frei (Fehler) |
|
||||||
| `EvtMacroBegin` | `_rxMacroBuf = new byte[512]` |
|
| `EvtMacroBegin/Data/End` | vollständigen 512-Byte-Dump prüfen; danach `MacroTable.FromBytes()` |
|
||||||
| `EvtMacroData` | Chunk in `_rxMacroBuf` eintragen |
|
|
||||||
| `EvtMacroEnd` | `MacroTable.FromBytes()` |
|
|
||||||
| `EvtMacroAck` | `serial.SignalMacroAck()` — gibt SendMacros()-Thread frei |
|
| `EvtMacroAck` | `serial.SignalMacroAck()` — gibt SendMacros()-Thread frei |
|
||||||
| `EvtMacroNack` | `serial.SignalMacroNack()` — gibt SendMacros()-Thread frei (Fehler) |
|
| `EvtMacroNack` | `serial.SignalMacroNack()` — gibt SendMacros()-Thread frei (Fehler) |
|
||||||
| `EvtPong` | MessageBox "Ping OK" |
|
| `EvtPong` | MessageBox "Ping OK" |
|
||||||
| `EvtKeyDown` | TODO: HOST_COMMAND-Aktion ausführen |
|
| `EvtKeyDown/Up` | Command-ID in Byte 2/3 empfangen |
|
||||||
| `EvtEncCw/Ccw` | TODO: Encoder HOST_COMMAND |
|
| `EvtEncCw/Ccw` | Command-ID und Encoderrichtung empfangen |
|
||||||
|
|
||||||
ACK/NACK-Events zeigen keine eigene MessageBox mehr — das Ergebnis wird nach Abschluss beider Transfers gebündelt in `ConfigForm.OnSave()` angezeigt.
|
ACK/NACK-Events zeigen keine eigene MessageBox mehr — das Ergebnis wird nach Abschluss beider Transfers gebündelt in `ConfigForm.OnSave()` angezeigt.
|
||||||
|
|
||||||
@@ -53,14 +49,17 @@ ACK/NACK-Events zeigen keine eigene MessageBox mehr — das Ergebnis wird nach A
|
|||||||
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 unvollständiger oder ungültiger Dump wird einmal wiederholt. Schlägt auch
|
||||||
|
der zweite Versuch fehl, zeigt das Tray-Icon eine Warnung.
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ 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,
|
||||||
@@ -37,6 +38,8 @@ Menschenlesbares JSON mit `System.Text.Json` (`WriteIndented=true`, Enums als St
|
|||||||
|
|
||||||
`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
|
||||||
@@ -46,3 +49,5 @@ Menschenlesbares JSON mit `System.Text.Json` (`WriteIndented=true`, Enums als St
|
|||||||
- `MacroTable` wird **nicht** exportiert (kein JSON-Format für Makros definiert)
|
- `MacroTable` wird **nicht** exportiert (kein JSON-Format für Makros definiert)
|
||||||
- `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
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
Die Dateien hier beschreiben die aktuelle C#-Referenz-GUI fuer Config v3 und 32x8 Makros.
|
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 |
|
| [00_architecture.md](00_architecture.md) | Threading-Modell, Datenfluss, Verbindungslebenszyklus |
|
||||||
@@ -19,3 +23,5 @@ Die Dateien hier beschreiben die aktuelle C#-Referenz-GUI fuer Config v3 und 32x
|
|||||||
- DTR und Connect-Pfad -> [00_architecture.md](00_architecture.md), [01_serial_manager.md](01_serial_manager.md)
|
- DTR und Connect-Pfad -> [00_architecture.md](00_architecture.md), [01_serial_manager.md](01_serial_manager.md)
|
||||||
- Makro-Slot-Konvention -> [02_device_config.md](02_device_config.md)
|
- Makro-Slot-Konvention -> [02_device_config.md](02_device_config.md)
|
||||||
- Host-Command-Status -> [03_tray_app.md](03_tray_app.md)
|
- Host-Command-Status -> [03_tray_app.md](03_tray_app.md)
|
||||||
|
- automatisierte Binär-/Transferverträge ->
|
||||||
|
`../tests/VersaGUI.ContractTests/`
|
||||||
|
|||||||
+3
-1
@@ -336,7 +336,9 @@ public class ActionDialog : Form
|
|||||||
_profileCombo.Items.AddRange(new object[]
|
_profileCombo.Items.AddRange(new object[]
|
||||||
{ "Nächstes Profil (Zyklus)", "Profil 1", "Profil 2", "Profil 3" });
|
{ "Nächstes Profil (Zyklus)", "Profil 1", "Profil 2", "Profil 3" });
|
||||||
_profileCombo.SelectedIndex = action.Type == ActionType.ProfileSwitch
|
_profileCombo.SelectedIndex = action.Type == ActionType.ProfileSwitch
|
||||||
? (action.Data == 0xFFFF ? 0 : Math.Clamp((int)action.Data + 1, 1, 3))
|
? (action.Data is 0x00FF or 0xFFFF
|
||||||
|
? 0
|
||||||
|
: Math.Clamp((int)action.Data + 1, 1, 3))
|
||||||
: 0;
|
: 0;
|
||||||
_profilePanel.Controls.AddRange(new Control[] { profileLabel, _profileCombo });
|
_profilePanel.Controls.AddRange(new Control[] { profileLabel, _profileCombo });
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-4
@@ -11,9 +11,8 @@
|
|||||||
// └────────────────────────────────────────────────────────────────────┘
|
// └────────────────────────────────────────────────────────────────────┘
|
||||||
// [Auf Board speichern] [Schließen]
|
// [Auf Board speichern] [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;
|
||||||
|
|
||||||
@@ -435,7 +434,7 @@ internal static class Extensions
|
|||||||
{
|
{
|
||||||
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",
|
||||||
ActionType.Macro => "Makro-Sequenz",
|
ActionType.Macro => "Makro-Sequenz",
|
||||||
ActionType.ProfileSwitch => "Profil wechseln",
|
ActionType.ProfileSwitch => "Profil wechseln",
|
||||||
_ => "Keine Aktion",
|
_ => "Keine Aktion",
|
||||||
|
|||||||
@@ -86,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)
|
||||||
{
|
{
|
||||||
@@ -127,6 +132,46 @@ 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
|
||||||
|
|||||||
+150
-12
@@ -37,9 +37,9 @@ 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 (0–31)
|
Macro = 4, // data = Makro-Slot-Index (0–31)
|
||||||
ProfileSwitch = 5, // data = Profil-Index (0–2)
|
ProfileSwitch = 5, // data = Profil 0–2 oder 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)
|
||||||
@@ -57,6 +57,7 @@ public class MacroTable
|
|||||||
{
|
{
|
||||||
public const int Slots = 32;
|
public const int Slots = 32;
|
||||||
public const int MaxSteps = 8;
|
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];
|
||||||
@@ -75,7 +76,11 @@ public class MacroTable
|
|||||||
// Serialisierung: 32 × 8 × 2 = 512 Bytes
|
// Serialisierung: 32 × 8 × 2 = 512 Bytes
|
||||||
public byte[] ToBytes()
|
public byte[] ToBytes()
|
||||||
{
|
{
|
||||||
var buf = new byte[512];
|
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++)
|
||||||
@@ -86,9 +91,11 @@ public class MacroTable
|
|||||||
return buf;
|
return buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void FromBytes(byte[] buf)
|
public bool FromBytes(byte[] buf)
|
||||||
{
|
{
|
||||||
if (buf.Length < 512) 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++)
|
||||||
@@ -96,6 +103,24 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +130,10 @@ public enum LedAnimType : byte
|
|||||||
Static = 0,
|
Static = 0,
|
||||||
Blink = 1,
|
Blink = 1,
|
||||||
Pulse = 2,
|
Pulse = 2,
|
||||||
|
FadeIn = 3,
|
||||||
|
FadeOut = 4,
|
||||||
ColorCycle = 5,
|
ColorCycle = 5,
|
||||||
|
ColorFade = 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DeviceAction
|
public class DeviceAction
|
||||||
@@ -147,7 +175,9 @@ public class DeviceAction
|
|||||||
if (Type == ActionType.Macro)
|
if (Type == ActionType.Macro)
|
||||||
return $"Makro {Data}";
|
return $"Makro {Data}";
|
||||||
if (Type == ActionType.ProfileSwitch)
|
if (Type == ActionType.ProfileSwitch)
|
||||||
return Data == 0xFFFF ? "→ Nächstes Profil" : $"→ Profil {Data + 1}";
|
return Data is 0x00FF or 0xFFFF
|
||||||
|
? "→ Nächstes Profil"
|
||||||
|
: $"→ Profil {Data + 1}";
|
||||||
return $"CMD {Data}";
|
return $"CMD {Data}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +281,9 @@ public class DeviceConfig
|
|||||||
{
|
{
|
||||||
public const uint Magic = 0x56503203;
|
public const uint Magic = 0x56503203;
|
||||||
public const byte Version = 3;
|
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 EncSw = 0;
|
||||||
public const int EncCw = 1;
|
public const int EncCw = 1;
|
||||||
@@ -278,8 +311,10 @@ public class DeviceConfig
|
|||||||
|
|
||||||
public byte[] ToBytes()
|
public byte[] ToBytes()
|
||||||
{
|
{
|
||||||
const int size = 740;
|
if (!TryValidate(out string reason))
|
||||||
var buf = new byte[size];
|
throw new InvalidOperationException($"Ungültige DeviceConfig: {reason}");
|
||||||
|
|
||||||
|
var buf = new byte[Size];
|
||||||
int pos = 0;
|
int pos = 0;
|
||||||
|
|
||||||
WriteU32(buf, ref pos, Magic);
|
WriteU32(buf, ref pos, Magic);
|
||||||
@@ -297,7 +332,7 @@ public class DeviceConfig
|
|||||||
Profiles[p].ToBytes(buf, pos + p * 236);
|
Profiles[p].ToBytes(buf, pos + p * 236);
|
||||||
pos += 3 * 236;
|
pos += 3 * 236;
|
||||||
|
|
||||||
ushort crc = Crc16(buf, 7, size - 7);
|
ushort crc = Crc16(buf, 7, Size - 7);
|
||||||
buf[crcOffset] = (byte)(crc & 0xFF);
|
buf[crcOffset] = (byte)(crc & 0xFF);
|
||||||
buf[crcOffset + 1] = (byte)(crc >> 8);
|
buf[crcOffset + 1] = (byte)(crc >> 8);
|
||||||
|
|
||||||
@@ -306,18 +341,19 @@ public class DeviceConfig
|
|||||||
|
|
||||||
public bool FromBytes(byte[] buf)
|
public bool FromBytes(byte[] buf)
|
||||||
{
|
{
|
||||||
if (buf.Length < 740) return false;
|
if (buf.Length != Size) return false;
|
||||||
|
|
||||||
uint magic = ReadU32(buf, 0);
|
uint magic = ReadU32(buf, 0);
|
||||||
if (magic != Magic) return false;
|
if (magic != Magic) return false;
|
||||||
if (buf[4] != Version) return false;
|
if (buf[4] != Version) return false;
|
||||||
|
|
||||||
ushort storedCrc = (ushort)(buf[5] | (buf[6] << 8));
|
ushort storedCrc = (ushort)(buf[5] | (buf[6] << 8));
|
||||||
ushort computedCrc = Crc16(buf, 7, 740 - 7);
|
ushort computedCrc = Crc16(buf, 7, Size - 7);
|
||||||
if (storedCrc != computedCrc) return false;
|
if (storedCrc != computedCrc) return false;
|
||||||
|
if (!SerializedFieldsAreValid(buf)) return false;
|
||||||
|
|
||||||
int pos = 7;
|
int pos = 7;
|
||||||
ActiveProfileIndex = Math.Min(buf[pos++], (byte)2);
|
ActiveProfileIndex = buf[pos++];
|
||||||
GlobalBrightness = buf[pos++];
|
GlobalBrightness = buf[pos++];
|
||||||
for (int i = 0; i < 4; i++) EncSensitivity[i] = buf[pos++];
|
for (int i = 0; i < 4; i++) EncSensitivity[i] = buf[pos++];
|
||||||
pos += 19; // Reserve überspringen
|
pos += 19; // Reserve überspringen
|
||||||
@@ -328,6 +364,108 @@ public class DeviceConfig
|
|||||||
return true;
|
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 ─────────────────────────────────────────────────────────
|
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static void WriteU32(byte[] buf, ref int pos, uint v)
|
private static void WriteU32(byte[] buf, ref int pos, uint v)
|
||||||
|
|||||||
+12
-9
@@ -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 0–24 oder Encoder 0–3)
|
// [1] key_id (Button 0–24 oder Encoder 0–3)
|
||||||
// [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 (0x01–0x7F) ────────────────────────────────────
|
// ── Commands: App → Board (0x01–0x7F) ────────────────────────────────────
|
||||||
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 (0x81–0xFF) ──────────────────────────────────────
|
// ── Events: Board → App (0x81–0xFF) ──────────────────────────────────────
|
||||||
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; // key_id → Button gedrückt
|
||||||
public const byte EvtEncCw = 0x83; // key_id → Encoder Schritt CW
|
public const byte EvtKeyUp = 0x82; // key_id → Button losgelassen
|
||||||
public const byte EvtEncCcw = 0x84; // key_id → Encoder Schritt CCW
|
public const byte EvtEncCw = 0x83; // enc_id → Schritt CW
|
||||||
|
public const byte EvtEncCcw = 0x84; // 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
|
||||||
@@ -64,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() { }
|
||||||
|
|||||||
+149
-107
@@ -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,14 +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
|
// Synchronisation: Board-ACK/NACK abwarten bevor nächster Transfer startet
|
||||||
private readonly SemaphoreSlim _configAckGate = new(0, 1);
|
private readonly SemaphoreSlim _configAckGate = new(0, 1);
|
||||||
private readonly SemaphoreSlim _macroAckGate = new(0, 1);
|
private readonly SemaphoreSlim _macroAckGate = new(0, 1);
|
||||||
private volatile bool _configAckOk;
|
private volatile bool _configAckOk;
|
||||||
private volatile bool _macroAckOk;
|
private volatile bool _macroAckOk;
|
||||||
|
private volatile bool _configAckExpected;
|
||||||
|
private volatile bool _macroAckExpected;
|
||||||
|
|
||||||
public bool IsConnected => _port?.IsOpen == true;
|
public bool IsConnected => _port?.IsOpen == true;
|
||||||
|
|
||||||
@@ -67,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
|
||||||
{
|
{
|
||||||
@@ -109,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 ────────────────────────────────────────────
|
||||||
@@ -201,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 ────────────────────────────────────────────────────────
|
||||||
@@ -231,72 +261,86 @@ public class SerialManager : IDisposable
|
|||||||
// Gibt den wartenden SendConfig()/SendMacros()-Thread frei.
|
// Gibt den wartenden SendConfig()/SendMacros()-Thread frei.
|
||||||
public void SignalConfigAck()
|
public void SignalConfigAck()
|
||||||
{
|
{
|
||||||
|
if (!_configAckExpected) return;
|
||||||
_configAckOk = true;
|
_configAckOk = true;
|
||||||
if (_configAckGate.CurrentCount == 0) _configAckGate.Release();
|
ReleaseGate(_configAckGate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SignalConfigNack()
|
public void SignalConfigNack()
|
||||||
{
|
{
|
||||||
|
if (!_configAckExpected) return;
|
||||||
_configAckOk = false;
|
_configAckOk = false;
|
||||||
if (_configAckGate.CurrentCount == 0) _configAckGate.Release();
|
ReleaseGate(_configAckGate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SignalMacroAck()
|
public void SignalMacroAck()
|
||||||
{
|
{
|
||||||
|
if (!_macroAckExpected) return;
|
||||||
_macroAckOk = true;
|
_macroAckOk = true;
|
||||||
if (_macroAckGate.CurrentCount == 0) _macroAckGate.Release();
|
ReleaseGate(_macroAckGate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SignalMacroNack()
|
public void SignalMacroNack()
|
||||||
{
|
{
|
||||||
|
if (!_macroAckExpected) return;
|
||||||
_macroAckOk = false;
|
_macroAckOk = false;
|
||||||
if (_macroAckGate.CurrentCount == 0) _macroAckGate.Release();
|
ReleaseGate(_macroAckGate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Makro-Tabelle (256 Bytes) in 6-Byte-Chunks senden.
|
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.
|
// Gibt true zurück wenn das Board MACRO_ACK gesendet hat.
|
||||||
public bool SendMacros(MacroTable macros)
|
public bool SendMacros(MacroTable macros)
|
||||||
{
|
{
|
||||||
byte[] data = macros.ToBytes();
|
lock (_transferLock)
|
||||||
const int payload = 6;
|
|
||||||
int chunks = (data.Length + payload - 1) / payload; // 43
|
|
||||||
|
|
||||||
File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"),
|
|
||||||
$"{DateTime.Now:HH:mm:ss.fff} TX MacroBegin chunks={chunks} dataLen={data.Length} connected={IsConnected}\n");
|
|
||||||
|
|
||||||
Send(new SerialPacket(Protocol.CmdMacroBegin, (byte)chunks));
|
|
||||||
Thread.Sleep(10);
|
|
||||||
|
|
||||||
for (int i = 0; i < chunks; i++)
|
|
||||||
{
|
{
|
||||||
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;
|
ResetGate(_macroAckGate);
|
||||||
int count = Math.Min(payload, data.Length - offset);
|
_macroAckOk = false;
|
||||||
Buffer.BlockCopy(data, offset, pkt.Data, 2, count);
|
_macroAckExpected = false;
|
||||||
Send(pkt);
|
|
||||||
if (i % 10 == 0)
|
if (!Send(new SerialPacket(
|
||||||
File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"),
|
Protocol.CmdMacroBegin, (byte)Protocol.MacroChunks)))
|
||||||
$"{DateTime.Now:HH:mm:ss.fff} TX MacroData chunk {i}/{chunks}\n");
|
return false;
|
||||||
Thread.Sleep(5);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"),
|
|
||||||
$"{DateTime.Now:HH:mm:ss.fff} TX MacroCommit (sent {chunks} chunks)\n");
|
|
||||||
|
|
||||||
// Sicherstellen dass das Gate leer ist bevor wir warten
|
|
||||||
while (_macroAckGate.CurrentCount > 0) _macroAckGate.Wait(0);
|
|
||||||
|
|
||||||
Thread.Sleep(10);
|
|
||||||
Send(new SerialPacket(Protocol.CmdMacroCommit));
|
|
||||||
|
|
||||||
// Auf MACRO_ACK/NACK warten – Board braucht bis zu ~600ms für NVM-Erase + Write (2 Rows)
|
|
||||||
bool gateAcquired = _macroAckGate.Wait(3000);
|
|
||||||
bool success = gateAcquired && _macroAckOk;
|
|
||||||
File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"),
|
|
||||||
$"{DateTime.Now:HH:mm:ss.fff} TX MacroAck gateAcquired={gateAcquired} ok={success}\n");
|
|
||||||
return success;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config in 6-Byte-Chunks an das Board senden.
|
// Config in 6-Byte-Chunks an das Board senden.
|
||||||
@@ -304,46 +348,44 @@ public class SerialManager : IDisposable
|
|||||||
// Gibt true zurück wenn das Board CONFIG_ACK gesendet hat.
|
// Gibt true zurück wenn das Board CONFIG_ACK gesendet hat.
|
||||||
public bool 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} connected={IsConnected}\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);
|
|
||||||
if (i % 10 == 0)
|
if (!Send(new SerialPacket(
|
||||||
File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"),
|
Protocol.CmdConfigBegin, (byte)Protocol.ConfigChunks)))
|
||||||
$"{DateTime.Now:HH:mm:ss.fff} TX ConfigData chunk {i}/{chunks}\n");
|
return false;
|
||||||
Thread.Sleep(5); // Firmware-Loop Zeit geben den Puffer zu leeren
|
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 (sent {chunks} chunks)\n");
|
|
||||||
|
|
||||||
// Sicherstellen dass das Gate leer ist bevor wir warten
|
|
||||||
while (_configAckGate.CurrentCount > 0) _configAckGate.Wait(0);
|
|
||||||
|
|
||||||
Thread.Sleep(10);
|
|
||||||
Send(new SerialPacket(Protocol.CmdConfigCommit));
|
|
||||||
|
|
||||||
// Auf CONFIG_ACK/NACK warten – Board braucht bis zu ~1s für NVM-Erase + Write (3 Rows)
|
|
||||||
bool gateAcquired = _configAckGate.Wait(3000);
|
|
||||||
bool success = gateAcquired && _configAckOk;
|
|
||||||
File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"),
|
|
||||||
$"{DateTime.Now:HH:mm:ss.fff} TX ConfigAck gateAcquired={gateAcquired} ok={success}\n");
|
|
||||||
return success;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── IDisposable ───────────────────────────────────────────────────────────
|
// ── IDisposable ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
+55
-38
@@ -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:
|
||||||
@@ -137,25 +138,30 @@ public class TrayApp : ApplicationContext
|
|||||||
|
|
||||||
// ── Config-Dump vom Board ─────────────────────────────────────────
|
// ── Config-Dump vom Board ─────────────────────────────────────────
|
||||||
case Protocol.EvtConfigBegin:
|
case Protocol.EvtConfigBegin:
|
||||||
_rxConfigBuf = new byte[740]; // sizeof(SDeviceConfig) = 740 (Version 3)
|
_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;
|
||||||
|
|
||||||
@@ -169,29 +175,40 @@ public class TrayApp : ApplicationContext
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case Protocol.EvtMacroBegin:
|
case Protocol.EvtMacroBegin:
|
||||||
_rxMacroBuf = new byte[512]; // sizeof(SMacroTable) = 512 (8 Steps)
|
_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)
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user