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

602 lines
28 KiB
Python

"""Top toolbar - connection status, upload, settings."""
import os
import subprocess
import tkinter as tk
from tkinter import ttk, messagebox
import threading
from utils.constants import APP_NAME, ORIENTATION_OPTIONS
class Toolbar(tk.Frame):
"""Top toolbar with device connection and upload controls."""
def __init__(self, parent, serial_manager=None, on_settings_change=None,
on_profile_switch=None, on_profile_new=None, on_profile_delete=None,
on_ble_variables=None, on_backups=None, on_rs232_terminal=None,
on_bt_keyboard=None,
on_settings_open=None, on_settings_close=None,
profile_manager=None):
super().__init__(parent, bg="#1A1A2E", height=44)
self.serial_manager = serial_manager
self.on_settings_change = on_settings_change
self.on_profile_switch = on_profile_switch
self.on_profile_new = on_profile_new
self.on_profile_delete = on_profile_delete
self.on_ble_variables = on_ble_variables
self.on_backups = on_backups
self.on_rs232_terminal = on_rs232_terminal
self.on_bt_keyboard = on_bt_keyboard
self.on_settings_open = on_settings_open
self.on_settings_close = on_settings_close
self.profile_manager = profile_manager
self.project = None
self._profile_names = []
self.pack_propagate(False)
self._build_ui()
def _build_ui(self):
tk.Label(self, text=APP_NAME, bg="#1A1A2E", fg="#CCCCCC",
font=("Segoe UI", 10, "bold")).pack(side="left", padx=12)
profile_frame = tk.Frame(self, bg="#1A1A2E")
profile_frame.pack(side="left", padx=(0, 4))
tk.Label(profile_frame, text="Profile:", bg="#1A1A2E", fg="#AAAAAA",
font=("Segoe UI", 9)).pack(side="left", padx=(0, 4))
self._profile_var = tk.StringVar()
self._profile_combo = ttk.Combobox(
profile_frame, textvariable=self._profile_var,
state="readonly", width=18, font=("Segoe UI", 9),
)
self._profile_combo.pack(side="left")
self._profile_combo.bind("<<ComboboxSelected>>", self._on_profile_selected)
tk.Button(profile_frame, text="+", bg="#3D3D5C", fg="white",
font=("Segoe UI", 9, "bold"), relief="flat", width=2,
command=self._new_profile_dialog).pack(side="left", padx=(4, 2))
tk.Button(profile_frame, text="\u00d7", bg="#3D3D5C", fg="#FF7777",
font=("Segoe UI", 9, "bold"), relief="flat", width=2,
command=self._delete_profile).pack(side="left", padx=(0, 4))
tk.Frame(self, bg="#333355", width=1).pack(side="left", fill="y", pady=6)
self.status_frame = tk.Frame(self, bg="#1A1A2E")
self.status_frame.pack(side="left", padx=12)
self.status_dot = tk.Canvas(self.status_frame, width=12, height=12,
bg="#1A1A2E", highlightthickness=0)
self.status_dot.pack(side="left", padx=(0, 6))
self._dot_id = self.status_dot.create_oval(2, 2, 10, 10, fill="#FF4444", outline="")
self.status_label = tk.Label(self.status_frame, text="Disconnected",
bg="#1A1A2E", fg="#888888",
font=("Segoe UI", 9))
self.status_label.pack(side="left")
self.connect_btn = tk.Button(self, text="Connect", bg="#3D3D5C", fg="white",
font=("Segoe UI", 9), relief="flat", padx=12,
command=self._toggle_connection)
self.connect_btn.pack(side="left", padx=4, pady=6)
tk.Frame(self, bg="#333355", width=1).pack(side="left", fill="y", pady=6)
self.ble_status_frame = tk.Frame(self, bg="#1A1A2E")
self.ble_status_frame.pack(side="left", padx=8)
self.ble_dot = tk.Canvas(self.ble_status_frame, width=12, height=12,
bg="#1A1A2E", highlightthickness=0)
self.ble_dot.pack(side="left", padx=(0, 4))
self._ble_dot_id = self.ble_dot.create_oval(2, 2, 10, 10, fill="#888888", outline="")
self.ble_label = tk.Label(self.ble_status_frame, text="BLE: Off",
bg="#1A1A2E", fg="#888888",
font=("Segoe UI", 8))
self.ble_label.pack(side="left")
self.settings_btn = tk.Button(self, text="\u2699 Settings", bg="#3D3D5C", fg="white",
font=("Segoe UI", 9), relief="flat", padx=12,
command=self._show_settings)
self.settings_btn.pack(side="right", padx=6, pady=6)
# Live multi-device keyboard streaming. Packed to the right of
# the other action buttons so it sits near the top-right corner
# without crowding Upload Profile, the canonical primary action.
self.bt_kbd_btn = tk.Button(self, text="\u2328 Keyboard",
bg="#7C3AED", fg="white",
activebackground="#6D28D9",
font=("Segoe UI", 9), relief="flat", padx=12,
command=self._on_bt_keyboard_click)
self.bt_kbd_btn.pack(side="right", padx=4, pady=6)
self.ble_btn = tk.Button(self, text="\u25c6 Variables", bg="#005599", fg="white",
font=("Segoe UI", 9), relief="flat", padx=12,
command=self._on_ble_variables)
self.ble_btn.pack(side="right", padx=4, pady=6)
self.upload_btn = tk.Button(self, text="\u25b6 Upload Profile", bg="#27AE60", fg="white",
font=("Segoe UI", 9, "bold"), relief="flat", padx=16,
command=self._upload_all)
self.upload_btn.pack(side="right", padx=4, pady=6)
self.progress_var = tk.DoubleVar(value=0)
self.progress_bar = ttk.Progressbar(self, variable=self.progress_var,
maximum=1.0, length=150)
def set_project(self, project):
self.project = project
def set_profiles(self, names: list, active: str):
"""Repopulate the profile combobox."""
self._profile_names = names
self._profile_combo["values"] = names
self._profile_var.set(active)
def _on_profile_selected(self, event=None):
name = self._profile_var.get()
if name and self.on_profile_switch:
self.on_profile_switch(name)
def _new_profile_dialog(self):
dialog = tk.Toplevel(self)
dialog.title("New Profile")
dialog.geometry("300x180")
dialog.resizable(False, False)
dialog.configure(bg="#2D2D3D")
dialog.transient(self.winfo_toplevel())
dialog.grab_set()
tk.Label(dialog, text="Name:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 10)).pack(anchor="w", padx=16, pady=(16, 2))
name_var = tk.StringVar(value="New Profile")
name_entry = tk.Entry(dialog, textvariable=name_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10), relief="flat")
name_entry.pack(fill="x", padx=16, pady=(0, 8))
name_entry.select_range(0, "end")
name_entry.focus_set()
copy_var = tk.BooleanVar(value=True)
rb_frame = tk.Frame(dialog, bg="#2D2D3D")
rb_frame.pack(fill="x", padx=16)
for text, val in [("Duplicate current profile", True), ("Empty profile", False)]:
tk.Radiobutton(rb_frame, text=text, variable=copy_var, value=val,
bg="#2D2D3D", fg="white", selectcolor="#1E1E2E",
activebackground="#2D2D3D", activeforeground="white",
font=("Segoe UI", 9)).pack(anchor="w")
def create():
name = name_var.get().strip()
if not name:
messagebox.showerror("New Profile", "Name cannot be empty.", parent=dialog)
return
dialog.destroy()
if self.on_profile_new:
self.on_profile_new(name, copy_var.get())
btn_frame = tk.Frame(dialog, bg="#2D2D3D")
btn_frame.pack(side="bottom", pady=12)
tk.Button(btn_frame, text="Cancel", command=dialog.destroy,
bg="#3D3D5C", fg="white", relief="flat", padx=12,
font=("Segoe UI", 9)).pack(side="left", padx=6)
tk.Button(btn_frame, text="Create", command=create,
bg="#27AE60", fg="white", relief="flat", padx=12,
font=("Segoe UI", 9, "bold")).pack(side="left", padx=6)
dialog.bind("<Return>", lambda e: create())
def _delete_profile(self):
name = self._profile_var.get()
if not name:
return
if not messagebox.askyesno("Delete Profile",
f"Delete profile '{name}'?\nThis cannot be undone.",
parent=self.winfo_toplevel()):
return
if self.on_profile_delete:
self.on_profile_delete(name)
def set_connected(self, port: str):
self.status_dot.itemconfig(self._dot_id, fill="#44FF44")
# Append the device's live transport (Mesh / BLE) when the firmware
# reports it, so the user can see each device's mode as it's plugged
# in. Older firmware omits it and we just show the port.
mode = None
if self.serial_manager is not None:
try:
mode = self.serial_manager.live_mode_label()
except Exception:
mode = None
text = f"Connected: {port}" + (f" · {mode}" if mode else "")
self.status_label.config(text=text, fg="white")
self.connect_btn.config(text="Disconnect")
def set_disconnected(self):
self.status_dot.itemconfig(self._dot_id, fill="#FF4444")
self.status_label.config(text="Disconnected", fg="#888888")
self.connect_btn.config(text="Connect")
def _toggle_connection(self):
if not self.serial_manager:
return
if self.serial_manager.connected:
self.serial_manager.disconnect()
self.set_disconnected()
else:
self.connect_btn.config(text="Scanning...", state="disabled")
self.update_idletasks()
def scan():
found = self.serial_manager.scan_and_connect()
self.after(0, lambda: self._scan_complete(found))
threading.Thread(target=scan, daemon=True).start()
def _scan_complete(self, found):
self.connect_btn.config(state="normal")
if found:
self.set_connected(self.serial_manager.port)
else:
self.set_disconnected()
messagebox.showwarning("Connection", "ATOMS3 MacroPad not found.\n\n"
"Make sure the device is connected via USB\n"
"and the firmware is uploaded.")
def _upload_all(self):
if not self.serial_manager or not self.serial_manager.connected:
messagebox.showwarning("Upload", "Device not connected.")
return
if not self.project:
messagebox.showwarning("Upload", "No project to upload.")
return
if not self.project.macros:
messagebox.showinfo("Upload", "No routines to upload.")
return
self.upload_btn.config(state="disabled", text="Uploading...")
self.progress_bar.pack(side="right", padx=4, pady=6)
self.progress_var.set(0)
self.update_idletasks()
def do_upload():
def progress(p):
self.after(0, lambda: self.progress_var.set(p))
# Flush so the sub-routines profile on disk is current before upload
if self.profile_manager:
self.profile_manager.save_current()
sub_macros = []
if self.profile_manager:
sub_project = self.profile_manager.get_subroutine_project()
sub_macros = sub_project.macros
success = self.serial_manager.upload_all(self.project, progress, subroutine_macros=sub_macros)
self.after(0, lambda: self._upload_complete(success))
threading.Thread(target=do_upload, daemon=True).start()
def _upload_complete(self, success):
self.upload_btn.config(state="normal", text="\u25b6 Upload Profile")
self.progress_bar.pack_forget()
self.progress_var.set(0)
if success:
messagebox.showinfo("Upload", "Upload complete!")
else:
messagebox.showerror("Upload", "Upload failed.\nCheck device connection.")
def set_ble_status(self, status: str):
"""Update the BLE status indicator."""
dot_colors = {
"idle": "#44FF44", "connecting": "#44AAFF",
"awaiting": "#FFCC44",
"synced": "#44FF44", "not_found": "#FFAA44",
"error": "#FF4444", "off": "#888888",
}
label_texts = {
"idle": "BLE: Ready", "connecting": "BLE: Connecting...",
"awaiting": "BLE: Awaiting input",
"synced": "BLE: Synced", "not_found": "BLE: Not Found",
"error": "BLE: Error", "off": "BLE: Off",
}
dot_color = dot_colors.get(status, "#888888")
label_text = label_texts.get(status, f"BLE: {status}")
self.ble_dot.itemconfig(self._ble_dot_id, fill=dot_color)
self.ble_label.config(text=label_text, fg=dot_color)
def _on_ble_variables(self):
if self.on_ble_variables:
self.on_ble_variables()
def _on_backups_click(self):
if self.on_backups:
self.on_backups()
def _on_rs232_terminal_click(self):
if self.on_rs232_terminal:
self.on_rs232_terminal()
def _on_bt_keyboard_click(self):
if self.on_bt_keyboard:
self.on_bt_keyboard()
def _show_settings(self):
if not self.project:
return
if self.on_settings_open:
self.on_settings_open()
dialog = tk.Toplevel(self)
dialog.title("Device Settings")
dialog.geometry("380x600")
dialog.resizable(False, True)
dialog.configure(bg="#2D2D3D")
dialog.transient(self.winfo_toplevel())
dialog.grab_set()
def _on_dialog_destroy(event, _self=self, _dialog=dialog):
if event.widget is _dialog and _self.on_settings_close:
_self.on_settings_close()
dialog.bind("<Destroy>", _on_dialog_destroy)
settings = self.project.settings
outer = tk.Frame(dialog, bg="#2D2D3D")
outer.pack(fill="both", expand=True)
canvas = tk.Canvas(outer, bg="#2D2D3D", highlightthickness=0)
scrollbar = tk.Scrollbar(outer, orient="vertical", command=canvas.yview)
content = tk.Frame(canvas, bg="#2D2D3D")
content.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=content, anchor="nw", tags="inner")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.bind("<Configure>", lambda e: canvas.itemconfig("inner", width=e.width))
canvas.bind_all("<MouseWheel>",
lambda e: canvas.yview_scroll(-1 * (e.delta // 120), "units"))
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
lbl_style = {"bg": "#2D2D3D", "fg": "white", "font": ("Segoe UI", 9)}
hint_style = {"bg": "#2D2D3D", "fg": "#888888", "font": ("Segoe UI", 7), "justify": "left"}
scale_style = {"orient": "horizontal", "bg": "#2D2D3D", "fg": "white",
"troughcolor": "#1E1E2E", "highlightthickness": 0,
"font": ("Segoe UI", 8)}
def section_label(text):
f = tk.Frame(content, bg="#444466", height=1)
f.pack(fill="x", padx=12, pady=(14, 2))
tk.Label(content, text=text, bg="#2D2D3D", fg="#AAAAAA",
font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=16, pady=(2, 4))
def flash_firmware(variant=""):
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
bat_path = os.path.join(project_root, "CompileAndUpload", "upload.bat")
if not os.path.isfile(bat_path):
messagebox.showerror(
"Flash Firmware",
f"upload.bat not found:\n{bat_path}",
parent=dialog,
)
return
# Release the serial port BEFORE spawning the flasher. The
# flasher's auto_enter_bootloader.py opens the COM port itself
# to ping the device and send a "bootloader" command. If the
# GUI still holds the port, that step silently fails and the
# flasher falls back to the manual "Unplug → Replug" prompts.
# The port watcher in app.py is already paused while the
# settings dialog is open (on_settings_open) and resumes when
# the dialog closes — it'll re-attach to the M5Stack on its
# next 3-second poll after the flasher releases the port and
# the device reboots into the new firmware.
if self.serial_manager and self.serial_manager.connected:
try:
self.serial_manager.disconnect()
except Exception:
pass
try:
args = ["cmd", "/c", "start", "", bat_path]
if variant:
args.append(variant)
subprocess.Popen(
args,
cwd=os.path.dirname(bat_path),
shell=False,
)
except Exception as e:
messagebox.showerror(
"Flash Firmware",
f"Failed to launch upload.bat:\n{e}",
parent=dialog,
)
# Both buttons flash the SAME universal binary (the AtomS3 and the
# AtomS3 Lite are the identical ESP32-S3 module; the firmware
# detects the board at boot). The Lite button only switches
# upload.bat's manual-recovery instructions to LED-based wording,
# since the Lite has no screen to watch during flashing.
tk.Button(content, text="⚡ Flash Firmware — AtomS3",
command=flash_firmware,
bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"),
relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(12, 4))
tk.Button(content, text="⚡ Flash Firmware — AtomS3 Lite (no screen)",
command=lambda: flash_firmware("--lite"),
bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"),
relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(4, 4))
tk.Button(content, text="🖧 RS232 Terminal",
command=lambda: (dialog.destroy(), self._on_rs232_terminal_click()),
bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"),
relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(4, 4))
tk.Button(content, text="⧉ Backups",
command=lambda: (dialog.destroy(), self._on_backups_click()),
bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"),
relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(4, 8))
section_label("General")
tk.Label(content, text="Long press duration (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
hold_var = tk.IntVar(value=settings.hold_ms)
tk.Scale(content, from_=200, to=2000, variable=hold_var, resolution=50,
**scale_style).pack(fill="x", padx=16, pady=2)
tk.Label(content, text="Display orientation:", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0))
orient_var = tk.IntVar(value=settings.orientation)
orient_frame = tk.Frame(content, bg="#2D2D3D")
orient_frame.pack(fill="x", padx=16, pady=2)
for val, label in ORIENTATION_OPTIONS:
tk.Radiobutton(orient_frame, text=label, variable=orient_var, value=val,
bg="#2D2D3D", fg="white", selectcolor="#1E1E2E",
activebackground="#2D2D3D", activeforeground="white",
font=("Segoe UI", 8)).pack(side="left", padx=4)
tk.Label(content, text="Power-on resume delay (seconds):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0))
resume_var = tk.IntVar(value=settings.resume_delay)
tk.Scale(content, from_=0, to=60, variable=resume_var,
**scale_style).pack(fill="x", padx=16, pady=2)
tk.Label(content, text="0 = disabled. Auto-resumes routine after power loss.", **hint_style).pack(anchor="w", padx=16)
section_label("Typing")
tk.Label(content, text="Per-character delay (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
delay_var = tk.IntVar(value=settings.type_delay)
tk.Scale(content, from_=0, to=200, variable=delay_var,
**scale_style).pack(fill="x", padx=16, pady=2)
tk.Label(content, text="Base delay between each typed character.\n"
"Lower = faster typing. Below ~10ms the host may drop keys.",
**hint_style).pack(anchor="w", padx=16)
tk.Label(content, text="Extra delay for shifted chars (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0))
shift_extra_var = tk.IntVar(value=settings.type_shift_extra_ms)
tk.Scale(content, from_=0, to=200, variable=shift_extra_var,
**scale_style).pack(fill="x", padx=16, pady=2)
tk.Label(content, text="Added to the per-character delay only for uppercase\n"
"letters and symbols like !@#$. 0 = uniform rate.",
**hint_style).pack(anchor="w", padx=16)
tk.Label(content, text="End-of-text settle delay (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0))
settle_var = tk.IntVar(value=settings.type_settle_ms)
tk.Scale(content, from_=0, to=1000, variable=settle_var, resolution=10,
**scale_style).pack(fill="x", padx=16, pady=2)
tk.Label(content, text="Pause after the last character before moving on,\n"
"so the last HID reports fully drain to the host.",
**hint_style).pack(anchor="w", padx=16)
section_label("Key Combo Timing")
tk.Label(content, text="Delay before combo (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
combo_pre_var = tk.IntVar(value=settings.combo_pre_ms)
tk.Scale(content, from_=0, to=2000, variable=combo_pre_var, resolution=25,
**scale_style).pack(fill="x", padx=16, pady=2)
tk.Label(content, text="Wait time before the key combo is sent.", **hint_style).pack(anchor="w", padx=16)
tk.Label(content, text="Delay after combo (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0))
combo_post_var = tk.IntVar(value=settings.combo_post_ms)
tk.Scale(content, from_=0, to=2000, variable=combo_post_var, resolution=25,
**scale_style).pack(fill="x", padx=16, pady=2)
tk.Label(content, text="Wait time after the key combo is released.", **hint_style).pack(anchor="w", padx=16)
section_label("Media Key Timing")
tk.Label(content, text="Media key hold time (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
media_var = tk.IntVar(value=settings.media_hold_ms)
tk.Scale(content, from_=25, to=500, variable=media_var, resolution=25,
**scale_style).pack(fill="x", padx=16, pady=2)
tk.Label(content, text="How long to hold the media key before releasing.", **hint_style).pack(anchor="w", padx=16)
section_label("PC Alive Check")
tk.Label(content, text="Probe timeout (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
probe_var = tk.IntVar(value=settings.probe_timeout_ms)
tk.Scale(content, from_=50, to=1000, variable=probe_var, resolution=25,
**scale_style).pack(fill="x", padx=16, pady=2)
tk.Label(content, text="How long to wait for the host PC to respond\nto a Num Lock toggle.", **hint_style).pack(anchor="w", padx=16)
section_label("Pause Display Margins")
tk.Label(content,
text="Pixel padding around pause-screen text (128x128 LCD).\n"
"Larger = narrower text box, more aggressive truncation.",
**hint_style).pack(anchor="w", padx=16)
tk.Label(content, text="Left margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(6, 0))
pml_var = tk.IntVar(value=settings.pause_margin_left)
tk.Scale(content, from_=0, to=48, variable=pml_var,
**scale_style).pack(fill="x", padx=16, pady=2)
tk.Label(content, text="Right margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
pmr_var = tk.IntVar(value=settings.pause_margin_right)
tk.Scale(content, from_=0, to=48, variable=pmr_var,
**scale_style).pack(fill="x", padx=16, pady=2)
tk.Label(content, text="Top margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
pmt_var = tk.IntVar(value=settings.pause_margin_top)
tk.Scale(content, from_=0, to=64, variable=pmt_var,
**scale_style).pack(fill="x", padx=16, pady=2)
tk.Label(content, text="Bottom margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
pmb_var = tk.IntVar(value=settings.pause_margin_bottom)
tk.Scale(content, from_=0, to=48, variable=pmb_var,
**scale_style).pack(fill="x", padx=16, pady=2)
tk.Label(content,
text="Bottom margin also reserves room for the timer\n"
"progress bar on timed pauses.",
**hint_style).pack(anchor="w", padx=16)
section_label("Mesh (Keyboard hub)")
tk.Label(content,
text="Wi-Fi channel (1-13) the ESP-NOW mesh uses. Every "
"device in the fleet must match. Applied on the device "
"now; change it on each unit if Lag climbs.",
**hint_style).pack(anchor="w", padx=16)
# Seed from the connected device's current channel (best-effort).
_mesh_ch = 1
if self.serial_manager and self.serial_manager.connected:
try:
_png = self.serial_manager.ping()
if _png and isinstance(_png.get("mesh_ch"), int):
_mesh_ch = _png["mesh_ch"]
except Exception:
pass
mesh_ch_var = tk.IntVar(value=_mesh_ch)
tk.Scale(content, from_=1, to=13, variable=mesh_ch_var,
**scale_style).pack(fill="x", padx=16, pady=2)
def save():
# Mesh channel is device-local NVS (not part of the project),
# so push it straight to the device rather than via the project
# settings upload.
if self.serial_manager and self.serial_manager.connected:
try:
self.serial_manager.set_setting("mesh_ch", mesh_ch_var.get())
except Exception:
pass
settings.hold_ms = hold_var.get()
settings.type_delay = delay_var.get()
settings.type_shift_extra_ms = shift_extra_var.get()
settings.type_settle_ms = settle_var.get()
settings.orientation = orient_var.get()
settings.resume_delay = resume_var.get()
settings.combo_pre_ms = combo_pre_var.get()
settings.combo_post_ms = combo_post_var.get()
settings.probe_timeout_ms = probe_var.get()
settings.media_hold_ms = media_var.get()
settings.pause_margin_left = pml_var.get()
settings.pause_margin_right = pmr_var.get()
settings.pause_margin_top = pmt_var.get()
settings.pause_margin_bottom = pmb_var.get()
dialog.destroy()
if self.on_settings_change:
self.on_settings_change()
btn_frame = tk.Frame(dialog, bg="#2D2D3D")
btn_frame.pack(fill="x", side="bottom", pady=10)
tk.Button(btn_frame, text="Save", command=save, bg="#27AE60", fg="white",
font=("Segoe UI", 10, "bold"), relief="flat", padx=20, pady=6).pack()