Move config storage next to the install and auto-create it if missing

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.
This commit is contained in:
Julian Appel 2026-08-14 23:17:11 +02:00
parent 13c3745479
commit 5d14bdd826
7 changed files with 147 additions and 57 deletions

4
.gitignore vendored
View file

@ -3,3 +3,7 @@ __pycache__/
*.log *.log
build/ build/
dist/ dist/
# Nutzer-Config -- landet neben der Installation (versapad_data.app_dir()),
# beim Start aus dem Quellcode also direkt hier im Projektordner
versapad_config_all.json

View file

@ -16,6 +16,12 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
- `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/ - `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/
Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie
(`index = spalte*5+reihe`), `hid_key_choices()`/`consumer_choices()`. (`index = spalte*5+reihe`), `hid_key_choices()`/`consumer_choices()`.
`app_dir()` liefert das Verzeichnis für die eigene Config
(`versapad_combined.DEFAULT_PATH`) — bei der `.exe` der Installations-
ordner, sonst der Projektordner, siehe Installierbarkeit-Notiz unten.
`CONFIG_PATHS` bleibt bewusst auf dem OneDrive-Desktop hartkodiert (Lese-
Interop mit einem JSON-Export der offiziellen VersaGUI, kein von diesem
Tool geschriebenes Format, siehe dort).
- `server.py` — stdlib `http.server`, generiert HTML pro Request neu, - `server.py` — stdlib `http.server`, generiert HTML pro Request neu,
Profil-Wechsel über `?profile=0|1|2`, Auto-Reload alle 4s. Rein lesend, Profil-Wechsel über `?profile=0|1|2`, Auto-Reload alle 4s. Rein lesend,
kein Programmiermodus (bewusst einfach gehalten). kein Programmiermodus (bewusst einfach gehalten).
@ -39,8 +45,14 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
liefert `READ_STATUS` schlicht Timeout, kein Absturz. liefert `READ_STATUS` schlicht Timeout, kein Absturz.
- `versapad_combined.py` — Ein-Datei-Format (alle 3 Profile + Makros + - `versapad_combined.py` — Ein-Datei-Format (alle 3 Profile + Makros +
**nur lokal gespeicherte** Profilnamen), Default-Pfad **nur lokal gespeicherte** Profilnamen), Default-Pfad
`~\OneDrive\Desktop\versapad_config_all.json`. Passt zum Wire-Protokoll: `versapad_data.app_dir()\versapad_config_all.json` (neben der
`CONFIG_BEGIN/COMMIT` überträgt ohnehin immer den kompletten 740B-Block. Installation, nicht mehr hartkodiert auf einer bestimmten Maschine).
Passt zum Wire-Protokoll: `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer
den kompletten 740B-Block. `load_or_fetch()` legt bei fehlender Datei
automatisch eine neue an — erst Versuch per Serial vom Board, sonst als
leere `default_combined()` (kein Board noetig fuer die Erstbenutzung).
`read_profile_names()` liest nur die Namen (kein Board-Zugriff, fuer
Tab-Beschriftungen im Nur-Lese-Modus, siehe Installierbarkeit-Notiz).
**UI:** **UI:**
- `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi - `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi
@ -93,6 +105,34 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
vorhanden, und fällt sonst auf die klassischen `versapad_config{1,2,3}.json` 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 zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine
Bearbeitung "verschwunden". Bearbeitung "verschwunden".
- **Installierbarkeit verbessert 2026-08-14:** Drei Probleme beim
Weitergeben an andere Leute behoben. (1) `build_and_deploy.ps1` starb bei
fehlenden Paketen (pyinstaller/pystray/pillow) kommentarlos, v.a. bei
Doppelklick im Explorer, weil das Fenster sich sofort schließt. Fix:
`requirements.txt` (alle Pakete an einer Stelle, README und Skript nutzen
dieselbe Datei), Skript installiert sie selbst per `pip install -r`,
läuft komplett in try/catch, prüft `$LASTEXITCODE` nach jedem nativen
Aufruf, und pausiert am Ende (Erfolg wie Fehler) auf Tastendruck --
`-NoPause` für CI/Automation. (2) `versapad_combined.DEFAULT_PATH` war
hartkodiert auf `~\OneDrive\Desktop` einer bestimmten Maschine -- für
andere Nutzer unbrauchbar. Fix: `versapad_data.app_dir()` (neue
Funktion) liefert bei der gebauten `.exe` deren Installationsordner
(`sys.executable`-Verzeichnis), sonst den Projektordner (`__file__`-
Verzeichnis) -- `DEFAULT_PATH` hängt jetzt daran, landet also immer neben
der laufenden Installation. `versapad_data.CONFIG_PATHS` (Lese-Interop
mit der C#-VersaGUI) bleibt bewusst auf dem Desktop, siehe oben. (3) Fehlte
die Config UND war kein Board erreichbar, blockierte das Tool mit einer
Fehlermeldung statt zu starten. Fix: `load_or_fetch()` legt jetzt bei
Board-Fehlschlag eine leere `default_combined()` an statt `RuntimeError`
zu werfen -- Erstbenutzung ganz ohne vorhandene Config oder Board
funktioniert jetzt. (4) Profilnamen waren zusätzlich hartkodiert in
`versapad_data.PROFILE_NAMES` (Dict, jetzt entfernt, ersetzt durch
`NUM_PROFILES = 3`) und liefen der JSON-`profile_names` parallel --
Programmiermodus zeigte umbenannte Profile, Lesemodus/Browser-Ansicht
weiterhin die alten Namen. Fix: `server.py` und `desktop_viewer.py` lesen
Namen jetzt immer aus der kombinierten JSON (`combined["profile_names"]`
bzw. `versapad_combined.read_profile_names()`), eine einzige Quelle der
Wahrheit für alle Frontends.
- **Bug behoben 2026-08-08:** `server.py` (`vp.load_profile()`) und - **Bug behoben 2026-08-08:** `server.py` (`vp.load_profile()`) und
`desktop_viewer.py` (`_current_profile_view()`) lasen im Nur-Lese-Modus `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 hart von den Desktop-JSONs -- fehlten sie (z.B. User loescht sie), gab es

