Compare commits

...

3 Commits

12 changed files with 449 additions and 192 deletions

View File

@ -61,6 +61,7 @@ Die App erscheint als Icon in der Windows-Taskleiste (System Tray). Kein Hauptfe
| 2.4 | **Layout-unabhängige Erfassung** via PS/2-Scan-Code → HID-Usage; Ö/Ä/Ü auf QWERTZ korrekt | ✅ |
| 2.5 | Taste-Name wird laut **aktivem Windows-Layout** angezeigt (`GetKeyNameText`) | ✅ |
| 2.6 | Board führt Tastendruck als **USB-HID-Tastatureingabe** aus (funktioniert ohne laufende App) | ✅ |
| 2.7 | **Hold-Semantik**: Taste bleibt gedrückt solange physisch gehalten (OS initiiert Repeat nach ~500ms) | ✅ |
### 3 Tastenbelegung HID Consumer / Medientasten
@ -68,6 +69,7 @@ Die App erscheint als Icon in der Windows-Taskleiste (System Tray). Kein Hauptfe
|---|-------------|--------|
| 3.1 | Auswahl per **Dropdown** mit Klartext-Namen | ✅ |
| 3.2 | Unterstützte Aktionen: Play/Pause, Nächster/Vorheriger Titel, Stop, Lauter/Leiser, Mute, Taschenrechner, Browser Zurück/Vor | ✅ |
| 3.3 | **Hold-Semantik**: Media-Control bleibt aktiv solange Taste gehalten wird (z.B. Lautstärke-Wiederholung) | ✅ |
### 4 Tastenbelegung Makros
@ -86,6 +88,8 @@ Die App erscheint als Icon in der Windows-Taskleiste (System Tray). Kein Hauptfe
|---|-------------|--------|
| 5.1 | **4 Encoder**, je 3 Aktionen: SW (Drücken), CW (Rechts), CCW (Links) | ✅ |
| 5.2 | Gleiche Aktionstypen wie Tasten (HID Key, Consumer, Makro, Host Command) | ✅ |
| 5.3 | **Encoder-SW**: Hold-Semantik wie normale Tasten (Taste bleibt gedrückt) | ✅ |
| 5.4 | **Encoder-CW/CCW**: Tap-Modell (diskrete Ereignisse, atomare down+delay+up Sequenzen, kein Hold möglich) | ✅ |
### 6 LED-Konfiguration

View File

