Initial commit

This commit is contained in:
Julian Appel 2026-03-29 14:42:20 +02:00
commit 747bec985d
10 changed files with 1618 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
# .NET Build-Ausgaben
bin/
obj/
# VS Code / VS
.vscode/
.vs/
*.user
# macOS
.DS_Store

98
README.md Normal file
View File

@ -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 7162 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 |

441
src/ActionDialog.cs Normal file
View File

@ -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<Keys, byte> 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<byte, string> 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<string>();
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 (065535 erwartet).",
"Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
DialogResult = DialogResult.None;
return;
}
break;
}
ResultAction = new DeviceAction { Type = type, Data = data };
ResultColor = _color;
}
}

310
src/ConfigForm.cs Normal file
View File

@ -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 (0255) 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",
};
}

218
src/DeviceConfig.cs Normal file
View File

@ -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 7162)
// 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<string>();
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 524, 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;
}
}

11
src/Program.cs Normal file
View File

@ -0,0 +1,11 @@
namespace VersaGUI;
static class Program
{
[STAThread]
static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new TrayApp());
}
}

72
src/Protocol.cs Normal file
View File

@ -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 024 oder Encoder 03)
// [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 (0x010x7F) ────────────────────────────────────
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 (0x810xFF) ──────────────────────────────────────
public const byte EvtKeyDown = 0x81; // key_id → HOST_COMMAND-Button gedrückt
public const byte EvtKeyUp = 0x82; // key_id → HOST_COMMAND-Button losgelassen
public const byte EvtEncCw = 0x83; // key_id → Encoder Schritt CW
public const byte EvtEncCcw = 0x84; // key_id → Encoder Schritt CCW
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
}
}

263
src/SerialManager.cs Normal file
View File

@ -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<SerialPacket>? 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();
}
}

174
src/TrayApp.cs Normal file
View File

@ -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);
}
}

20
src/VersaGUI.csproj Normal file
View File

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>VersaGUI</AssemblyName>
<RootNamespace>VersaGUI</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- Serieller Port (.NET Core/5+ benötigt explizites Paket) -->
<PackageReference Include="System.IO.Ports" Version="7.0.0" />
<!-- WMI-Zugriff für COM-Port-Erkennung per VID/PID -->
<PackageReference Include="System.Management" Version="7.0.2" />
</ItemGroup>
</Project>