#!/usr/bin/env python3 """ protocol_tool_gui.py -- point-and-click front end for protocol_tool.py. Lets you pick a serial port and a protocol file with dialogs/dropdowns instead of typing command-line flags. """ import json import os import queue import threading import tkinter as tk from tkinter import filedialog, messagebox, scrolledtext, ttk import serial.tools.list_ports import protocol_tool as pt SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) # Falls back to the Protocols-x folder next to whichever copy of this script # is actually running, instead of a hardcoded path to one specific project # version folder -- each version copy should default into its own data. FALLBACK_START_DIR = os.path.join(SCRIPT_DIR, "Protocols-x") if not os.path.isdir(FALLBACK_START_DIR): FALLBACK_START_DIR = SCRIPT_DIR # Remembers the last folder actually used, across runs and across which # version copy of the script gets launched -- lives in the user's home # directory rather than next to the script, since it tracks the human's # last working folder, not a per-copy setting. SETTINGS_PATH = os.path.expanduser("~/.protocol_tool_gui_settings.json") def _load_last_dir(): try: with open(SETTINGS_PATH, "r", encoding="utf-8") as f: last_dir = json.load(f).get("last_dir") except (FileNotFoundError, json.JSONDecodeError, OSError): return FALLBACK_START_DIR return last_dir if last_dir and os.path.isdir(last_dir) else FALLBACK_START_DIR def _save_last_dir(directory): try: with open(SETTINGS_PATH, "w", encoding="utf-8") as f: json.dump({"last_dir": directory}, f) except OSError: pass # not remembering the folder isn't worth failing the operation over class ProtocolToolGUI(tk.Tk): def __init__(self): super().__init__() self.title("ESP32 Generator Protocol Tool") self.geometry("640x480") self.minsize(560, 400) self.event_queue = queue.Queue() self.worker_thread = None self.last_dir = _load_last_dir() self._build_widgets() self._refresh_ports() self.after(100, self._poll_events) # ----------------------------------------------------------- def _build_widgets(self): pad = {"padx": 8, "pady": 6} port_frame = ttk.Frame(self) port_frame.pack(fill="x", **pad) ttk.Label(port_frame, text="Serial port:").pack(side="left") self.port_var = tk.StringVar(value=pt.PORT_DEFAULT) self.port_combo = ttk.Combobox(port_frame, textvariable=self.port_var, width=25) self.port_combo.pack(side="left", padx=6) ttk.Button(port_frame, text="Refresh", command=self._refresh_ports).pack(side="left") file_frame = ttk.Frame(self) file_frame.pack(fill="x", **pad) ttk.Label(file_frame, text="Protocol file:").pack(side="left") self.file_var = tk.StringVar() ttk.Entry(file_frame, textvariable=self.file_var, width=45).pack( side="left", padx=6, fill="x", expand=True ) ttk.Button(file_frame, text="Browse...", command=self._browse_file).pack(side="left") # The buttons are worded from the PC's point of view, to mirror the # Generator's own menu rather than fight it. The Generator says "Send To # CPU" and "Receive Fm CPU"; the same two transfers seen from this end # are "Receive Fm Generator" and "Send to Generator". So each button # here pairs with the opposite-sounding option on the Generator, which # is what you would expect of the two ends of one cable. "Download" and # "Upload" survive only as the internal method and protocol names. action_frame = ttk.Frame(self) action_frame.pack(fill="x", **pad) self.download_btn = ttk.Button( # Generator -> PC action_frame, text="Receive Fm Generator", command=self._start_download ) self.download_btn.pack(side="left", padx=6) self.upload_btn = ttk.Button( # PC -> Generator action_frame, text="Send to Generator", command=self._start_upload ) self.upload_btn.pack(side="left", padx=6) ttk.Label(self, text="Activity log:").pack(anchor="w", padx=8) self.log_widget = scrolledtext.ScrolledText( self, height=18, state="disabled", font=("Courier", 10) ) self.log_widget.pack(fill="both", expand=True, padx=8, pady=(0, 8)) self.status_var = tk.StringVar(value="Ready.") ttk.Label(self, textvariable=self.status_var, anchor="w").pack( fill="x", padx=8, pady=(0, 8) ) # ----------------------------------------------------------- def _refresh_ports(self): ports = [p.device for p in serial.tools.list_ports.comports()] self.port_combo["values"] = ports if ports and self.port_var.get() not in ports: self.port_var.set(ports[0]) def _browse_file(self): # askopenfilename, not asksaveasfilename: in both directions you're # picking among files that already exist on the PC. asksaveasfilename's # built-in "overwrite this file?" prompt was misleading for upload -- # it implied picking the source file would overwrite it, when upload # only ever overwrites whatever's active on the generator (see the # explicit confirm dialogs in _start_upload/_start_download instead). path = filedialog.askopenfilename( title="Choose protocol file", initialdir=self.last_dir, filetypes=[("Protocol files", "*.txt"), ("All files", "*.*")], ) if path: self.file_var.set(path) self.last_dir = os.path.dirname(path) _save_last_dir(self.last_dir) # ----------------------------------------------------------- # Everything below the worker-thread line only ever touches Tk widgets # from the main thread via the queue + after() polling loop -- Tk is # not safe to call into directly from a background thread. # ----------------------------------------------------------- def _log_from_worker(self, message): self.event_queue.put(("log", message)) def _poll_events(self): while True: try: event = self.event_queue.get_nowait() except queue.Empty: break if event[0] == "log": self._append_log(event[1]) elif event[0] == "done": _, result, label = event ok = result == 0 self._append_log(f"--- {label} {'succeeded' if ok else 'failed'} ---") self._set_busy(False, f"{label} {'succeeded' if ok else 'failed'}.") self.after(100, self._poll_events) def _append_log(self, message): self.log_widget.configure(state="normal") self.log_widget.insert("end", message + "\n") self.log_widget.see("end") self.log_widget.configure(state="disabled") def _set_busy(self, busy, status_text): state = "disabled" if busy else "normal" self.download_btn.configure(state=state) self.upload_btn.configure(state=state) self.status_var.set(status_text) def _start_download(self): if not self._check_ready(): return # Kept deliberately plain -- a less technical user shouldn't need to # parse an explanation of protocols-0.txt/SD-card routing just to # click a button. Full technical detail belongs in the docs, not # in a popup. See protocol_tool.py's module docstring / project docs # for what "download" and "upload" actually do under the hood. if messagebox.askyesno("Confirm", "Confirm to Proceed with Receive?"): self._run_in_background(self._worker_download, "Receiving from Generator...") def _start_upload(self): if not self._check_ready(): return if messagebox.askyesno("Confirm", "Confirm to Proceed with Send?"): self._run_in_background(self._worker_upload, "Sending to Generator...") def _check_ready(self): if self.worker_thread and self.worker_thread.is_alive(): messagebox.showwarning("Busy", "An operation is already in progress.") return False if not self.file_var.get(): messagebox.showerror("No file selected", "Choose a protocol file first.") return False if not self.port_var.get(): messagebox.showerror("No port selected", "Choose a serial port first.") return False # Also remember hand-typed paths, not just ones picked via Browse. typed_dir = os.path.dirname(self.file_var.get()) if typed_dir and os.path.isdir(typed_dir) and typed_dir != self.last_dir: self.last_dir = typed_dir _save_last_dir(self.last_dir) return True def _run_in_background(self, target, status_text): self.log_widget.configure(state="normal") self.log_widget.delete("1.0", "end") self.log_widget.configure(state="disabled") self._set_busy(True, status_text) self.worker_thread = threading.Thread(target=target, daemon=True) self.worker_thread.start() def _worker_download(self): path = self.file_var.get() port = self.port_var.get() try: result = pt.do_download(path, port, log=self._log_from_worker) except Exception as e: self._log_from_worker(f"Unexpected error: {e}") result = 1 self.event_queue.put(("done", result, "Receive")) def _worker_upload(self): path = self.file_var.get() port = self.port_var.get() try: result = pt.do_upload(path, port, log=self._log_from_worker) except Exception as e: self._log_from_worker(f"Unexpected error: {e}") result = 1 self.event_queue.put(("done", result, "Send")) def main(): app = ProtocolToolGUI() app.mainloop() if __name__ == "__main__": main()