Compare commits
3 commits
553ecc54d8
...
540ce5b1eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
540ce5b1eb | ||
|
|
8a2a73c68d | ||
|
|
4215323f3f |
5 changed files with 173 additions and 19 deletions
45
AGENTS.md
45
AGENTS.md
|
|
@ -29,7 +29,14 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
|
|||
Makros per 8-Byte-Paket-Protokoll, Board-Identifikation per VID/PID
|
||||
`239A:0042`. Schreiben ist sicher im Sinne von "kann NVM nicht zerlegen"
|
||||
— Firmware prüft Magic/CRC/Keycode-Bereich vor jedem Save, antwortet
|
||||
sonst nur mit NACK.
|
||||
sonst nur mit NACK. `read_active_profile()` (für Live-Sync-Polling)
|
||||
nutzt seit 2026-08-07 `CMD_READ_STATUS`/`EVT_STATUS` (0x06/0x86, ein
|
||||
Antwortpaket) statt eines vollen `CONFIG_READ`-Dumps (124 Pakete) —
|
||||
Letzterer blockiert die Firmware in `poll_vendor()` lang genug, dass
|
||||
laufende LED-Pulse-Animationen sichtbar stottern (Root Cause + Fix in
|
||||
VersaMCU-Commit "Add lightweight READ_STATUS command..."). **Braucht
|
||||
entsprechend neue Firmware auf dem Board** — mit altem `versapad`-Firmwarestand
|
||||
liefert `READ_STATUS` schlicht Timeout, kein Absturz.
|
||||
- `versapad_combined.py` — Ein-Datei-Format (alle 3 Profile + Makros +
|
||||
**nur lokal gespeicherte** Profilnamen), Default-Pfad
|
||||
`~\OneDrive\Desktop\versapad_config_all.json`. Passt zum Wire-Protokoll:
|
||||
|
|
@ -86,6 +93,32 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
|
|||
vorhanden, und fällt sonst auf die klassischen `versapad_config{1,2,3}.json`
|
||||
zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine
|
||||
Bearbeitung "verschwunden".
|
||||
- **Bug behoben 2026-08-08:** `server.py` (`vp.load_profile()`) und
|
||||
`desktop_viewer.py` (`_current_profile_view()`) lasen im Nur-Lese-Modus
|
||||
hart von den Desktop-JSONs -- fehlten sie (z.B. User loescht sie), gab es
|
||||
eine ungefangene `FileNotFoundError` bzw. "Config-Datei fehlt"-Anzeige,
|
||||
obwohl das Board die Config laengst dauerhaft im NVM haelt. Fix: neue
|
||||
`versapad_combined.fetch_from_board()`/`load_or_fetch()` -- fehlt die
|
||||
Kombi-JSON, wird sie automatisch per Serial vom Board neu aufgebaut und
|
||||
als Cache gespeichert (self-healing), nur bei unerreichbarem Board (Port
|
||||
belegt/kein Board) bleibt der Fallback auf die alten Einzel-JSONs bzw.
|
||||
eine Klartext-Fehlermeldung. In `desktop_viewer.py` nur versucht, wenn
|
||||
Live-Sync aus ist (sonst haelt der Serial-Hintergrundthread den
|
||||
COM-Port -- zwei gleichzeitige Zugriffe auf denselben `self.ser` waeren
|
||||
eine Race Condition). Die Desktop-JSONs sind damit reiner Lesecache, kein
|
||||
Pflegeaufwand mehr fuers Board-Backup.
|
||||
- **Bug behoben 2026-08-07:** `_on_toggle_editing()` initialisierte
|
||||
`self.combined` beim ersten Aktivieren des Programmiermodus mit
|
||||
`vcomb.default_combined()` — das seedet ALLE 3 Profile aus den alten
|
||||
Einzel-JSONs `versapad_config{1,2,3}.json`, nicht aus der aktuellen
|
||||
`versapad_config_all.json` oder vom Board. Ein Klick auf "Zum Board
|
||||
übertragen" hat dadurch beim Testen alle 3 Profile auf einen veralteten
|
||||
Stand zurückgesetzt, obwohl nur ein Profil-Tab sichtbar bearbeitet wurde —
|
||||
der Schaden an den anderen beiden Profilen blieb unbemerkt, bis explizit
|
||||
jedes Profil einzeln gegengelesen wurde. Fix: lädt jetzt zuerst
|
||||
`vcomb.DEFAULT_PATH`, fällt nur bei fehlender/kaputter Datei auf
|
||||
`default_combined()` zurück. Bei jedem "komisches Layout"-Report hier immer
|
||||
ALLE 3 Profile prüfen, nicht nur das gemeldete.
|
||||
- HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein
|
||||
Tastendruck-Capture (bewusst — kein WinAPI-Hook, um keinen AV-Fehlalarm
|
||||
wie bei den Fensterverstecktricks in anderen Projekten zu riskieren).
|
||||
|
|
@ -189,3 +222,13 @@ Committen: prägnante Commit-Message je abgeschlossenem, verifiziertem
|
|||
Arbeitspaket. Nach jedem Push: alle bekannten Remotes prüfen (`origin` auf
|
||||
GitHub, `jappel` auf git.jappel.io) — beide müssen synchron bleiben, siehe
|
||||
Speicher-Notiz "Multi-Remote-Repos synchron halten".
|
||||
|
||||
**PR-Erstellung auf git.jappel.io per API/curl mit Access-Token wird vom
|
||||
Bash-Classifier geblockt** (Auto-Mode, gilt auch für `git credential fill`),
|
||||
siehe Speicher-Notiz "Bash-Klassifikator blockt Credential/Auth-Schreibzugriffe".
|
||||
Branch pushen geht (nutzt den Git-eigenen Credential-Helper, kein Token im
|
||||
Klartext im Bash-Aufruf), den fertigen PR muss der User über den von Forgejo
|
||||
nach dem Push ausgegebenen Compare-Link selbst anlegen (oder Claude einen
|
||||
Token geben, der dann NICHT wiederverwendbar im Bash-Aufruf landen darf,
|
||||
sondern nur für den einen `curl`-Call — auch das kann der Classifier trotzdem
|
||||
blocken, dann bleibt nur der manuelle Link).
|
||||
|
|
|
|||
|
|
@ -391,7 +391,13 @@ class VersaPadViewer(tk.Tk):
|
|||
self._on_toggle_sync()
|
||||
self.sync_check.configure(state="disabled")
|
||||
if self.combined is None:
|
||||
self.combined = vcomb.default_combined()
|
||||
if os.path.exists(vcomb.DEFAULT_PATH):
|
||||
try:
|
||||
self.combined = vcomb.load_file(vcomb.DEFAULT_PATH)
|
||||
except (OSError, ValueError, KeyError):
|
||||
self.combined = vcomb.default_combined()
|
||||
else:
|
||||
self.combined = vcomb.default_combined()
|
||||
self.prog_row.pack(fill="x", padx=20, pady=(0, 14), after=self.tabs)
|
||||
self.header_sub.configure(text="Programmiermodus · Zelle anklicken zum Bearbeiten")
|
||||
else:
|
||||
|
|
@ -520,8 +526,13 @@ class VersaPadViewer(tk.Tk):
|
|||
Bevorzugt die kombinierte Datei (versapad_config_all.json), falls
|
||||
vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen
|
||||
sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen.
|
||||
Faellt zurueck auf die klassischen versapad_config{1,2,3}.json, wenn
|
||||
es noch keine kombinierte Datei gibt."""
|
||||
Fehlt sie (z.B. versehentlich geloescht) und ist Live-Sync gerade aus
|
||||
(COM-Port frei), wird sie automatisch per Serial vom Board neu
|
||||
aufgebaut und als neuer Cache gespeichert -- das Board ist die
|
||||
eigentliche Quelle der Wahrheit, kein Datei-Handling von Hand mehr
|
||||
noetig. Nur wenn das nicht klappt (Board nicht erreichbar, Live-Sync
|
||||
haelt den Port), weicht es zuletzt auf die klassischen
|
||||
versapad_config{1,2,3}.json aus."""
|
||||
if os.path.exists(vcomb.DEFAULT_PATH):
|
||||
try:
|
||||
data = vcomb.load_file(vcomb.DEFAULT_PATH)
|
||||
|
|
@ -532,8 +543,25 @@ class VersaPadViewer(tk.Tk):
|
|||
})
|
||||
return cfg, f"Quelle: {vcomb.DEFAULT_PATH}"
|
||||
except (KeyError, IndexError, ValueError):
|
||||
pass # kaputte/unvollstaendige Datei -- auf Einzel-JSONs ausweichen
|
||||
return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}"
|
||||
pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen
|
||||
elif not self.live_sync.get():
|
||||
try:
|
||||
data = vcomb.fetch_from_board(link=self._link)
|
||||
vcomb.save_file(data, vcomb.DEFAULT_PATH)
|
||||
raw = data["profiles"][self.profile]
|
||||
cfg = vp.annotate_profile({
|
||||
"buttons": [dict(b) for b in raw["buttons"]],
|
||||
"encoders": [dict(e) for e in raw["encoders"]],
|
||||
})
|
||||
return cfg, "Quelle: Board (neu vom Geraet geladen)"
|
||||
except RuntimeError:
|
||||
pass # Board nicht erreichbar -- weiter unten ausweichen
|
||||
try:
|
||||
return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}"
|
||||
except FileNotFoundError as e:
|
||||
hint = (" (Live-Sync ist an -- COM-Port belegt, zum automatischen "
|
||||
"Neuladen vom Board erst ausschalten)") if self.live_sync.get() else ""
|
||||
raise FileNotFoundError(f"{e}{hint}") from e
|
||||
|
||||
def _render(self):
|
||||
for w in self.grid_frame.winfo_children():
|
||||
|
|
|
|||
21
server.py
21
server.py
|
|
@ -1,7 +1,10 @@
|
|||
"""
|
||||
VersaPad Viewer -- Browser-Variante.
|
||||
Liest bei jedem Request live die 3 Config-JSONs vom Desktop und rendert
|
||||
die Steuermatrix (4x5 Grid + 4 Encoder) je Profil als HTML.
|
||||
Liest bei jedem Request die kombinierte Config-JSON vom Desktop und
|
||||
rendert die Steuermatrix (4x5 Grid + 4 Encoder) je Profil als HTML. Fehlt
|
||||
die JSON (z.B. geloescht), wird sie automatisch per Serial vom Board neu
|
||||
aufgebaut und als neuer Cache gespeichert -- das Board ist die eigentliche
|
||||
Quelle der Wahrheit, siehe versapad_combined.load_or_fetch().
|
||||
Kein Build-Schritt, kein externes Framework -- nur stdlib.
|
||||
|
||||
Start: python server.py [--port 8765]
|
||||
|
|
@ -12,6 +15,7 @@ import webbrowser
|
|||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
import versapad_combined as vcomb
|
||||
import versapad_data as vp
|
||||
|
||||
PAGE_CSS = """
|
||||
|
|
@ -87,7 +91,12 @@ def render_encoder(enc):
|
|||
|
||||
|
||||
def render_page(profile):
|
||||
cfg = vp.load_profile(profile)
|
||||
combined = vcomb.load_or_fetch()
|
||||
raw = combined["profiles"][profile]
|
||||
cfg = vp.annotate_profile({
|
||||
"buttons": [dict(b) for b in raw["buttons"]],
|
||||
"encoders": [dict(e) for e in raw["encoders"]],
|
||||
})
|
||||
tabs = "".join(
|
||||
f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(vp.PROFILE_NAMES[p])}</a>'
|
||||
for p in sorted(vp.PROFILE_NAMES)
|
||||
|
|
@ -107,7 +116,7 @@ def render_page(profile):
|
|||
<div class="grid">{cells}</div>
|
||||
<h2>Encoder</h2>
|
||||
<div class="encoders">{encoders}</div>
|
||||
<footer>Quelle: {html.escape(vp.CONFIG_PATHS[profile])}</footer>
|
||||
<footer>Quelle: {html.escape(vcomb.DEFAULT_PATH)}</footer>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
|
|
@ -130,10 +139,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
except FileNotFoundError as e:
|
||||
except (FileNotFoundError, RuntimeError) as e:
|
||||
self.send_response(500)
|
||||
self.end_headers()
|
||||
self.wfile.write(f"Config-Datei fehlt: {e}".encode("utf-8"))
|
||||
self.wfile.write(f"Config nicht verfuegbar: {e}".encode("utf-8"))
|
||||
|
||||
|
||||
def main():
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import os
|
|||
|
||||
import versapad_data as vp
|
||||
import versapad_protocol as proto
|
||||
import versapad_serial as vs
|
||||
|
||||
DEFAULT_PATH = os.path.expanduser(r"~\OneDrive\Desktop\versapad_config_all.json")
|
||||
|
||||
|
|
@ -92,3 +93,51 @@ def save_file(combined, path=DEFAULT_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), wird sie automatisch per Serial vom Board neu aufgebaut und
|
||||
als neuer Cache gespeichert, statt einen Fehler zu werfen -- das Board
|
||||
behaelt die Config dauerhaft im NVM, die Desktop-JSON ist nur ein
|
||||
Lesecache dafuer und muss nicht von Hand gepflegt werden. Ist das Board
|
||||
nicht erreichbar (nicht verbunden, COM-Port belegt), wirft es
|
||||
RuntimeError mit Klartext-Ursache -- Aufrufer entscheidet, ob es einen
|
||||
weiteren Fallback gibt (z.B. alte Einzel-JSONs)."""
|
||||
if os.path.exists(path):
|
||||
return load_file(path)
|
||||
combined = fetch_from_board(link=link, profile_names=profile_names)
|
||||
try:
|
||||
save_file(combined, path)
|
||||
except OSError:
|
||||
pass # Board-Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden
|
||||
return combined
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ CONFIG_SIZE = 740
|
|||
MACRO_SIZE = 512
|
||||
ACTIVE_PROFILE_OFFSET = 7
|
||||
|
||||
CMD_READ_STATUS = 0x06
|
||||
CMD_CONFIG_BEGIN = 0x10
|
||||
CMD_CONFIG_DATA = 0x11
|
||||
CMD_CONFIG_COMMIT = 0x12
|
||||
|
|
@ -46,6 +47,7 @@ CMD_MACRO_DATA = 0x21
|
|||
CMD_MACRO_COMMIT = 0x22
|
||||
CMD_MACRO_READ = 0x23
|
||||
|
||||
EVT_STATUS = 0x86
|
||||
EVT_CONFIG_ACK = 0x90
|
||||
EVT_CONFIG_NACK = 0x91
|
||||
EVT_CONFIG_BEGIN = 0x92
|
||||
|
|
@ -187,16 +189,39 @@ class VersaPadLink:
|
|||
|
||||
# ── Oeffentliche API ────────────────────────────────────────
|
||||
|
||||
def read_active_profile(self):
|
||||
"""0-2 bei Erfolg, None bei Timeout/Fehler/kein Board."""
|
||||
buf = self._read_dump(CMD_CONFIG_READ, EVT_CONFIG_BEGIN, EVT_CONFIG_DATA, EVT_CONFIG_END, CONFIG_SIZE)
|
||||
if buf is None:
|
||||
def read_active_profile(self, deadline_s=1.0):
|
||||
"""0-2 bei Erfolg, None bei Timeout/Fehler/kein Board.
|
||||
|
||||
Nutzt CMD_READ_STATUS (1 Antwortpaket) statt eines vollen CONFIG_READ-
|
||||
Dumps (124 Pakete) -- der volle Dump blockiert die Firmware lange genug,
|
||||
dass laufende LED-Pulse-Animationen beim Live-Sync-Polling sichtbar
|
||||
stottern (siehe VersaMCU doc/07_serial_protocol.md, "READ_STATUS vs.
|
||||
CONFIG_READ fuer Polling"). Für ältere Firmware ohne CMD_READ_STATUS
|
||||
faellt das Board auf keine Antwort zurueck -> Timeout, kein Absturz."""
|
||||
if not self._ensure_open():
|
||||
return None
|
||||
profile = buf[ACTIVE_PROFILE_OFFSET]
|
||||
if profile not in (0, 1, 2):
|
||||
try:
|
||||
self.ser.reset_input_buffer()
|
||||
self.ser.write(bytes([CMD_READ_STATUS, 0, 0, 0, 0, 0, 0, 0]))
|
||||
|
||||
deadline = time.time() + deadline_s
|
||||
while time.time() < deadline:
|
||||
raw = self.ser.read(PACKET_SIZE)
|
||||
if len(raw) < PACKET_SIZE:
|
||||
continue
|
||||
if raw[0] == EVT_STATUS:
|
||||
profile = raw[1]
|
||||
if profile not in (0, 1, 2):
|
||||
self.last_error = "timeout"
|
||||
return None
|
||||
self.last_error = None
|
||||
return profile
|
||||
self.last_error = "timeout"
|
||||
return None
|
||||
return profile
|
||||
except serial.SerialException:
|
||||
self.close()
|
||||
self.last_error = "busy"
|
||||
return None
|
||||
|
||||
def read_full_config(self):
|
||||
"""740B Rohdaten (alle 3 Profile) oder None."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue