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.
200 lines
8.4 KiB
Python
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)
|