From 08db94bb6bba2e55b0d19dfa3889bf7fadf523a2 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 24 Jul 2026 09:49:39 +0200 Subject: [PATCH] Harden protocol transfers and config validation --- AGENTS.md | 66 +++++ README.md | 28 +- doc/00_architecture.md | 26 +- doc/01_serial_manager.md | 7 +- doc/02_device_config.md | 6 +- doc/03_tray_app.md | 25 +- doc/06_config_json.md | 7 +- doc/INDEX.md | 6 + src/ActionDialog.cs | 4 +- src/ChunkTransferBuffer.cs | 73 +++++ src/ConfigForm.cs | 7 +- src/ConfigJson.cs | 45 +++ src/DeviceConfig.cs | 162 ++++++++++- src/Protocol.cs | 21 +- src/SerialManager.cs | 256 ++++++++++-------- src/TrayApp.cs | 93 ++++--- tests/VersaGUI.ContractTests/Program.cs | 97 +++++++ .../VersaGUI.ContractTests.csproj | 14 + 18 files changed, 741 insertions(+), 202 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/ChunkTransferBuffer.cs create mode 100644 tests/VersaGUI.ContractTests/Program.cs create mode 100644 tests/VersaGUI.ContractTests/VersaGUI.ContractTests.csproj diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bb3642b --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 6e7303f..1b9a487 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Windows-Tray-App zur Konfiguration des VersaPad v2. Geschrieben in C# mit WinForms. -Hinweis: -Diese GUI ist weiterhin die Referenzimplementierung fuer Protokoll und Datenlayout. -Parallel existiert mit `VersaGUIDelphi` ein Delphi/VCL-Port unter dem Namen `VersaGUI Delphi`. +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 @@ -17,7 +17,7 @@ Parallel existiert mit `VersaGUIDelphi` ein Delphi/VCL-Port unter dem Namen `Ver ## Starten ```bash -dotnet run +dotnet run --project src/VersaGUI.csproj ``` ## Was die App macht @@ -25,6 +25,7 @@ dotnet run - 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 - schreibt Config und Makros getrennt, aber in einem UI-Vorgang auf das Board @@ -82,6 +83,9 @@ wait for ACK/NACK ```text VersaGUI/ +|-- AGENTS.md +|-- doc/ +|-- tests/VersaGUI.ContractTests/ `-- src/ |-- Program.cs |-- TrayApp.cs @@ -93,11 +97,23 @@ VersaGUI/ `-- 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 -- Host-Commands sind protokollseitig vorhanden, aber appseitig noch nicht voll ausgebaut - 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 diff --git a/doc/00_architecture.md b/doc/00_architecture.md index e3cff66..d02c530 100644 --- a/doc/00_architecture.md +++ b/doc/00_architecture.md @@ -18,8 +18,9 @@ | `SerialManager` | Verbindungsverwaltung, WMI-Erkennung, Lese-Thread, Sende-Methoden | | `ConfigForm` | Hauptfenster (Grid + Encoder-Panel + Footer); öffnet ActionDialog | | `ActionDialog` | Modaler Dialog zum Bearbeiten einer Aktion + LED-Einstellungen | -| `DeviceConfig` | C#-Spiegel von `SDeviceConfig`; Serialisierung/Deserialisierung (223 B) | -| `MacroTable` | C#-Spiegel von `SMacroTable`; Serialisierung/Deserialisierung (256 B) | +| `DeviceConfig` | C#-Spiegel von `SDeviceConfig`; Validierung und Serialisierung (740 B) | +| `MacroTable` | C#-Spiegel von `SMacroTable`; Validierung und Serialisierung (512 B) | +| `ChunkTransferBuffer` | prüft Dump-Chunkzahl, Indizes und Vollständigkeit | | `ConfigJson` | JSON-Import/Export für `DeviceConfig` | | `Protocol` | Konstanten für alle Command/Event-IDs (spiegelt `usb_serial.h`) | @@ -29,9 +30,9 @@ Board → SerialManager (ReadLoop, BG-Thread) → SynchronizationContext.Post (→ UI-Thread) → TrayApp.OnPacket() - ├── Config-Dump: _rxConfigBuf aufbauen → DeviceConfig.FromBytes() - ├── Makro-Dump: _rxMacroBuf aufbauen → MacroTable.FromBytes() - └── HOST_COMMAND-Events: (TODO: Aktion ausführen) + ├── Config-Dump vollständig sammeln → DeviceConfig.FromBytes() + ├── danach Makro-Dump vollständig sammeln → MacroTable.FromBytes() + └── HOST_COMMAND-Events mit 16-Bit-Command-ID empfangen Benutzer → ConfigForm → ActionDialog → DeviceConfig / MacroTable (in-memory ändern) @@ -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) → 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 ``` @@ -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. - **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. -- **Config-Version**: `DeviceConfig.Version == 2`. `FromBytes()` prüft Magic + Version + CRC; schlägt einer fehl → Methode gibt `false` zurück, Config bleibt unverändert. -- **Debug-Log**: `versapad_rx.txt` in `%TEMP%` – vor Release-Nutzung entfernen (TODO). +- **Packed-kompatible Serialisierung**: `DeviceConfig.ToBytes()` erzeugt exakt + 740 B in derselben Reihenfolge wie `SDeviceConfig`; `MacroTable` exakt 512 B. +- **Config-Version**: `DeviceConfig.Version == 3`. `FromBytes()` prüft Magic, + Version, CRC, Profilindex, Actions, LED-Enums und kritische Perioden, bevor + Objektzustand geändert wird. +- **Transfer-Synchronisation**: Config- und Makro-Uploads laufen unter einem + gemeinsamen Transfer-Lock; einzelne Pakete unter einem Write-Lock. +- **Host-Commands**: Pakete sind vollständig definiert, die Ausführung einer + Command-ID als Desktop-Aktion ist bewusst noch nicht implementiert. diff --git a/doc/01_serial_manager.md b/doc/01_serial_manager.md index 38e3cd2..2d15fbf 100644 --- a/doc/01_serial_manager.md +++ b/doc/01_serial_manager.md @@ -75,7 +75,7 @@ _configAckOk / _macroAckOk – volatile bool (true = ACK, false = NACK) | 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 | | `ClearLedOverride(keyId)` | void | CMD 0x02 | | `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. +Vor `BEGIN` wird das zugehörige ACK-Gate geleert. Beide Blob-Sender teilen +einen Transfer-Lock, prüfen jeden `Send()`-Rückgabewert und brechen bei +Disconnect sofort ab. Ein Disconnect gibt wartende ACK-Gates mit Fehlerstatus +frei. + ## Events | Event | Gefeuert wenn | diff --git a/doc/02_device_config.md b/doc/02_device_config.md index b00ff89..57529f0 100644 --- a/doc/02_device_config.md +++ b/doc/02_device_config.md @@ -110,6 +110,8 @@ Ein Step besteht aus: - `modifier` 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 @@ -117,4 +119,6 @@ Wenn sich Firmware-Layout, Magic, Version, Profilzahl oder Makrogroesse aendern, - `VersaMCU` - `VersaGUI` -- `VersaGUIDelphi` + +Beide sind eigenständige Git-Repositories und werden getrennt gebaut und +committed. Andere GUI-Ports gehören nicht zum gepflegten Scope. diff --git a/doc/03_tray_app.md b/doc/03_tray_app.md index 1c308a9..bae9fc5 100644 --- a/doc/03_tray_app.md +++ b/doc/03_tray_app.md @@ -7,7 +7,7 @@ - App-Lebenszyklus als `ApplicationContext` (kein Hauptfenster) - Tray-Icon mit Verbindungsstatus und Kontextmenü - 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) @@ -31,19 +31,15 @@ Icon und Tooltip spiegeln den Verbindungsstatus: | Event-ID | Aktion | |---|---| -| `EvtConfigBegin` | `_rxConfigBuf = new byte[740]` | -| `EvtConfigData` | Chunk in `_rxConfigBuf` eintragen (`KeyId * 6` = Byte-Offset) | -| `EvtConfigEnd` | `DeviceConfig.FromBytes()` → `ConfigForm.RefreshAll()` | +| `EvtConfigBegin/Data/End` | Chunkzahl, Indizes und Vollständigkeit prüfen; danach `DeviceConfig.FromBytes()` | | `EvtConfigAck` | `serial.SignalConfigAck()` — gibt SendConfig()-Thread frei | | `EvtConfigNack` | `serial.SignalConfigNack()` — gibt SendConfig()-Thread frei (Fehler) | -| `EvtMacroBegin` | `_rxMacroBuf = new byte[512]` | -| `EvtMacroData` | Chunk in `_rxMacroBuf` eintragen | -| `EvtMacroEnd` | `MacroTable.FromBytes()` | +| `EvtMacroBegin/Data/End` | vollständigen 512-Byte-Dump prüfen; danach `MacroTable.FromBytes()` | | `EvtMacroAck` | `serial.SignalMacroAck()` — gibt SendMacros()-Thread frei | | `EvtMacroNack` | `serial.SignalMacroNack()` — gibt SendMacros()-Thread frei (Fehler) | | `EvtPong` | MessageBox "Ping OK" | -| `EvtKeyDown` | TODO: HOST_COMMAND-Aktion ausführen | -| `EvtEncCw/Ccw` | TODO: Encoder HOST_COMMAND | +| `EvtKeyDown/Up` | Command-ID in Byte 2/3 empfangen | +| `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. @@ -53,14 +49,17 @@ ACK/NACK-Events zeigen keine eigene MessageBox mehr — das Ergebnis wird nach A OnConnected(): Icon + Text + Menü-Item aktualisieren serial.RequestConfig() → Config-Dump vom Board - serial.RequestMacros() → Makro-Dump vom Board + nach gültigem Config-End: + serial.RequestMacros() → Makro-Dump vom Board OnDisconnected(): 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` -- Debug-Log (`versapad_rx.txt`) entfernen diff --git a/doc/06_config_json.md b/doc/06_config_json.md index 72f7eb3..a3af2d6 100644 --- a/doc/06_config_json.md +++ b/doc/06_config_json.md @@ -8,7 +8,8 @@ Menschenlesbares JSON mit `System.Text.Json` (`WriteIndented=true`, Enums als St ```json { - "version": 2, + "version": 3, + "profile": 0, "buttons": [ { "index": 0, @@ -37,6 +38,8 @@ Menschenlesbares JSON mit `System.Text.Json` (`WriteIndented=true`, Enums als St `ConfigJson.Deserialize(json, cfg)`: - Prüft `version` – wirft `InvalidDataException` bei Mismatch +- prüft und übernimmt `profile` im Bereich `0..2` +- validiert Actions, LED-Enums und die Mindestperiode für `Pulse` - Schreibt in bestehendes `DeviceConfig`-Objekt (kein `new`) - Fehlende `buttons`/`encoders`-Arrays werden ignoriert (partial import möglich) - Ungültige `index`-Werte werden übersprungen @@ -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) - `data` enthält den `ushort`-Wert direkt (für HidKey z.B. `Keycode | (Modifier << 8)`) - Die Datei ist kein Binärformat und kann manuell bearbeitet werden +- Ein ungültiger Wert wird vor dem Anwenden mit `InvalidDataException` + abgelehnt diff --git a/doc/INDEX.md b/doc/INDEX.md index 9a10cc9..917158d 100644 --- a/doc/INDEX.md +++ b/doc/INDEX.md @@ -2,6 +2,10 @@ Die Dateien hier beschreiben die aktuelle C#-Referenz-GUI fuer Config v3 und 32x8 Makros. +Repository-weite LLM-/Agent-Richtlinien stehen in +[`../AGENTS.md`](../AGENTS.md). Gemeinsame Firmwareverträge müssen zusätzlich +gegen `../../VersaMCU` geprüft werden. + | Datei | Inhalt | |---|---| | [00_architecture.md](00_architecture.md) | Threading-Modell, Datenfluss, Verbindungslebenszyklus | @@ -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) - Makro-Slot-Konvention -> [02_device_config.md](02_device_config.md) - Host-Command-Status -> [03_tray_app.md](03_tray_app.md) +- automatisierte Binär-/Transferverträge -> + `../tests/VersaGUI.ContractTests/` diff --git a/src/ActionDialog.cs b/src/ActionDialog.cs index 2ce9c7a..563579f 100644 --- a/src/ActionDialog.cs +++ b/src/ActionDialog.cs @@ -336,7 +336,9 @@ public class ActionDialog : Form _profileCombo.Items.AddRange(new object[] { "Nächstes Profil (Zyklus)", "Profil 1", "Profil 2", "Profil 3" }); _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; _profilePanel.Controls.AddRange(new Control[] { profileLabel, _profileCombo }); diff --git a/src/ChunkTransferBuffer.cs b/src/ChunkTransferBuffer.cs new file mode 100644 index 0000000..539929e --- /dev/null +++ b/src/ChunkTransferBuffer.cs @@ -0,0 +1,73 @@ +namespace VersaGUI; + +/// +/// Sammelt einen Board→GUI-Dump und akzeptiert ihn nur, wenn Chunkzahl, +/// Indizes, Vollständigkeit und END-Metadaten exakt zum erwarteten Blob passen. +/// +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(); + + 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; + } +} diff --git a/src/ConfigForm.cs b/src/ConfigForm.cs index 754ae6c..77833c3 100644 --- a/src/ConfigForm.cs +++ b/src/ConfigForm.cs @@ -11,9 +11,8 @@ // └────────────────────────────────────────────────────────────────────┘ // [Auf Board speichern] [Schließen] // -// "Auf Board speichern" sendet die Config in 6-Byte-Chunks über Serial. -// Firmware-Seite (WRITE_CONFIG) ist noch TODO → Schaltfläche ist -// deaktiviert wenn das Board nicht verbunden ist. +// "Auf Board speichern" sendet Config und Makros in 6-Byte-Chunks über CDC. +// Die Schaltfläche ist deaktiviert, wenn das Board nicht verbunden ist. namespace VersaGUI; @@ -435,7 +434,7 @@ internal static class Extensions { ActionType.HidKey => "HID Tastatur-Keycode", ActionType.HidConsumer => "HID Consumer Control (Media/Volume)", - ActionType.HostCommand => "Host Command – App führt aus", + ActionType.HostCommand => "Host Command-ID für Desktop-Mapping", ActionType.Macro => "Makro-Sequenz", ActionType.ProfileSwitch => "Profil wechseln", _ => "Keine Aktion", diff --git a/src/ConfigJson.cs b/src/ConfigJson.cs index 8bf0a59..cc898af 100644 --- a/src/ConfigJson.cs +++ b/src/ConfigJson.cs @@ -86,6 +86,11 @@ internal static class ConfigJson if (doc.Version != DeviceConfig.Version) throw new InvalidDataException( $"Config-Version {doc.Version} wird nicht unterstützt (erwartet {DeviceConfig.Version})."); + if (doc.Profile >= DeviceConfig.ProfileCount) + throw new InvalidDataException("Profilindex muss im Bereich 0..2 liegen."); + + ValidateDocument(doc); + cfg.ActiveProfileIndex = doc.Profile; if (doc.Buttons != null) { @@ -127,6 +132,46 @@ internal static class ConfigJson dst.Data = src.Data; } + private static void ValidateDocument(ConfigJsonDoc doc) + { + if (doc.Buttons != null) + { + foreach (ButtonEntry button in doc.Buttons) + { + ValidateAction(button.Action); + if (button.Led == null) continue; + + byte anim = (byte)button.Led.Anim; + if (anim > (byte)LedAnimType.ColorFade) + throw new InvalidDataException( + $"Button {button.Index}: unbekannte LED-Animation {anim}."); + if (button.Led.Anim == LedAnimType.Pulse && + button.Led.PeriodMs < 2) + { + throw new InvalidDataException( + $"Button {button.Index}: Pulse benötigt mindestens 2 ms."); + } + } + } + + if (doc.Encoders != null) + { + foreach (EncoderEntry encoder in doc.Encoders) + { + ValidateAction(encoder.Sw); + ValidateAction(encoder.Cw); + ValidateAction(encoder.Ccw); + } + } + } + + private static void ValidateAction(ActionEntry? action) + { + if (action != null && !DeviceConfig.ActionIsValid(action.Type, action.Data)) + throw new InvalidDataException( + $"Ungültige Action {action.Type} mit Datenwert {action.Data}."); + } + // ── JSON-Datenklassen ───────────────────────────────────────────────────── private class ConfigJsonDoc diff --git a/src/DeviceConfig.cs b/src/DeviceConfig.cs index aa02aec..e39cdc3 100644 --- a/src/DeviceConfig.cs +++ b/src/DeviceConfig.cs @@ -37,9 +37,9 @@ public enum ActionType : byte None = 0, HidKey = 1, // data = Keycode (Low-Byte) + Modifier (High-Byte) HidConsumer = 2, // data = HID Consumer Usage ID - HostCommand = 3, // data = Command-ID → App führt aus + HostCommand = 3, // data = Command-ID für ein sicheres App-Mapping Macro = 4, // data = Makro-Slot-Index (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) @@ -57,6 +57,7 @@ public class MacroTable { public const int Slots = 32; public const int MaxSteps = 8; + public const int Size = Slots * MaxSteps * 2; // [slot][step] public MacroStep[,] Steps { get; } = new MacroStep[Slots, MaxSteps]; @@ -75,7 +76,11 @@ public class MacroTable // Serialisierung: 32 × 8 × 2 = 512 Bytes 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; for (int s = 0; s < Slots; s++) for (int i = 0; i < MaxSteps; i++) @@ -86,9 +91,11 @@ public class MacroTable 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; for (int s = 0; s < Slots; s++) for (int i = 0; i < MaxSteps; i++) @@ -96,6 +103,24 @@ public class MacroTable Steps[s, i].Keycode = buf[pos++]; Steps[s, i].Modifier = buf[pos++]; } + return true; + } + + public bool IsValid() + { + for (int slot = 0; slot < Slots; slot++) + for (int step = 0; step < MaxSteps; step++) + if (Steps[slot, step].Keycode > 0x65) + return false; + return true; + } + + private static bool SerializedDataIsValid(byte[] buf) + { + for (int pos = 0; pos < buf.Length; pos += 2) + if (buf[pos] > 0x65) + return false; + return true; } } @@ -105,7 +130,10 @@ public enum LedAnimType : byte Static = 0, Blink = 1, Pulse = 2, + FadeIn = 3, + FadeOut = 4, ColorCycle = 5, + ColorFade = 6, } public class DeviceAction @@ -147,7 +175,9 @@ public class DeviceAction if (Type == ActionType.Macro) return $"Makro {Data}"; 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}"; } } @@ -251,6 +281,9 @@ 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; @@ -278,8 +311,10 @@ public class DeviceConfig public byte[] ToBytes() { - const int size = 740; - var buf = new byte[size]; + 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); @@ -297,7 +332,7 @@ public class DeviceConfig Profiles[p].ToBytes(buf, pos + p * 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 + 1] = (byte)(crc >> 8); @@ -306,18 +341,19 @@ public class DeviceConfig public bool FromBytes(byte[] buf) { - if (buf.Length < 740) return false; + 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, 740 - 7); + ushort computedCrc = Crc16(buf, 7, Size - 7); if (storedCrc != computedCrc) return false; + if (!SerializedFieldsAreValid(buf)) return false; int pos = 7; - ActiveProfileIndex = Math.Min(buf[pos++], (byte)2); + ActiveProfileIndex = buf[pos++]; GlobalBrightness = buf[pos++]; for (int i = 0; i < 4; i++) EncSensitivity[i] = buf[pos++]; pos += 19; // Reserve überspringen @@ -328,6 +364,108 @@ public class DeviceConfig 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) diff --git a/src/Protocol.cs b/src/Protocol.cs index a1ee1c1..797f35d 100644 --- a/src/Protocol.cs +++ b/src/Protocol.cs @@ -5,16 +5,18 @@ // Alle Pakete: 8 Bytes fest // [0] Command/Event-ID // [1] key_id (Button 0–24 oder Encoder 0–3) -// [2] r / Daten-Byte A -// [3] g / Daten-Byte B -// [4] b -// [5..7] reserviert (0x00) +// [2..7] kommandospezifische Nutzdaten namespace VersaGUI; public static class Protocol { public const int PacketSize = 8; + public const int PayloadSize = 6; + public const int ConfigSize = 740; + public const int ConfigChunks = (ConfigSize + PayloadSize - 1) / PayloadSize; + public const int MacroSize = 512; + public const int MacroChunks = (MacroSize + PayloadSize - 1) / PayloadSize; // ── Commands: App → Board (0x01–0x7F) ──────────────────────────────────── public const byte CmdSetLedOverride = 0x01; // key_id, r, g, b @@ -25,7 +27,6 @@ public static class Protocol // CmdConfigBegin: Data[1] = Anzahl Chunks // CmdConfigData: Data[1] = Chunk-Index (0-based), Data[2..7] = 6 Bytes Nutzdaten // CmdConfigCommit: Board schreibt empfangene Daten in NVM - // Firmware-Seite ist noch TODO – Kommandos sind hier schon definiert. public const byte CmdPing = 0x05; // Board antwortet mit EvtPong public const byte CmdConfigBegin = 0x10; public const byte CmdConfigData = 0x11; @@ -37,10 +38,11 @@ public static class Protocol public const byte CmdMacroRead = 0x23; // Board sendet aktuelle Makro-Tabelle zurück // ── Events: Board → App (0x81–0xFF) ────────────────────────────────────── - public const byte EvtKeyDown = 0x81; // key_id → HOST_COMMAND-Button gedrückt - public const byte EvtKeyUp = 0x82; // key_id → HOST_COMMAND-Button losgelassen - public const byte EvtEncCw = 0x83; // key_id → Encoder Schritt CW - public const byte EvtEncCcw = 0x84; // key_id → Encoder Schritt CCW + // Host-Events übertragen die Command-ID little-endian in Data[2..3]. + public const byte EvtKeyDown = 0x81; // key_id → Button gedrückt + public const byte EvtKeyUp = 0x82; // key_id → Button losgelassen + 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 EvtConfigAck = 0x90; // Config erfolgreich in NVM geschrieben public const byte EvtConfigNack = 0x91; // Config CRC/Magic ungültig @@ -64,6 +66,7 @@ public class SerialPacket public byte R => Data[2]; public byte G => Data[3]; public byte B => Data[4]; + public ushort DataU16 => (ushort)(Data[2] | (Data[3] << 8)); // Leeres Paket (für eingehende Daten) public SerialPacket() { } diff --git a/src/SerialManager.cs b/src/SerialManager.cs index d119e89..5228af7 100644 --- a/src/SerialManager.cs +++ b/src/SerialManager.cs @@ -7,7 +7,7 @@ // // Threading: // - _readThread: Hintergrund-Thread, liest eingehende Bytes und baut Pakete zusammen. -// - _reconnectTimer: System.Threading.Timer, versucht alle 2s neu zu verbinden. +// - _reconnectTimer: System.Threading.Timer, versucht alle 3s neu zu verbinden. // - Alle Events werden auf dem UI-Thread gefeuert (via SynchronizationContext), // damit TrayApp direkt NotifyIcon und Menü aktualisieren kann. // @@ -36,14 +36,18 @@ public class SerialManager : IDisposable private System.Threading.Timer? _reconnectTimer; private readonly SynchronizationContext _ui; private readonly object _connectLock = new(); // Verhindert parallele TryConnect()-Aufrufe + private readonly object _writeLock = new(); // Ein Paket atomar schreiben + private readonly object _transferLock = new(); // Keine parallelen Blob-Transfers private bool _disposed; - private bool _waitingAfterDisconnect; // Backoff nach Trennung aktiv + private volatile bool _waitingAfterDisconnect; // Backoff nach Trennung aktiv // Synchronisation: Board-ACK/NACK abwarten bevor nächster Transfer startet private readonly SemaphoreSlim _configAckGate = new(0, 1); private readonly SemaphoreSlim _macroAckGate = new(0, 1); private volatile bool _configAckOk; private volatile bool _macroAckOk; + private volatile bool _configAckExpected; + private volatile bool _macroAckExpected; public bool IsConnected => _port?.IsOpen == true; @@ -67,34 +71,43 @@ public class SerialManager : IDisposable if (!Monitor.TryEnter(_connectLock)) return; try { - if (IsConnected || _waitingAfterDisconnect) return; + if (_disposed || IsConnected || _waitingAfterDisconnect) return; string? portName = FindPort(); if (portName is null) return; - var port = new SerialPort(portName, 115200) + SerialPort? port = null; + try { - ReadTimeout = 500, - WriteTimeout = 500, - DtrEnable = true, // Muss VOR Open() gesetzt werden – SAMD21 prüft DTR - // in SerialUSB-bool für usb_serial_send(). Default ist - // false, was alle Board→PC-Antworten still verwirft. - // Vor Open() gesetzt verursacht es keinen Zustandswechsel - // und triggert keinen CDC-Disconnect auf dem Board. - }; + port = new SerialPort(portName, 115200) + { + ReadTimeout = 500, + WriteTimeout = 500, + DtrEnable = true, + }; - port.Open(); + port.Open(); - // Kurz warten bis USB/CDC sich nach der Verbindungsherstellung stabilisiert - Thread.Sleep(200); + // Kurz warten bis USB/CDC sich nach der Verbindungsherstellung stabilisiert + Thread.Sleep(200); - _port = port; + _port = port; - // Lesethread starten - _readThread = new Thread(ReadLoop) { IsBackground = true, Name = "VersaPad-Read" }; - _readThread.Start(); + // Lesethread starten + _readThread = new Thread(ReadLoop) + { + 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 { @@ -109,10 +122,16 @@ public class SerialManager : IDisposable public void Disconnect() { - var port = _port; - _port = null; + SerialPort? port = Interlocked.Exchange(ref _port, null); try { port?.Close(); } catch { } port?.Dispose(); + + _configAckOk = false; + _macroAckOk = false; + _configAckExpected = false; + _macroAckExpected = false; + ReleaseGate(_configAckGate); + ReleaseGate(_macroAckGate); } // ── COM-Port-Erkennung per WMI ──────────────────────────────────────────── @@ -201,11 +220,22 @@ public class SerialManager : IDisposable // ── Senden ─────────────────────────────────────────────────────────────── - public void Send(SerialPacket pkt) + public bool Send(SerialPacket pkt) { - if (!IsConnected) return; - try { _port!.Write(pkt.Data, 0, Protocol.PacketSize); } - catch { /* Sendefehler ignorieren – ReadLoop erkennt Disconnect */ } + lock (_writeLock) + { + SerialPort? port = _port; + if (port?.IsOpen != true) return false; + try + { + port.Write(pkt.Data, 0, Protocol.PacketSize); + return true; + } + catch + { + return false; + } + } } // ── Hilfsmethoden ──────────────────────────────────────────────────────── @@ -231,72 +261,86 @@ public class SerialManager : IDisposable // Gibt den wartenden SendConfig()/SendMacros()-Thread frei. public void SignalConfigAck() { + if (!_configAckExpected) return; _configAckOk = true; - if (_configAckGate.CurrentCount == 0) _configAckGate.Release(); + ReleaseGate(_configAckGate); } public void SignalConfigNack() { + if (!_configAckExpected) return; _configAckOk = false; - if (_configAckGate.CurrentCount == 0) _configAckGate.Release(); + ReleaseGate(_configAckGate); } public void SignalMacroAck() { + if (!_macroAckExpected) return; _macroAckOk = true; - if (_macroAckGate.CurrentCount == 0) _macroAckGate.Release(); + ReleaseGate(_macroAckGate); } public void SignalMacroNack() { + if (!_macroAckExpected) return; _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. public bool SendMacros(MacroTable macros) { - byte[] data = macros.ToBytes(); - 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++) + lock (_transferLock) { - var pkt = new SerialPacket(); - pkt.Data[0] = Protocol.CmdMacroData; - pkt.Data[1] = (byte)i; - int offset = i * payload; - int count = Math.Min(payload, data.Length - offset); - Buffer.BlockCopy(data, offset, pkt.Data, 2, count); - Send(pkt); - if (i % 10 == 0) - File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"), - $"{DateTime.Now:HH:mm:ss.fff} TX MacroData chunk {i}/{chunks}\n"); - Thread.Sleep(5); + if (!macros.IsValid() || !IsConnected) return false; + byte[] data = macros.ToBytes(); + + ResetGate(_macroAckGate); + _macroAckOk = false; + _macroAckExpected = false; + + if (!Send(new SerialPacket( + Protocol.CmdMacroBegin, (byte)Protocol.MacroChunks))) + return false; + Thread.Sleep(10); + + for (int i = 0; i < 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. @@ -304,46 +348,44 @@ public class SerialManager : IDisposable // Gibt true zurück wenn das Board CONFIG_ACK gesendet hat. public bool SendConfig(DeviceConfig config) { - byte[] data = config.ToBytes(); - const int payload = 6; // Nutzbytes pro Paket (8 - 2 Header-Bytes) - int chunks = (data.Length + payload - 1) / payload; - - File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"), - $"{DateTime.Now:HH:mm:ss.fff} TX ConfigBegin chunks={chunks} dataLen={data.Length} connected={IsConnected}\n"); - - Send(new SerialPacket(Protocol.CmdConfigBegin, (byte)chunks)); - Thread.Sleep(10); - - for (int i = 0; i < chunks; i++) + lock (_transferLock) { - var pkt = new SerialPacket(); - pkt.Data[0] = Protocol.CmdConfigData; - pkt.Data[1] = (byte)i; - int offset = i * payload; - int count = Math.Min(payload, data.Length - offset); - Buffer.BlockCopy(data, offset, pkt.Data, 2, count); - Send(pkt); - if (i % 10 == 0) - File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"), - $"{DateTime.Now:HH:mm:ss.fff} TX ConfigData chunk {i}/{chunks}\n"); - Thread.Sleep(5); // Firmware-Loop Zeit geben den Puffer zu leeren + if (!config.TryValidate(out _) || !IsConnected) return false; + byte[] data = config.ToBytes(); + + ResetGate(_configAckGate); + _configAckOk = false; + _configAckExpected = false; + + 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 (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 ─────────────────────────────────────────────────────────── diff --git a/src/TrayApp.cs b/src/TrayApp.cs index 4ad487b..232b14f 100644 --- a/src/TrayApp.cs +++ b/src/TrayApp.cs @@ -10,8 +10,8 @@ // Beenden // // Board-Events: -// KEY_DOWN / ENC_CW / ENC_CCW mit Aktion HOST_COMMAND werden hier empfangen. -// Aktuell: Benachrichtigung anzeigen (HOST_COMMAND-Ausführung folgt später). +// KEY_DOWN/KEY_UP/ENC_CW/ENC_CCW enthalten die konfigurierte Host-Command-ID. +// Eine konkrete Host-Aktionsausführung ist bewusst noch nicht implementiert. namespace VersaGUI; @@ -25,9 +25,12 @@ public class TrayApp : ApplicationContext private ToolStripMenuItem _statusItem = null!; private ConfigForm? _configForm; - // Empfangspuffer für eingehende Dumps vom Board - private byte[]? _rxConfigBuf; - private byte[]? _rxMacroBuf; + private readonly ChunkTransferBuffer _configRx = + new(Protocol.ConfigSize, Protocol.ConfigChunks); + private readonly ChunkTransferBuffer _macroRx = + new(Protocol.MacroSize, Protocol.MacroChunks); + private int _configReadAttempts; + private int _macroReadAttempts; public TrayApp() { @@ -92,9 +95,11 @@ public class TrayApp : ApplicationContext _tray.Icon = SystemIcons.Information; _statusItem.Text = "● Verbunden"; - // Config und Makro-Tabelle vom Board laden + // Dumps bewusst sequenziell anfordern, damit sich zwei lange + // Board→Host-Transfers nicht gegenseitig überholen können. + _configReadAttempts = 1; + _macroReadAttempts = 0; _serial.RequestConfig(); - _serial.RequestMacros(); } private void OnDisconnected(object? sender, EventArgs e) @@ -102,24 +107,20 @@ public class TrayApp : ApplicationContext _tray.Text = "VersaPad – nicht verbunden"; _tray.Icon = SystemIcons.Application; _statusItem.Text = "○ Nicht verbunden"; + _configRx.Reset(); + _macroRx.Reset(); } private void OnPacket(object? sender, SerialPacket pkt) { - // Debug: alle empfangenen Pakete loggen - File.AppendAllText( - Path.Combine(Path.GetTempPath(), "versapad_rx.txt"), - $"{DateTime.Now:HH:mm:ss.fff} RX cmd=0x{pkt.Command:X2} key={pkt.KeyId}\n"); - switch (pkt.Command) { case Protocol.EvtKeyDown: - // TODO: HOST_COMMAND-Aktion aus Config laden und ausführen - break; - + case Protocol.EvtKeyUp: case Protocol.EvtEncCw: case Protocol.EvtEncCcw: - // TODO: Encoder HOST_COMMAND + // Command-ID steht jetzt direkt in pkt.DataU16. Die sichere + // Zuordnung zu konkreten Host-Aktionen folgt separat. break; case Protocol.EvtPong: @@ -137,25 +138,30 @@ public class TrayApp : ApplicationContext // ── Config-Dump vom Board ───────────────────────────────────────── case Protocol.EvtConfigBegin: - _rxConfigBuf = new byte[740]; // sizeof(SDeviceConfig) = 740 (Version 3) + _configRx.Begin(pkt.KeyId); break; case Protocol.EvtConfigData: - if (_rxConfigBuf != null) - { - int offset = pkt.KeyId * 6; - for (int i = 0; i < 6; i++) - if (offset + i < _rxConfigBuf.Length) - _rxConfigBuf[offset + i] = pkt.Data[2 + i]; - } + _configRx.Add(pkt); break; case Protocol.EvtConfigEnd: - if (_rxConfigBuf != null) + if (_configRx.TryComplete(pkt.KeyId, out byte[] configData) && + _config.FromBytes(configData)) { - _config.FromBytes(_rxConfigBuf); - _rxConfigBuf = null; _configForm?.RefreshAll(); + + _macroReadAttempts = 1; + _serial.RequestMacros(); + } + else if (_configReadAttempts < 2 && _serial.IsConnected) + { + _configReadAttempts++; + _serial.RequestConfig(); + } + else + { + ShowTransferError("Config"); } break; @@ -169,29 +175,40 @@ public class TrayApp : ApplicationContext break; case Protocol.EvtMacroBegin: - _rxMacroBuf = new byte[512]; // sizeof(SMacroTable) = 512 (8 Steps) + _macroRx.Begin(pkt.KeyId); break; case Protocol.EvtMacroData: - if (_rxMacroBuf != null) - { - int offset = pkt.KeyId * 6; - for (int i = 0; i < 6; i++) - if (offset + i < _rxMacroBuf.Length) - _rxMacroBuf[offset + i] = pkt.Data[2 + i]; - } + _macroRx.Add(pkt); break; case Protocol.EvtMacroEnd: - if (_rxMacroBuf != null) + if (_macroRx.TryComplete(pkt.KeyId, out byte[] macroData) && + _macros.FromBytes(macroData)) { - _macros.FromBytes(_rxMacroBuf); - _rxMacroBuf = null; + _configForm?.RefreshAll(); + } + else if (_macroReadAttempts < 2 && _serial.IsConnected) + { + _macroReadAttempts++; + _serial.RequestMacros(); + } + else + { + ShowTransferError("Makro-Tabelle"); } break; } } + private void ShowTransferError(string name) + { + _tray.BalloonTipTitle = "VersaPad – Übertragungsfehler"; + _tray.BalloonTipText = $"{name} konnte nicht vollständig gelesen werden."; + _tray.BalloonTipIcon = ToolTipIcon.Warning; + _tray.ShowBalloonTip(4000); + } + // ── Aufräumen ───────────────────────────────────────────────────────────── protected override void Dispose(bool disposing) diff --git a/tests/VersaGUI.ContractTests/Program.cs b/tests/VersaGUI.ContractTests/Program.cs new file mode 100644 index 0000000..1514591 --- /dev/null +++ b/tests/VersaGUI.ContractTests/Program.cs @@ -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."); diff --git a/tests/VersaGUI.ContractTests/VersaGUI.ContractTests.csproj b/tests/VersaGUI.ContractTests/VersaGUI.ContractTests.csproj new file mode 100644 index 0000000..74dc7aa --- /dev/null +++ b/tests/VersaGUI.ContractTests/VersaGUI.ContractTests.csproj @@ -0,0 +1,14 @@ + + + + Exe + net7.0-windows + enable + enable + + + + + + +