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.
199 lines
8.3 KiB
Python
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)
|