View file

@ -11,10 +11,14 @@ Board-Schreibzugriff) und der MCP-Server sind funktionsfähig und gegen ein
echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original, echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original,
inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft
manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum
Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene
Standard-Dateipfade für die Config-JSONs sind aktuell für eine bestimmte Config-Datei (`versapad_config_all.json`) liegt automatisch neben der
Windows-Maschine hartkodiert (`versapad_data.CONFIG_PATHS`, Installation (siehe „Aufbau" unten) und wird bei Bedarf automatisch neu
`versapad_combined.DEFAULT_PATH`) — für einen anderen Rechner dort anpassen. angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale*
Lese-Interop mit den JSON-Exports der offiziellen VersaGUI
(`versapad_data.CONFIG_PATHS`) ist noch für eine bestimmte Windows-Maschine
hartkodiert (OneDrive-Desktop) — für einen anderen Rechner dort anpassen,
falls gewünscht.
## Features ## Features
@ -102,11 +106,21 @@ Das Binärformat (`versapad_protocol.py`) ist 1:1 aus den
VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert
(Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC). (Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC).
Config-Dateien liegen standardmäßig auf dem Desktop Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile +
(`versapad_config1/2/3.json` für den reinen Lesemodus, Makros + lokale Profilnamen, siehe `versapad_combined.py`) liegt neben der
`versapad_config_all.json` für den Programmiermodus — Pfade sind aktuell Installation: bei der gebauten `.exe` im selben Ordner, beim Start aus dem
hartkodiert für eine bestimmte Windows-Maschine, siehe `CONFIG_PATHS` in Quellcode im Projektordner (`versapad_data.app_dir()`). Fehlt sie (z.B.
`versapad_data.py` bzw. `DEFAULT_PATH` in `versapad_combined.py`). frische Installation), wird sie automatisch angelegt — per Serial vom
Board, falls eins angeschlossen ist, sonst als leere Default-Config.
Profilnamen (`profile_names`) stehen direkt in dieser Datei und lassen sich
per Doppelklick auf einen Tab (Programmiermodus) oder `rename_profile()`
(MCP) ändern.
Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool
geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export
der offiziellen VersaGUI (C#/.NET) — Pfad aktuell hartkodiert auf den
OneDrive-Desktop einer bestimmten Windows-Maschine, siehe `CONFIG_PATHS` in
`versapad_data.py`.
## MCP-Server ## MCP-Server

View file

@ -167,8 +167,8 @@ class VersaPadViewer(tk.Tk):
self.tabs = tk.Frame(self, bg=BG) self.tabs = tk.Frame(self, bg=BG)
self.tabs.pack(fill="x", padx=20, pady=(12, 10)) self.tabs.pack(fill="x", padx=20, pady=(12, 10))
self.tab_buttons = {} self.tab_buttons = {}
for p in sorted(vp.PROFILE_NAMES): for p in range(vp.NUM_PROFILES):
btn = tk.Label(self.tabs, text=vp.PROFILE_NAMES[p], bg=CARD_BG, fg=TEXT, btn = tk.Label(self.tabs, text=f"Profil {p}", bg=CARD_BG, fg=TEXT,
font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2") font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2")
btn.pack(side="left", padx=(0, 8)) btn.pack(side="left", padx=(0, 8))
btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True)) btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True))
@ -242,11 +242,16 @@ class VersaPadViewer(tk.Tk):
self._render() self._render()
def _update_tab_labels(self): def _update_tab_labels(self):
for p, btn in self.tab_buttons.items(): """Profilnamen kommen immer aus der kombinierten JSON (nicht mehr
hartkodiert) -- im Programmiermodus aus dem In-Memory-State, sonst
per schlankem Datei-Read (kein Board-Zugriff, siehe
versapad_combined.profile_names())."""
if self.editing.get() and self.combined: if self.editing.get() and self.combined:
btn.configure(text=self.combined["profile_names"][p]) names = self.combined["profile_names"]
else: else:
btn.configure(text=vp.PROFILE_NAMES[p]) names = vcomb.read_profile_names()
for p, btn in self.tab_buttons.items():
btn.configure(text=names[p])
def _rename_tab(self, profile): def _rename_tab(self, profile):
if not self.editing.get() or self.combined is None: if not self.editing.get() or self.combined is None:
@ -526,16 +531,19 @@ class VersaPadViewer(tk.Tk):
Bevorzugt die kombinierte Datei (versapad_config_all.json), falls Bevorzugt die kombinierte Datei (versapad_config_all.json), falls
vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen
sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen. sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen.
Fehlt sie (z.B. versehentlich geloescht) und ist Live-Sync gerade aus Fehlt sie (z.B. versehentlich geloescht, oder frische Installation
(COM-Port frei), wird sie automatisch per Serial vom Board neu ganz ohne Config) und ist Live-Sync gerade aus (COM-Port frei), wird
aufgebaut und als neuer Cache gespeichert -- das Board ist die sie automatisch angelegt -- per Serial vom Board, oder als leere
eigentliche Quelle der Wahrheit, kein Datei-Handling von Hand mehr Default-Config, wenn auch kein Board erreichbar ist (siehe
noetig. Nur wenn das nicht klappt (Board nicht erreichbar, Live-Sync versapad_combined.load_or_fetch()) -- das Tool ist damit auch ganz
haelt den Port), weicht es zuletzt auf die klassischen ohne vorhandene Config sofort benutzbar, kein Datei-Handling von
versapad_config{1,2,3}.json aus.""" Hand mehr noetig. Nur wenn Live-Sync gerade an ist (haelt den
if os.path.exists(vcomb.DEFAULT_PATH): COM-Port) und noch keine Datei existiert, weicht es zuletzt auf die
klassischen versapad_config{1,2,3}.json (Export der offiziellen
VersaGUI) aus."""
if os.path.exists(vcomb.DEFAULT_PATH) or not self.live_sync.get():
try: try:
data = vcomb.load_file(vcomb.DEFAULT_PATH) data = vcomb.load_or_fetch(link=self._link)
raw = data["profiles"][self.profile] raw = data["profiles"][self.profile]
cfg = vp.annotate_profile({ cfg = vp.annotate_profile({
"buttons": [dict(b) for b in raw["buttons"]], "buttons": [dict(b) for b in raw["buttons"]],
@ -544,18 +552,6 @@ class VersaPadViewer(tk.Tk):
return cfg, f"Quelle: {vcomb.DEFAULT_PATH}" return cfg, f"Quelle: {vcomb.DEFAULT_PATH}"
except (KeyError, IndexError, ValueError): except (KeyError, IndexError, ValueError):
pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen 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: try:
return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}" return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}"
except FileNotFoundError as e: except FileNotFoundError as e:
@ -564,6 +560,7 @@ class VersaPadViewer(tk.Tk):
raise FileNotFoundError(f"{e}{hint}") from e raise FileNotFoundError(f"{e}{hint}") from e
def _render(self): def _render(self):
self._update_tab_labels()
for w in self.grid_frame.winfo_children(): for w in self.grid_frame.winfo_children():
w.destroy() w.destroy()
for w in self.enc_frame.winfo_children(): for w in self.enc_frame.winfo_children():

View file

@ -98,8 +98,8 @@ def render_page(profile):
"encoders": [dict(e) for e in raw["encoders"]], "encoders": [dict(e) for e in raw["encoders"]],
}) })
tabs = "".join( tabs = "".join(
f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(vp.PROFILE_NAMES[p])}</a>' f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(combined["profile_names"][p])}</a>'
for p in sorted(vp.PROFILE_NAMES) for p in range(vp.NUM_PROFILES)
) )
cells = "".join(render_cell(b) for b in cfg["buttons"]) cells = "".join(render_cell(b) for b in cfg["buttons"])
encoders = "".join(render_encoder(e) for e in cfg["encoders"]) encoders = "".join(render_encoder(e) for e in cfg["encoders"])
@ -130,7 +130,7 @@ class Handler(BaseHTTPRequestHandler):
profile = int(query.get("profile", ["0"])[0]) profile = int(query.get("profile", ["0"])[0])
except ValueError: except ValueError:
profile = 0 profile = 0
if profile not in vp.PROFILE_NAMES: if not (0 <= profile < vp.NUM_PROFILES):
profile = 0 profile = 0
try: try:
body = render_page(profile).encode("utf-8") body = render_page(profile).encode("utf-8")

