diff --git a/AGENTS.md b/AGENTS.md
index 525e540..4afe1a9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -242,8 +242,6 @@ selbst vorgegeben (`SAction.data`), dort beibehalten statt umzubenennen.
## Deferred Work
-- Volle `docs/`-Baumstruktur (siehe Dokumentation und Verifikation unten —
- Projektgröße rechtfertigt das aktuell nicht, kein DB-/API-Dienst)
- Board-seitiges Umschalten des aktiven Profils per Button in der GUI —
explizit vom User abgelehnt ("lass uns weg"), Live-Sync bleibt read-only
- Profilnamen aufs Board schreiben — technisch unmöglich (kein Platz im
@@ -262,13 +260,16 @@ Nicht an diesen Punkten arbeiten, ohne dass der User es explizit anfragt.
- `README.md` ist der Einstiegspunkt (Installation, Nutzung, Architektur-
Überblick).
- `AGENTS.md` (diese Datei) ist die agentenseitige Quelle der Wahrheit für
- Domänenregeln und Architekturgrenzen — jede Session aktualisieren, die
- daran etwas ändert oder etwas Wichtiges lernt.
-- Größeneinschätzung nach Projekt-Dokumentationsstandard: kleines/mittleres
- Tool ohne eigene Datenbank und ohne persistenten API-Dienst (der
- Browser-Server ist ein einfacher lokaler Lese-Viewer, kein
- Mehrbenutzer-Backend) → `README.md` + `AGENTS.md` sind Pflicht und
- vorhanden, ein voller `docs/`-Baum ist nicht angemessen.
+ Domänenregeln, Architekturgrenzen und Bug-Historie — jede Session
+ aktualisieren, die daran etwas ändert oder etwas Wichtiges lernt.
+- `docs/` (seit 2026-08-14, auf expliziten User-Wunsch) enthält die
+ menschenlesbare Referenzdoku: `architecture.md` (Schichten, Prozess-/
+ Nebenläufigkeitsmodell, Config-Speicherort), `data-model.md` (JSON-
+ Formate, binäres NVM-Layout, Geometrie, Enums), `protocol.md`
+ (Serial-Wire-Protokoll). Bug-Historie/Domänenregeln bleiben bewusst nur
+ in `AGENTS.md`, nicht dupliziert in `docs/`. Bei Änderungen am
+ Binärformat/Protokoll/Datenmodell `docs/data-model.md` bzw.
+ `docs/protocol.md` mitpflegen.
Prüfungen vor einem Commit an Binärformat/Protokoll:
```bash
diff --git a/README.md b/README.md
index e556a47..c6af12c 100644
--- a/README.md
+++ b/README.md
@@ -156,10 +156,22 @@ rechts im Fenster, oder direkt in `versapad_mcp_server.py`.
werden; `--onedir` (statt `--onefile`) verringert das Risiko, verhindert
es aber nicht
+## Dokumentation
+
+Ausführlichere technische Doku im [`docs/`](docs/)-Ordner:
+
+- [`docs/architecture.md`](docs/architecture.md) — Schichtenmodell,
+ Prozessmodell, Modi, Config-Speicherort, Nebenläufigkeit
+- [`docs/data-model.md`](docs/data-model.md) — JSON-Formate (kombiniert +
+ Legacy), binäres NVM-Layout, Geometrie, Enums
+- [`docs/protocol.md`](docs/protocol.md) — Serial-Wire-Protokoll (Befehle,
+ Events, Paketformat, CRC16)
+
## Weiterentwicklung
-Tiefere technische Notizen (Protokoll-Details, bekannte Stolpersteine beim
-Bauen, Design-Entscheidungen) stehen in [`AGENTS.md`](AGENTS.md).
+Agentenseitige Notizen (Domänenregeln, bekannte Bugs und ihre Fixes,
+Implementierungsdisziplin, Design-Entscheidungen) stehen in
+[`AGENTS.md`](AGENTS.md).
## Lizenz / Herkunft
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..142fc34
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,185 @@
+# Architektur
+
+Überblick über die Schichten, Prozesse und Datenflüsse von VersaPad Viewer.
+Für Domänenregeln, bekannte Bugs und Implementierungsdisziplin siehe
+[`AGENTS.md`](../AGENTS.md); für Installation/Nutzung siehe
+[`README.md`](../README.md). Für die genauen Datenformate siehe
+[`data-model.md`](data-model.md), für das Serial-Wire-Protokoll
+[`protocol.md`](protocol.md).
+
+## Ziel und Kontext
+
+VersaPad Viewer ist ein eigenständiges Python-Tool für das VersaPad-Makropad
+(4×5-Button-Grid + 4 Encoder, SAMD21-Firmware). Es ergänzt die offizielle
+VersaGUI (C#/.NET) um eine schlankere, plattformunabhängigere Alternative
+zum Anzeigen, Live-Synchronisieren und Neuprogrammieren der Belegung, plus
+einen MCP-Server, der dieselbe Programmierung KI-gesteuert per Tool-Aufruf
+erlaubt. Beide GUIs (die offizielle VersaGUI und dieses Tool) konkurrieren
+um denselben exklusiven USB-CDC-Port — siehe „Nebenläufigkeit" unten.
+
+## Schichtenmodell
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Frontends │
+│ ┌────────────┐ ┌──────────────────┐ ┌────────────────────┐ │
+│ │ server.py │ │ desktop_viewer.py │ │ versapad_mcp_ │ │
+│ │ (Browser, │ │ (Tkinter, Tray, │ │ server.py │ │
+│ │ read-only)│ │ Live-Sync, Edit) │ │ (KI-Tool-Aufrufe) │ │
+│ └─────┬──────┘ └────────┬──────────┘ └─────────┬──────────┘ │
+│ │ │ │ │
+│ └───────────┬───────┴──────────────┬───────────┘ │
+│ ▼ ▼ │
+│ versapad_combined.py versapad_data.py │
+│ (Ein-Datei-Format, (Decoding fürs Anzeigen, │
+│ Board-Sync, Auto- Legacy-Einzel-JSONs) │
+│ Create) │
+│ │ │
+│ ▼ │
+│ versapad_protocol.py (pack/unpack, CRC16) │
+│ │ │
+│ ▼ │
+│ versapad_serial.py (VersaPadLink, 8-Byte-Pakete) │
+│ │ │
+└─────────────────────┼──────────────────────────────────────────────┘
+ ▼
+ VersaPad-Board (USB-CDC, VID:PID 239A:0042)
+```
+
+`action_dialog.py` ist ein UI-Hilfsmodul von `desktop_viewer.py`
+(Bearbeiten-Dialoge für den Programmiermodus) und taucht oben nicht separat
+auf.
+
+### Read-only-Schicht
+
+- **`versapad_data.py`** — decodiert JSON-Rohdaten (Keycodes, Consumer-IDs,
+ Modifier-Bits) zu lesbarem Text, kennt die Grid-Geometrie
+ (`index = spalte*5 + reihe`). Liest wahlweise:
+ - die klassischen `versapad_config1/2/3.json` (`CONFIG_PATHS`) — Export
+ der offiziellen VersaGUI, kein von diesem Tool geschriebenes Format;
+ - oder (über `versapad_combined.py`) die kombinierte Datei.
+ - `app_dir()` liefert das Basisverzeichnis für die eigene Config, siehe
+ „Config-Speicherort" unten.
+- **`server.py`** — generiert bei jedem HTTP-Request frisch HTML aus dem
+ aktuellen Zustand (`vcomb.load_or_fetch()`), Auto-Reload alle 4s per
+ ``. Rein lesend, kein eigener Zustand zwischen
+ Requests, daher nie „veraltet" im Sinne von In-Memory-Staleness.
+
+### Binär-/Serial-Schicht
+
+- **`versapad_protocol.py`** — pack/unpack für `SDeviceConfig` (740B, alle
+ 3 Profile) und `SMacroTable` (512B, 32 Slots), plus CRC16. 1:1 aus den
+ Firmware-Structs übernommen, siehe [`data-model.md`](data-model.md).
+- **`versapad_serial.py`** — `VersaPadLink`: öffnet bei Bedarf den COM-Port
+ (per VID/PID-Erkennung), spricht das 8-Byte-Paket-Protokoll, siehe
+ [`protocol.md`](protocol.md). **Schließt die Verbindung nicht von selbst**
+ nach einem Befehl — Aufrufer müssen das selbst tun, wenn sie den Port
+ nicht dauerhaft blockieren wollen (siehe „Nebenläufigkeit" unten).
+- **`versapad_combined.py`** — das Ein-Datei-Format: alle 3 Profile +
+ Makro-Tabelle + lokale Profilnamen in einer JSON
+ (`versapad_config_all.json`). Bindeglied zwischen den JSON-Strukturen und
+ den Binärblobs aus `versapad_protocol.py`. Zentrale Funktionen:
+ - `load_or_fetch()` — bevorzugt die lokale Datei, baut sie bei Bedarf
+ automatisch neu auf (erst Board-Versuch, sonst leere Default-Config).
+ - `save_file()`/`load_file()` — reines Lesen/Schreiben der JSON.
+ - `fetch_from_board()`/`to_binary()` — Konvertierung zu/von den
+ Binärblobs für Board-Lese-/Schreibvorgänge.
+ - `read_profile_names()` — liest nur die Profilnamen, ohne Board-Zugriff
+ (für Tab-Beschriftungen im Nur-Lese-Modus).
+
+### UI
+
+- **`desktop_viewer.py`** — Tkinter-Fenster mit drei unabhängig
+ umschaltbaren Modi (siehe „Modi" unten), Tray-Icon, Info-Dialog mit
+ MCP-Doku.
+- **`action_dialog.py`** — modale Bearbeiten-Dialoge (`ActionEditDialog`,
+ `MacroStepsDialog`) für den Programmiermodus.
+
+### MCP-Server
+
+- **`versapad_mcp_server.py`** — registriert als projektgebundener
+ MCP-Server "versapad" (`.mcp.json`) oder wahlweise user-scope
+ (`claude mcp add -s user versapad -- versapad_mcp_server.py`).
+ Hält einen eigenen In-Memory-Zustand (`_state["combined"]`,
+ unabhängig von jeder laufenden GUI), der explizit per `save_local()` /
+ `load_local()` mit der Datei bzw. `load_from_board()` / `write_to_board()`
+ mit dem Board synchronisiert wird. Board-Serial-Tools schließen die
+ Verbindung nach jedem Aufruf wieder (siehe unten).
+
+## Config-Speicherort
+
+`versapad_data.app_dir()` bestimmt das Basisverzeichnis für die eigene
+Config-Datei (`versapad_combined.DEFAULT_PATH` =
+`app_dir()/versapad_config_all.json`):
+
+- **Gebaute `.exe`** (PyInstaller `--onedir`): `sys.executable`s Ordner —
+ die Config liegt also neben `VersaPadViewer.exe`, in welchem
+ Installationsverzeichnis sie auch liegt.
+- **Start aus dem Quellcode** (`py desktop_viewer.py`, `py server.py`,
+ `py versapad_mcp_server.py`): der Projektordner (`__file__`-Verzeichnis).
+
+Fehlt die Datei, legt `load_or_fetch()` sie automatisch an — zuerst per
+Serial-Versuch vom Board (das ist die eigentliche Quelle der Wahrheit, die
+Datei nur ein Lesecache dafür), sonst als leere Default-Config
+(`default_combined()`). Das Tool ist damit auch ganz ohne vorhandene
+Config oder angeschlossenes Board sofort benutzbar.
+
+Die klassischen `versapad_config1/2/3.json` (`versapad_data.CONFIG_PATHS`)
+bleiben bewusst getrennt hartkodiert auf `~\OneDrive\Desktop` — das ist
+optionale Lese-Interop mit einem JSON-Export der offiziellen VersaGUI,
+kein von diesem Tool selbst gepflegtes Format, und daher nicht Teil der
+„portablen Installation".
+
+## Modi in `desktop_viewer.py`
+
+Drei Checkboxen, unabhängig voneinander:
+
+| Modus | Zweck | Zustand |
+|---|---|---|
+| Nur-Lesen (Default) | Zeigt das aktuelle Profil an | Liest bei jedem Poll (alle 1,5s) frisch über `_current_profile_view()` → `vcomb.load_or_fetch()`. Kein eigener In-Memory-Snapshot, daher nie veraltet. |
+| Live-Sync | Fragt per Serial das aktuell aktive Profil ab, schaltet die Ansicht mit | Hintergrund-Thread pollt `read_active_profile()`, hält dafür den COM-Port dauerhaft offen, solange die Checkbox an ist. Schließt sich mit VersaGUI/Programmiermodus/MCP-Board-Zugriff gegenseitig aus (exklusiver Port). |
+| Programmiermodus | Zellen anklicken zum Bearbeiten | Lädt `self.combined` **einmalig pro Prozesslauf** beim ersten Aktivieren (bevorzugt `DEFAULT_PATH`, sonst `default_combined()`). Jede Bearbeitung speichert sofort automatisch (`_autosave_combined()`). **Achtung:** Da der Snapshot nur einmal geladen wird, sieht der Programmiermodus externe Änderungen (z.B. per MCP) erst nach einem Neustart der exe oder einem expliziten „Datei laden…“. |
+
+Tk-Aufrufe passieren nie direkt aus dem Serial- oder Tray-Hintergrundthread
+— Ergebnisse landen in einer `queue.Queue`, der Main-Thread holt sie per
+`after()`-Polling ab (Absturzrisiko bei Cross-Thread-Tk-Zugriff, siehe
+`AGENTS.md`).
+
+## Nebenläufigkeit / Prozessmodell
+
+Der USB-CDC-Port ist **exklusiv** — nur eine Verbindung gleichzeitig. Drei
+potenzielle Halter existieren parallel und wissen nichts voneinander:
+
+1. Die offizielle VersaGUI (C#/.NET), läuft dauerhaft als Tray-App.
+2. `desktop_viewer.py`, wenn Live-Sync an ist (hält den Port dauerhaft) oder
+ während eines Board-Lese-/Schreibvorgangs im Programmiermodus (hält ihn
+ nur kurz).
+3. `versapad_mcp_server.py`, während eines Board-Tool-Aufrufs — schließt
+ die Verbindung danach explizit wieder (`finally: _link.close()` in
+ `get_board_status()`, `load_from_board()`, `write_to_board()`), damit ein
+ einzelner MCP-Aufruf nicht dauerhaft blockiert, was Live-Sync/VersaGUI
+ sonst mit „busy“ aussperren würde.
+
+**Mehrere MCP-Server-Prozesse:** Je nach Host-Umgebung können mehrere
+unabhängige `versapad_mcp_server.py`-Prozesse gleichzeitig laufen (z.B.
+durch wiederholte Tool-Ladevorgänge/Reconnects), jeder mit eigenem,
+nicht geteiltem In-Memory-Zustand. Ein `write_to_board()`-Aufruf kann daher
+auf einem anderen Prozess landen als vorherige `set_*`-Aufrufe und einen
+veralteten/leeren Zustand schreiben, obwohl die Antwort `{"ok": true}`
+meldet. Empfohlenes Muster: vor `write_to_board()` immer `load_local()`
+aufrufen (liest die Datei prozessunabhängig frisch von der Platte) und
+nach dem Schreiben mit `load_from_board()` + `get_profile()` gegenlesen,
+statt dem ACK allein zu vertrauen. Details siehe „Bug beobachtet
+2026-08-14“ in `AGENTS.md`.
+
+## Build/Deploy
+
+`build_and_deploy.ps1` installiert `requirements.txt` selbst
+(`pip install -r`), baut mit PyInstaller (`--onedir --windowed`, nur
+`desktop_viewer.py` wird gebündelt) und kopiert das Ergebnis nach
+`dist\VersaPadViewer\` im Projektordner. `--onedir` statt `--onefile`, um
+AV-Fehlalarme zu verringern. Läuft komplett in try/catch mit
+Exit-Code-Prüfung und pausiert am Ende (Erfolg wie Fehler) auf
+Tastendruck, außer bei `-NoPause`. Muss lokal laufen, nicht auf einem
+Netzlaufwerk (Pfadlängen-/DLL-Ladeprobleme, siehe `AGENTS.md`). Details:
+[`README.md`](../README.md#als-eigenständige-exe-windows).
diff --git a/docs/data-model.md b/docs/data-model.md
new file mode 100644
index 0000000..88d160f
--- /dev/null
+++ b/docs/data-model.md
@@ -0,0 +1,205 @@
+# Datenmodell
+
+Alle Formate, die VersaPad Viewer liest/schreibt: die JSON-Repräsentationen
+und das binäre NVM-Layout des Boards. Das binäre Layout ist 1:1 aus den
+VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert
+(Read → unpack → pack ist bytegenau identisch zum Original, inklusive
+CRC) — siehe `versapad_protocol.py` und die „Existing-Codebase-Regel“ in
+[`AGENTS.md`](../AGENTS.md), bevor hier etwas geändert wird.
+
+## Geometrie
+
+```
+index = spalte * 5 + reihe
+```
+
+4 Spalten (`GRID_COLS`), 5 Reihen (`GRID_ROWS`), Reihe 0 = oben, Reihe 4 =
+unten. Firmware-Reihenfolge, nicht neu herleiten. Damit ergeben sich die
+20 Button-Indizes so auf dem physischen Grid:
+
+| | Spalte 0 | Spalte 1 | Spalte 2 | Spalte 3 |
+|---|---|---|---|---|
+| Reihe 0 (oben) | 0 | 5 | 10 | 15 |
+| Reihe 1 | 1 | 6 | 11 | 16 |
+| Reihe 2 | 2 | 7 | 12 | 17 |
+| Reihe 3 | 3 | 8 | 13 | 18 |
+| Reihe 4 (unten) | 4 | 9 | 14 | 19 |
+
+## Action
+
+Eine `Action` beschreibt, was ein Button oder eine Encoder-Bewegung
+auslöst. Sowohl in JSON als auch binär ein `{type, data}`-Paar
+(binär: `SAction`, 3 Byte — 1 Byte Typ + 2 Byte `data`, little-endian).
+
+| `type` | Enum-Index | `data`-Bedeutung |
+|---|---|---|
+| `None` | 0 | ungenutzt (0) |
+| `HidKey` | 1 | `data = keycode \| (modifier << 8)` — Keycode HID Usage Page 0x07 im unteren Byte, Modifier-Bitmaske im oberen Byte |
+| `HidConsumer` | 2 | `data` = HID-Consumer-Usage-ID (Usage Page 0x0C), z.B. `0x00CD` = Play/Pause |
+| `HostCommand` | 3 | Enum-Wert existiert in der Firmware, wird von diesem Tool aktuell nicht gesetzt/editiert (kein `set_button_hostcommand`-Äquivalent) |
+| `Macro` | 4 | `data` = Makro-Slot-Index (0-31) |
+| `ProfileSwitch` | 5 | `data` = Ziel-Profil (0/1/2) oder `0xFFFF`/`0x00FF` = „nächstes Profil“ (Zyklus) |
+
+Modifier-Bitmaske (für `HidKey`, gilt **nicht** 1:1 für Makro-Schritte,
+siehe unten):
+
+| Bit | Modifier |
+|---|---|
+| `0x01` | Strg |
+| `0x02` | Shift |
+| `0x04` | Alt |
+| `0x08` | Win |
+
+## LED
+
+Pro MX-Button (nicht pro Encoder — Encoder haben keine eigene LED):
+
+| Feld | Typ | Bedeutung |
+|---|---|---|
+| `r`, `g`, `b` | uint8 (0-255) | Farbe |
+| `brightness` | uint8 (0-255) | Helligkeit |
+| `anim` | Enum-String (JSON) / Enum-Index (binär) | `Static`, `Blink`, `Pulse`, `FadeIn`, `FadeOut`, `ColorCycle`, `ColorFade` |
+| `period_ms` | uint16 (little-endian) | Animationsperiode in ms (Pulse braucht `>= 2`) |
+
+## Makro-Schritt
+
+Ein Makro-Schritt ist **kein** `Action` — Keycode und Modifier stehen in
+zwei getrennten Bytes (nicht in einem gepackten 16-Bit-`data`-Feld wie bei
+`HidKey`):
+
+| Feld | Typ | Bedeutung |
+|---|---|---|
+| `keycode` | uint8 | HID-Keycode. `0` beendet die Sequenz (Firmware-Konvention — keine Lücken vor dem letzten belegten Schritt lassen) |
+| `modifier` | uint8 | Bitmaske, aber **nur Strg/Shift/Alt** (kein Win — passend zu `ActionDialog.cs` im Original) |
+
+Eine Makro-Tabelle hat 32 Slots (`MACRO_SLOTS`) mit je bis zu 8 Schritten
+(`MACRO_MAX_STEPS`). Sie ist **eine einzige globale Tabelle**, nicht pro
+Profil — zwei Profile, die per `Macro`-Action denselben Slot referenzieren,
+spielen dieselben Schritte ab. Konvention für die Slot-Zuordnung (von den
+Tools/der GUI benutzt, nicht von der Firmware erzwungen):
+
+- Slot `0`–`19` = MX-Button-Index (Button `i` → Slot `i`)
+- Slot `20`–`31` = `20 + enc*3 + act_idx` (Encoder `enc`, `act_idx`:
+ 0=Druck/`sw`, 1=`cw`, 2=`ccw`)
+
+## JSON: kombiniertes Format (`versapad_config_all.json`)
+
+Das von diesem Tool selbst gepflegte Format (`versapad_combined.py`) — alle
+3 Profile + Makro-Tabelle + lokale Profilnamen in einer Datei. Passt zum
+Wire-Protokoll: `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer den
+kompletten 740B-Block, nie nur ein Profil.
+
+```jsonc
+{
+ "active_profile": 0,
+ "global_brightness": 255,
+ "enc_sensitivity": [1, 1, 1, 1],
+ "profile_names": ["Windows", "Fusion 360", "BricsCAD"],
+ "profiles": [
+ {
+ "buttons": [
+ {
+ "index": 0,
+ "action": { "type": "HidKey", "data": 30 },
+ "led": { "r": 80, "g": 40, "b": 0, "brightness": 255,
+ "anim": "Static", "period_ms": 4000 }
+ }
+ // ... 20 Buttons (index 0-19)
+ ],
+ "encoders": [
+ {
+ "index": 0,
+ "sw": { "type": "ProfileSwitch", "data": 65535 },
+ "cw": { "type": "None", "data": 0 },
+ "ccw": { "type": "None", "data": 0 }
+ }
+ // ... 4 Encoder (index 0-3)
+ ]
+ }
+ // ... 3 Profile
+ ],
+ "macros": [
+ [{ "keycode": 30, "modifier": 0 }, { "keycode": 39, "modifier": 0 }]
+ // ... 32 Slots, jeweils eine Liste mit 0-8 Schritten
+ ]
+}
+```
+
+Profilnamen (`profile_names`) sind **rein lokal** — die Firmware-Structs
+haben keinen Platz für einen String (Header exakt 32B, jedes Profil exakt
+236B, alles verplant), sie landen nie aufs Board, egal welcher
+Schreibpfad benutzt wird.
+
+## JSON: Legacy-Einzeldatei-Format (`versapad_config1/2/3.json`)
+
+Kein von diesem Tool geschriebenes Format — optionaler Export der
+offiziellen VersaGUI, gelesen von `versapad_data.load_profile()`. Enthält
+nur ein einzelnes Profil, keine Makro-Schritte, keinen Profilnamen:
+
+```jsonc
+{
+ "buttons": [ /* wie oben, 20 Eintraege */ ],
+ "encoders": [ /* wie oben, 4 Eintraege */ ]
+}
+```
+
+## MCP-Tool-Grenzfläche (`set_macro`, `set_button_key`, …)
+
+Die MCP-Tools nehmen **menschenlesbare** Namen entgegen, keine Rohwerte —
+`versapad_data.py` übersetzt:
+
+```jsonc
+// set_button_key(profile=0, index=0, key="S", modifiers=["Strg"])
+// set_macro(slot=7, steps=[{"key": "1", "modifiers": []}, {"key": "0", "modifiers": []}])
+```
+
+`hid_key_code_for_name()` / `consumer_id_for_name()` / `modifier_bits_for_names()`
+übersetzen Namen → Rohwerte (werfen `ValueError` mit einer Liste gültiger
+Namen bei Tippfehlern). Zeichentasten-Labels sind eine US-Layout-Näherung
+(keine `GetKeyNameText()`-Auflösung wie im C#-Original).
+
+## Binäres NVM-Layout
+
+### `SDeviceConfig` (740 Byte, `versapad_protocol.CONFIG_SIZE`)
+
+| Offset | Größe | Feld |
+|---|---|---|
+| 0 | 4B (uint32 LE) | Magic (`0x56503203`, `NVM_CONFIG_MAGIC`) |
+| 4 | 1B | Version (`3`, `NVM_CONFIG_VERSION`) |
+| 5 | 2B (uint16 LE) | CRC16 über Byte 7-739 |
+| 7 | 1B | `active_profile` (0-2) |
+| 8 | 1B | `global_brightness` (0-255) |
+| 9 | 4B | `enc_sensitivity` (4× uint8, einer je Encoder) |
+| 13 | 19B | reserviert/ungenutzt (Padding) |
+| 32 | 236B | Profil 0 (`SDeviceProfile`) |
+| 268 | 236B | Profil 1 |
+| 504 | 236B | Profil 2 |
+
+### `SDeviceProfile` (236 Byte)
+
+| Offset (relativ) | Größe | Feld |
+|---|---|---|
+| 0 | 60B (20× 3B `SAction`) | MX-Button-Actions, Index 0-19 |
+| 60 | 36B (4× 3× 3B `SAction`) | Encoder-Actions: je Encoder `sw`, `cw`, `ccw` |
+| 96 | 20B | LED `r` je Button |
+| 116 | 20B | LED `g` je Button |
+| 136 | 20B | LED `b` je Button |
+| 156 | 20B | LED `brightness` je Button |
+| 176 | 20B | LED `anim` (Enum-Index) je Button |
+| 196 | 40B (20× uint16 LE) | LED `period_ms` je Button |
+
+Die LED-Felder liegen **spaltenweise** (struct-of-arrays: alle 20
+`r`-Werte, dann alle 20 `g`-Werte, …), nicht verschachtelt pro Button —
+`pack_profile()`/`unpack_profile()` bauen das entsprechend um.
+
+### `SMacroTable` (512 Byte, `versapad_protocol.MACRO_SIZE`)
+
+32 Slots × 8 Schritte × 2 Byte (`keycode`, `modifier`) = 512 Byte, flach
+hintereinander: `offset = (slot_idx * 8 + step_idx) * 2`.
+
+### CRC16
+
+`crc16()` in `versapad_protocol.py`: CRC-CCITT, Polynom `0x1021`, Init
+`0xFFFF`, MSB-first, kein XOR-Out — exakt `nvm_config_crc()` aus der
+Firmware (`nvm_config.cpp`). Wird über Byte 7-739 der Config berechnet
+(alles nach Magic/Version/CRC-Header selbst).
diff --git a/docs/protocol.md b/docs/protocol.md
new file mode 100644
index 0000000..73f76e7
--- /dev/null
+++ b/docs/protocol.md
@@ -0,0 +1,136 @@
+# Serial-Wire-Protokoll
+
+Beschreibt, wie `versapad_serial.VersaPadLink` mit dem Board spricht. 1:1
+aus `VersaGUI/src/Protocol.cs` und `VersaMCU/doc/07_serial_protocol.md`
+übernommen (siehe „Existing-Codebase-Regel“ in
+[`AGENTS.md`](../AGENTS.md) — bei Änderungen gegen die Firmware-/
+C#-Quellen abgleichen, nicht aus dem Gedächtnis rekonstruieren). Für die
+Bedeutung der übertragenen Bytes (Config/Makro-Layout) siehe
+[`data-model.md`](data-model.md).
+
+## Transport
+
+- USB-CDC (virtueller COM-Port), 115200 Baud.
+- Board-Erkennung über USB VID:PID `239A:0042` (`versapad_serial.find_port()`
+ durchsucht `serial.tools.list_ports.comports()`).
+- Der Port ist **exklusiv** — siehe „Nebenläufigkeit“ in
+ [`architecture.md`](architecture.md).
+- Jedes Paket ist exakt **8 Byte**:
+
+ | Byte | Bedeutung |
+ |---|---|
+ | `[0]` | Command-/Event-ID |
+ | `[1]` | je nach Befehl: Chunk-Index, Chunk-Anzahl, oder ungenutzt |
+ | `[2..7]` | Payload, 6 Byte (`PAYLOAD_SIZE`) |
+
+## Befehle (Host → Board)
+
+| Konstante | Wert | Zweck |
+|---|---|---|
+| `CMD_READ_STATUS` | `0x06` | Leichtgewichtiger Status-Poll (aktives Profil) |
+| `CMD_CONFIG_BEGIN` | `0x10` | Start eines Config-Schreibvorgangs |
+| `CMD_CONFIG_DATA` | `0x11` | Ein Config-Datenpaket |
+| `CMD_CONFIG_COMMIT` | `0x12` | Config committen (NVM-Schreiben nach Validierung) |
+| `CMD_CONFIG_READ` | `0x13` | Komplette Config vom Board anfordern |
+| `CMD_MACRO_BEGIN` | `0x20` | Start eines Makro-Schreibvorgangs |
+| `CMD_MACRO_DATA` | `0x21` | Ein Makro-Datenpaket |
+| `CMD_MACRO_COMMIT` | `0x22` | Makros committen |
+| `CMD_MACRO_READ` | `0x23` | Komplette Makro-Tabelle vom Board anfordern |
+
+## Events (Board → Host)
+
+| Konstante | Wert | Zweck |
+|---|---|---|
+| `EVT_STATUS` | `0x86` | Antwort auf `CMD_READ_STATUS` |
+| `EVT_CONFIG_ACK` | `0x90` | Config-Commit erfolgreich |
+| `EVT_CONFIG_NACK` | `0x91` | Config-Commit abgelehnt (Validierung fehlgeschlagen) |
+| `EVT_CONFIG_BEGIN` | `0x92` | Board beginnt, Config-Dump zu senden |
+| `EVT_CONFIG_DATA` | `0x93` | Ein Config-Datenpaket (Antwort auf `CMD_CONFIG_READ`) |
+| `EVT_CONFIG_END` | `0x94` | Config-Dump vollständig |
+| `EVT_MACRO_ACK` | `0x95` | Makro-Commit erfolgreich |
+| `EVT_MACRO_BEGIN` | `0x96` | Board beginnt, Makro-Dump zu senden |
+| `EVT_MACRO_DATA` | `0x97` | Ein Makro-Datenpaket |
+| `EVT_MACRO_END` | `0x98` | Makro-Dump vollständig |
+| `EVT_MACRO_NACK` | `0x99` | Makro-Commit abgelehnt |
+
+## Ablauf: Lesen (`CONFIG_READ` / `MACRO_READ`)
+
+Genutzt von `read_full_config()` (740B, ⌈740/6⌉ = 124 Datenpakete) und
+`read_macros()` (512B, ⌈512/6⌉ = 86 Datenpakete). Deadline 3s.
+
+```
+Host → [CMD_CONFIG_READ, 0, 0,0,0,0,0,0]
+Board → [EVT_CONFIG_BEGIN, ...]
+Board → [EVT_CONFIG_DATA, chunk_idx=0, <=6B Payload]
+Board → [EVT_CONFIG_DATA, chunk_idx=1, <=6B Payload]
+ ... (124 Pakete insgesamt für Config, 86 für Makros)
+Board → [EVT_CONFIG_END, ...]
+```
+
+`chunk_idx` (Byte `[1]`) bestimmt den Ziel-Offset im Empfangspuffer:
+`offset = chunk_idx * 6`. Kommt `EVT_END`, ohne dass zuvor `EVT_BEGIN`
+gesehen wurde, gilt das als Timeout (unvollständige/verpasste Antwort).
+
+## Ablauf: Schreiben (`CONFIG_BEGIN/DATA/COMMIT` bzw. `MACRO_*`)
+
+Genutzt von `write_full_config()`/`write_macros()`. Deadline 5s. Anzahl
+Chunks maximal 255 (ein Byte) — bei größeren Blobs schlägt der Aufruf mit
+`"too_large"` fehl, bevor überhaupt gesendet wird.
+
+```
+Host → [CMD_CONFIG_BEGIN, chunk_count, 0,0,0,0,0,0]
+Host → [CMD_CONFIG_DATA, 0, <=6B Payload (zero-padded)]
+Host → [CMD_CONFIG_DATA, 1, <=6B Payload]
+ ... (ein Paket je Chunk)
+Host → [CMD_CONFIG_COMMIT, 0, 0,0,0,0,0,0]
+Board → [EVT_CONFIG_ACK, ...] -- oder EVT_CONFIG_NACK bei fehlgeschlagener Validierung
+```
+
+**Sicherheitsnetz:** Die Firmware prüft Magic/Version/CRC (Config) bzw.
+Keycode-Bereich (Makros) **vor** jedem NVM-Schreiben und antwortet sonst
+nur mit NACK — ein fehlerhafter Schreibversuch kann das Board laut
+Firmware-Design nicht in einen inkonsistenten Zustand bringen, siehe
+`nvm_config_validate()` in den Firmware-Quellen.
+
+## Ablauf: Leichtgewichtiger Status-Poll (`READ_STATUS`)
+
+Genutzt von `read_active_profile()` für kontinuierliches Live-Sync-Polling
+(alle 1,5s). Deadline 1s.
+
+```
+Host → [CMD_READ_STATUS, 0, 0,0,0,0,0,0]
+Board → [EVT_STATUS, active_profile (0-2), ...]
+```
+
+Ein einzelnes Antwortpaket statt eines vollen `CONFIG_READ`-Dumps (124
+Pakete) — Letzterer blockiert die Firmware in `poll_vendor()` lang genug,
+dass laufende LED-Pulse-Animationen sichtbar stottern. Ältere Firmware
+ohne `CMD_READ_STATUS` antwortet einfach gar nicht → sauberer Timeout,
+kein Absturz (siehe `AGENTS.md`, Eintrag „4215323“ in der Historie).
+
+## Verbindungsaufbau (`VersaPadLink._ensure_open()`)
+
+1. Board per VID/PID finden (`find_port()`).
+2. `serial.Serial(port, 115200, timeout=0.5)` öffnen.
+3. DTR auf `True` setzen, 0,2s warten (Board braucht kurz, bis es nach dem
+ Öffnen/DTR-Toggle wieder reagiert).
+4. Input-Buffer leeren.
+
+**Wichtig:** `VersaPadLink` schließt die Verbindung nicht automatisch nach
+einem Befehl — nur bei `serial.SerialException` (Fehlerfall) oder
+explizitem `.close()`-Aufruf. Aufrufer, die den exklusiven Port nicht
+dauerhaft blockieren wollen, müssen selbst schließen (siehe
+`versapad_mcp_server.py`, das nach jedem Board-Tool-Aufruf `_link.close()`
+in einem `finally`-Block aufruft).
+
+## Fehlerzustände (`VersaPadLink.last_error`)
+
+| Wert | Bedeutung |
+|---|---|
+| `None` | kein Fehler |
+| `"no_pyserial"` | Paket `pyserial` nicht installiert |
+| `"not_found"` | kein Gerät mit passender VID/PID gefunden |
+| `"busy"` | Port gefunden, aber Öffnen fehlgeschlagen (von woanders gehalten — VersaGUI, Live-Sync, ein anderer Prozess) |
+| `"timeout"` | keine (gültige) Antwort innerhalb der Deadline |
+| `"nack"` | Board hat den Commit explizit abgelehnt (Validierung fehlgeschlagen) |
+| `"too_large"` | zu sendender Blob braucht mehr als 255 Chunks |