diff --git a/AGENTS.md b/AGENTS.md index fcc3487..9fad436 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 9de3204..cd9d373 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/action_dialog.py b/action_dialog.py index 09a9f0d..6a5ccf2 100644 --- a/action_dialog.py +++ b/action_dialog.py @@ -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 diff --git a/desktop_viewer.py b/desktop_viewer.py index 81b1805..d604da5 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -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("", handler) - for child in row.winfo_children(): + top.bind("", handler) + note_label.bind("", handler) + for child in top.winfo_children(): child.bind("", handler) diff --git a/server.py b/server.py index 9188d64..238d13c 100644 --- a/server.py +++ b/server.py @@ -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"""
#{btn['index']}
{label}
+
{note}
{"" if empty else anim}
""" +def _encoder_row(key, label, note): + label = html.escape(label) or "—" + note_html = f'
{html.escape(note)}
' if note else "" + return f"""
{key}{label}
{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"""
Encoder {enc['index']}
-
Druck{html.escape(enc['sw_label']) or '—'}
-
CW{html.escape(enc['cw_label']) or '—'}
-
CCW{html.escape(enc['ccw_label']) or '—'}
+ {rows}
""" diff --git a/versapad_combined.py b/versapad_combined.py index aad8f48..ea96a41 100644 --- a/versapad_combined.py +++ b/versapad_combined.py @@ -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({ diff --git a/versapad_data.py b/versapad_data.py index 0e147c6..8c4380a 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -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 diff --git a/versapad_mcp_server.py b/versapad_mcp_server.py index 797ae5e..5a262c7 100644 --- a/versapad_mcp_server.py +++ b/versapad_mcp_server.py @@ -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()