VersaGUI-py/versapad_combined.py
cjjohn 91bac353b4 Keep user config out of the install dir; frameless window with working resize
Three related fixes after the notes feature landed:

Config location: build_and_deploy.ps1 wipes its target directory before
every deploy, and app_dir() had just been pointed at that same directory
-- so every rebuild silently deleted the user's config and the app
rebuilt it empty from the board, losing all notes. Config now lives in
%APPDATA%\VersaPadViewer (roaming), separate from the install dir, and
the build script additionally rescues any versapad_config*.json it finds
in the target so legacy installs survive an upgrade.

Window chrome: hide the title bar (plain Tk overrideredirect, no ctypes
window manipulation) and move the mode checkboxes up next to the title,
which reclaims two full rows of header height that the taller note cards
had eaten. Removing the title bar also removes the resize borders, so
add a grip -- anchored with place() to the window corner rather than
packed after the content, which would push it out of view exactly when
the window is too small and the grip is needed. Default geometry grown
to fit the taller cards, and empty encoder note lines are no longer
rendered at all.

Note truncation: the note area was a fixed 34px and cut longer notes
mid-word; it now takes the remaining card height.
2026-08-15 12:12:33 +02:00

200 lines
8.4 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):
os.makedirs(os.path.dirname(path), exist_ok=True)
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)