Initial public release
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user