View file

@ -19,7 +19,7 @@ import versapad_data as vp
import versapad_protocol as proto import versapad_protocol as proto
import versapad_serial as vs import versapad_serial as vs
DEFAULT_PATH = os.path.expanduser(r"~\OneDrive\Desktop\versapad_config_all.json") DEFAULT_PATH = os.path.join(vp.app_dir(), "versapad_config_all.json")
DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"] DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"]
@ -126,18 +126,37 @@ def fetch_from_board(link=None, profile_names=None):
def load_or_fetch(path=DEFAULT_PATH, link=None, profile_names=None): def load_or_fetch(path=DEFAULT_PATH, link=None, profile_names=None):
"""Bevorzugt die lokale Kombi-Datei. Fehlt sie (z.B. versehentlich """Bevorzugt die lokale Kombi-Datei. Fehlt sie (z.B. versehentlich
geloescht), wird sie automatisch per Serial vom Board neu aufgebaut und geloescht, oder frische Installation ohne jede Config), wird sie
als neuer Cache gespeichert, statt einen Fehler zu werfen -- das Board automatisch neu angelegt -- zuerst per Serial-Versuch vom Board (das
behaelt die Config dauerhaft im NVM, die Desktop-JSON ist nur ein behaelt die Config dauerhaft im NVM, die JSON ist nur ein Lesecache
Lesecache dafuer und muss nicht von Hand gepflegt werden. Ist das Board dafuer), und falls auch das Board nicht erreichbar ist (nicht
nicht erreichbar (nicht verbunden, COM-Port belegt), wirft es verbunden, COM-Port belegt, frisch installiert ohne Board in Reichweite)
RuntimeError mit Klartext-Ursache -- Aufrufer entscheidet, ob es einen als leere Default-Config (vgl. default_combined()) -- damit ist das
weiteren Fallback gibt (z.B. alte Einzel-JSONs).""" Tool auch ganz ohne vorhandene Config sofort benutzbar, statt mit
einem Fehler zu blockieren."""
if os.path.exists(path): if os.path.exists(path):
return load_file(path) return load_file(path)
try:
combined = fetch_from_board(link=link, profile_names=profile_names) combined = fetch_from_board(link=link, profile_names=profile_names)
except RuntimeError:
combined = default_combined()
if profile_names:
combined["profile_names"] = profile_names
try: try:
save_file(combined, path) save_file(combined, path)
except OSError: except OSError:
pass # Board-Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden pass # Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden
return combined 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)

