Add free-text notes per button/encoder action

Lets you record what a binding actually does (e.g. "Save in Fusion
360") alongside the auto-generated label ("Strg+S"). Notes live in a
new "note" field on every action dict, purely local like profile
names -- the firmware struct has no room for strings, and
pack_config/unpack_config already only touch type/data so the extra
key round-trips harmlessly.

Two things had to be handled carefully: changing a button's key/type
must not wipe its note (all set_button_*/set_encoder_* setters and the
edit dialog now carry the previous note forward), and re-reading from
the board must not erase notes either, since the firmware doesn't know
about them -- versapad_combined.merge_notes() restores them onto the
freshly-fetched state by button/encoder index.

Editable via the Programmiermodus dialog (new text field), visible on
both the desktop card (grown from 84 to 114px to fit it) and the
browser view. MCP server gets set_button_note()/set_encoder_note() so
notes can be set programmatically too.
This commit is contained in:
cjjohn 2026-08-15 11:15:36 +02:00
parent 9f0ae12794
commit 4b5da69174
8 changed files with 173 additions and 41 deletions

View file

@ -93,6 +93,18 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
(`profile_names`) — 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.
- **Notizen** (freier Text je Button/Encoder-Aktion, seit 2026-08-15) sind
aus demselben Grund rein lokal: leben im `"note"`-Feld JEDER Action
(`{"type","data","note"}`), nicht in einer separaten Struktur. `to_binary`/
`pack_config` ignorieren das Feld beim Schreiben (liest nur type/data),
`from_binary` liefert frisch vom Board immer `note=""` (Board kennt keine
Notizen) — `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. Action-Typ wechseln
(`set_button_key` etc.) darf die Notiz NICHT loeschen (baut die neue
Action ueber `_replace_action()`/`dlg.action["note"]` mit der alten Notiz),
nur `set_button_note()`/`set_encoder_note()`/das Notiz-Feld im
Programmiermodus-Dialog aendern sie gezielt.
- Der COM-Port ist exklusiv. Live-Sync und Programmiermodus schalten sich
gegenseitig aus (ein `VersaPadLink` kann nicht von zwei Konsumenten
gleichzeitig genutzt werden); VersaGUI läuft als Tray-App dauerhaft im

View file

@ -33,6 +33,10 @@ falls gewünscht.
oder als Datei speichern
- **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte
Makro-Tabelle vom Board
- **Notizen** — freier Text pro Button/Encoder-Aktion, was sie tatsächlich
tut (z.B. "Speichern in Fusion 360"), zusätzlich zur automatischen
Beschriftung ("Strg+S"). Rein lokal wie Profilnamen, geht nie aufs Board,
bleibt beim Tastenwechsel und beim "Vom Board laden" erhalten
- **MCP-Server** — lässt eine KI (Claude o.ä.) die Belegung direkt per
Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten)
- **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie

View file

@ -124,7 +124,9 @@ class MacroStepsDialog(_ModalDialog):
class ActionEditDialog(_ModalDialog):
"""Ergebnis in self.action / self.led nach run()==True. led bleibt None
wenn led_in None war (Encoder -- keine eigene Farbe)."""
wenn led_in None war (Encoder -- keine eigene Farbe). self.action enthaelt
immer ein "note"-Feld (freie Notiz, was die Aktion tut -- rein lokal wie
Profilnamen, geht nie aufs Board)."""
def __init__(self, parent, title, action, led, macros):
super().__init__(parent, title)
@ -134,8 +136,15 @@ class ActionEditDialog(_ModalDialog):
self.led = None
row = 0
note_row = tk.Frame(self, bg=BG2)
note_row.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(12, 4)); row += 1
tk.Label(note_row, text="Notiz:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
self._note_var = tk.StringVar(value=action.get("note", ""))
tk.Entry(note_row, textvariable=self._note_var, width=36, bg=BG, fg=TEXT,
insertbackground=TEXT, relief="flat").pack(side="left", padx=6)
tk.Label(self, text="Aktion", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).grid(
row=row, column=0, sticky="w", padx=12, pady=(12, 4)); row += 1
row=row, column=0, sticky="w", padx=12, pady=(4, 4)); row += 1
self._type_var = tk.StringVar(value=action["type"])
type_frame = tk.Frame(self, bg=BG2)
@ -320,6 +329,7 @@ class ActionEditDialog(_ModalDialog):
else:
action = {"type": "None", "data": 0}
action["note"] = self._note_var.get()
self.action = action
if self._led is not None:
r, g, b = self._led_color

View file

@ -54,7 +54,7 @@ OK_GREEN = "#3ecf6e"
WARN_RED = "#e0895a"
POLL_MS = 1500
CARD_W, CARD_H = 150, 84
CARD_W, CARD_H = 150, 114
SERIAL_POLL_S = 1.5
SERIAL_IDLE_S = 3.0
@ -97,9 +97,11 @@ Buttons setzen (profile 0-2, index 0-19):
set_button_profile_switch(profile, index, target)
set_button_none(profile, index)
set_button_led(profile, index, r, g, b, anim, period_ms)
set_button_note(profile, index, note) freie Notiz, was der Button tut
Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'):
set_encoder_key / _consumer / _macro / _profile_switch / _none(...)
set_encoder_note(profile, index, field, note)
Makro:
set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...]
@ -460,7 +462,9 @@ class VersaPadViewer(tk.Tk):
macro_slots = vproto.unpack_macros(raw_macros)
names = self.combined["profile_names"] if self.combined else None
self.combined = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names)
previous = self.combined
self.combined = vcomb.merge_notes(
vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous)
self.profile = self.combined["active_profile"]
self._status("vom Board geladen", True)
self._update_tab_labels()
@ -639,12 +643,16 @@ class VersaPadViewer(tk.Tk):
tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT,
font=("Segoe UI", 10, "normal" if empty else "bold"),
wraplength=CARD_W - 20, justify="left", anchor="nw").place(
x=10, y=26, width=CARD_W - 20, height=36)
x=10, y=26, width=CARD_W - 20, height=30)
tk.Label(card, text=btn["note"], bg=CARD_BG, fg=TEXT_DIM,
font=("Segoe UI", 8), wraplength=CARD_W - 20, justify="left", anchor="nw").place(
x=10, y=58, width=CARD_W - 20, height=34)
anim = "" if empty else vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"])
tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM,
font=("Segoe UI", 7), anchor="sw").place(
x=10, y=CARD_H - 20, width=CARD_W - 20, height=14)
x=10, y=CARD_H - 18, width=CARD_W - 20, height=14)
if editable:
card.configure(cursor="hand2")
@ -661,17 +669,25 @@ class VersaPadViewer(tk.Tk):
inner.pack(fill="both", expand=True, padx=10, pady=8)
tk.Label(inner, text=f"Encoder {enc['index']}", bg=CARD_BG, fg=TEXT_DIM,
font=("Segoe UI", 8, "bold")).pack(anchor="w", pady=(0, 4))
for key, field, val in (("Druck", "sw", enc["sw_label"]), ("CW", "cw", enc["cw_label"]),
("CCW", "ccw", enc["ccw_label"])):
for key, field, val, note in (("Druck", "sw", enc["sw_label"], enc["sw_note"]),
("CW", "cw", enc["cw_label"], enc["cw_note"]),
("CCW", "ccw", enc["ccw_label"], enc["ccw_note"])):
row = tk.Frame(inner, bg=CARD_BG)
row.pack(fill="x", pady=1)
tk.Label(row, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
tk.Label(row, text=val or "", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
row.pack(fill="x", pady=(1, 4))
top = tk.Frame(row, bg=CARD_BG)
top.pack(fill="x")
tk.Label(top, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
tk.Label(top, text=val or "", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
note_label = tk.Label(row, text=note, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7),
wraplength=150, justify="left", anchor="w")
note_label.pack(fill="x")
if editable:
row.configure(cursor="hand2")
handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl)
row.bind("<Button-1>", handler)
for child in row.winfo_children():
top.bind("<Button-1>", handler)
note_label.bind("<Button-1>", handler)
for child in top.winfo_children():
child.bind("<Button-1>", handler)

View file

@ -38,7 +38,7 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
.grid {
display: grid;
grid-template-columns: repeat(4, 140px);
grid-template-rows: repeat(5, 76px);
grid-template-rows: repeat(5, 104px);
grid-auto-flow: column;
gap: 10px;
margin-bottom: 36px;
@ -53,15 +53,17 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
}
.cell .idx { position: absolute; top: 8px; right: 10px; font-size: 11px; color: #6a6d78; }
.cell .label { font-size: 14px; font-weight: 600; line-height: 1.25; word-break: break-word; }
.cell .note { font-size: 11px; color: #8a8d98; margin-top: 4px; word-break: break-word; }
.cell .anim { font-size: 11px; color: #8a8d98; margin-top: 2px; }
.cell.empty .label { color: #4a4d58; font-weight: 400; }
h2 { font-size: 15px; font-weight: 600; color: #c4c6cf; margin: 0 0 12px; }
.encoders { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 720px; }
.enc { background: #1e2129; border: 1px solid #2a2d37; border-radius: 10px; padding: 12px 14px; }
.enc .idx { font-size: 12px; color: #6a6d78; margin-bottom: 8px; }
.enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0; }
.enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0 0; }
.enc .row .k { color: #8a8d98; }
.enc .row .v { font-weight: 500; text-align: right; }
.enc .note { font-size: 11px; color: #6a6d78; padding-bottom: 6px; word-break: break-word; }
footer { margin-top: 40px; color: #6a6d78; font-size: 12px; }
"""
@ -73,20 +75,31 @@ def render_cell(btn):
grid_col, grid_row = btn["col"] + 1, btn["row"] + 1
style = f"grid-column:{grid_col}; grid-row:{grid_row};"
label = html.escape(btn["label"]) if btn["label"] else ""
note = html.escape(btn["note"]) if btn.get("note") else ""
return f"""<div class="{css_class}" style="{style}">
<div class="swatch" style="background:{vp.led_css(btn['led'])};"></div>
<div class="idx">#{btn['index']}</div>
<div class="label">{label}</div>
<div class="note">{note}</div>
<div class="anim">{"" if empty else anim}</div>
</div>"""
def _encoder_row(key, label, note):
label = html.escape(label) or ""
note_html = f'<div class="note">{html.escape(note)}</div>' if note else ""
return f"""<div class="row"><span class="k">{key}</span><span class="v">{label}</span></div>{note_html}"""
def render_encoder(enc):
rows = (
_encoder_row("Druck", enc["sw_label"], enc["sw_note"])
+ _encoder_row("CW", enc["cw_label"], enc["cw_note"])
+ _encoder_row("CCW", enc["ccw_label"], enc["ccw_note"])
)
return f"""<div class="enc">
<div class="idx">Encoder {enc['index']}</div>
<div class="row"><span class="k">Druck</span><span class="v">{html.escape(enc['sw_label']) or ''}</span></div>
<div class="row"><span class="k">CW</span><span class="v">{html.escape(enc['cw_label']) or ''}</span></div>
<div class="row"><span class="k">CCW</span><span class="v">{html.escape(enc['ccw_label']) or ''}</span></div>
{rows}
</div>"""

View file

@ -25,10 +25,10 @@ DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"]
def _empty_profile():
buttons = [{"index": i, "action": {"type": "None", "data": 0},
buttons = [{"index": i, "action": {"type": "None", "data": 0, "note": ""},
"led": {"r": 80, "g": 40, "b": 0, "brightness": 255,
"anim": "Static", "period_ms": 4000}} for i in range(20)]
none = {"type": "None", "data": 0}
none = {"type": "None", "data": 0, "note": ""}
encoders = [{"index": i, "sw": dict(none), "cw": dict(none), "ccw": dict(none)} for i in range(4)]
return {"buttons": buttons, "encoders": encoders}
@ -61,17 +61,54 @@ def default_combined():
def from_binary(config_dict, macro_slots, profile_names=None):
"""config_dict: Ergebnis von versapad_protocol.unpack_config().
macro_slots: Ergebnis von versapad_protocol.unpack_macros()."""
macro_slots: Ergebnis von versapad_protocol.unpack_macros(). Actions
kommen frisch vom Board ohne "note" (die Firmware kennt keine Notizen,
siehe merge_notes()) -- hier nur mit leerem Default versehen, damit das
Feld ueberall verlaesslich existiert."""
profiles = config_dict["profiles"]
for profile in profiles:
for b in profile["buttons"]:
b["action"].setdefault("note", "")
for e in profile["encoders"]:
for field in ("sw", "cw", "ccw"):
e[field].setdefault("note", "")
return {
"active_profile": config_dict["active_profile"],
"global_brightness": config_dict["global_brightness"],
"enc_sensitivity": config_dict["enc_sensitivity"],
"profile_names": profile_names or list(DEFAULT_NAMES),
"profiles": config_dict["profiles"],
"profiles": profiles,
"macros": macro_slots,
}
def merge_notes(combined, previous):
"""Kopiert Notizen (Button/Encoder-Aktion) aus einem vorherigen State in
einen frisch vom Board gelesenen State -- wie profile_names sind Notizen
rein lokal und wuerden bei jedem load_from_board()/fetch_from_board()
sonst verloren gehen, weil die Firmware sie nicht kennt. previous=None
(z.B. allererstes Laden) -> nichts zu tun, combined unveraendert
zurueckgegeben. Aendert combined in-place und gibt es zurueck."""
if not previous:
return combined
for p_idx, profile in enumerate(combined["profiles"]):
if p_idx >= len(previous["profiles"]):
continue
prev_profile = previous["profiles"][p_idx]
prev_buttons = {b["index"]: b for b in prev_profile.get("buttons", [])}
for b in profile["buttons"]:
prev = prev_buttons.get(b["index"])
if prev:
b["action"]["note"] = prev["action"].get("note", "")
prev_encoders = {e["index"]: e for e in prev_profile.get("encoders", [])}
for e in profile["encoders"]:
prev = prev_encoders.get(e["index"])
if prev:
for field in ("sw", "cw", "ccw"):
e[field]["note"] = prev[field].get("note", "")
return combined
def to_binary(combined):
"""-> (config_bytes[740], macro_bytes[512])"""
config_bytes = proto.pack_config({

View file

@ -206,15 +206,20 @@ def button_grid_position(index):
def annotate_profile(cfg):
"""Fuegt label/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus
einer Einzel-JSON oder aus dem kombinierten Programmiermodus-State kommt."""
"""Fuegt label/note/col/row-Felder hinzu (fuer die Anzeige) -- egal ob
cfg aus einer Einzel-JSON (kennt keine Notizen, faellt auf "" zurueck)
oder aus dem kombinierten Programmiermodus-State kommt."""
for b in cfg["buttons"]:
b["label"] = action_label(b["action"])
b["note"] = b["action"].get("note", "")
b["col"], b["row"] = button_grid_position(b["index"])
for e in cfg["encoders"]:
e["sw_label"] = action_label(e["sw"])
e["sw_note"] = e["sw"].get("note", "")
e["cw_label"] = action_label(e["cw"])
e["cw_note"] = e["cw"].get("note", "")
e["ccw_label"] = action_label(e["ccw"])
e["ccw_note"] = e["ccw"].get("note", "")
return cfg

View file

@ -61,7 +61,17 @@ def _profile_switch_data(target):
def _describe_action(action):
return {"type": action["type"], "data": action["data"], "label": vp.action_label(action)}
return {"type": action["type"], "data": action["data"], "label": vp.action_label(action),
"note": action.get("note", "")}
def _replace_action(old_action, new_type, new_data):
"""Baut eine neue Action mit neuem Typ/Daten, behaelt aber die Notiz vom
vorherigen Stand bei (rein lokal, unabhaengig davon was die Aktion tut --
ein Tastenwechsel soll die Beschreibung 'was der Button macht' nicht
loeschen). Zum Loeschen explizit set_button_note()/set_encoder_note()
mit leerem String."""
return {"type": new_type, "data": new_data, "note": old_action.get("note", "")}
# ── Lesen ────────────────────────────────────────────────────────────────────
@ -138,7 +148,7 @@ def set_button_key(profile: int, index: int, key: str, modifiers: list[str] = []
btn = _find(_profile(profile)["buttons"], index)
keycode = vp.hid_key_code_for_name(key)
mod_bits = vp.modifier_bits_for_names(modifiers)
btn["action"] = {"type": "HidKey", "data": (mod_bits << 8) | keycode}
btn["action"] = _replace_action(btn["action"], "HidKey", (mod_bits << 8) | keycode)
return _describe_action(btn["action"])
@ -148,7 +158,7 @@ def set_button_consumer(profile: int, index: int, consumer: str) -> dict:
'Lauter', 'Leiser', 'Nächster Titel', 'Vorheriger Titel'."""
btn = _find(_profile(profile)["buttons"], index)
cid = vp.consumer_id_for_name(consumer)
btn["action"] = {"type": "HidConsumer", "data": cid}
btn["action"] = _replace_action(btn["action"], "HidConsumer", cid)
return _describe_action(btn["action"])
@ -157,7 +167,7 @@ def set_button_macro(profile: int, index: int, slot: int) -> dict:
"""Belegt einen MX-Button mit einem Makro-Slot (0-31). Die Schritte selbst
mit set_macro() befuellen."""
btn = _find(_profile(profile)["buttons"], index)
btn["action"] = {"type": "Macro", "data": slot}
btn["action"] = _replace_action(btn["action"], "Macro", slot)
return _describe_action(btn["action"])
@ -165,15 +175,27 @@ def set_button_macro(profile: int, index: int, slot: int) -> dict:
def set_button_profile_switch(profile: int, index: int, target) -> dict:
"""Belegt einen MX-Button mit Profilwechsel. target: 'next' (Zyklus) oder 0/1/2."""
btn = _find(_profile(profile)["buttons"], index)
btn["action"] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)}
btn["action"] = _replace_action(btn["action"], "ProfileSwitch", _profile_switch_data(target))
return _describe_action(btn["action"])
@mcp.tool()
def set_button_none(profile: int, index: int) -> dict:
"""Entfernt die Belegung eines MX-Buttons (Action = None)."""
"""Entfernt die Belegung eines MX-Buttons (Action = None). Notiz bleibt
erhalten -- zum Loeschen set_button_note(profile, index, "")."""
btn = _find(_profile(profile)["buttons"], index)
btn["action"] = {"type": "None", "data": 0}
btn["action"] = _replace_action(btn["action"], "None", 0)
return _describe_action(btn["action"])
@mcp.tool()
def set_button_note(profile: int, index: int, note: str) -> dict:
"""Setzt/aendert die freie Notiz eines MX-Buttons -- was der Button tut,
unabhaengig von der technischen Aktion (z.B. 'Speichern in Fusion 360').
Rein lokal, landet nie aufs Board (wie Profilnamen). Leerer String
loescht die Notiz."""
btn = _find(_profile(profile)["buttons"], index)
btn["action"]["note"] = note
return _describe_action(btn["action"])
@ -205,7 +227,7 @@ def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: l
enc, f = _encoder_field(profile, index, field)
keycode = vp.hid_key_code_for_name(key)
mod_bits = vp.modifier_bits_for_names(modifiers)
enc[f] = {"type": "HidKey", "data": (mod_bits << 8) | keycode}
enc[f] = _replace_action(enc[f], "HidKey", (mod_bits << 8) | keycode)
return _describe_action(enc[f])
@ -213,7 +235,7 @@ def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: l
def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) -> dict:
"""Belegt eine Encoder-Aktion mit einer Medientaste."""
enc, f = _encoder_field(profile, index, field)
enc[f] = {"type": "HidConsumer", "data": vp.consumer_id_for_name(consumer)}
enc[f] = _replace_action(enc[f], "HidConsumer", vp.consumer_id_for_name(consumer))
return _describe_action(enc[f])
@ -221,7 +243,7 @@ def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) ->
def set_encoder_macro(profile: int, index: int, field: str, slot: int) -> dict:
"""Belegt eine Encoder-Aktion mit einem Makro-Slot (0-31)."""
enc, f = _encoder_field(profile, index, field)
enc[f] = {"type": "Macro", "data": slot}
enc[f] = _replace_action(enc[f], "Macro", slot)
return _describe_action(enc[f])
@ -231,15 +253,26 @@ def set_encoder_profile_switch(profile: int, index: int, field: str, target) ->
Achtung: Encoder 0 'sw' ist normalerweise auf allen 3 Profilen der
Profilwechsel -- nicht ohne Ruecksprache mit dem User aendern."""
enc, f = _encoder_field(profile, index, field)
enc[f] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)}
enc[f] = _replace_action(enc[f], "ProfileSwitch", _profile_switch_data(target))
return _describe_action(enc[f])
@mcp.tool()
def set_encoder_none(profile: int, index: int, field: str) -> dict:
"""Entfernt eine Encoder-Belegung (Action = None)."""
"""Entfernt eine Encoder-Belegung (Action = None). Notiz bleibt erhalten
-- zum Loeschen set_encoder_note(profile, index, field, "")."""
enc, f = _encoder_field(profile, index, field)
enc[f] = {"type": "None", "data": 0}
enc[f] = _replace_action(enc[f], "None", 0)
return _describe_action(enc[f])
@mcp.tool()
def set_encoder_note(profile: int, index: int, field: str, note: str) -> dict:
"""Setzt/aendert die freie Notiz einer Encoder-Aktion (sw/cw/ccw) -- was
sie tut, unabhaengig von der technischen Aktion. Rein lokal, landet nie
aufs Board. Leerer String loescht die Notiz."""
enc, f = _encoder_field(profile, index, field)
enc[f]["note"] = note
return _describe_action(enc[f])
@ -303,10 +336,10 @@ def load_local(path: str = None) -> dict:
@mcp.tool()
def load_from_board() -> dict:
"""Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und
ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die
kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade
von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den COM-Port danach
sofort wieder frei (siehe get_board_status())."""
ersetzt damit den In-Memory-State. Profilnamen UND Notizen bleiben
erhalten (kennt nur wir, nicht das Board). Schlaegt fehl, wenn der
COM-Port gerade von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den
COM-Port danach sofort wieder frei (siehe get_board_status())."""
try:
raw_cfg = _link.read_full_config()
if raw_cfg is None:
@ -321,7 +354,9 @@ def load_from_board() -> dict:
macro_slots = vproto.unpack_macros(raw_macros)
names = _cfg()["profile_names"]
_state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names)
previous = _state["combined"]
_state["combined"] = vcomb.merge_notes(
vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous)
return list_profiles()
finally:
_link.close()