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:
parent
9f0ae12794
commit
4b5da69174
8 changed files with 173 additions and 41 deletions
12
AGENTS.md
12
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
|
(`profile_names`) — die Firmware-Structs haben keinen Platz für einen
|
||||||
String (Header exakt 32B, jedes Profil exakt 236B, alles verplant). Sie
|
String (Header exakt 32B, jedes Profil exakt 236B, alles verplant). Sie
|
||||||
landen nie aufs Board, egal welcher Schreibpfad benutzt wird.
|
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
|
- Der COM-Port ist exklusiv. Live-Sync und Programmiermodus schalten sich
|
||||||
gegenseitig aus (ein `VersaPadLink` kann nicht von zwei Konsumenten
|
gegenseitig aus (ein `VersaPadLink` kann nicht von zwei Konsumenten
|
||||||
gleichzeitig genutzt werden); VersaGUI läuft als Tray-App dauerhaft im
|
gleichzeitig genutzt werden); VersaGUI läuft als Tray-App dauerhaft im
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ falls gewünscht.
|
||||||
oder als Datei speichern
|
oder als Datei speichern
|
||||||
- **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte
|
- **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte
|
||||||
Makro-Tabelle vom Board
|
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
|
- **MCP-Server** — lässt eine KI (Claude o.ä.) die Belegung direkt per
|
||||||
Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten)
|
Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten)
|
||||||
- **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie
|
- **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,9 @@ class MacroStepsDialog(_ModalDialog):
|
||||||
|
|
||||||
class ActionEditDialog(_ModalDialog):
|
class ActionEditDialog(_ModalDialog):
|
||||||
"""Ergebnis in self.action / self.led nach run()==True. led bleibt None
|
"""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):
|
def __init__(self, parent, title, action, led, macros):
|
||||||
super().__init__(parent, title)
|
super().__init__(parent, title)
|
||||||
|
|
@ -134,8 +136,15 @@ class ActionEditDialog(_ModalDialog):
|
||||||
self.led = None
|
self.led = None
|
||||||
|
|
||||||
row = 0
|
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(
|
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"])
|
self._type_var = tk.StringVar(value=action["type"])
|
||||||
type_frame = tk.Frame(self, bg=BG2)
|
type_frame = tk.Frame(self, bg=BG2)
|
||||||
|
|
@ -320,6 +329,7 @@ class ActionEditDialog(_ModalDialog):
|
||||||
else:
|
else:
|
||||||
action = {"type": "None", "data": 0}
|
action = {"type": "None", "data": 0}
|
||||||
|
|
||||||
|
action["note"] = self._note_var.get()
|
||||||
self.action = action
|
self.action = action
|
||||||
if self._led is not None:
|
if self._led is not None:
|
||||||
r, g, b = self._led_color
|
r, g, b = self._led_color
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ OK_GREEN = "#3ecf6e"
|
||||||
WARN_RED = "#e0895a"
|
WARN_RED = "#e0895a"
|
||||||
|
|
||||||
POLL_MS = 1500
|
POLL_MS = 1500
|
||||||
CARD_W, CARD_H = 150, 84
|
CARD_W, CARD_H = 150, 114
|
||||||
SERIAL_POLL_S = 1.5
|
SERIAL_POLL_S = 1.5
|
||||||
SERIAL_IDLE_S = 3.0
|
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_profile_switch(profile, index, target)
|
||||||
set_button_none(profile, index)
|
set_button_none(profile, index)
|
||||||
set_button_led(profile, index, r, g, b, anim, period_ms)
|
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'):
|
Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'):
|
||||||
set_encoder_key / _consumer / _macro / _profile_switch / _none(...)
|
set_encoder_key / _consumer / _macro / _profile_switch / _none(...)
|
||||||
|
set_encoder_note(profile, index, field, note)
|
||||||
|
|
||||||
Makro:
|
Makro:
|
||||||
set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...]
|
set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...]
|
||||||
|
|
@ -460,7 +462,9 @@ class VersaPadViewer(tk.Tk):
|
||||||
macro_slots = vproto.unpack_macros(raw_macros)
|
macro_slots = vproto.unpack_macros(raw_macros)
|
||||||
|
|
||||||
names = self.combined["profile_names"] if self.combined else None
|
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.profile = self.combined["active_profile"]
|
||||||
self._status("vom Board geladen", True)
|
self._status("vom Board geladen", True)
|
||||||
self._update_tab_labels()
|
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,
|
tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT,
|
||||||
font=("Segoe UI", 10, "normal" if empty else "bold"),
|
font=("Segoe UI", 10, "normal" if empty else "bold"),
|
||||||
wraplength=CARD_W - 20, justify="left", anchor="nw").place(
|
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"])
|
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,
|
tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM,
|
||||||
font=("Segoe UI", 7), anchor="sw").place(
|
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:
|
if editable:
|
||||||
card.configure(cursor="hand2")
|
card.configure(cursor="hand2")
|
||||||
|
|
@ -661,17 +669,25 @@ class VersaPadViewer(tk.Tk):
|
||||||
inner.pack(fill="both", expand=True, padx=10, pady=8)
|
inner.pack(fill="both", expand=True, padx=10, pady=8)
|
||||||
tk.Label(inner, text=f"Encoder {enc['index']}", bg=CARD_BG, fg=TEXT_DIM,
|
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))
|
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"]),
|
for key, field, val, note in (("Druck", "sw", enc["sw_label"], enc["sw_note"]),
|
||||||
("CCW", "ccw", enc["ccw_label"])):
|
("CW", "cw", enc["cw_label"], enc["cw_note"]),
|
||||||
|
("CCW", "ccw", enc["ccw_label"], enc["ccw_note"])):
|
||||||
row = tk.Frame(inner, bg=CARD_BG)
|
row = tk.Frame(inner, bg=CARD_BG)
|
||||||
row.pack(fill="x", pady=1)
|
row.pack(fill="x", pady=(1, 4))
|
||||||
tk.Label(row, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
|
top = tk.Frame(row, bg=CARD_BG)
|
||||||
tk.Label(row, text=val or "—", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
|
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:
|
if editable:
|
||||||
row.configure(cursor="hand2")
|
row.configure(cursor="hand2")
|
||||||
handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl)
|
handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl)
|
||||||
row.bind("<Button-1>", handler)
|
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)
|
child.bind("<Button-1>", handler)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
23
server.py
23
server.py
|
|
@ -38,7 +38,7 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 140px);
|
grid-template-columns: repeat(4, 140px);
|
||||||
grid-template-rows: repeat(5, 76px);
|
grid-template-rows: repeat(5, 104px);
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 36px;
|
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 .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 .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 .anim { font-size: 11px; color: #8a8d98; margin-top: 2px; }
|
||||||
.cell.empty .label { color: #4a4d58; font-weight: 400; }
|
.cell.empty .label { color: #4a4d58; font-weight: 400; }
|
||||||
h2 { font-size: 15px; font-weight: 600; color: #c4c6cf; margin: 0 0 12px; }
|
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; }
|
.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 { background: #1e2129; border: 1px solid #2a2d37; border-radius: 10px; padding: 12px 14px; }
|
||||||
.enc .idx { font-size: 12px; color: #6a6d78; margin-bottom: 8px; }
|
.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 .k { color: #8a8d98; }
|
||||||
.enc .row .v { font-weight: 500; text-align: right; }
|
.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; }
|
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
|
grid_col, grid_row = btn["col"] + 1, btn["row"] + 1
|
||||||
style = f"grid-column:{grid_col}; grid-row:{grid_row};"
|
style = f"grid-column:{grid_col}; grid-row:{grid_row};"
|
||||||
label = html.escape(btn["label"]) if btn["label"] else "—"
|
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}">
|
return f"""<div class="{css_class}" style="{style}">
|
||||||
<div class="swatch" style="background:{vp.led_css(btn['led'])};"></div>
|
<div class="swatch" style="background:{vp.led_css(btn['led'])};"></div>
|
||||||
<div class="idx">#{btn['index']}</div>
|
<div class="idx">#{btn['index']}</div>
|
||||||
<div class="label">{label}</div>
|
<div class="label">{label}</div>
|
||||||
|
<div class="note">{note}</div>
|
||||||
<div class="anim">{"" if empty else anim}</div>
|
<div class="anim">{"" if empty else anim}</div>
|
||||||
</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):
|
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">
|
return f"""<div class="enc">
|
||||||
<div class="idx">Encoder {enc['index']}</div>
|
<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>
|
{rows}
|
||||||
<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>
|
|
||||||
</div>"""
|
</div>"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,10 @@ DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"]
|
||||||
|
|
||||||
|
|
||||||
def _empty_profile():
|
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,
|
"led": {"r": 80, "g": 40, "b": 0, "brightness": 255,
|
||||||
"anim": "Static", "period_ms": 4000}} for i in range(20)]
|
"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)]
|
encoders = [{"index": i, "sw": dict(none), "cw": dict(none), "ccw": dict(none)} for i in range(4)]
|
||||||
return {"buttons": buttons, "encoders": encoders}
|
return {"buttons": buttons, "encoders": encoders}
|
||||||
|
|
||||||
|
|
@ -61,17 +61,54 @@ def default_combined():
|
||||||
|
|
||||||
def from_binary(config_dict, macro_slots, profile_names=None):
|
def from_binary(config_dict, macro_slots, profile_names=None):
|
||||||
"""config_dict: Ergebnis von versapad_protocol.unpack_config().
|
"""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 {
|
return {
|
||||||
"active_profile": config_dict["active_profile"],
|
"active_profile": config_dict["active_profile"],
|
||||||
"global_brightness": config_dict["global_brightness"],
|
"global_brightness": config_dict["global_brightness"],
|
||||||
"enc_sensitivity": config_dict["enc_sensitivity"],
|
"enc_sensitivity": config_dict["enc_sensitivity"],
|
||||||
"profile_names": profile_names or list(DEFAULT_NAMES),
|
"profile_names": profile_names or list(DEFAULT_NAMES),
|
||||||
"profiles": config_dict["profiles"],
|
"profiles": profiles,
|
||||||
"macros": macro_slots,
|
"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):
|
def to_binary(combined):
|
||||||
"""-> (config_bytes[740], macro_bytes[512])"""
|
"""-> (config_bytes[740], macro_bytes[512])"""
|
||||||
config_bytes = proto.pack_config({
|
config_bytes = proto.pack_config({
|
||||||
|
|
|
||||||
|
|
@ -206,15 +206,20 @@ def button_grid_position(index):
|
||||||
|
|
||||||
|
|
||||||
def annotate_profile(cfg):
|
def annotate_profile(cfg):
|
||||||
"""Fuegt label/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus
|
"""Fuegt label/note/col/row-Felder hinzu (fuer die Anzeige) -- egal ob
|
||||||
einer Einzel-JSON oder aus dem kombinierten Programmiermodus-State kommt."""
|
cfg aus einer Einzel-JSON (kennt keine Notizen, faellt auf "" zurueck)
|
||||||
|
oder aus dem kombinierten Programmiermodus-State kommt."""
|
||||||
for b in cfg["buttons"]:
|
for b in cfg["buttons"]:
|
||||||
b["label"] = action_label(b["action"])
|
b["label"] = action_label(b["action"])
|
||||||
|
b["note"] = b["action"].get("note", "")
|
||||||
b["col"], b["row"] = button_grid_position(b["index"])
|
b["col"], b["row"] = button_grid_position(b["index"])
|
||||||
for e in cfg["encoders"]:
|
for e in cfg["encoders"]:
|
||||||
e["sw_label"] = action_label(e["sw"])
|
e["sw_label"] = action_label(e["sw"])
|
||||||
|
e["sw_note"] = e["sw"].get("note", "")
|
||||||
e["cw_label"] = action_label(e["cw"])
|
e["cw_label"] = action_label(e["cw"])
|
||||||
|
e["cw_note"] = e["cw"].get("note", "")
|
||||||
e["ccw_label"] = action_label(e["ccw"])
|
e["ccw_label"] = action_label(e["ccw"])
|
||||||
|
e["ccw_note"] = e["ccw"].get("note", "")
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,17 @@ def _profile_switch_data(target):
|
||||||
|
|
||||||
|
|
||||||
def _describe_action(action):
|
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 ────────────────────────────────────────────────────────────────────
|
# ── Lesen ────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -138,7 +148,7 @@ def set_button_key(profile: int, index: int, key: str, modifiers: list[str] = []
|
||||||
btn = _find(_profile(profile)["buttons"], index)
|
btn = _find(_profile(profile)["buttons"], index)
|
||||||
keycode = vp.hid_key_code_for_name(key)
|
keycode = vp.hid_key_code_for_name(key)
|
||||||
mod_bits = vp.modifier_bits_for_names(modifiers)
|
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"])
|
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'."""
|
'Lauter', 'Leiser', 'Nächster Titel', 'Vorheriger Titel'."""
|
||||||
btn = _find(_profile(profile)["buttons"], index)
|
btn = _find(_profile(profile)["buttons"], index)
|
||||||
cid = vp.consumer_id_for_name(consumer)
|
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"])
|
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
|
"""Belegt einen MX-Button mit einem Makro-Slot (0-31). Die Schritte selbst
|
||||||
mit set_macro() befuellen."""
|
mit set_macro() befuellen."""
|
||||||
btn = _find(_profile(profile)["buttons"], index)
|
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"])
|
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:
|
def set_button_profile_switch(profile: int, index: int, target) -> dict:
|
||||||
"""Belegt einen MX-Button mit Profilwechsel. target: 'next' (Zyklus) oder 0/1/2."""
|
"""Belegt einen MX-Button mit Profilwechsel. target: 'next' (Zyklus) oder 0/1/2."""
|
||||||
btn = _find(_profile(profile)["buttons"], index)
|
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"])
|
return _describe_action(btn["action"])
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def set_button_none(profile: int, index: int) -> dict:
|
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 = _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"])
|
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)
|
enc, f = _encoder_field(profile, index, field)
|
||||||
keycode = vp.hid_key_code_for_name(key)
|
keycode = vp.hid_key_code_for_name(key)
|
||||||
mod_bits = vp.modifier_bits_for_names(modifiers)
|
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])
|
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:
|
def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) -> dict:
|
||||||
"""Belegt eine Encoder-Aktion mit einer Medientaste."""
|
"""Belegt eine Encoder-Aktion mit einer Medientaste."""
|
||||||
enc, f = _encoder_field(profile, index, field)
|
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])
|
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:
|
def set_encoder_macro(profile: int, index: int, field: str, slot: int) -> dict:
|
||||||
"""Belegt eine Encoder-Aktion mit einem Makro-Slot (0-31)."""
|
"""Belegt eine Encoder-Aktion mit einem Makro-Slot (0-31)."""
|
||||||
enc, f = _encoder_field(profile, index, field)
|
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])
|
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
|
Achtung: Encoder 0 'sw' ist normalerweise auf allen 3 Profilen der
|
||||||
Profilwechsel -- nicht ohne Ruecksprache mit dem User aendern."""
|
Profilwechsel -- nicht ohne Ruecksprache mit dem User aendern."""
|
||||||
enc, f = _encoder_field(profile, index, field)
|
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])
|
return _describe_action(enc[f])
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def set_encoder_none(profile: int, index: int, field: str) -> dict:
|
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 = _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])
|
return _describe_action(enc[f])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -303,10 +336,10 @@ def load_local(path: str = None) -> dict:
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def load_from_board() -> dict:
|
def load_from_board() -> dict:
|
||||||
"""Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und
|
"""Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und
|
||||||
ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die
|
ersetzt damit den In-Memory-State. Profilnamen UND Notizen bleiben
|
||||||
kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade
|
erhalten (kennt nur wir, nicht das Board). Schlaegt fehl, wenn der
|
||||||
von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den COM-Port danach
|
COM-Port gerade von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den
|
||||||
sofort wieder frei (siehe get_board_status())."""
|
COM-Port danach sofort wieder frei (siehe get_board_status())."""
|
||||||
try:
|
try:
|
||||||
raw_cfg = _link.read_full_config()
|
raw_cfg = _link.read_full_config()
|
||||||
if raw_cfg is None:
|
if raw_cfg is None:
|
||||||
|
|
@ -321,7 +354,9 @@ def load_from_board() -> dict:
|
||||||
macro_slots = vproto.unpack_macros(raw_macros)
|
macro_slots = vproto.unpack_macros(raw_macros)
|
||||||
|
|
||||||
names = _cfg()["profile_names"]
|
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()
|
return list_profiles()
|
||||||
finally:
|
finally:
|
||||||
_link.close()
|
_link.close()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue