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

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 (0–65535 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 (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",
};
}

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

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 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
}
}

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>