Reconciles two independent lines of work: main cherry-picked and then extended dev/jappel's build-script/config-portability/rename-tab/ COM-port fixes, additionally fixing a config-loss bug (rebuild wiped the config when it lived in the install dir -- moved to roaming %APPDATA% instead) and adding free-text notes per action plus a frameless resizable window. dev/jappel keeps its .mcp.json registration and docs/ reference tree, which main deliberately left out. Conflict resolutions favored main's versions where the two sides solved the same problem (config location, build deploy target, COM-port release, tab rename) since main's fixes were validated against a real rebuild-wipes-config incident. docs/architecture.md and docs/data-model.md updated to describe the resulting %APPDATA% config path and the note field.
216 lines
8.2 KiB
Markdown
216 lines
8.2 KiB
Markdown
# 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 |
|
||
|
||
Jede `Action` trägt zusätzlich ein optionales `note`-Feld (freier Text,
|
||
z.B. `"Speichern in Fusion 360"`) — **rein lokal**, wie `profile_names`
|
||
(siehe unten): kein Platz dafür in `SAction` (3 Byte, komplett verplant),
|
||
`to_binary()`/`pack_config()` ignorieren das Feld beim Schreiben ans
|
||
Board, `from_binary()` liefert frisch vom Board immer `note=""`.
|
||
`versapad_combined.merge_notes(neu, alt)` kopiert bestehende Notizen nach
|
||
jedem `load_from_board()`/`fetch_from_board()` zurück, sonst gingen sie
|
||
bei jedem Board-Refresh verloren. Editierbar per Programmiermodus-Dialog
|
||
oder MCP (`set_button_note()`/`set_encoder_note()`).
|
||
|
||
## 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, "note": "Speichern in Fusion 360" },
|
||
"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. Dasselbe gilt für `note` in jeder `Action`
|
||
(siehe oben).
|
||
|
||
## 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).
|