VersaGUI-py/docs/data-model.md
Julian Appel 83429363c1 Add architecture/data-model/protocol reference docs
Human-facing reference documentation, split from AGENTS.md's agent-facing
domain rules and bug history (which stays there, not duplicated here):

- docs/architecture.md: layer diagram, module responsibilities, config
  storage location, the three GUI modes, and the port-exclusivity /
  multi-process caveats around concurrent access
- docs/data-model.md: the combined and legacy JSON formats, the binary
  SDeviceConfig/SDeviceProfile/SMacroTable NVM layout byte-for-byte, action
  types, LED fields, macro-slot conventions, button grid geometry
- docs/protocol.md: the 8-byte serial packet format, command/event tables,
  the read/write/status-poll flows, connection lifecycle, and error states

README.md now links to all three from a new "Dokumentation" section, and
AGENTS.md's outdated "docs/ tree isn't warranted yet" note is removed now
that it exists on explicit user request.
2026-08-14 23:19:13 +02:00

205 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).