"""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("<>", 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("", 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("", _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("", 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("", lambda e: canvas.itemconfig("inner", width=e.width)) canvas.bind_all("", 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()