Initial commit
This commit is contained in:
commit
747bec985d
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
# .NET Build-Ausgaben
|
||||
bin/
|
||||
obj/
|
||||
|
||||
# VS Code / VS
|
||||
.vscode/
|
||||
.vs/
|
||||
*.user
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
98
README.md
Normal file
98
README.md
Normal 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
441
src/ActionDialog.cs
Normal 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
310
src/ConfigForm.cs
Normal 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
218
src/DeviceConfig.cs
Normal 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
11
src/Program.cs
Normal 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
72
src/Protocol.cs
Normal 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
263
src/SerialManager.cs
Normal 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
174
src/TrayApp.cs
Normal 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
20
src/VersaGUI.csproj
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user