Files
m5stack-automation-tool/widgets/ble_variables_window.py
T
2026-07-17 15:29:53 -04:00

417 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()