versapad_combined.DEFAULT_PATH was hardcoded to this one machine's OneDrive desktop, which made the tool unusable anywhere else. versapad_data.app_dir() now resolves to the running .exe's own folder when frozen, or the project directory when run from source, and DEFAULT_PATH hangs off that instead. load_or_fetch() previously raised when both the file was missing and the board unreachable, blocking a fresh install with no config and no board attached. It now falls back to an empty default_combined() in that case, so the tool is immediately usable either way. desktop_viewer's _current_profile_view() picks up the same fallback instead of re-implementing a narrower version of it. Also drop the hardcoded PROFILE_NAMES dict, which had drifted out of sync with the profile_names already stored in the combined JSON -- renaming a profile in Programmiermodus never showed up in the read-only/browser views. server.py and desktop_viewer.py now read names from the same JSON everywhere. versapad_data.CONFIG_PATHS (read-only interop with the official C# VersaGUI's JSON export) is intentionally left on the OneDrive desktop -- nothing in this codebase writes there, it's not part of this tool's own config.
162 lines
6.6 KiB
Python
162 lines
6.6 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},
|
|
"led": {"r": 80, "g": 40, "b": 0, "brightness": 255,
|
|
"anim": "Static", "period_ms": 4000}} for i in range(20)]
|
|
none = {"type": "None", "data": 0}
|
|
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()."""
|
|
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": config_dict["profiles"],
|
|
"macros": macro_slots,
|
|
}
|
|
|
|
|
|
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)
|