View file

@ -7,19 +7,35 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen.
""" """
import json import json
import os import os
import sys
NUM_PROFILES = 3
def app_dir():
"""Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json,
siehe versapad_combined.DEFAULT_PATH): bei der gebauten .exe (--onedir)
das Installationsverzeichnis neben der .exe, sonst der Ordner dieses
Moduls (Projektordner beim Start aus dem Quellcode). Kein hartkodierter
Pfad mehr -- so laesst sich das Tool auf jede Maschine kopieren/
installieren, ohne Pfade von Hand anzupassen."""
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
# CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind
# keine von diesem Tool geschriebenen Dateien, sondern ein Export der
# offiziellen (C#/.NET-)VersaGUI auf dieser einen Maschine (reine Lese-
# Interop, siehe README "Bekannte Einschraenkungen"). Fuer das eigentliche,
# von diesem Tool selbst gepflegte Format siehe versapad_combined.DEFAULT_PATH
# (liegt jetzt in app_dir(), nicht mehr hartkodiert auf dem Desktop).
CONFIG_PATHS = { CONFIG_PATHS = {
0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"), 0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"),
1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"), 1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"),
2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"), 2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"),
} }
PROFILE_NAMES = {
0: "Profil 0 Windows",
1: "Profil 1 Fusion 360",
2: "Profil 2 BricsCAD",
}
# index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge) # index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge)
GRID_COLS = 4 GRID_COLS = 4
GRID_ROWS = 5 GRID_ROWS = 5