commit 747bec985de8c57293166cbd5a398eb0fd8d80f8 Author: Julian Appel Date: Sun Mar 29 14:42:20 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52e4dd4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# .NET Build-Ausgaben +bin/ +obj/ + +# VS Code / VS +.vscode/ +.vs/ +*.user + +# macOS +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..e396ba8 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# VersaGUI + +Windows-Tray-App zur Konfiguration und Steuerung des VersaPad v2. +Geschrieben in **C# / .NET 7 / WinForms**. + +## Voraussetzungen + +- Windows 10/11 +- [.NET 7 SDK](https://dotnet.microsoft.com/download/dotnet/7.0) (zum Bauen) +- VersaMCU-Firmware auf dem Board geflasht +- Board per USB verbunden (erscheint als CDC COM-Port, kein Treiber nötig) + +## Starten + +```bash +dotnet run +``` + +Oder als Release-Build: + +```bash +dotnet publish -c Release -r win-x64 --self-contained +``` + +Die App erscheint als Icon in der Windows-Taskleiste (System Tray). Kein Hauptfenster. + +## Bedienung + +1. **Board verbinden** – App erkennt das Board automatisch per VID/PID (`0x239A / 0x0042`) via WMI +2. **Rechtsklick** auf das Tray-Icon → **Konfiguration...** +3. Taste/Encoder anklicken → Aktion auswählen: + - **HID Tastatur**: Großen Button klicken, dann gewünschte Taste drücken (Strg+C, F5, …) + - **HID Consumer**: Dropdown – Play/Pause, Lautstärke, etc. + - **Host Command**: Numerische Command-ID (zukünftig: URL/Programm) + - **LED-Farbe**: Farbpicker für die Idle-LED des Buttons +4. **Auf Board speichern** – überträgt Config in den NVM des Boards +5. Beim nächsten Verbinden wird die Config automatisch vom Board geladen + +## Projekt-Struktur + +``` +VersaGUI/ +├── VersaGUI.csproj – .NET 7 WinForms Projekt +├── Program.cs – Einstiegspunkt, startet TrayApp +├── TrayApp.cs – ApplicationContext: Tray-Icon, Menü, Paket-Routing +├── ConfigForm.cs – Konfigurations-Fenster (4×5 Grid + Encoder-Panel) +├── ActionDialog.cs – Dialog: Taste erfassen / Consumer auswählen / Host-Command +├── DeviceConfig.cs – C#-Spiegel von SDeviceConfig (163B Serialisierung, CRC16) +├── Protocol.cs – Protokoll-Konstanten (Commands + Events) +└── SerialManager.cs – COM-Port-Erkennung, Read-Loop, Config senden/empfangen +``` + +## Architektur + +``` +TrayApp + ├── SerialManager – Hintergrund-Thread: COM-Port-Verbindung + Leseloop + │ ├── Connected-Event → Config vom Board anfordern (CMD_CONFIG_READ) + │ └── PacketReceived → TrayApp.OnPacket() + ├── DeviceConfig – In-Memory-Modell der Board-Config + └── ConfigForm (optional) – Öffnet sich auf Benutzeranfrage + └── ActionDialog – Modaler Dialog pro Taste/Encoder +``` + +### SerialManager + +- Erkennt das Board per **WMI** (`Win32_PnPEntity`, VID/PID-Filter) → COM-Port-Name +- Lese-Loop im Hintergrund-Thread: sammelt 8-Byte-Pakete, feuert `PacketReceived`-Event auf dem UI-Thread +- **Reconnect-Timer**: versucht alle 3 Sekunden neu zu verbinden; nach Disconnect 5 Sekunden Backoff +- Wichtig: `DtrEnable = true` muss **vor** `Open()` gesetzt werden – der SAMD21 prüft die DTR-Leitung in `if (!SerialUSB)` und verwirft sonst alle ausgehenden Pakete + +### DeviceConfig / Serialisierung + +`DeviceConfig.ToBytes()` erzeugt den exakt 163-Byte-Puffer der `SDeviceConfig`-Struct in der Firmware entspricht (little-endian, packed). `CRC16-CCITT` (Poly 0x1021, Init 0xFFFF) über Bytes 7–162 – identisch zur Firmware-Implementierung. + +Config-Übertragung Board→PC (Lesen): +``` +PC sendet: CMD_CONFIG_READ (0x13) +Board sendet: EVT_CONFIG_BEGIN (Chunk-Anzahl) + EVT_CONFIG_DATA × 28 (je 6 Nutzbytes) + EVT_CONFIG_END +``` + +Config-Übertragung PC→Board (Schreiben): +``` +PC sendet: CMD_CONFIG_BEGIN (Chunk-Anzahl) + CMD_CONFIG_DATA × 28 + CMD_CONFIG_COMMIT +Board sendet: EVT_CONFIG_ACK oder EVT_CONFIG_NACK +``` + +### Bekannte .NET-Eigenheiten + +| Problem | Lösung | +|---|---| +| `.NET 7 SerialPort.ReadByte()` wirft `IOException` statt `TimeoutException` auf Timeout | `catch (IOException)` → nur als Fehler behandeln wenn Port nicht mehr offen ist | +| `DtrEnable = false` (Standard) → Board sendet nichts | `DtrEnable = true` im SerialPort-Konstruktor setzen, **vor** `Open()` | +| DTR-Zustandswechsel nach `Open()` löst CDC-Disconnect auf dem Board aus | DTR vor `Open()` setzen → kein Zustandswechsel | diff --git a/src/ActionDialog.cs b/src/ActionDialog.cs new file mode 100644 index 0000000..6f1d882 --- /dev/null +++ b/src/ActionDialog.cs @@ -0,0 +1,441 @@ +// ActionDialog.cs +// Modaler Dialog zum Bearbeiten einer einzelnen Button-/Encoder-Aktion. +// +// Layout je nach Typ: +// HID Tastatur → Capture-Button (Klicken + Taste drücken) + Modifier-Checkboxen +// HID Consumer → Dropdown mit benannten Medien-/Lautstärke-Aktionen +// Host Command → Zahlen-Eingabe (Command-ID) +// Keine Aktion → nichts +// +// Optional (nur MX-Buttons): LED-Basisfarbe + +namespace VersaGUI; + +public class ActionDialog : Form +{ + // ── Ergebnis ────────────────────────────────────────────────────────────── + public DeviceAction ResultAction { get; private set; } + public Color ResultColor { get; private set; } + + // ── Controls ────────────────────────────────────────────────────────────── + private readonly ComboBox _typeCombo; + + // HID Tastatur + private readonly Panel _hidKeyPanel; + private readonly Button _captureBtn; + private readonly CheckBox _chkCtrl, _chkShift, _chkAlt, _chkWin; + + // HID Consumer + private readonly Panel _consumerPanel; + private readonly ComboBox _consumerCombo; + + // Host Command + private readonly Panel _cmdPanel; + private readonly TextBox _cmdBox; + + // LED-Farbe + private readonly Panel _colorPanel; + private readonly Button _colorBtn; + private Color _color; + + // Capture-Zustand + private bool _capturing; + private byte _hidKeycode; // 0 = nicht belegt + + // ── Lookup-Tabellen ─────────────────────────────────────────────────────── + + // Windows VK → HID Keyboard Usage (USB HID Usage Table 0x07) + private static readonly Dictionary s_vkToHid = new() + { + // Buchstaben + [Keys.A]=0x04,[Keys.B]=0x05,[Keys.C]=0x06,[Keys.D]=0x07, + [Keys.E]=0x08,[Keys.F]=0x09,[Keys.G]=0x0A,[Keys.H]=0x0B, + [Keys.I]=0x0C,[Keys.J]=0x0D,[Keys.K]=0x0E,[Keys.L]=0x0F, + [Keys.M]=0x10,[Keys.N]=0x11,[Keys.O]=0x12,[Keys.P]=0x13, + [Keys.Q]=0x14,[Keys.R]=0x15,[Keys.S]=0x16,[Keys.T]=0x17, + [Keys.U]=0x18,[Keys.V]=0x19,[Keys.W]=0x1A,[Keys.X]=0x1B, + [Keys.Y]=0x1C,[Keys.Z]=0x1D, + // Ziffernreihe + [Keys.D1]=0x1E,[Keys.D2]=0x1F,[Keys.D3]=0x20,[Keys.D4]=0x21, + [Keys.D5]=0x22,[Keys.D6]=0x23,[Keys.D7]=0x24,[Keys.D8]=0x25, + [Keys.D9]=0x26,[Keys.D0]=0x27, + // Steuerung + [Keys.Return]=0x28,[Keys.Escape]=0x29,[Keys.Back]=0x2A, + [Keys.Tab]=0x2B, [Keys.Space]=0x2C, [Keys.Delete]=0x4C, + [Keys.Insert]=0x49,[Keys.Home]=0x4A, [Keys.PageUp]=0x4B, + [Keys.End]=0x4D, [Keys.PageDown]=0x4E, + [Keys.Right]=0x4F,[Keys.Left]=0x50, [Keys.Down]=0x51,[Keys.Up]=0x52, + [Keys.CapsLock]=0x39, + [Keys.PrintScreen]=0x46,[Keys.Scroll]=0x47,[Keys.Pause]=0x48, + [Keys.NumLock]=0x53, + // F-Tasten + [Keys.F1]=0x3A,[Keys.F2]=0x3B,[Keys.F3]=0x3C,[Keys.F4]=0x3D, + [Keys.F5]=0x3E,[Keys.F6]=0x3F,[Keys.F7]=0x40,[Keys.F8]=0x41, + [Keys.F9]=0x42,[Keys.F10]=0x43,[Keys.F11]=0x44,[Keys.F12]=0x45, + // Sonderzeichen (QWERTZ-kompatibel, basiert auf US-HID-Positionen) + [Keys.OemMinus]=0x2D, [Keys.Oemplus]=0x2E, + [Keys.OemOpenBrackets]=0x2F, [Keys.OemCloseBrackets]=0x30, + [Keys.OemPipe]=0x31, [Keys.OemSemicolon]=0x33, + [Keys.OemQuotes]=0x34, [Keys.Oemtilde]=0x35, + [Keys.Oemcomma]=0x36, [Keys.OemPeriod]=0x37, + [Keys.OemQuestion]=0x38, + // Numpad + [Keys.NumPad1]=0x59,[Keys.NumPad2]=0x5A,[Keys.NumPad3]=0x5B, + [Keys.NumPad4]=0x5C,[Keys.NumPad5]=0x5D,[Keys.NumPad6]=0x5E, + [Keys.NumPad7]=0x5F,[Keys.NumPad8]=0x60,[Keys.NumPad9]=0x61, + [Keys.NumPad0]=0x62,[Keys.Decimal]=0x63, + [Keys.Multiply]=0x55,[Keys.Add]=0x57, + [Keys.Subtract]=0x56,[Keys.Divide]=0x54, + }; + + // HID-Code → lesbarer Name (für Capture-Button-Beschriftung) + private static readonly Dictionary s_hidNames = new() + { + [0x04]="A",[0x05]="B",[0x06]="C",[0x07]="D",[0x08]="E",[0x09]="F", + [0x0A]="G",[0x0B]="H",[0x0C]="I",[0x0D]="J",[0x0E]="K",[0x0F]="L", + [0x10]="M",[0x11]="N",[0x12]="O",[0x13]="P",[0x14]="Q",[0x15]="R", + [0x16]="S",[0x17]="T",[0x18]="U",[0x19]="V",[0x1A]="W",[0x1B]="X", + [0x1C]="Y",[0x1D]="Z", + [0x1E]="1",[0x1F]="2",[0x20]="3",[0x21]="4",[0x22]="5", + [0x23]="6",[0x24]="7",[0x25]="8",[0x26]="9",[0x27]="0", + [0x28]="Enter", [0x29]="Escape", [0x2A]="Backspace", + [0x2B]="Tab", [0x2C]="Leertaste", [0x2D]="-", + [0x2E]="=", [0x2F]="[", [0x30]="]", + [0x31]="\\", [0x33]=";", [0x34]="'", + [0x35]="`", [0x36]=",", [0x37]=".", + [0x38]="/", [0x39]="CapsLock", + [0x3A]="F1", [0x3B]="F2", [0x3C]="F3", [0x3D]="F4", + [0x3E]="F5", [0x3F]="F6", [0x40]="F7", [0x41]="F8", + [0x42]="F9", [0x43]="F10",[0x44]="F11",[0x45]="F12", + [0x46]="Druck", [0x47]="Rollen", [0x48]="Pause", + [0x49]="Einfg", [0x4A]="Pos1", [0x4B]="Bild Auf", + [0x4C]="Entf", [0x4D]="Ende", [0x4E]="Bild Ab", + [0x4F]="Rechts", [0x50]="Links", [0x51]="Runter", [0x52]="Hoch", + [0x53]="NumLock",[0x54]="Num/", [0x55]="Num*", + [0x56]="Num-", [0x57]="Num+", + [0x59]="Num1", [0x5A]="Num2", [0x5B]="Num3", [0x5C]="Num4", + [0x5D]="Num5", [0x5E]="Num6", [0x5F]="Num7", [0x60]="Num8", + [0x61]="Num9", [0x62]="Num0", [0x63]="Num.", + }; + + // HID Consumer Usage IDs (Usage Page 0x0C) + private static readonly (ushort Usage, string Name)[] s_consumer = + { + (0x00CD, "Play / Pause"), + (0x00B5, "Nächster Titel"), + (0x00B6, "Vorheriger Titel"), + (0x00B7, "Stop"), + (0x00E9, "Lauter"), + (0x00EA, "Leiser"), + (0x00E2, "Stummschalten"), + (0x0192, "Taschenrechner"), + (0x0223, "Browser – Startseite"), + (0x0224, "Browser – Zurück"), + (0x0225, "Browser – Vor"), + (0x00B0, "Aufnahme"), + }; + + // ── Konstruktor ─────────────────────────────────────────────────────────── + + public ActionDialog(DeviceAction action, Color ledColor, bool showColor = true) + { + ResultAction = action.Clone(); + ResultColor = ledColor; + _color = ledColor; + + // Formular-Grundeinstellungen + Text = "Aktion bearbeiten"; + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterParent; + MinimizeBox = false; + MaximizeBox = false; + KeyPreview = true; // Tastendrücke abfangen bevor Controls sie kriegen + + // ── Typ-Auswahl ─────────────────────────────────────────────────────── + var typeLabel = new Label { Text = "Typ:", Location = new Point(12, 16), AutoSize = true }; + _typeCombo = new ComboBox + { + Location = new Point(80, 12), + Width = 208, + DropDownStyle = ComboBoxStyle.DropDownList, + }; + _typeCombo.Items.AddRange(new object[] + { "Keine Aktion", "HID Tastatur", "HID Consumer", "Host Command" }); + _typeCombo.SelectedIndex = (int)action.Type; + _typeCombo.SelectedIndexChanged += OnTypeChanged; + + // ── HID-Tastatur-Panel ──────────────────────────────────────────────── + _hidKeyPanel = new Panel { Location = new Point(12, 48), Size = new Size(280, 110) }; + + _captureBtn = new Button + { + Size = new Size(280, 48), + Location = new Point(0, 0), + FlatStyle = FlatStyle.Flat, + Font = new Font(Font.FontFamily, 12, FontStyle.Regular), + Cursor = Cursors.Hand, + }; + _captureBtn.FlatAppearance.BorderColor = Color.DarkGray; + _captureBtn.Click += OnCaptureClick; + + var hint = new Label + { + Text = "Klicken und dann Taste drücken | Esc = Abbrechen", + Location = new Point(0, 54), + AutoSize = true, + ForeColor = SystemColors.GrayText, + Font = new Font(Font.FontFamily, 8), + }; + + _chkCtrl = new CheckBox { Text = "Strg", Location = new Point(0, 78), AutoSize = true }; + _chkShift = new CheckBox { Text = "Shift", Location = new Point(60, 78), AutoSize = true }; + _chkAlt = new CheckBox { Text = "Alt", Location = new Point(120, 78), AutoSize = true }; + _chkWin = new CheckBox { Text = "Win", Location = new Point(176, 78), AutoSize = true }; + _chkCtrl.CheckedChanged += (_, _) => RefreshCaptureBtn(); + _chkShift.CheckedChanged += (_, _) => RefreshCaptureBtn(); + _chkAlt.CheckedChanged += (_, _) => RefreshCaptureBtn(); + _chkWin.CheckedChanged += (_, _) => RefreshCaptureBtn(); + + // Aus bestehender Aktion befüllen + if (action.Type == ActionType.HidKey) + { + _hidKeycode = (byte)(action.Data & 0xFF); + byte mod = (byte)(action.Data >> 8); + _chkCtrl.Checked = (mod & 0x11) != 0; + _chkShift.Checked = (mod & 0x22) != 0; + _chkAlt.Checked = (mod & 0x44) != 0; + _chkWin.Checked = (mod & 0x88) != 0; + } + + _hidKeyPanel.Controls.AddRange(new Control[] + { _captureBtn, hint, _chkCtrl, _chkShift, _chkAlt, _chkWin }); + + RefreshCaptureBtn(); // erst nach Checkbox-Initialisierung aufrufen + + // ── Consumer-Panel ──────────────────────────────────────────────────── + _consumerPanel = new Panel { Location = new Point(12, 48), Size = new Size(280, 30) }; + _consumerCombo = new ComboBox + { + Location = new Point(0, 0), + Width = 280, + DropDownStyle = ComboBoxStyle.DropDownList, + }; + foreach (var (_, name) in s_consumer) + _consumerCombo.Items.Add(name); + + int consumerIdx = action.Type == ActionType.HidConsumer + ? Array.FindIndex(s_consumer, e => e.Usage == action.Data) + : -1; + _consumerCombo.SelectedIndex = consumerIdx >= 0 ? consumerIdx : 0; + _consumerPanel.Controls.Add(_consumerCombo); + + // ── Host-Command-Panel ──────────────────────────────────────────────── + _cmdPanel = new Panel { Location = new Point(12, 48), Size = new Size(280, 30) }; + var cmdLabel = new Label { Text = "Command-ID:", Location = new Point(0, 8), AutoSize = true }; + _cmdBox = new TextBox + { + Location = new Point(90, 4), + Width = 190, + Text = action.Type == ActionType.HostCommand ? $"{action.Data}" : "0", + }; + _cmdPanel.Controls.AddRange(new Control[] { cmdLabel, _cmdBox }); + + // ── Farb-Panel ──────────────────────────────────────────────────────── + _colorPanel = new Panel + { + Location = new Point(12, 168), + Size = new Size(280, 30), + Visible = showColor, + }; + var colorLabel = new Label { Text = "LED-Farbe:", Location = new Point(0, 8), AutoSize = true }; + _colorBtn = new Button + { + Location = new Point(80, 2), + Size = new Size(200, 26), + BackColor = ledColor, + FlatStyle = FlatStyle.Flat, + Text = string.Empty, + }; + _colorBtn.FlatAppearance.BorderSize = 1; + _colorBtn.Click += OnColorClick; + _colorPanel.Controls.AddRange(new Control[] { colorLabel, _colorBtn }); + + // ── Buttons Fußzeile ────────────────────────────────────────────────── + int footerY = showColor ? 208 : 168; + var okBtn = new Button + { + Text = "OK", + DialogResult = DialogResult.OK, + Location = new Point(112, footerY), + Width = 80, + }; + okBtn.Click += OnOk; + + var cancelBtn = new Button + { + Text = "Abbrechen", + DialogResult = DialogResult.Cancel, + Location = new Point(200, footerY), + Width = 92, + }; + + AcceptButton = okBtn; + CancelButton = cancelBtn; + ClientSize = new Size(304, footerY + 44); + + Controls.AddRange(new Control[] + { + typeLabel, _typeCombo, + _hidKeyPanel, _consumerPanel, _cmdPanel, _colorPanel, + okBtn, cancelBtn, + }); + + UpdatePanelVisibility(); + } + + // ── Panel-Sichtbarkeit ──────────────────────────────────────────────────── + + private void OnTypeChanged(object? sender, EventArgs e) + { + StopCapture(); + UpdatePanelVisibility(); + } + + private void UpdatePanelVisibility() + { + var t = (ActionType)_typeCombo.SelectedIndex; + _hidKeyPanel.Visible = t == ActionType.HidKey; + _consumerPanel.Visible = t == ActionType.HidConsumer; + _cmdPanel.Visible = t == ActionType.HostCommand; + } + + // ── Tasten-Erfassung ────────────────────────────────────────────────────── + + private void OnCaptureClick(object? sender, EventArgs e) + { + if (_capturing) StopCapture(); + else StartCapture(); + } + + private void StartCapture() + { + _capturing = true; + _captureBtn.Text = "Taste drücken..."; + _captureBtn.BackColor = Color.FromArgb(255, 220, 80); + _captureBtn.ForeColor = Color.Black; + _captureBtn.FlatAppearance.BorderColor = Color.DarkOrange; + } + + private void StopCapture() + { + _capturing = false; + RefreshCaptureBtn(); + } + + private void RefreshCaptureBtn() + { + if (_hidKeycode != 0 && s_hidNames.TryGetValue(_hidKeycode, out string? kn)) + { + var parts = new List(); + if (_chkCtrl.Checked) parts.Add("Strg"); + if (_chkShift.Checked) parts.Add("Shift"); + if (_chkAlt.Checked) parts.Add("Alt"); + if (_chkWin.Checked) parts.Add("Win"); + parts.Add(kn); + _captureBtn.Text = string.Join(" + ", parts); + } + else + { + _captureBtn.Text = "— (klicken zum Erfassen)"; + } + _captureBtn.BackColor = SystemColors.Control; + _captureBtn.ForeColor = SystemColors.ControlText; + _captureBtn.FlatAppearance.BorderColor = Color.DarkGray; + } + + // Tasten werden form-weit abgefangen (KeyPreview = true) + protected override void OnKeyDown(KeyEventArgs e) + { + if (!_capturing) { base.OnKeyDown(e); return; } + + // Reine Modifier-Tasten ignorieren – auf echte Taste warten + if (e.KeyCode is Keys.ControlKey or Keys.LControlKey or Keys.RControlKey + or Keys.ShiftKey or Keys.LShiftKey or Keys.RShiftKey + or Keys.Menu or Keys.LMenu or Keys.RMenu + or Keys.LWin or Keys.RWin or Keys.Apps) + { + e.Handled = true; + return; + } + + // Escape bricht Erfassung ab (ohne Wert zu ändern) + if (e.KeyCode == Keys.Escape) + { + StopCapture(); + e.Handled = true; + e.SuppressKeyPress = true; + return; + } + + // Modifikatoren übernehmen + _chkCtrl.Checked = e.Control; + _chkShift.Checked = e.Shift; + _chkAlt.Checked = e.Alt; + + // VK → HID-Code + _hidKeycode = s_vkToHid.TryGetValue(e.KeyCode, out byte hid) ? hid : (byte)0; + + StopCapture(); + e.Handled = true; + e.SuppressKeyPress = true; + } + + // ── Farbe ───────────────────────────────────────────────────────────────── + + private void OnColorClick(object? sender, EventArgs e) + { + using var dlg = new ColorDialog { Color = _color, FullOpen = true }; + if (dlg.ShowDialog(this) == DialogResult.OK) + { + _color = dlg.Color; + _colorBtn.BackColor = dlg.Color; + } + } + + // ── OK ──────────────────────────────────────────────────────────────────── + + private void OnOk(object? sender, EventArgs e) + { + var type = (ActionType)_typeCombo.SelectedIndex; + ushort data = 0; + + switch (type) + { + case ActionType.HidKey: + byte mod = 0; + if (_chkCtrl.Checked) mod |= 0x01; // LCtrl + if (_chkShift.Checked) mod |= 0x02; // LShift + if (_chkAlt.Checked) mod |= 0x04; // LAlt + if (_chkWin.Checked) mod |= 0x08; // LGUI + data = (ushort)(_hidKeycode | (mod << 8)); + break; + + case ActionType.HidConsumer: + data = s_consumer[_consumerCombo.SelectedIndex].Usage; + break; + + case ActionType.HostCommand: + if (!ushort.TryParse(_cmdBox.Text.Trim(), out data)) + { + MessageBox.Show("Ungültige Command-ID (0–65535 erwartet).", + "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning); + DialogResult = DialogResult.None; + return; + } + break; + } + + ResultAction = new DeviceAction { Type = type, Data = data }; + ResultColor = _color; + } +} diff --git a/src/ConfigForm.cs b/src/ConfigForm.cs new file mode 100644 index 0000000..155a41f --- /dev/null +++ b/src/ConfigForm.cs @@ -0,0 +1,310 @@ +// ConfigForm.cs +// Hauptfenster der VersaPad-Konfiguration. +// +// Layout: +// ┌── Tasten (4 × 5 Grid) ──────────────────────────────────────────────┐ +// │ Jede Taste = Button mit Hintergrundfarbe (= LED-Base) und │ +// │ Aktionsbeschriftung. Klick öffnet ActionDialog. │ +// └────────────────────────────────────────────────────────────────────┘ +// ┌── Encoder ─────────────────────────────────────────────────────────┐ +// │ 4 Zeilen à [SW][CW][CCW] – ohne LED-Farbe │ +// └────────────────────────────────────────────────────────────────────┘ +// [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. + +namespace VersaGUI; + +public class ConfigForm : Form +{ + private readonly DeviceConfig _config; + private readonly SerialManager _serial; + + // Grid-Buttons für die 20 MX-Buttons (Index = mx_idx = key_id - 5) + private readonly Button[] _btnGrid = new Button[20]; + + // Encoder-Buttons [enc][SW/CW/CCW] + private readonly Button[,] _encBtns = new Button[4, 3]; + + private Button _saveBtn = null!; + + public ConfigForm(DeviceConfig config, SerialManager serial) + { + _config = config; + _serial = serial; + + Text = "VersaPad – Konfiguration"; + FormBorderStyle = FormBorderStyle.FixedSingle; + StartPosition = FormStartPosition.CenterScreen; + MaximizeBox = false; + ClientSize = new Size(520, 480); + + int y = 12; + y = BuildButtonGrid(y); + y = BuildEncoderPanel(y); + BuildFooter(y); + + // Verbindungsstatus → Save-Button + _serial.Connected += (_, _) => InvokeOnUi(() => _saveBtn.Enabled = true); + _serial.Disconnected += (_, _) => InvokeOnUi(() => _saveBtn.Enabled = false); + _saveBtn.Enabled = _serial.IsConnected; + } + + // ── Tasten-Grid (4 Spalten × 5 Zeilen) ─────────────────────────────────── + + private int BuildButtonGrid(int startY) + { + var group = new GroupBox + { + Text = "Tasten", + Location = new Point(12, startY), + Size = new Size(496, 220), + }; + + var grid = new TableLayoutPanel + { + Location = new Point(8, 20), + Size = new Size(480, 192), + ColumnCount = 4, + RowCount = 5, + CellBorderStyle = TableLayoutPanelCellBorderStyle.Single, + }; + + // Gleichmäßige Spalten/Zeilen + for (int c = 0; c < 4; c++) + grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25f)); + for (int r = 0; r < 5; r++) + grid.RowStyles.Add(new RowStyle(SizeType.Percent, 20f)); + + // MX-Buttons: col=0..3 (physisch COL1..4), row=0..4 + // mx_idx = col * 5 + row (entspricht key_id - 5) + for (int col = 0; col < 4; col++) + { + for (int row = 0; row < 5; row++) + { + int mxIdx = col * 5 + row; + var btn = new Button + { + Dock = DockStyle.Fill, + Margin = new Padding(2), + FlatStyle = FlatStyle.Flat, + Tag = mxIdx, + }; + btn.FlatAppearance.BorderSize = 0; + btn.Click += OnMxButtonClick; + _btnGrid[mxIdx] = btn; + grid.Controls.Add(btn, col, row); + RefreshMxButton(mxIdx); + } + } + + group.Controls.Add(grid); + Controls.Add(group); + return startY + group.Height + 8; + } + + // ── Encoder-Panel ───────────────────────────────────────────────────────── + + private int BuildEncoderPanel(int startY) + { + var group = new GroupBox + { + Text = "Encoder", + Location = new Point(12, startY), + Size = new Size(496, 145), + }; + + string[] labels = { "SW", "CW", "CCW" }; + + // Header-Zeile + for (int i = 0; i < 3; i++) + { + group.Controls.Add(new Label + { + Text = labels[i], + Location = new Point(110 + i * 122, 22), + AutoSize = true, + Font = new Font(Font, FontStyle.Bold), + TextAlign = ContentAlignment.MiddleCenter, + }); + } + + for (int enc = 0; enc < 4; enc++) + { + group.Controls.Add(new Label + { + Text = $"ENC {enc}:", + Location = new Point(10, 44 + enc * 26), + AutoSize = true, + }); + + for (int act = 0; act < 3; act++) + { + int e = enc, a = act; + var btn = new Button + { + Location = new Point(80 + act * 122, 40 + enc * 26), + Size = new Size(116, 22), + FlatStyle = FlatStyle.Flat, + Tag = (e, a), + }; + btn.FlatAppearance.BorderColor = Color.Gray; + btn.Click += OnEncButtonClick; + _encBtns[enc, act] = btn; + group.Controls.Add(btn); + RefreshEncButton(enc, act); + } + } + + Controls.Add(group); + return startY + group.Height + 8; + } + + // ── Fußzeile ────────────────────────────────────────────────────────────── + + private void BuildFooter(int startY) + { + _saveBtn = new Button + { + Text = "Auf Board speichern", + Location = new Point(12, startY), + Size = new Size(160, 30), + }; + _saveBtn.Click += OnSave; + + var pingBtn = new Button + { + Text = "Ping", + Location = new Point(184, startY), + Size = new Size(60, 30), + }; + pingBtn.Click += (_, _) => _serial.Send(new SerialPacket(Protocol.CmdPing)); + + var closeBtn = new Button + { + Text = "Schließen", + Location = new Point(424, startY), + Size = new Size(84, 30), + }; + closeBtn.Click += (_, _) => Close(); + + Controls.AddRange(new Control[] { _saveBtn, pingBtn, closeBtn }); + + // Fensterhöhe anpassen + ClientSize = new Size(ClientSize.Width, startY + 50); + } + + // ── Klick-Handler ───────────────────────────────────────────────────────── + + private void OnMxButtonClick(object? sender, EventArgs e) + { + if (sender is not Button btn || btn.Tag is not int mxIdx) return; + + using var dlg = new ActionDialog( + _config.MxActions[mxIdx], + _config.LedBase[mxIdx], + showColor: true); + + if (dlg.ShowDialog(this) != DialogResult.OK) return; + + _config.MxActions[mxIdx] = dlg.ResultAction; + _config.LedBase[mxIdx] = dlg.ResultColor; + RefreshMxButton(mxIdx); + } + + private void OnEncButtonClick(object? sender, EventArgs e) + { + if (sender is not Button btn || btn.Tag is not (int enc, int act)) return; + + using var dlg = new ActionDialog( + _config.EncActions[enc, act], + Color.Black, + showColor: false); + + if (dlg.ShowDialog(this) != DialogResult.OK) return; + + _config.EncActions[enc, act] = dlg.ResultAction; + RefreshEncButton(enc, act); + } + + private void OnSave(object? sender, EventArgs e) + { + _saveBtn.Enabled = false; + _saveBtn.Text = "Wird gesendet..."; + + // SendConfig blockiert ~180ms (Delays zwischen Paketen) → Background-Thread + Task.Run(() => + { + _serial.SendConfig(_config); + InvokeOnUi(() => + { + _saveBtn.Text = "Auf Board speichern"; + _saveBtn.Enabled = _serial.IsConnected; + // Ergebnis kommt als ACK/NACK-Event vom Board → TrayApp zeigt Balloon + }); + }); + } + + // ── Anzeige aktualisieren ───────────────────────────────────────────────── + + // Alle Buttons neu zeichnen (z.B. nach Config-Laden vom Board) + public void RefreshAll() + { + for (int i = 0; i < 20; i++) RefreshMxButton(i); + for (int enc = 0; enc < 4; enc++) + for (int act = 0; act < 3; act++) + RefreshEncButton(enc, act); + } + + private void RefreshMxButton(int mxIdx) + { + var btn = _btnGrid[mxIdx]; + var action = _config.MxActions[mxIdx]; + var color = _config.LedBase[mxIdx]; + + btn.BackColor = color; + // Textfarbe kontrastreich zur Hintergrundfarbe wählen + btn.ForeColor = Luminance(color) > 128 ? Color.Black : Color.White; + btn.Text = action.Display; + btn.ToolTipText(action.TypeDescription()); + } + + private void RefreshEncButton(int enc, int act) + { + var btn = _encBtns[enc, act]; + var action = _config.EncActions[enc, act]; + btn.Text = action.Display; + } + + // Helligkeit einer Farbe (0–255) für Kontrast-Entscheidung + private static int Luminance(Color c) + => (c.R * 299 + c.G * 587 + c.B * 114) / 1000; + + private void InvokeOnUi(Action a) + { + if (InvokeRequired) Invoke(a); + else a(); + } +} + +// ── Erweiterungsmethoden ────────────────────────────────────────────────────── + +internal static class Extensions +{ + // Button.ToolTipText existiert in WinForms nicht direkt – ToolTip-Komponente nötig. + // Wir verwenden einen statischen ToolTip für alle Buttons. + private static readonly ToolTip _tip = new(); + + public static void ToolTipText(this Control ctrl, string text) + => _tip.SetToolTip(ctrl, text); + + public static string TypeDescription(this DeviceAction a) => a.Type switch + { + ActionType.HidKey => "HID Tastatur-Keycode", + ActionType.HidConsumer => "HID Consumer Control (Media/Volume)", + ActionType.HostCommand => "Host Command – App führt aus", + _ => "Keine Aktion", + }; +} diff --git a/src/DeviceConfig.cs b/src/DeviceConfig.cs new file mode 100644 index 0000000..7e69ca4 --- /dev/null +++ b/src/DeviceConfig.cs @@ -0,0 +1,218 @@ +// DeviceConfig.cs +// C#-Spiegel der SDeviceConfig-Struktur aus der Firmware (nvm_config.h). +// +// Serialisiertes Layout (packed, 163 Bytes): +// Offset 0 4B Magic 0x56503202 +// Offset 4 1B Version 1 +// Offset 5 2B CRC16 (CCITT über Bytes 7–162) +// Offset 7 60B mx_actions[20] je 3B: type(1) + data_lo(1) + data_hi(1) +// Offset 67 36B enc_actions[4][3] je 3B +// Offset103 20B led_r[20] +// Offset123 20B led_g[20] +// Offset143 20B led_b[20] + +using System.Drawing; + +namespace VersaGUI; + +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 +} + +public class DeviceAction +{ + public ActionType Type { get; set; } = ActionType.None; + public ushort Data { get; set; } = 0; + + // Kurzanzeige für Buttons und Tooltips + public string Display + { + get + { + if (Type == ActionType.None) return "—"; + if (Type == ActionType.HidKey) + { + byte keycode = (byte)(Data & 0xFF); + byte mod = (byte)(Data >> 8); + var parts = new System.Collections.Generic.List(); + if ((mod & 0x11) != 0) parts.Add("Strg"); + if ((mod & 0x22) != 0) parts.Add("Shift"); + if ((mod & 0x44) != 0) parts.Add("Alt"); + if ((mod & 0x88) != 0) parts.Add("Win"); + parts.Add(HidKeyName(keycode)); + return string.Join("+", parts); + } + if (Type == ActionType.HidConsumer) + { + return Data switch + { + 0x00CD => "Play/Pause", + 0x00B5 => "Nächster", + 0x00B6 => "Vorheriger", + 0x00B7 => "Stop", + 0x00E9 => "Lauter", + 0x00EA => "Leiser", + 0x00E2 => "Mute", + _ => $"C:{Data:X3}", + }; + } + return $"CMD {Data}"; + } + } + + private static string HidKeyName(byte hid) => hid switch + { + >= 0x04 and <= 0x1D => ((char)('A' + hid - 0x04)).ToString(), + 0x1E => "1", 0x1F => "2", 0x20 => "3", 0x21 => "4", 0x22 => "5", + 0x23 => "6", 0x24 => "7", 0x25 => "8", 0x26 => "9", 0x27 => "0", + 0x28 => "Enter", 0x29 => "Esc", 0x2A => "Bksp", 0x2B => "Tab", + 0x2C => "Space", 0x4C => "Entf", 0x49 => "Einfg", 0x4A => "Pos1", + 0x4D => "Ende", 0x4B => "PgUp", 0x4E => "PgDn", + 0x4F => "→", 0x50 => "←", 0x51 => "↓", 0x52 => "↑", + 0x3A => "F1", 0x3B => "F2", 0x3C => "F3", 0x3D => "F4", + 0x3E => "F5", 0x3F => "F6", 0x40 => "F7", 0x41 => "F8", + 0x42 => "F9", 0x43 => "F10", 0x44 => "F11", 0x45 => "F12", + _ => $"0x{hid:X2}", + }; + + public DeviceAction Clone() => new() { Type = Type, Data = Data }; +} + +public class DeviceConfig +{ + public const uint Magic = 0x56503202; + public const byte Version = 1; + + // Encoder-Aktions-Indizes (Spalte in enc_actions[enc, idx]) + public const int EncSw = 0; + public const int EncCw = 1; + public const int EncCcw = 2; + + // 20 MX-Button-Aktionen (key_id 5–24, mx_idx = key_id - 5) + public DeviceAction[] MxActions { get; } = + Enumerable.Range(0, 20).Select(_ => new DeviceAction()).ToArray(); + + // 4 Encoder × 3 Aktionen [enc][SW/CW/CCW] + public DeviceAction[,] EncActions { get; } = InitEncActions(); + + // Base-LED-Farben für die 20 MX-Buttons (Default: warm-weiß wie Firmware) + public Color[] LedBase { get; } = + Enumerable.Repeat(Color.FromArgb(80, 40, 0), 20).ToArray(); + + private static DeviceAction[,] InitEncActions() + { + var a = new DeviceAction[4, 3]; + for (int e = 0; e < 4; e++) + for (int i = 0; i < 3; i++) + a[e, i] = new DeviceAction(); + return a; + } + + // ── Serialisierung ──────────────────────────────────────────────────────── + + // Erzeugt den 163-Byte-Puffer der 1:1 SDeviceConfig entspricht. + public byte[] ToBytes() + { + const int size = 163; + var buf = new byte[size]; + int pos = 0; + + // Magic (little-endian) + WriteU32(buf, ref pos, Magic); + // Version + buf[pos++] = Version; + // CRC-Platzhalter (wird am Ende eingetragen) + int crcOffset = pos; + pos += 2; + + // mx_actions[20] + foreach (var a in MxActions) + WriteAction(buf, ref pos, a); + + // enc_actions[4][3] + for (int e = 0; e < 4; e++) + for (int i = 0; i < 3; i++) + WriteAction(buf, ref pos, EncActions[e, i]); + + // led_r / led_g / led_b + for (int i = 0; i < 20; i++) buf[pos++] = LedBase[i].R; + for (int i = 0; i < 20; i++) buf[pos++] = LedBase[i].G; + for (int i = 0; i < 20; i++) buf[pos++] = LedBase[i].B; + + // CRC über alles ab mx_actions (Offset 7) + ushort crc = Crc16(buf, 7, size - 7); + buf[crcOffset] = (byte)(crc & 0xFF); + buf[crcOffset + 1] = (byte)(crc >> 8); + + return buf; + } + + // Lädt Config aus einem 163-Byte-Puffer (empfangen vom Board). + // Gibt false zurück wenn Magic/Version/CRC ungültig. + public bool FromBytes(byte[] buf) + { + if (buf.Length < 163) return false; + + uint magic = ReadU32(buf, 0); + if (magic != Magic) return false; + if (buf[4] != Version) return false; + + ushort storedCrc = (ushort)(buf[5] | (buf[6] << 8)); + ushort computedCrc = Crc16(buf, 7, 163 - 7); + if (storedCrc != computedCrc) return false; + + int pos = 7; + for (int i = 0; i < 20; i++) ReadAction(buf, ref pos, MxActions[i]); + for (int e = 0; e < 4; e++) + for (int i = 0; i < 3; i++) + ReadAction(buf, ref pos, EncActions[e, i]); + + for (int i = 0; i < 20; i++) LedBase[i] = Color.FromArgb(buf[pos + i], buf[pos + 20 + i], buf[pos + 40 + i]); + + return true; + } + + // ── Hilfsmethoden ───────────────────────────────────────────────────────── + + private static void WriteAction(byte[] buf, ref int pos, DeviceAction a) + { + buf[pos++] = (byte)a.Type; + buf[pos++] = (byte)(a.Data & 0xFF); + buf[pos++] = (byte)(a.Data >> 8); + } + + private static void ReadAction(byte[] buf, ref int pos, DeviceAction a) + { + a.Type = (ActionType)buf[pos++]; + a.Data = (ushort)(buf[pos] | (buf[pos + 1] << 8)); + pos += 2; + } + + private static void WriteU32(byte[] buf, ref int pos, uint v) + { + buf[pos++] = (byte)(v); + buf[pos++] = (byte)(v >> 8); + buf[pos++] = (byte)(v >> 16); + buf[pos++] = (byte)(v >> 24); + } + + private static uint ReadU32(byte[] buf, int pos) + => (uint)(buf[pos] | (buf[pos+1] << 8) | (buf[pos+2] << 16) | (buf[pos+3] << 24)); + + // CRC16-CCITT (Poly 0x1021, Init 0xFFFF) – identisch zur Firmware + public static ushort Crc16(byte[] data, int offset, int length) + { + ushort crc = 0xFFFF; + for (int i = offset; i < offset + length; i++) + { + crc ^= (ushort)(data[i] << 8); + for (int b = 0; b < 8; b++) + crc = (crc & 0x8000) != 0 ? (ushort)((crc << 1) ^ 0x1021) : (ushort)(crc << 1); + } + return crc; + } +} diff --git a/src/Program.cs b/src/Program.cs new file mode 100644 index 0000000..5c55fce --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,11 @@ +namespace VersaGUI; + +static class Program +{ + [STAThread] + static void Main() + { + ApplicationConfiguration.Initialize(); + Application.Run(new TrayApp()); + } +} \ No newline at end of file diff --git a/src/Protocol.cs b/src/Protocol.cs new file mode 100644 index 0000000..224d358 --- /dev/null +++ b/src/Protocol.cs @@ -0,0 +1,72 @@ +// Protocol.cs +// Paket-Definitionen und Konstanten für die Board↔App-Kommunikation. +// Spiegelt usb_serial.h aus der Firmware 1:1 wider. +// +// 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) + +namespace VersaGUI; + +public static class Protocol +{ + public const int PacketSize = 8; + + // ── Commands: App → Board (0x01–0x7F) ──────────────────────────────────── + public const byte CmdSetLedOverride = 0x01; // key_id, r, g, b + public const byte CmdClearLedOverride = 0x02; // key_id + public const byte CmdSetLedBase = 0x03; // key_id, r, g, b + + // Config-Übertragung (mehrteilig, 6 Nutzbytes pro Paket): + // 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; + public const byte CmdConfigCommit = 0x12; + public const byte CmdConfigRead = 0x13; // Board sendet aktuelle NVM-Config 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 + 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 + public const byte EvtConfigBegin = 0x92; // Beginn Config-Dump (Data[1] = Chunks) + public const byte EvtConfigData = 0x93; // Config-Chunk (Data[1] = Index, Data[2..7] = 6B) + public const byte EvtConfigEnd = 0x94; // Config-Dump vollständig +} + +// Ein 8-Byte-Paket mit benannten Accessoren. +public class SerialPacket +{ + public byte[] Data { get; } = new byte[Protocol.PacketSize]; + + public byte Command => Data[0]; + public byte KeyId => Data[1]; + public byte R => Data[2]; + public byte G => Data[3]; + public byte B => Data[4]; + + // Leeres Paket (für eingehende Daten) + public SerialPacket() { } + + // Ausgehendes Paket bauen + public SerialPacket(byte command, byte keyId = 0, byte a = 0, byte b = 0, byte c = 0) + { + Data[0] = command; + Data[1] = keyId; + Data[2] = a; + Data[3] = b; + Data[4] = c; + // Data[5..7] bleiben 0x00 + } +} diff --git a/src/SerialManager.cs b/src/SerialManager.cs new file mode 100644 index 0000000..24fafbf --- /dev/null +++ b/src/SerialManager.cs @@ -0,0 +1,263 @@ +// SerialManager.cs +// Verwaltet die Verbindung zum VersaPad-Board über den CDC Serial-Port. +// +// COM-Port-Erkennung: +// Sucht per WMI nach einem Gerät mit VID 0x239A / PID 0x0042. +// Funktioniert auch wenn das Board unter einer wechselnden COM-Nummer erscheint. +// +// Threading: +// - _readThread: Hintergrund-Thread, liest eingehende Bytes und baut Pakete zusammen. +// - _reconnectTimer: System.Threading.Timer, versucht alle 2s neu zu verbinden. +// - Alle Events werden auf dem UI-Thread gefeuert (via SynchronizationContext), +// damit TrayApp direkt NotifyIcon und Menü aktualisieren kann. +// +// Paketgrenze: +// Pakete sind immer genau 8 Bytes. Der Leseloop sammelt Bytes bis ein vollständiges +// Paket vorliegt, dann wird PacketReceived gefeuert. + +using System.IO.Ports; +using System.Management; + +namespace VersaGUI; + +public class SerialManager : IDisposable +{ + // VID/PID des VersaPad-Boards (muss mit platformio.ini übereinstimmen) + private const string VidPid = "VID_239A&PID_0042"; + + // ── Events ──────────────────────────────────────────────────────────────── + public event EventHandler? PacketReceived; + public event EventHandler? Connected; + public event EventHandler? Disconnected; + + // ── State ───────────────────────────────────────────────────────────────── + private SerialPort? _port; + private Thread? _readThread; + private System.Threading.Timer? _reconnectTimer; + private readonly SynchronizationContext _ui; + private readonly object _connectLock = new(); // Verhindert parallele TryConnect()-Aufrufe + private bool _disposed; + private bool _waitingAfterDisconnect; // Backoff nach Trennung aktiv + + public bool IsConnected => _port?.IsOpen == true; + + public SerialManager() + { + // UI-Synchronisationskontext merken (muss im UI-Thread konstruiert werden) + _ui = SynchronizationContext.Current ?? new SynchronizationContext(); + + // Alle 3s versuchen zu verbinden (kurze Pause beim Start, dann periodisch) + _reconnectTimer = new System.Threading.Timer( + _ => TryConnect(), null, + dueTime: TimeSpan.FromMilliseconds(500), + period: TimeSpan.FromSeconds(3)); + } + + // ── Verbindungsaufbau ───────────────────────────────────────────────────── + + public void TryConnect() + { + // Nur ein gleichzeitiger Verbindungsversuch + kein Versuch während Backoff + if (!Monitor.TryEnter(_connectLock)) return; + try + { + if (IsConnected || _waitingAfterDisconnect) return; + + string? portName = FindPort(); + if (portName is null) return; + + var port = new SerialPort(portName, 115200) + { + ReadTimeout = 500, + WriteTimeout = 500, + DtrEnable = true, // Muss VOR Open() gesetzt werden – SAMD21 prüft DTR + // in SerialUSB-bool für usb_serial_send(). Default ist + // false, was alle Board→PC-Antworten still verwirft. + // Vor Open() gesetzt verursacht es keinen Zustandswechsel + // und triggert keinen CDC-Disconnect auf dem Board. + }; + + port.Open(); + + // Kurz warten bis USB/CDC sich nach der Verbindungsherstellung stabilisiert + Thread.Sleep(200); + + _port = port; + + // Lesethread starten + _readThread = new Thread(ReadLoop) { IsBackground = true, Name = "VersaPad-Read" }; + _readThread.Start(); + + _ui.Post(_ => Connected?.Invoke(this, EventArgs.Empty), null); + } + catch + { + _port?.Dispose(); + _port = null; + } + finally + { + Monitor.Exit(_connectLock); + } + } + + public void Disconnect() + { + var port = _port; + _port = null; + try { port?.Close(); } catch { } + port?.Dispose(); + } + + // ── COM-Port-Erkennung per WMI ──────────────────────────────────────────── + // + // Sucht in Win32_PnPEntity nach einem Eintrag der VID+PID enthält. + // Der Name des Eintrags enthält die COM-Bezeichnung in Klammern, z.B. + // "USB Serial Device (COM3)" + // → extrahiert "COM3" + + private string? FindPort() + { + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT Name, DeviceID FROM Win32_PnPEntity " + + $"WHERE DeviceID LIKE '%{VidPid}%'"); + + foreach (ManagementObject obj in searcher.Get()) + { + string? name = obj["Name"]?.ToString(); + if (name is null) continue; + + // Name enthält "(COMx)" am Ende + int start = name.LastIndexOf('('); + int end = name.LastIndexOf(')'); + if (start >= 0 && end > start) + return name.Substring(start + 1, end - start - 1); + } + } + catch { /* WMI nicht verfügbar – ignorieren */ } + + return null; + } + + // ── Lese-Schleife (Hintergrund-Thread) ─────────────────────────────────── + + private void ReadLoop() + { + var buf = new byte[Protocol.PacketSize]; + int bufFill = 0; + + while (_port is { IsOpen: true }) + { + try + { + int b = _port.ReadByte(); // blockiert bis Byte da oder Timeout + if (b < 0) continue; + + buf[bufFill++] = (byte)b; + + if (bufFill < Protocol.PacketSize) continue; + + // Vollständiges Paket – auf UI-Thread feuern + var pkt = new SerialPacket(); + Buffer.BlockCopy(buf, 0, pkt.Data, 0, Protocol.PacketSize); + bufFill = 0; + + _ui.Post(_ => PacketReceived?.Invoke(this, pkt), null); + } + catch (TimeoutException) + { + // Normal – kein Byte im Timeout-Fenster + } + catch (IOException) + { + // .NET 5+ wirft IOException statt TimeoutException wenn ReadTimeout abläuft. + // Nur als echten Fehler behandeln wenn der Port nicht mehr offen ist. + if (_port?.IsOpen != true) break; + } + catch + { + // Anderer Port-Fehler (Board abgezogen o.ä.) → Verbindung beenden + break; + } + } + + // Verbindung verloren – Backoff damit das Board sich vollständig + // neu enumerieren kann bevor wir erneut versuchen zu verbinden. + Disconnect(); + _ui.Post(_ => Disconnected?.Invoke(this, EventArgs.Empty), null); + + _waitingAfterDisconnect = true; + Thread.Sleep(5000); + _waitingAfterDisconnect = false; + } + + // ── Senden ─────────────────────────────────────────────────────────────── + + public void Send(SerialPacket pkt) + { + if (!IsConnected) return; + try { _port!.Write(pkt.Data, 0, Protocol.PacketSize); } + catch { /* Sendefehler ignorieren – ReadLoop erkennt Disconnect */ } + } + + // ── Hilfsmethoden ──────────────────────────────────────────────────────── + + public void SetLedOverride(byte keyId, byte r, byte g, byte b) + => Send(new SerialPacket(Protocol.CmdSetLedOverride, keyId, r, g, b)); + + public void ClearLedOverride(byte keyId) + => Send(new SerialPacket(Protocol.CmdClearLedOverride, keyId)); + + public void SetLedBase(byte keyId, byte r, byte g, byte b) + => Send(new SerialPacket(Protocol.CmdSetLedBase, keyId, r, g, b)); + + // Config vom Board anfordern – Board antwortet mit EvtConfigBegin/Data/End + public void RequestConfig() + => Send(new SerialPacket(Protocol.CmdConfigRead)); + + // Config in 6-Byte-Chunks an das Board senden. + // Protokoll: BEGIN → n×DATA → COMMIT + // Board schreibt nach COMMIT in den NVM (Firmware-Seite noch TODO). + public void SendConfig(DeviceConfig config) + { + byte[] data = config.ToBytes(); + const int payload = 6; // Nutzbytes pro Paket (8 - 2 Header-Bytes) + int chunks = (data.Length + payload - 1) / payload; + + File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"), + $"{DateTime.Now:HH:mm:ss.fff} TX ConfigBegin chunks={chunks} dataLen={data.Length}\n"); + + Send(new SerialPacket(Protocol.CmdConfigBegin, (byte)chunks)); + Thread.Sleep(10); + + for (int i = 0; i < chunks; i++) + { + var pkt = new SerialPacket(); + pkt.Data[0] = Protocol.CmdConfigData; + pkt.Data[1] = (byte)i; + int offset = i * payload; + int count = Math.Min(payload, data.Length - offset); + Buffer.BlockCopy(data, offset, pkt.Data, 2, count); + Send(pkt); + Thread.Sleep(5); // Firmware-Loop Zeit geben den Puffer zu leeren + } + + File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"), + $"{DateTime.Now:HH:mm:ss.fff} TX ConfigCommit\n"); + + Thread.Sleep(10); + Send(new SerialPacket(Protocol.CmdConfigCommit)); + } + + // ── IDisposable ─────────────────────────────────────────────────────────── + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _reconnectTimer?.Dispose(); + Disconnect(); + } +} diff --git a/src/TrayApp.cs b/src/TrayApp.cs new file mode 100644 index 0000000..75d1f2b --- /dev/null +++ b/src/TrayApp.cs @@ -0,0 +1,174 @@ +// TrayApp.cs +// ApplicationContext für die VersaPad Tray-Anwendung. +// +// Hält die App am Laufen ohne ein Hauptfenster anzuzeigen. +// Das Tray-Icon zeigt den Verbindungsstatus und bietet ein Kontextmenü. +// +// Kontextmenü: +// [●/○] VersaPad – verbunden / nicht verbunden (Status, nicht klickbar) +// ───────────────────────────────────────────── +// 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). + +namespace VersaGUI; + +public class TrayApp : ApplicationContext +{ + private readonly NotifyIcon _tray; + private readonly SerialManager _serial; + private readonly DeviceConfig _config = new(); + + private ToolStripMenuItem _statusItem = null!; + private ConfigForm? _configForm; + + // Empfangspuffer für eingehenden Config-Dump vom Board + private byte[]? _rxConfigBuf; + + public TrayApp() + { + _serial = new SerialManager(); + _serial.Connected += OnConnected; + _serial.Disconnected += OnDisconnected; + _serial.PacketReceived += OnPacket; + + _tray = new NotifyIcon + { + Icon = SystemIcons.Application, // TODO: eigenes Icon einbinden + Text = "VersaPad – nicht verbunden", + Visible = true, + ContextMenuStrip = BuildMenu(), + }; + } + + // ── Kontextmenü ────────────────────────────────────────────────────────── + + private ContextMenuStrip BuildMenu() + { + _statusItem = new ToolStripMenuItem("○ Nicht verbunden") { Enabled = false }; + + var configItem = new ToolStripMenuItem("Konfiguration..."); + configItem.Click += (_, _) => OpenConfigForm(); + + var exitItem = new ToolStripMenuItem("Beenden"); + exitItem.Click += (_, _) => + { + _tray.Visible = false; + Application.Exit(); + }; + + var menu = new ContextMenuStrip(); + menu.Items.Add(_statusItem); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add(configItem); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add(exitItem); + return menu; + } + + // ── Config-Fenster ──────────────────────────────────────────────────────── + + private void OpenConfigForm() + { + if (_configForm is { IsDisposed: false }) + { + // Bereits offen → in den Vordergrund bringen + _configForm.BringToFront(); + return; + } + _configForm = new ConfigForm(_config, _serial); + _configForm.Show(); + } + + // ── Serial-Events ──────────────────────────────────────────────────────── + + private void OnConnected(object? sender, EventArgs e) + { + _tray.Text = "VersaPad – verbunden"; + _tray.Icon = SystemIcons.Information; + _statusItem.Text = "● Verbunden"; + + // Config sofort vom Board laden + _serial.RequestConfig(); + } + + private void OnDisconnected(object? sender, EventArgs e) + { + _tray.Text = "VersaPad – nicht verbunden"; + _tray.Icon = SystemIcons.Application; + _statusItem.Text = "○ Nicht verbunden"; + } + + 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.EvtEncCw: + case Protocol.EvtEncCcw: + // TODO: Encoder HOST_COMMAND + break; + + case Protocol.EvtPong: + MessageBox.Show("Ping OK – Board antwortet.", + "VersaPad", MessageBoxButtons.OK, MessageBoxIcon.Information); + break; + + case Protocol.EvtConfigAck: + MessageBox.Show("Config erfolgreich gespeichert!", + "VersaPad", MessageBoxButtons.OK, MessageBoxIcon.Information); + break; + + case Protocol.EvtConfigNack: + MessageBox.Show("Config FEHLER: CRC/Magic ungültig.\nÜbertragung fehlgeschlagen.", + "VersaPad", MessageBoxButtons.OK, MessageBoxIcon.Error); + break; + + // ── Config-Dump vom Board ───────────────────────────────────────── + case Protocol.EvtConfigBegin: + _rxConfigBuf = new byte[163]; + 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]; + } + break; + + case Protocol.EvtConfigEnd: + if (_rxConfigBuf != null) + { + _config.FromBytes(_rxConfigBuf); + _rxConfigBuf = null; + _configForm?.RefreshAll(); // offenes Fenster sofort aktualisieren + } + break; + } + } + + // ── Aufräumen ───────────────────────────────────────────────────────────── + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _serial.Dispose(); + _tray.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/src/VersaGUI.csproj b/src/VersaGUI.csproj new file mode 100644 index 0000000..798bb97 --- /dev/null +++ b/src/VersaGUI.csproj @@ -0,0 +1,20 @@ + + + + WinExe + net7.0-windows + enable + true + enable + VersaGUI + VersaGUI + + + + + + + + + + \ No newline at end of file