VersaGUI-py/versapad_combined.py
cjjohn 4b5da69174 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.
2026-08-15 11:15:36 +02:00

199 lines
8.3 KiB
Python

"""
Kombiniertes Ein-Datei-Format fuer den Programmiermodus: alle 3 Profile +
Makro-Tabelle + (nur lokal gespeicherte) Profilnamen in einer JSON-Datei,
plus Konvertierung zu/von den Binaerblobs aus versapad_protocol.py.
Passt besser zum echten Geraeteprotokoll als die 3 Einzeldateien von
versapad_data.py: CONFIG_BEGIN/DATA/COMMIT ueberträgt ohnehin immer den
kompletten 740B-Block (alle 3 Profile auf einmal), nie nur ein Profil.
Profilnamen: die Firmware-Structs (SDeviceConfig/SDeviceProfile) haben
keinerlei Platz fuer einen String -- Header exakt 32B, jedes Profil exakt
236B, alles verplant (siehe nvm_config.h). Namen bleiben deshalb rein
lokal in dieser Datei, landen nie auf dem Board.
"""
import json
import os
import versapad_data as vp
import versapad_protocol as proto
import versapad_serial as vs
DEFAULT_PATH = os.path.join(vp.app_dir(), "versapad_config_all.json")
DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"]
def _empty_profile():
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, "note": ""}
encoders = [{"index": i, "sw": dict(none), "cw": dict(none), "ccw": dict(none)} for i in range(4)]
return {"buttons": buttons, "encoders": encoders}
def default_combined():
"""Seed aus den 3 bestehenden Einzel-JSONs (versapad_data.CONFIG_PATHS),
fehlende Dateien werden als leeres Profil aufgefuellt. Makros leer,
da die Einzel-JSONs keine Makro-Schritte enthalten (kein Exportformat
dafuer) -- fuer echte Makro-Inhalte "Vom Board laden" benutzen."""
profiles = []
for p in range(3):
try:
cfg = vp.load_profile(p)
profiles.append({
"buttons": [{"index": b["index"], "action": b["action"], "led": b["led"]} for b in cfg["buttons"]],
"encoders": [{"index": e["index"], "sw": e["sw"], "cw": e["cw"], "ccw": e["ccw"]} for e in cfg["encoders"]],
})
except FileNotFoundError:
profiles.append(_empty_profile())
return {
"active_profile": 0,
"global_brightness": 255,
"enc_sensitivity": [1, 1, 1, 1],
"profile_names": list(DEFAULT_NAMES),
"profiles": profiles,
"macros": [[] for _ in range(proto.MACRO_SLOTS)],
}
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(). 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": 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({
"active_profile": combined["active_profile"],
"global_brightness": combined["global_brightness"],
"enc_sensitivity": combined["enc_sensitivity"],
"profiles": combined["profiles"],
})
macro_bytes = proto.pack_macros(combined["macros"])
return config_bytes, macro_bytes
def save_file(combined, path=DEFAULT_PATH):
with open(path, "w", encoding="utf-8") as f:
json.dump(combined, f, indent=2, ensure_ascii=False)
return path
def load_file(path=DEFAULT_PATH):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def fetch_from_board(link=None, profile_names=None):
"""Liest Config+Makros direkt vom Board per Serial (~1-2s), das Board
ist die eigentliche Quelle der Wahrheit (write_to_board() speichert
dauerhaft im NVM -- die JSON-Dateien hier sind nur ein Lesecache).
link: bestehender VersaPadLink wiederverwenden (z.B. desktop_viewer's
self._link, damit nicht zwei Verbindungen um denselben COM-Port
konkurrieren) -- sonst wird eine eigene geoeffnet und wieder
geschlossen. Wirft RuntimeError mit Klartext-Ursache (last_error),
z.B. wenn der Port gerade von VersaGUI/einem anderen Viewer belegt ist."""
owns_link = link is None
if owns_link:
link = vs.VersaPadLink()
try:
raw_cfg = link.read_full_config()
if raw_cfg is None:
raise RuntimeError(f"Config vom Board laden fehlgeschlagen: {link.last_error}")
raw_macros = link.read_macros()
if raw_macros is None:
raise RuntimeError(f"Makros vom Board laden fehlgeschlagen: {link.last_error}")
cfg_dict = proto.unpack_config(raw_cfg)
if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]):
raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)")
macro_slots = proto.unpack_macros(raw_macros)
return from_binary(cfg_dict, macro_slots, profile_names=profile_names)
finally:
if owns_link:
link.close()
def load_or_fetch(path=DEFAULT_PATH, link=None, profile_names=None):
"""Bevorzugt die lokale Kombi-Datei. Fehlt sie (z.B. versehentlich
geloescht, oder frische Installation ohne jede Config), wird sie
automatisch neu angelegt -- zuerst per Serial-Versuch vom Board (das
behaelt die Config dauerhaft im NVM, die JSON ist nur ein Lesecache
dafuer), und falls auch das Board nicht erreichbar ist (nicht
verbunden, COM-Port belegt, frisch installiert ohne Board in Reichweite)
als leere Default-Config (vgl. default_combined()) -- damit ist das
Tool auch ganz ohne vorhandene Config sofort benutzbar, statt mit
einem Fehler zu blockieren."""
if os.path.exists(path):
return load_file(path)
try:
combined = fetch_from_board(link=link, profile_names=profile_names)
except RuntimeError:
combined = default_combined()
if profile_names:
combined["profile_names"] = profile_names
try:
save_file(combined, path)
except OSError:
pass # Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden
return combined
def read_profile_names(path=DEFAULT_PATH):
"""Nur die (lokalen) Profilnamen lesen, ohne die volle Config zu
brauchen -- fuer Tab-Beschriftungen im Nur-Lese-Modus. Greift bewusst
nicht aufs Board zu (kein COM-Port-Konflikt mit Live-Sync), faellt bei
fehlender/kaputter Datei auf DEFAULT_NAMES zurueck."""
if os.path.exists(path):
try:
return load_file(path).get("profile_names", list(DEFAULT_NAMES))
except (OSError, ValueError):
pass
return list(DEFAULT_NAMES)