Initial public release

This commit is contained in:
2026-07-17 15:29:53 -04:00
commit 2d71ce77a1
81 changed files with 32056 additions and 0 deletions
+416
View File
@@ -0,0 +1,416 @@
"""Pop-out window for defining variables (Universal + per-device profiles)."""
import re
import tkinter as tk
from tkinter import messagebox, ttk
UNIVERSAL_LABEL = "Universal"
_MAC_RE = re.compile(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$")
class BLEVariablesWindow(tk.Toplevel):
"""Window to create / edit / delete variables across profiles.
Variables live in two scopes:
- Universal: a shared dict applied to every device that doesn't override.
- Per-device: keyed by the ATOMS3's eFuse base MAC. Each device known to
the host gets its own dict.
A scope dropdown at the top swaps which dict is being edited. Save commits
only the currently-visible dict back into the project — other scopes are
left untouched.
Args:
parent: Tk parent widget.
ble_variables: Project-shape dict {"universal": {...}, "devices": {mac: {...}}}.
Old flat dicts are accepted and treated as Universal.
ble_comments: Same shape as ble_variables but values are comment
strings. Optional — defaults to an empty structure
so legacy callers keep working unchanged. Comments
are host-side only (never sent to the device).
on_save: Callback(new_variables, new_comments) invoked with
the FULL updated structures (both scopes). For
backward compatibility, callbacks that accept only
a single positional are invoked with just the
variables dict.
"""
_BG = "#2D2D3D"
_ROW_BG = "#252535"
_ENTRY_BG = "#1E1E2E"
_FG = "#FFFFFF"
_DIM_FG = "#AAAAAA"
_BTN_BG = "#3D3D5C"
_SAVE_BG = "#0077CC"
_DEL_FG = "#FF7777"
def __init__(self, parent, ble_variables, on_save, ble_comments=None):
super().__init__(parent)
self.title("Variables")
self.geometry("780x500")
self.minsize(560, 320)
self.configure(bg=self._BG)
self.transient(parent)
self.grab_set()
self._on_save = on_save
self._rows: list[dict] = []
# In-memory working copies so users can flip between scopes without losing edits
self._working = self._normalize(ble_variables)
self._working_comments = self._normalize(ble_comments)
self._active_label = UNIVERSAL_LABEL
try:
import ctypes
self.update_idletasks()
hwnd = ctypes.windll.user32.GetParent(self.winfo_id())
ctypes.windll.dwmapi.DwmSetWindowAttribute(
hwnd, 20, ctypes.byref(ctypes.c_int(1)), ctypes.sizeof(ctypes.c_int))
except Exception:
pass
self._build_ui()
self._load_active()
self.bind("<Return>", lambda _: self._save())
@staticmethod
def _normalize(raw) -> dict:
if not isinstance(raw, dict):
return {"universal": {}, "devices": {}}
if "universal" in raw or "devices" in raw:
return {
"universal": dict(raw.get("universal") or {}),
"devices": {str(k): dict(v or {}) for k, v in (raw.get("devices") or {}).items()},
}
return {"universal": dict(raw), "devices": {}}
def _build_ui(self):
hdr = tk.Frame(self, bg=self._BG)
hdr.pack(fill="x", padx=14, pady=(12, 6))
tk.Label(hdr, text="Variables", bg=self._BG, fg="#0AACFF",
font=("Segoe UI", 11, "bold")).pack(side="left")
tk.Label(
hdr,
text="Use (VAR{name}) in text blocks to insert a value (case-insensitive)",
bg=self._BG, fg=self._DIM_FG, font=("Segoe UI", 8),
).pack(side="left", padx=(10, 0))
scope_row = tk.Frame(self, bg=self._BG)
scope_row.pack(fill="x", padx=14, pady=(0, 6))
tk.Label(scope_row, text="Profile:", bg=self._BG, fg=self._DIM_FG,
font=("Segoe UI", 9)).pack(side="left", padx=(0, 6))
self._scope_var = tk.StringVar(value=self._active_label)
self._scope_combo = ttk.Combobox(
scope_row, textvariable=self._scope_var, state="readonly",
width=28, font=("Segoe UI", 9),
)
self._scope_combo.pack(side="left")
self._scope_combo.bind("<<ComboboxSelected>>", self._on_scope_change)
tk.Button(
scope_row, text="+ Device", bg=self._BTN_BG, fg=self._FG,
font=("Segoe UI", 9), relief="flat", padx=8,
command=self._add_device_dialog,
).pack(side="left", padx=(6, 0))
tk.Button(
scope_row, text=" Device", bg=self._BTN_BG, fg=self._DEL_FG,
font=("Segoe UI", 9), relief="flat", padx=8,
command=self._remove_active_device,
).pack(side="left", padx=(4, 0))
self._refresh_scope_options()
col_hdr = tk.Frame(self, bg=self._BG)
col_hdr.pack(fill="x", padx=14)
tk.Label(col_hdr, text="Variable Name", bg=self._BG, fg=self._DIM_FG,
font=("Segoe UI", 8), width=20, anchor="w").pack(side="left")
# Value and Comment share the remaining width 50/50; using
# ``expand=True`` on both with explicit fill makes Tk distribute
# leftover space evenly without us having to compute pixels.
tk.Label(col_hdr, text="Value", bg=self._BG, fg=self._DIM_FG,
font=("Segoe UI", 8), anchor="w").pack(
side="left", padx=(4, 0), fill="x", expand=True)
tk.Label(col_hdr, text="Comment", bg=self._BG, fg=self._DIM_FG,
font=("Segoe UI", 8), anchor="w").pack(
side="left", padx=(4, 24), fill="x", expand=True)
outer = tk.Frame(self, bg=self._BG)
outer.pack(fill="both", expand=True, padx=14, pady=4)
canvas = tk.Canvas(outer, bg=self._BG, highlightthickness=0)
scrollbar = tk.Scrollbar(outer, orient="vertical", command=canvas.yview)
canvas.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side="right", fill="y")
canvas.pack(side="left", fill="both", expand=True)
self._rows_frame = tk.Frame(canvas, bg=self._BG)
self._rows_window = canvas.create_window((0, 0), window=self._rows_frame,
anchor="nw")
self._rows_frame.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")),
)
canvas.bind(
"<Configure>",
lambda e: canvas.itemconfig(self._rows_window, width=e.width),
)
canvas.bind_all("<MouseWheel>", lambda e: canvas.yview_scroll(
int(-1 * (e.delta / 120)), "units"))
self._canvas = canvas
btn_row = tk.Frame(self, bg=self._BG)
btn_row.pack(fill="x", padx=14, pady=(4, 12))
tk.Button(btn_row, text="+ Add Variable", bg=self._BTN_BG, fg=self._FG,
font=("Segoe UI", 9), relief="flat", padx=10,
command=self._add_row).pack(side="left")
tk.Button(btn_row, text="Save", bg=self._SAVE_BG, fg=self._FG,
font=("Segoe UI", 9, "bold"), relief="flat", padx=16,
command=self._save).pack(side="right")
tk.Button(btn_row, text="Cancel", bg=self._BTN_BG, fg=self._FG,
font=("Segoe UI", 9), relief="flat", padx=10,
command=self.destroy).pack(side="right", padx=(0, 6))
def _refresh_scope_options(self):
macs = sorted((self._working.get("devices") or {}).keys())
opts = [UNIVERSAL_LABEL] + [f"Device {m}" for m in macs]
self._scope_combo["values"] = opts
if self._active_label not in opts:
self._active_label = UNIVERSAL_LABEL
self._scope_var.set(self._active_label)
def _active_dict(self) -> dict:
if self._active_label == UNIVERSAL_LABEL:
return self._working.setdefault("universal", {})
mac = self._active_label.replace("Device ", "", 1)
return self._working.setdefault("devices", {}).setdefault(mac, {})
def _active_comments(self) -> dict:
if self._active_label == UNIVERSAL_LABEL:
return self._working_comments.setdefault("universal", {})
mac = self._active_label.replace("Device ", "", 1)
return self._working_comments.setdefault("devices", {}).setdefault(mac, {})
def _commit_visible_rows(self):
"""Write the currently displayed rows back into the active dicts.
Called before swapping scopes so unsaved edits don't disappear.
Comments are committed only when both name and a non-empty
comment string exist, but they're always keyed off the variable
name so renaming a variable also re-keys its comment.
"""
new_vars = {}
new_comments = {}
for rd in self._rows:
name = rd["name"].get().strip()
if not name:
continue
new_vars[name] = rd["value"].get()
comment = rd["comment"].get()
if comment:
new_comments[name] = comment
if self._active_label == UNIVERSAL_LABEL:
self._working["universal"] = new_vars
self._working_comments["universal"] = new_comments
else:
mac = self._active_label.replace("Device ", "", 1)
self._working.setdefault("devices", {})[mac] = new_vars
self._working_comments.setdefault("devices", {})[mac] = new_comments
def _on_scope_change(self, _event=None):
self._commit_visible_rows()
self._active_label = self._scope_var.get()
self._load_active()
def _load_active(self):
for rd in self._rows:
rd["frame"].destroy()
self._rows.clear()
active_vars = self._active_dict()
active_comments = self._active_comments()
for name, value in active_vars.items():
self._add_row(
name=str(name),
value=str(value),
comment=str(active_comments.get(name, "")),
)
def _add_row(self, name: str = "", value: str = "", comment: str = ""):
row_frame = tk.Frame(self._rows_frame, bg=self._ROW_BG, pady=3)
row_frame.pack(fill="x", pady=2)
name_var = tk.StringVar(value=name)
value_var = tk.StringVar(value=value)
comment_var = tk.StringVar(value=comment)
name_entry = tk.Entry(
row_frame, textvariable=name_var, bg=self._ENTRY_BG,
fg=self._FG, insertbackground=self._FG,
font=("Segoe UI", 9), relief="flat", width=20,
)
name_entry.pack(side="left", padx=(6, 4), ipady=3)
# Pack the delete button BEFORE the stretchy entries so it stays
# pinned to the right edge regardless of which entry happens to
# win the leftover pixels in any given resize tick.
row_data = {
"name": name_var,
"value": value_var,
"comment": comment_var,
"frame": row_frame,
}
del_btn = tk.Button(
row_frame, text="×", bg=self._ROW_BG, fg=self._DEL_FG,
font=("Segoe UI", 10, "bold"), relief="flat", width=2,
command=lambda rd=row_data: self._delete_row(rd),
)
del_btn.pack(side="right", padx=(0, 4))
value_entry = tk.Entry(
row_frame, textvariable=value_var, bg=self._ENTRY_BG,
fg=self._FG, insertbackground=self._FG,
font=("Segoe UI", 9), relief="flat",
)
value_entry.pack(side="left", fill="x", expand=True, padx=(0, 4), ipady=3)
comment_entry = tk.Entry(
row_frame, textvariable=comment_var, bg=self._ENTRY_BG,
fg="#BBBBCC", insertbackground=self._FG,
font=("Segoe UI", 9, "italic"), relief="flat",
)
comment_entry.pack(side="left", fill="x", expand=True, padx=(0, 4), ipady=3)
self._rows.append(row_data)
def _delete_row(self, row_data: dict):
row_data["frame"].destroy()
self._rows.remove(row_data)
def _add_device_dialog(self):
dialog = tk.Toplevel(self)
dialog.title("Add Device Profile")
dialog.configure(bg=self._BG)
dialog.geometry("320x140")
dialog.resizable(False, False)
dialog.transient(self)
dialog.grab_set()
tk.Label(dialog, text="MAC (AA:BB:CC:DD:EE:FF):",
bg=self._BG, fg=self._FG,
font=("Segoe UI", 9)).pack(anchor="w", padx=12, pady=(14, 2))
var = tk.StringVar()
entry = tk.Entry(dialog, textvariable=var, bg=self._ENTRY_BG,
fg=self._FG, insertbackground=self._FG,
font=("Segoe UI", 10), relief="flat")
entry.pack(fill="x", padx=12, pady=(0, 8), ipady=4)
entry.focus_set()
def commit():
mac = var.get().strip().upper()
if not _MAC_RE.match(mac):
messagebox.showerror(
"Add Device",
"Enter a MAC in AA:BB:CC:DD:EE:FF form.",
parent=dialog,
)
return
self._commit_visible_rows()
self._working.setdefault("devices", {}).setdefault(mac, {})
self._active_label = f"Device {mac}"
self._refresh_scope_options()
self._load_active()
dialog.destroy()
btns = tk.Frame(dialog, bg=self._BG)
btns.pack(side="bottom", pady=10)
tk.Button(btns, text="Cancel", command=dialog.destroy,
bg=self._BTN_BG, fg=self._FG, relief="flat",
padx=10, font=("Segoe UI", 9)).pack(side="left", padx=4)
tk.Button(btns, text="Add", command=commit,
bg=self._SAVE_BG, fg=self._FG, relief="flat",
padx=14, font=("Segoe UI", 9, "bold")).pack(side="left", padx=4)
dialog.bind("<Return>", lambda _e: commit())
def _remove_active_device(self):
if self._active_label == UNIVERSAL_LABEL:
messagebox.showinfo(
"Variables",
"Universal can't be removed. Switch to a device profile first.",
parent=self,
)
return
mac = self._active_label.replace("Device ", "", 1)
if not messagebox.askyesno(
"Remove Device Profile",
f"Remove all variables for device {mac}?",
parent=self,
):
return
devices = self._working.setdefault("devices", {})
devices.pop(mac, None)
self._active_label = UNIVERSAL_LABEL
self._refresh_scope_options()
self._load_active()
def _save(self):
# Validate the visible scope and commit it. Other scopes were already
# committed when the user swapped away from them.
seen = set()
new_vars = {}
new_comments = {}
for rd in self._rows:
name = rd["name"].get().strip()
value = rd["value"].get()
comment = rd["comment"].get()
if not name:
messagebox.showerror(
"Variables",
"Variable names cannot be empty.",
parent=self,
)
return
if name in seen:
messagebox.showerror(
"Variables",
f"Duplicate variable name: '{name}'",
parent=self,
)
return
seen.add(name)
new_vars[name] = value
if comment:
new_comments[name] = comment
if self._active_label == UNIVERSAL_LABEL:
self._working["universal"] = new_vars
self._working_comments["universal"] = new_comments
else:
mac = self._active_label.replace("Device ", "", 1)
self._working.setdefault("devices", {})[mac] = new_vars
self._working_comments.setdefault("devices", {})[mac] = new_comments
# Backward-compat: older callers pass a 1-arg on_save and don't
# know about comments. Try the 2-arg form first; on TypeError
# (wrong arity) fall back to the legacy single-arg call so the
# variable save still lands.
try:
self._on_save(self._working, self._working_comments)
except TypeError:
self._on_save(self._working)
self.destroy()
# External API — call this from the BLE thread (via after()) when a new
# device MAC appears so the dropdown refreshes.
def register_device(self, mac: str):
self._working.setdefault("devices", {}).setdefault(mac, {})
self._refresh_scope_options()