@ -8,6 +8,7 @@
- Verbindungsaufbau und automatischer Reconnect
- Hintergrund-Lese-Thread mit Paket-Assembler
- Sende-Methoden für alle Protokoll-Operationen
- ACK/NACK-Synchronisation zwischen ReadLoop-Thread und `SendConfig`/`SendMacros`-Task
## COM-Port-Erkennung
@ -52,20 +53,38 @@ Bei IOException:
else: break → Disconnect
```
## ACK/NACK-Synchronisation
`SendConfig` und `SendMacros` blockieren nach COMMIT auf ein `SemaphoreSlim`-Gate bis das Board antwortet:
```
_configAckGate / _macroAckGate SemaphoreSlim(0, 1)
_configAckOk / _macroAckOk volatile bool (true = ACK, false = NACK)
```
| Methode | Aufruf durch | Wirkung |
|---|---|---|
| `SignalConfigAck()` | TrayApp bei EvtConfigAck | `_configAckOk = true`, Gate freigeben |
| `SignalConfigNack()` | TrayApp bei EvtConfigNack | `_configAckOk = false`, Gate freigeben |
| `SignalMacroAck()` | TrayApp bei EvtMacroAck | `_macroAckOk = true`, Gate freigeben |
| `SignalMacroNack()` | TrayApp bei EvtMacroNack | `_macroAckOk = false`, Gate freigeben |
`SendConfig()` und `SendMacros()` geben `bool` zurück (`true` = ACK erhalten, `false` = NACK oder Timeout nach 3 s).
## Sende-Methoden
| Methode | Funktion |
|---|---|
| `Send(pkt)` | Rohe 8-Byte-Übertragung (fire-and-forget, ignoriert Sendefehler) |
| `SetLedOverride(keyId, r, g, b)` | CMD 0x01 |
| `ClearLedOverride(keyId)` | CMD 0x02 |
| `SetLedBase(keyId, r, g, b)` | CMD 0x03 |
| `RequestConfig()` | CMD 0x13 Board antwortet mit Config-Dump |
| `RequestMacros()` | CMD 0x23 Board antwortet mit Makro-Dump |
| `SendConfig(cfg)` | BEGIN(0x10) → 38×DATA(0x11) → COMMIT(0x12), 5 ms zwischen Chunks |
| `SendMacros(macros)` | BEGIN(0x20) → 43×DATA(0x21) → COMMIT(0x22), 5 ms zwischen Chunks |
| Methode | Rückgabe | Funktion |
|---|---|---|
| `Send(pkt)` | void | Rohe 8-Byte-Übertragung (fire-and-forget) |
| `SetLedOverride(keyId, r, g, b)` | void | CMD 0x01 |
| `ClearLedOverride(keyId)` | void | CMD 0x02 |
| `SetLedBase(keyId, r, g, b)` | void | CMD 0x03 |
| `RequestConfig()` | void | CMD 0x13 Board antwortet mit Config-Dump |
| `RequestMacros()` | void | CMD 0x23 Board antwortet mit Makro-Dump |
| `SendConfig(cfg)` | bool | BEGIN(0x10) → 124×DATA(0x11) → COMMIT(0x12), 5 ms zwischen Chunks, wartet auf ACK/NACK |
| `SendMacros(macros)` | bool | BEGIN(0x20) → 86×DATA(0x21) → COMMIT(0x22), 5 ms zwischen Chunks, wartet auf ACK/NACK |
`SendConfig` und `SendMacros` blockieren ~400 ms → werden in `Task.Run()` aus `ConfigForm.OnSave()` aufgerufen.
`SendConfig` und `SendMacros` blockieren ~1,5 s (Chunks + NVM-Zeit) → werden in `Task.Run()` aus `ConfigForm.OnSave()` aufgerufen.
## Events

View File

@ -14,43 +14,58 @@ C#-Spiegel der Firmware-Structs. Muss byte-kompatibel mit `SDeviceConfig` (nvm_c
| Feld | Typ | Inhalt |
|---|---|---|
| `MxActions[20]` | `DeviceAction[]` | Aktionen für MX-Buttons 019 |
| `EncActions[4,3]` | `DeviceAction[,]` | Encoder [03][SW=0/CW=1/CCW=2] |
| `LedBase[20]` | `Color[]` | RGB-Basis-LED-Farbe je Button |
| `LedAnim[20]` | `LedAnimType[]` | Animation je Button |
| `LedPeriod[20]` | `ushort[]` | Animationsperiode in ms |
| `ActiveProfileIndex` | `byte` | Aktives Profil (02) |
| `GlobalBrightness` | `byte` | Globale LED-Helligkeit (0255) |
| `MxActions[20]` | `DeviceAction[]` | Aktionen für MX-Buttons 019 (aus aktivem Profil) |
| `EncActions[4,3]` | `DeviceAction[,]` | Encoder [03][SW=0/CW=1/CCW=2] (aus aktivem Profil) |
| `LedBase[20]` | `Color[]` | RGB-Basis-LED-Farbe je Button (aus aktivem Profil) |
| `LedAnim[20]` | `LedAnimType[]` | Animation je Button (aus aktivem Profil) |
| `LedPeriod[20]` | `ushort[]` | Animationsperiode in ms (aus aktivem Profil) |
### Serialisierungs-Layout (ToBytes / FromBytes, 223 B)
### Serialisierungs-Layout (ToBytes / FromBytes, 740 B)
```
Offset 0 4B Magic 0x56503202 (little-endian)
Offset 4 1B Version = 2
Offset 5 2B CRC16-CCITT über Bytes 7222 (little-endian)
Offset 7 60B MxActions[20] je 3B: type(1) + data_lo(1) + data_hi(1)
Offset 67 36B EncActions[4][3] je 3B
Offset103 20B LedBase[i].R
Offset123 20B LedBase[i].G
Offset143 20B LedBase[i].B
Offset163 20B LedAnim[i] als byte
Offset183 40B LedPeriod[i] als uint16 little-endian
Offset 0 4B Magic 0x56503203 (little-endian)
Offset 4 1B Version = 3
Offset 5 2B CRC16-CCITT über Bytes 7739 (little-endian)
Offset 7 1B active_profile (02)
Offset 8 1B global_brightness
Offset 9 4B enc_sensitivity[4]
Offset 13 19B Reserve (_reserve)
Profil 0 (Offset 32, 236 B):
Offset 32 60B MxActions[20] je 3B: type(1) + data_lo(1) + data_hi(1)
Offset 92 36B EncActions[4][3] je 3B
Offset 128 20B LedBase[i].R
Offset 148 20B LedBase[i].G
Offset 168 20B LedBase[i].B
Offset 188 20B LedBrightness[i]
Offset 208 20B LedAnim[i] als byte
Offset 228 40B LedPeriod[i] als uint16 little-endian
Profil 1 (Offset 268, 236 B): identisches Layout
Profil 2 (Offset 504, 236 B): identisches Layout
```
### CRC16-CCITT
Polynom `0x1021`, Init `0xFFFF`, über Bytes 7222 (nach dem CRC-Feld selbst). Muss identisch mit Firmware-Implementierung sein. `DeviceConfig.Crc16()` ist statisch und direkt testbar.
Polynom `0x1021`, Init `0xFFFF`, über Bytes 7739 (nach dem CRC-Feld selbst, einschließlich `active_profile`). Muss identisch mit Firmware-Implementierung sein. `DeviceConfig.Crc16()` ist statisch und direkt testbar.
### Defaults (entspricht Firmware-Defaults)
- Alle Aktionen: `None`
- LEDs: warm-weiß (R=80, G=40, B=0)
- LEDs: warm-weiß (R=80, G=40, B=0), Helligkeit 255
- Animation: `ColorCycle` (Regenbogen), Period 4000 ms
- `active_profile = 0`, `global_brightness = 255`
---
## DeviceAction
```csharp
public enum ActionType : byte { None=0, HidKey=1, HidConsumer=2, HostCommand=3, Macro=4 }
public enum ActionType : byte {
None=0, HidKey=1, HidConsumer=2, HostCommand=3, Macro=4, ProfileSwitch=5
}
public class DeviceAction {
public ActionType Type { get; set; }
@ -59,10 +74,11 @@ public class DeviceAction {
// HidConsumer: Consumer Usage ID
// HostCommand: Command-ID
// Macro: Slot-Index 031
// ProfileSwitch: 02 = Ziel-Profil, 0xFFFF = nächstes Profil (Zyklus)
}
```
`DeviceAction.Display` gibt einen lesbaren String zurück (z.B. `"Strg+C"`, `"Play/Pause"`, `"Makro 3"`).
`DeviceAction.Display` gibt einen lesbaren String zurück (z.B. `"Strg+C"`, `"Play/Pause"`, `"→ Profil 2"`, `"→ Nächstes Profil"`).
---
@ -71,7 +87,7 @@ public class DeviceAction {
```csharp
public class MacroTable {
public const int Slots = 32;
public const int MaxSteps = 4;
public const int MaxSteps = 8;
public MacroStep[,] Steps { get; } // [slot][step]
}
public class MacroStep {
@ -87,9 +103,9 @@ public class MacroStep {
| 019 | MX-Button `mxIdx` (`MacroTable.SlotForMx(mxIdx)`) |
| 2031 | Encoder: `20 + enc * 3 + actIdx` (`MacroTable.SlotForEncoder(enc, actIdx)`) |
### Serialisierung (256 B)
### Serialisierung (512 B)
32 Slots × 4 Steps × 2 B = 256 B. Keycode zuerst, dann Modifier. Kein Magic/CRC (Board akzeptiert jeden Inhalt).
32 Slots × 8 Steps × 2 B = 512 B. Keycode zuerst, dann Modifier. Kein Magic/CRC (Board akzeptiert jeden Inhalt).
---
@ -101,4 +117,4 @@ public enum LedAnimType : byte {
}
```
Werte entsprechen `LEDAnim` in der Firmware. `FADE_IN` (3) und `FADE_OUT` (4) existieren in der Firmware aber nicht in der GUI (nicht konfigurierbar, nur `COLOR_FADE` intern).
Werte entsprechen `LEDAnim` in der Firmware. `FADE_IN` (3) und `FADE_OUT` (4) existieren in der Firmware aber nicht in der GUI (nicht konfigurierbar).

View File

@ -8,6 +8,7 @@
- Tray-Icon mit Verbindungsstatus und Kontextmenü
- Empfang und Routing aller Board-Events (via `SerialManager.PacketReceived`)
- Config/Makro-Dump-Empfang (chunked, via `_rxConfigBuf` / `_rxMacroBuf`)
- ACK/NACK-Weiterleitung an SerialManager
- Öffnen des `ConfigForm` (nur eine Instanz gleichzeitig)
## Tray-Menü
@ -30,19 +31,22 @@ Icon und Tooltip spiegeln den Verbindungsstatus:
| Event-ID | Aktion |
|---|---|
| `EvtConfigBegin` | `_rxConfigBuf = new byte[223]` |
| `EvtConfigBegin` | `_rxConfigBuf = new byte[740]` |
| `EvtConfigData` | Chunk in `_rxConfigBuf` eintragen (`KeyId * 6` = Byte-Offset) |
| `EvtConfigEnd` | `DeviceConfig.FromBytes()``ConfigForm.RefreshAll()` |
| `EvtMacroBegin` | `_rxMacroBuf = new byte[256]` |
| `EvtConfigAck` | `serial.SignalConfigAck()` — gibt SendConfig()-Thread frei |
| `EvtConfigNack` | `serial.SignalConfigNack()` — gibt SendConfig()-Thread frei (Fehler) |
| `EvtMacroBegin` | `_rxMacroBuf = new byte[512]` |
| `EvtMacroData` | Chunk in `_rxMacroBuf` eintragen |
| `EvtMacroEnd` | `MacroTable.FromBytes()` |
| `EvtMacroAck` | `serial.SignalMacroAck()` — gibt SendMacros()-Thread frei |
| `EvtMacroNack` | `serial.SignalMacroNack()` — gibt SendMacros()-Thread frei (Fehler) |
| `EvtPong` | MessageBox "Ping OK" |
| `EvtConfigAck` | MessageBox "Config gespeichert" |
| `EvtConfigNack` | MessageBox "Config FEHLER" |
| `EvtMacroAck` | MessageBox "Makros gespeichert" |
| `EvtKeyDown` | TODO: HOST_COMMAND-Aktion ausführen |
| `EvtEncCw/Ccw` | TODO: Encoder HOST_COMMAND |
ACK/NACK-Events zeigen keine eigene MessageBox mehr — das Ergebnis wird nach Abschluss beider Transfers gebündelt in `ConfigForm.OnSave()` angezeigt.
## Verbindungslebenszyklus
```

View File

@ -22,13 +22,34 @@ public ushort ResultPeriod { get; } // Periode in ms
| HID Tastatur | `_hidKeyPanel` | Capture-Button + Strg/Shift/Alt/Win-Checkboxen |
| HID Consumer | `_consumerPanel` | Dropdown mit 12 Medien-Aktionen |
| Host Command | `_cmdPanel` | TextBox für numerische Command-ID |
| Makro | `_macroPanel` | 4 Step-Buttons + je Strg/Shift/Alt-Checkboxen |
| Makro | `_macroPanel` | 8 Step-Buttons + je Strg/Shift/Alt-Checkboxen |
| Profil wechseln | `_profilePanel` | Dropdown: "Nächstes Profil (Zyklus)" / "Profil 1" / "Profil 2" / "Profil 3" |
| Keine | — | Alle Panels ausgeblendet |
LED-Panels (`_colorPanel`, `_animPanel`) erscheinen zusätzlich wenn `showColor=true` (nur MX-Buttons, nicht Encoder).
`UpdateLayout()` repositioniert LED-Panels und passt `ClientSize` dynamisch an wenn der Typ gewechselt wird.
## Profil-Panel
```csharp
// Items in _profileCombo:
// Index 0: "Nächstes Profil (Zyklus)" → Data = 0xFFFF
// Index 1: "Profil 1" → Data = 0
// Index 2: "Profil 2" → Data = 1
// Index 3: "Profil 3" → Data = 2
// Initialbelegung:
_profileCombo.SelectedIndex = action.Data == 0xFFFF ? 0 : action.Data + 1;
// In OnOk():
data = _profileCombo.SelectedIndex == 0
? (ushort)0xFFFF
: (ushort)(_profileCombo.SelectedIndex - 1);
```
Im Board wird `0xFFFF` als `(uint8_t)0xFF` gespeichert und in der Firmware als "nächstes Profil" interpretiert.
## Tasten-Capture (HID-Modus)
1. Benutzer klickt "Taste drücken..."
@ -41,7 +62,7 @@ WinForms behandelt Pfeil- und Enter-Tasten als "Dialog Keys" in `ProcessDialogKe
## Makro-Capture
Jeder der 4 Steps hat einen eigenen Capture-Button. `_captureStep` (03, -1 = inaktiv) zeigt welcher Step gerade aufnimmt. Capture-Logik identisch mit HID-Modus, schreibt in `_stepKeycodes[captureStep]`.
Jeder der 8 Steps hat einen eigenen Capture-Button. `_captureStep` (07, -1 = inaktiv) zeigt welcher Step gerade aufnimmt. Capture-Logik identisch mit HID-Modus, schreibt in `_stepKeycodes[captureStep]`.
## Schlüssellookup (layout-unabhängig)

View File

@ -35,6 +35,10 @@ public class ActionDialog : Form
private readonly Panel _cmdPanel;
private readonly TextBox _cmdBox;
// Profil wechseln
private readonly Panel _profilePanel;
private readonly ComboBox _profileCombo;
// LED-Farbe
private readonly Panel _colorPanel;
private readonly Button _colorBtn;
@ -50,11 +54,11 @@ public class ActionDialog : Form
private readonly MacroTable _macros;
private readonly int _macroSlot;
// Je Step: [CaptureBtn, CheckCtrl, CheckShift, CheckAlt]
private readonly Button[] _stepBtns = new Button[4];
private readonly CheckBox[] _stepCtrl = new CheckBox[4];
private readonly CheckBox[] _stepShift = new CheckBox[4];
private readonly CheckBox[] _stepAlt = new CheckBox[4];
private readonly byte[] _stepKeycodes = new byte[4];
private readonly Button[] _stepBtns = new Button[8];
private readonly CheckBox[] _stepCtrl = new CheckBox[8];
private readonly CheckBox[] _stepShift = new CheckBox[8];
private readonly CheckBox[] _stepAlt = new CheckBox[8];
private readonly byte[] _stepKeycodes = new byte[8];
private int _captureStep = -1; // -1 = nicht im Capture-Modus
// Capture-Zustand
@ -220,7 +224,7 @@ public class ActionDialog : Form
_showColor = showColor;
// Bestehende Makro-Steps aus der Tabelle laden
for (int i = 0; i < 4; i++)
for (int i = 0; i < 8; i++)
_stepKeycodes[i] = _macros.Steps[_macroSlot, i].Keycode;
// Formular-Grundeinstellungen
@ -240,7 +244,7 @@ public class ActionDialog : Form
DropDownStyle = ComboBoxStyle.DropDownList,
};
_typeCombo.Items.AddRange(new object[]
{ "Keine Aktion", "HID Tastatur", "HID Consumer", "Host Command", "Makro" });
{ "Keine Aktion", "HID Tastatur", "HID Consumer", "Host Command", "Makro", "Profil wechseln" });
_typeCombo.SelectedIndex = (int)action.Type;
_typeCombo.SelectedIndexChanged += OnTypeChanged;
@ -320,6 +324,22 @@ public class ActionDialog : Form
};
_cmdPanel.Controls.AddRange(new Control[] { cmdLabel, _cmdBox });
// ── Profil-Panel ──────────────────────────────────────────────────────
_profilePanel = new Panel { Location = new Point(12, 48), Size = new Size(396, 30) };
var profileLabel = new Label { Text = "Ziel-Profil:", Location = new Point(0, 8), AutoSize = true };
_profileCombo = new ComboBox
{
Location = new Point(90, 4),
Width = 200,
DropDownStyle = ComboBoxStyle.DropDownList,
};
_profileCombo.Items.AddRange(new object[]
{ "Nächstes Profil (Zyklus)", "Profil 1", "Profil 2", "Profil 3" });
_profileCombo.SelectedIndex = action.Type == ActionType.ProfileSwitch
? (action.Data == 0xFFFF ? 0 : Math.Clamp((int)action.Data + 1, 1, 3))
: 0;
_profilePanel.Controls.AddRange(new Control[] { profileLabel, _profileCombo });
// ── Farb-Panel ────────────────────────────────────────────────────────
_colorPanel = new Panel
{
@ -378,8 +398,8 @@ public class ActionDialog : Form
_animPanel.Controls.AddRange(new Control[]
{ animLabel, _animCombo, periodLabel, _periodCombo });
// ── Makro-Panel (4 Steps) ─────────────────────────────────────────────
_macroPanel = new Panel { Location = new Point(12, 48), Size = new Size(396, 4 * 30 + 22) };
// ── Makro-Panel (8 Steps) ─────────────────────────────────────────────
_macroPanel = new Panel { Location = new Point(12, 48), Size = new Size(396, 8 * 30 + 22) };
var macroHint = new Label
{
@ -391,7 +411,7 @@ public class ActionDialog : Form
};
_macroPanel.Controls.Add(macroHint);
for (int step = 0; step < 4; step++)
for (int step = 0; step < 8; step++)
{
int s = step;
int y2 = 30 + step * 30;
@ -454,7 +474,7 @@ public class ActionDialog : Form
Controls.AddRange(new Control[]
{
typeLabel, _typeCombo,
_hidKeyPanel, _consumerPanel, _cmdPanel, _macroPanel,
_hidKeyPanel, _consumerPanel, _cmdPanel, _profilePanel, _macroPanel,
_colorPanel, _animPanel,
_okBtn, _cancelBtn,
});
@ -481,6 +501,7 @@ public class ActionDialog : Form
_hidKeyPanel.Visible = t == ActionType.HidKey;
_consumerPanel.Visible = t == ActionType.HidConsumer;
_cmdPanel.Visible = t == ActionType.HostCommand;
_profilePanel.Visible = t == ActionType.ProfileSwitch;
_macroPanel.Visible = t == ActionType.Macro;
_colorPanel.Visible = _showColor;
_animPanel.Visible = _showColor;
@ -499,7 +520,8 @@ public class ActionDialog : Form
ActionType.HidKey => 110,
ActionType.HidConsumer => 30,
ActionType.HostCommand => 30,
ActionType.Macro => 4 * 30 + 22,
ActionType.ProfileSwitch => 30,
ActionType.Macro => 8 * 30 + 22,
_ => 0,
};
@ -710,12 +732,18 @@ public class ActionDialog : Form
return;
}
break;
case ActionType.ProfileSwitch:
data = _profileCombo.SelectedIndex == 0
? (ushort)0xFFFF // Zyklus: Firmware rechnet (current+1)%3
: (ushort)(_profileCombo.SelectedIndex - 1); // Profil 0/1/2
break;
}
if (type == ActionType.Macro)
{
// Steps in die Makro-Tabelle schreiben
for (int i = 0; i < 4; i++)
for (int i = 0; i < 8; i++)
{
byte mod = 0;
if (_stepCtrl[i].Checked) mod |= 0x01;

View File

@ -26,6 +26,7 @@ public class ConfigForm : Form
private readonly Button[] _btnGrid = new Button[20];
private readonly Button[,] _encBtns = new Button[4, 3];
private Button _saveBtn = null!;
private ComboBox _profileCombo = null!;
public ConfigForm(DeviceConfig config, MacroTable macros, SerialManager serial)
{
@ -40,6 +41,7 @@ public class ConfigForm : Form
ClientSize = new Size(520, 480);
int y = 12;
y = BuildProfileBar(y);
y = BuildButtonGrid(y);
y = BuildEncoderPanel(y);
BuildFooter(y);
@ -50,6 +52,35 @@ public class ConfigForm : Form
_saveBtn.Enabled = _serial.IsConnected;
}
// ── Profil-Leiste ─────────────────────────────────────────────────────────
private int BuildProfileBar(int startY)
{
var label = new Label
{
Text = "Profil:",
Location = new Point(12, startY + 4),
AutoSize = true,
};
_profileCombo = new ComboBox
{
Location = new Point(60, startY),
Width = 160,
DropDownStyle = ComboBoxStyle.DropDownList,
};
_profileCombo.Items.AddRange(new object[] { "Profil 1", "Profil 2", "Profil 3" });
_profileCombo.SelectedIndex = _config.ActiveProfileIndex;
_profileCombo.SelectedIndexChanged += (_, _) =>
{
_config.ActiveProfileIndex = (byte)_profileCombo.SelectedIndex;
RefreshAll();
};
Controls.AddRange(new Control[] { label, _profileCombo });
return startY + 34;
}
// ── Tasten-Grid (4 Spalten × 5 Zeilen) ───────────────────────────────────
private int BuildButtonGrid(int startY)
@ -260,13 +291,25 @@ public class ConfigForm : Form
// SendConfig + SendMacros blockieren ~400ms → Background-Thread
Task.Run(() =>
{
_serial.SendConfig(_config);
Thread.Sleep(50); // kurze Pause zwischen Config- und Makro-Transfer
_serial.SendMacros(_macros);
bool cfgOk = _serial.SendConfig(_config); // wartet intern auf CONFIG_ACK/NACK
bool macroOk = _serial.SendMacros(_macros); // wartet intern auf MACRO_ACK/NACK
InvokeOnUi(() =>
{
_saveBtn.Text = "Auf Board speichern";
_saveBtn.Enabled = _serial.IsConnected;
if (cfgOk && macroOk)
{
MessageBox.Show("Konfiguration erfolgreich gespeichert.",
"VersaPad", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
else
{
string detail = (!cfgOk && !macroOk) ? "Config und Makros"
: !cfgOk ? "Config"
: "Makros";
MessageBox.Show($"Speichern fehlgeschlagen ({detail}).\nBoard hat NACK gesendet oder nicht geantwortet.",
"VersaPad Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
});
});
}
@ -393,6 +436,8 @@ internal static class Extensions
ActionType.HidKey => "HID Tastatur-Keycode",
ActionType.HidConsumer => "HID Consumer Control (Media/Volume)",
ActionType.HostCommand => "Host Command App führt aus",
ActionType.Macro => "Makro-Sequenz",
ActionType.ProfileSwitch => "Profil wechseln",
_ => "Keine Aktion",
};
}

View File

@ -1,12 +1,14 @@
// ConfigJson.cs
// JSON-Import/Export für DeviceConfig.
//
// Format (menschenlesbar, kommentiert mit Feldnamen):
// Format (menschenlesbar):
// {
// "version": 2,
// "version": 3,
// "profile": 0,
// "buttons": [
// { "index": 0, "action": { "type": "HidKey", "data": 260 },
// "led": { "r": 80, "g": 40, "b": 0, "anim": "ColorCycle", "period_ms": 4000 } },
// "led": { "r": 80, "g": 40, "b": 0, "brightness": 255,
// "anim": "ColorCycle", "period_ms": 4000 } },
// ...
// ],
// "encoders": [
@ -17,6 +19,8 @@
// ...
// ]
// }
//
// Import/Export bezieht sich immer auf das aktive Profil.
using System.Text.Json;
using System.Text.Json.Serialization;
@ -37,6 +41,7 @@ internal static class ConfigJson
var doc = new ConfigJsonDoc
{
Version = DeviceConfig.Version,
Profile = cfg.ActiveProfileIndex,
Buttons = new ButtonEntry[20],
Encoders = new EncoderEntry[4],
};
@ -52,6 +57,7 @@ internal static class ConfigJson
R = cfg.LedBase[i].R,
G = cfg.LedBase[i].G,
B = cfg.LedBase[i].B,
Brightness = cfg.LedBrightness[i],
Anim = cfg.LedAnim[i],
PeriodMs = cfg.LedPeriod[i],
},
@ -90,6 +96,7 @@ internal static class ConfigJson
if (b.Led != null)
{
cfg.LedBase[b.Index] = Color.FromArgb(b.Led.R, b.Led.G, b.Led.B);
cfg.LedBrightness[b.Index] = b.Led.Brightness;
cfg.LedAnim[b.Index] = b.Led.Anim;
cfg.LedPeriod[b.Index] = b.Led.PeriodMs;
}
@ -125,6 +132,7 @@ internal static class ConfigJson
private class ConfigJsonDoc
{
[JsonPropertyName("version")] public byte Version { get; set; }
[JsonPropertyName("profile")] public byte Profile { get; set; }
[JsonPropertyName("buttons")] public ButtonEntry[]? Buttons { get; set; }
[JsonPropertyName("encoders")] public EncoderEntry[]? Encoders { get; set; }
}
@ -155,6 +163,7 @@ internal static class ConfigJson
[JsonPropertyName("r")] public byte R { get; set; }
[JsonPropertyName("g")] public byte G { get; set; }
[JsonPropertyName("b")] public byte B { get; set; }
[JsonPropertyName("brightness")] public byte Brightness { get; set; } = 255;
[JsonPropertyName("anim")] public LedAnimType Anim { get; set; }
[JsonPropertyName("period_ms")] public ushort PeriodMs { get; set; }
}

View File

@ -1,17 +1,32 @@
// DeviceConfig.cs
// C#-Spiegel der SDeviceConfig-Struktur aus der Firmware (nvm_config.h).
// C#-Spiegel der Firmware-Structs. Muss byte-kompatibel mit SDeviceConfig (nvm_config.h)
// und SMacroTable (macro_config.h) sein.
//
// Serialisiertes Layout (packed, 223 Bytes):
// Offset 0 4B Magic 0x56503202
// Offset 4 1B Version 2
// Offset 5 2B CRC16 (CCITT über Bytes 7222)
// 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]
// Offset163 20B led_anim[20] je 1B (LedAnimType)
// Offset183 40B led_period_ms[20] je 2B (uint16, little-endian)
// Serialisiertes Config-Layout (740 B, packed):
// ── Globaler Header (32B) ──────────────────────────────────────────────────
// Offset 0 4B Magic 0x56503203
// Offset 4 1B Version = 3
// Offset 5 2B CRC16-CCITT über Bytes 7739
// Offset 7 1B ActiveProfile (02)
// Offset 8 1B GlobalBrightness (0255)
// Offset 9 4B EncSensitivity[4]
// Offset 13 19B Reserve
// ── Profil 0 (236B, ab Offset 32) ─────────────────────────────────────────
// ── Profil 1 (236B, ab Offset 268) ────────────────────────────────────────
// ── Profil 2 (236B, ab Offset 504) ────────────────────────────────────────
//
// Pro Profil (236B):
// Offset 0 60B MxActions[20] je 3B: type(1) + data_lo(1) + data_hi(1)
// Offset 60 36B EncActions[4][3] je 3B
// Offset 96 20B LedBase[i].R
// Offset116 20B LedBase[i].G
// Offset136 20B LedBase[i].B
// Offset156 20B LedBrightness[i] per-LED 0255
// Offset176 20B LedAnim[i]
// Offset196 40B LedPeriod[i] uint16 little-endian
//
// MacroTable-Layout (512 B):
// 32 Slots × 8 Steps × 2B = 512B
using System.Drawing;
@ -24,6 +39,7 @@ public enum ActionType : byte
HidConsumer = 2, // data = HID Consumer Usage ID
HostCommand = 3, // data = Command-ID → App führt aus
Macro = 4, // data = Makro-Slot-Index (031)
ProfileSwitch = 5, // data = Profil-Index (02)
}
// Ein Step in einem Makro (keycode=0 → leerer/letzter Step)
@ -36,11 +52,11 @@ public class MacroStep
public MacroStep Clone() => new() { Keycode = Keycode, Modifier = Modifier };
}
// Makro-Tabelle: 32 Slots × 4 Steps (spiegelt SMacroTable aus macro_config.h)
// Makro-Tabelle: 32 Slots × 8 Steps (spiegelt SMacroTable aus macro_config.h)
public class MacroTable
{
public const int Slots = 32;
public const int MaxSteps = 4;
public const int MaxSteps = 8;
// [slot][step]
public MacroStep[,] Steps { get; } = new MacroStep[Slots, MaxSteps];
@ -53,13 +69,13 @@ public class MacroTable
}
// Slot-Index für MX-Buttons und Encoder-Aktionen
public static int SlotForMx(int mxIdx) => mxIdx; // 019
public static int SlotForEncoder(int enc, int actIdx) => 20 + enc * 3 + actIdx; // 2031
public static int SlotForMx(int mxIdx) => mxIdx;
public static int SlotForEncoder(int enc, int actIdx) => 20 + enc * 3 + actIdx;
// Serialisierung: 32 × 4 × 2 = 256 Bytes
// Serialisierung: 32 × 8 × 2 = 512 Bytes
public byte[] ToBytes()
{
var buf = new byte[256];
var buf = new byte[512];
int pos = 0;
for (int s = 0; s < Slots; s++)
for (int i = 0; i < MaxSteps; i++)
@ -72,7 +88,7 @@ public class MacroTable
public void FromBytes(byte[] buf)
{
if (buf.Length < 256) return;
if (buf.Length < 512) return;
int pos = 0;
for (int s = 0; s < Slots; s++)
for (int i = 0; i < MaxSteps; i++)
@ -83,13 +99,13 @@ public class MacroTable
}
}
// Spiegelt LEDAnim aus CButton.h nur die in der GUI konfigurierbaren Werte
// Spiegelt LEDAnim aus CButton.h
public enum LedAnimType : byte
{
Static = 0,
Blink = 1,
Pulse = 2,
ColorCycle = 5, // Regenbogen (entspricht LEDAnim::COLOR_CYCLE in Firmware)
ColorCycle = 5,
}
public class DeviceAction
@ -130,6 +146,8 @@ public class DeviceAction
}
if (Type == ActionType.Macro)
return $"Makro {Data}";
if (Type == ActionType.ProfileSwitch)
return Data == 0xFFFF ? "→ Nächstes Profil" : $"→ Profil {Data + 1}";
return $"CMD {Data}";
}
}
@ -152,31 +170,19 @@ public class DeviceAction
public DeviceAction Clone() => new() { Type = Type, Data = Data };
}
public class DeviceConfig
// ── Pro-Profil-Daten (spiegelt SDeviceProfile) ────────────────────────────────
public class DeviceProfile
{
public const uint Magic = 0x56503202;
public const byte Version = 2;
public const int EncSw = 0;
public const int EncCw = 1;
public const int EncCcw = 2;
public DeviceAction[] MxActions { get; } =
Enumerable.Range(0, 20).Select(_ => new DeviceAction()).ToArray();
public DeviceAction[,] EncActions { get; } = InitEncActions();
// Base-LED-Farben (Default: warm-weiß wie Firmware)
public Color[] LedBase { get; } =
Enumerable.Repeat(Color.FromArgb(80, 40, 0), 20).ToArray();
// LED-Animationen (Default: Regenbogen)
public LedAnimType[] LedAnim { get; } =
Enumerable.Repeat(LedAnimType.ColorCycle, 20).ToArray();
// Animationsperiode in ms (Default: 4000ms)
public ushort[] LedPeriod { get; } =
Enumerable.Repeat((ushort)4000, 20).ToArray();
public Color[] LedBase { get; } = Enumerable.Repeat(Color.FromArgb(80, 40, 0), 20).ToArray();
public byte[] LedBrightness { get; } = Enumerable.Repeat((byte)255, 20).ToArray();
public LedAnimType[] LedAnim { get; } = Enumerable.Repeat(LedAnimType.ColorCycle, 20).ToArray();
public ushort[] LedPeriod { get; } = Enumerable.Repeat((ushort)4000, 20).ToArray();
private static DeviceAction[,] InitEncActions()
{
@ -187,79 +193,43 @@ public class DeviceConfig
return a;
}
// ── Serialisierung ────────────────────────────────────────────────────────
public byte[] ToBytes()
// Serialisiert ein Profil in 236 Bytes
public void ToBytes(byte[] buf, int offset)
{
const int size = 223;
var buf = new byte[size];
int pos = 0;
WriteU32(buf, ref pos, Magic);
buf[pos++] = Version;
int crcOffset = pos;
pos += 2;
foreach (var a in MxActions)
WriteAction(buf, ref pos, a);
int pos = offset;
foreach (var a in MxActions) WriteAction(buf, ref pos, a);
for (int e = 0; e < 4; e++)
for (int i = 0; i < 3; i++)
WriteAction(buf, ref pos, EncActions[e, i]);
for (int i = 0; i < 3; i++) WriteAction(buf, ref pos, EncActions[e, i]);
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;
for (int i = 0; i < 20; i++) buf[pos++] = LedBrightness[i];
for (int i = 0; i < 20; i++) buf[pos++] = (byte)LedAnim[i];
for (int i = 0; i < 20; i++)
{
buf[pos++] = (byte)(LedPeriod[i] & 0xFF);
buf[pos++] = (byte)(LedPeriod[i] >> 8);
}
ushort crc = Crc16(buf, 7, size - 7);
buf[crcOffset] = (byte)(crc & 0xFF);
buf[crcOffset + 1] = (byte)(crc >> 8);
return buf;
}
public bool FromBytes(byte[] buf)
// Deserialisiert ein Profil aus 236 Bytes
public void FromBytes(byte[] buf, int offset)
{
if (buf.Length < 223) 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, 223 - 7);
if (storedCrc != computedCrc) return false;
int pos = 7;
int pos = offset;
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 < 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]);
pos += 60;
for (int i = 0; i < 20; i++) LedBrightness[i] = buf[pos++];
for (int i = 0; i < 20; i++) LedAnim[i] = (LedAnimType)buf[pos++];
for (int i = 0; i < 20; i++)
{
LedPeriod[i] = (ushort)(buf[pos] | (buf[pos + 1] << 8));
pos += 2;
}
return true;
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static void WriteAction(byte[] buf, ref int pos, DeviceAction a)
{
buf[pos++] = (byte)a.Type;
@ -273,6 +243,92 @@ public class DeviceConfig
a.Data = (ushort)(buf[pos] | (buf[pos + 1] << 8));
pos += 2;
}
}
// ── Gesamt-Config (spiegelt SDeviceConfig) ────────────────────────────────────
public class DeviceConfig
{
public const uint Magic = 0x56503203;
public const byte Version = 3;
public const int EncSw = 0;
public const int EncCw = 1;
public const int EncCcw = 2;
// Globale Felder
public byte ActiveProfileIndex { get; set; } = 0;
public byte GlobalBrightness { get; set; } = 255;
public byte[] EncSensitivity { get; } = new byte[] { 1, 1, 1, 1 };
// 3 Profile
public DeviceProfile[] Profiles { get; } =
Enumerable.Range(0, 3).Select(_ => new DeviceProfile()).ToArray();
// ── Shortcuts auf aktives Profil (ConfigForm braucht keine Änderung) ──────
public DeviceAction[] MxActions => Profiles[ActiveProfileIndex].MxActions;
public DeviceAction[,] EncActions => Profiles[ActiveProfileIndex].EncActions;
public Color[] LedBase => Profiles[ActiveProfileIndex].LedBase;
public byte[] LedBrightness => Profiles[ActiveProfileIndex].LedBrightness;
public LedAnimType[] LedAnim => Profiles[ActiveProfileIndex].LedAnim;
public ushort[] LedPeriod => Profiles[ActiveProfileIndex].LedPeriod;
// ── Serialisierung ────────────────────────────────────────────────────────
public byte[] ToBytes()
{
const int size = 740;
var buf = new byte[size];
int pos = 0;
WriteU32(buf, ref pos, Magic);
buf[pos++] = Version;
int crcOffset = pos;
pos += 2; // CRC-Platzhalter
buf[pos++] = ActiveProfileIndex;
buf[pos++] = GlobalBrightness;
for (int i = 0; i < 4; i++) buf[pos++] = EncSensitivity[i];
pos += 19; // Reserve (bleibt 0)
// 3 Profile à 236B
for (int p = 0; p < 3; p++)
Profiles[p].ToBytes(buf, pos + p * 236);
pos += 3 * 236;
ushort crc = Crc16(buf, 7, size - 7);
buf[crcOffset] = (byte)(crc & 0xFF);
buf[crcOffset + 1] = (byte)(crc >> 8);
return buf;
}
public bool FromBytes(byte[] buf)
{
if (buf.Length < 740) return false;
uint magic = ReadU32(buf, 0);
if (magic != Magic) return false;
if (buf[4] != Version) return false;
ushort storedCrc = (ushort)(buf[5] | (buf[6] << 8));
ushort computedCrc = Crc16(buf, 7, 740 - 7);
if (storedCrc != computedCrc) return false;
int pos = 7;
ActiveProfileIndex = Math.Min(buf[pos++], (byte)2);
GlobalBrightness = buf[pos++];
for (int i = 0; i < 4; i++) EncSensitivity[i] = buf[pos++];
pos += 19; // Reserve überspringen
for (int p = 0; p < 3; p++)
Profiles[p].FromBytes(buf, pos + p * 236);
return true;
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static void WriteU32(byte[] buf, ref int pos, uint v)
{

View File

@ -51,6 +51,7 @@ public static class Protocol
public const byte EvtMacroBegin = 0x96; // Beginn Makro-Dump (Data[1] = Chunks)
public const byte EvtMacroData = 0x97; // Makro-Chunk (Data[1] = Index, Data[2..7] = 6B)
public const byte EvtMacroEnd = 0x98; // Makro-Dump vollständig
public const byte EvtMacroNack = 0x99; // Makro-Tabelle: NVM-Fehler
}
// Ein 8-Byte-Paket mit benannten Accessoren.

View File

@ -39,6 +39,12 @@ public class SerialManager : IDisposable
private bool _disposed;
private bool _waitingAfterDisconnect; // Backoff nach Trennung aktiv
// Synchronisation: Board-ACK/NACK abwarten bevor nächster Transfer startet
private readonly SemaphoreSlim _configAckGate = new(0, 1);
private readonly SemaphoreSlim _macroAckGate = new(0, 1);
private volatile bool _configAckOk;
private volatile bool _macroAckOk;
public bool IsConnected => _port?.IsOpen == true;
public SerialManager()
@ -221,8 +227,35 @@ public class SerialManager : IDisposable
public void RequestMacros()
=> Send(new SerialPacket(Protocol.CmdMacroRead));
// Wird von TrayApp aufgerufen wenn das Board CONFIG_ACK/NACK oder MACRO_ACK/NACK sendet.
// Gibt den wartenden SendConfig()/SendMacros()-Thread frei.
public void SignalConfigAck()
{
_configAckOk = true;
if (_configAckGate.CurrentCount == 0) _configAckGate.Release();
}
public void SignalConfigNack()
{
_configAckOk = false;
if (_configAckGate.CurrentCount == 0) _configAckGate.Release();
}
public void SignalMacroAck()
{
_macroAckOk = true;
if (_macroAckGate.CurrentCount == 0) _macroAckGate.Release();
}
public void SignalMacroNack()
{
_macroAckOk = false;
if (_macroAckGate.CurrentCount == 0) _macroAckGate.Release();
}
// Makro-Tabelle (256 Bytes) in 6-Byte-Chunks senden.
public void SendMacros(MacroTable macros)
// Gibt true zurück wenn das Board MACRO_ACK gesendet hat.
public bool SendMacros(MacroTable macros)
{
byte[] data = macros.ToBytes();
const int payload = 6;
@ -252,14 +285,24 @@ public class SerialManager : IDisposable
File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"),
$"{DateTime.Now:HH:mm:ss.fff} TX MacroCommit (sent {chunks} chunks)\n");
// Sicherstellen dass das Gate leer ist bevor wir warten
while (_macroAckGate.CurrentCount > 0) _macroAckGate.Wait(0);
Thread.Sleep(10);
Send(new SerialPacket(Protocol.CmdMacroCommit));
// Auf MACRO_ACK/NACK warten Board braucht bis zu ~600ms für NVM-Erase + Write (2 Rows)
bool gateAcquired = _macroAckGate.Wait(3000);
bool success = gateAcquired && _macroAckOk;
File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"),
$"{DateTime.Now:HH:mm:ss.fff} TX MacroAck gateAcquired={gateAcquired} ok={success}\n");
return success;
}
// Config in 6-Byte-Chunks an das Board senden.
// Protokoll: BEGIN → n×DATA → COMMIT
// Board schreibt nach COMMIT in den NVM (Firmware-Seite noch TODO).
public void SendConfig(DeviceConfig config)
// Gibt true zurück wenn das Board CONFIG_ACK gesendet hat.
public bool SendConfig(DeviceConfig config)
{
byte[] data = config.ToBytes();
const int payload = 6; // Nutzbytes pro Paket (8 - 2 Header-Bytes)
@ -289,8 +332,18 @@ public class SerialManager : IDisposable
File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"),
$"{DateTime.Now:HH:mm:ss.fff} TX ConfigCommit (sent {chunks} chunks)\n");
// Sicherstellen dass das Gate leer ist bevor wir warten
while (_configAckGate.CurrentCount > 0) _configAckGate.Wait(0);
Thread.Sleep(10);
Send(new SerialPacket(Protocol.CmdConfigCommit));
// Auf CONFIG_ACK/NACK warten Board braucht bis zu ~1s für NVM-Erase + Write (3 Rows)
bool gateAcquired = _configAckGate.Wait(3000);
bool success = gateAcquired && _configAckOk;
File.AppendAllText(Path.Combine(Path.GetTempPath(), "versapad_rx.txt"),
$"{DateTime.Now:HH:mm:ss.fff} TX ConfigAck gateAcquired={gateAcquired} ok={success}\n");
return success;
}
// ── IDisposable ───────────────────────────────────────────────────────────

View File

@ -128,18 +128,16 @@ public class TrayApp : ApplicationContext
break;
case Protocol.EvtConfigAck:
MessageBox.Show("Config erfolgreich gespeichert!",
"VersaPad", MessageBoxButtons.OK, MessageBoxIcon.Information);
_serial.SignalConfigAck(); // SendConfig()-Thread freigeben
break;
case Protocol.EvtConfigNack:
MessageBox.Show("Config FEHLER: CRC/Magic ungültig.\nÜbertragung fehlgeschlagen.",
"VersaPad", MessageBoxButtons.OK, MessageBoxIcon.Error);
_serial.SignalConfigNack(); // SendConfig()-Thread freigeben (Fehler)
break;
// ── Config-Dump vom Board ─────────────────────────────────────────
case Protocol.EvtConfigBegin:
_rxConfigBuf = new byte[223]; // sizeof(SDeviceConfig) = 223 (Version 2)
_rxConfigBuf = new byte[740]; // sizeof(SDeviceConfig) = 740 (Version 3)
break;
case Protocol.EvtConfigData:
@ -163,12 +161,15 @@ public class TrayApp : ApplicationContext
// ── Makro-Dump vom Board ──────────────────────────────────────────
case Protocol.EvtMacroAck:
MessageBox.Show("Makros erfolgreich gespeichert!",
"VersaPad", MessageBoxButtons.OK, MessageBoxIcon.Information);
_serial.SignalMacroAck(); // SendMacros()-Thread freigeben
break;
case Protocol.EvtMacroNack:
_serial.SignalMacroNack(); // SendMacros()-Thread freigeben (Fehler)
break;
case Protocol.EvtMacroBegin:
_rxMacroBuf = new byte[256];
_rxMacroBuf = new byte[512]; // sizeof(SMacroTable) = 512 (8 Steps)
break;
case Protocol.EvtMacroData: