import tkinter as tk from tkinter import ttk, messagebox import serial import serial.tools.list_ports import threading import time import queue BAUD = 115200 def list_ports(): return [p.device for p in serial.tools.list_ports.comports()] class OctopusGUI: def __init__(self, root: tk.Tk): self.root = root self.root.title("OCTOPUS ABIGAIL🐙") self.root.geometry("860x620") self.root.minsize(820, 580) # ---- Cute pink theme colors ---- self.bg = "#ffe6f2" # soft pink self.panel = "#fff1f8" # lighter panel self.accent = "#ff5fa2" # hot pink self.accent2 = "#ff9cc8" # pastel accent self.text = "#3b2a35" # warm dark self.btn_text = "#ffffff" self.root.configure(bg=self.bg) # Serial self.ser = None self.reader_thread = None self.reader_stop = threading.Event() self.rx_queue = queue.Queue() # ----- Styles ----- style = ttk.Style() try: style.theme_use("clam") except Exception: pass style.configure("TFrame", background=self.bg) style.configure("Card.TFrame", background=self.panel) style.configure("TLabel", background=self.bg, foreground=self.text, font=("Helvetica", 11)) style.configure("Title.TLabel", background=self.bg, foreground=self.text, font=("Helvetica", 18, "bold")) style.configure("CardTitle.TLabel", background=self.panel, foreground=self.text, font=("Helvetica", 12, "bold")) style.configure("TEntry", padding=6) style.configure("TCombobox", padding=4) # We'll use tk.Button for reliable coloring across platforms. # ----- Layout ----- self._build_header() self._build_connection_card() self._build_controls_card() self._build_log_card() self.root.after(50, self._drain_rx_queue) self._refresh_ports() self.root.protocol("WM_DELETE_WINDOW", self.on_close) # ---------------- UI BUILDERS ---------------- def _build_header(self): header = ttk.Frame(self.root) header.pack(fill="x", padx=18, pady=(14, 8)) title = ttk.Label(header, text="OCTOPUS ABIGAIL 🐙", style="Title.TLabel") title.pack(side="left") self.status_var = tk.StringVar(value="Disconnected") status = ttk.Label(header, textvariable=self.status_var) status.pack(side="right") def _card(self, parent, title: str): card = ttk.Frame(parent, style="Card.TFrame") card.pack(fill="x", padx=18, pady=10) t = ttk.Label(card, text=title, style="CardTitle.TLabel") t.pack(anchor="w", padx=14, pady=(12, 8)) inner = ttk.Frame(card, style="Card.TFrame") inner.pack(fill="x", padx=14, pady=(0, 14)) return card, inner def _build_connection_card(self): _, inner = self._card(self.root, "Connection") row = ttk.Frame(inner, style="Card.TFrame") row.pack(fill="x") ttk.Label(row, text="Port:").pack(side="left", padx=(0, 8)) self.port_var = tk.StringVar() self.port_combo = ttk.Combobox(row, textvariable=self.port_var, state="readonly", width=30) self.port_combo.pack(side="left") self.refresh_btn = tk.Button( row, text="Refresh", bg=self.accent2, fg=self.text, activebackground=self.accent, activeforeground=self.btn_text, relief="flat", padx=14, pady=8, command=self._refresh_ports ) self.refresh_btn.pack(side="left", padx=10) self.connect_btn = tk.Button( row, text="Connect", bg=self.accent, fg=self.btn_text, activebackground="#ff2f86", activeforeground=self.btn_text, relief="flat", padx=18, pady=8, command=self.connect ) self.connect_btn.pack(side="left") self.disconnect_btn = tk.Button( row, text="Disconnect", bg="#ffd1e6", fg=self.text, activebackground=self.accent2, activeforeground=self.text, relief="flat", padx=14, pady=8, command=self.disconnect, state="disabled" ) self.disconnect_btn.pack(side="left", padx=10) ttk.Label(inner, text="Baud: 115200").pack(anchor="w", pady=(10, 0)) def _build_controls_card(self): _, inner = self._card(self.root, "Controls") # Row of main command buttons btn_row = ttk.Frame(inner, style="Card.TFrame") btn_row.pack(fill="x", pady=(0, 10)) def cmd_button(text, command, primary=False): bg = self.accent if primary else self.accent2 fg = self.btn_text if primary else self.text return tk.Button( btn_row, text=text, command=command, bg=bg, fg=fg, activebackground="#ff2f86" if primary else self.accent, activeforeground=self.btn_text, relief="flat", padx=18, pady=10, font=("Helvetica", 11, "bold") ) self.btn_grab = cmd_button("Grab", lambda: self.send_line("grab")) self.btn_rest = cmd_button("Rest", lambda: self.send_line("rest")) self.btn_curl = cmd_button("Curl", lambda: self.send_line("curl")) self.btn_zero = cmd_button("Zero", lambda: self.send_line("zero")) self.btn_pos = cmd_button("Pos", lambda: self.send_line("pos")) self.btn_stop = cmd_button("STOP", lambda: self.send_line("stop")) for b in [self.btn_grab, self.btn_rest, self.btn_curl, self.btn_zero, self.btn_pos, self.btn_stop]: b.pack(side="left", padx=6) # Dual motor move row dual = ttk.Frame(inner, style="Card.TFrame") dual.pack(fill="x", pady=(8, 10)) ttk.Label(dual, text="Move both motors (relative rev):").pack(side="left", padx=(0, 10)) ttk.Label(dual, text="M1:").pack(side="left") self.m1_var = tk.StringVar(value="0.0") self.m1_entry = ttk.Entry(dual, textvariable=self.m1_var, width=10) self.m1_entry.pack(side="left", padx=(6, 16)) ttk.Label(dual, text="M2:").pack(side="left") self.m2_var = tk.StringVar(value="0.0") self.m2_entry = ttk.Entry(dual, textvariable=self.m2_var, width=10) self.m2_entry.pack(side="left", padx=(6, 16)) self.send_dual_btn = tk.Button( dual, text="Send Move", bg=self.accent, fg=self.btn_text, activebackground="#ff2f86", activeforeground=self.btn_text, relief="flat", padx=16, pady=9, command=self.send_dual_move ) self.send_dual_btn.pack(side="left") # Manual command entry manual = ttk.Frame(inner, style="Card.TFrame") manual.pack(fill="x", pady=(6, 0)) ttk.Label(manual, text="Manual command:").pack(side="left", padx=(0, 10)) self.manual_var = tk.StringVar() self.manual_entry = ttk.Entry(manual, textvariable=self.manual_var) self.manual_entry.pack(side="left", fill="x", expand=True, padx=(0, 10)) self.send_manual_btn = tk.Button( manual, text="Send", bg=self.accent2, fg=self.text, activebackground=self.accent, activeforeground=self.btn_text, relief="flat", padx=16, pady=9, command=self.send_manual ) self.send_manual_btn.pack(side="left") self.manual_entry.bind("", lambda e: self.send_manual()) def _build_log_card(self): _, inner = self._card(self.root, "Serial Monitor") top = ttk.Frame(inner, style="Card.TFrame") top.pack(fill="x", pady=(0, 8)) self.clear_btn = tk.Button( top, text="Clear", bg="#ffd1e6", fg=self.text, activebackground=self.accent2, activeforeground=self.text, relief="flat", padx=14, pady=8, command=self.clear_log ) self.clear_btn.pack(side="left") self.autoscroll_var = tk.BooleanVar(value=True) self.autoscroll_check = tk.Checkbutton( top, text="Auto-scroll", variable=self.autoscroll_var, bg=self.panel, fg=self.text, activebackground=self.panel, selectcolor=self.panel ) self.autoscroll_check.pack(side="left", padx=12) self.log = tk.Text( inner, height=14, wrap="word", bg="#fff8fc", fg=self.text, insertbackground=self.text, relief="flat", padx=10, pady=10, font=("Menlo", 11) ) self.log.pack(fill="both", expand=True) # ---------------- SERIAL ---------------- def _refresh_ports(self): ports = list_ports() if not ports: ports = ["(no ports found)"] self.port_combo["values"] = ports if self.port_var.get() not in ports: self.port_var.set(ports[0]) def connect(self): port = self.port_var.get().strip() if not port or port == "(no ports found)": messagebox.showerror("No port", "No serial port selected/found.") return try: self.ser = serial.Serial(port, BAUD, timeout=0.1) time.sleep(0.2) # allow ESP32 to reset / enumerate except serial.SerialException as e: self.ser = None messagebox.showerror("Connection error", str(e)) return self.status_var.set(f"Connected: {port}") self.connect_btn.config(state="disabled") self.disconnect_btn.config(state="normal") self._log_line(f"[GUI] Connected to {port} @ {BAUD}") self.reader_stop.clear() self.reader_thread = threading.Thread(target=self._reader_loop, daemon=True) self.reader_thread.start() def disconnect(self): self.reader_stop.set() if self.reader_thread and self.reader_thread.is_alive(): try: self.reader_thread.join(timeout=0.4) except Exception: pass if self.ser: try: self.ser.close() except Exception: pass self.ser = None self.status_var.set("Disconnected") self.connect_btn.config(state="normal") self.disconnect_btn.config(state="disabled") self._log_line("[GUI] Disconnected") def _reader_loop(self): while not self.reader_stop.is_set(): try: if self.ser and self.ser.in_waiting: data = self.ser.read(self.ser.in_waiting) if data: try: text = data.decode(errors="replace") except Exception: text = str(data) self.rx_queue.put(text) else: time.sleep(0.02) except Exception as e: self.rx_queue.put(f"\n[GUI] Serial read error: {e}\n") break def _drain_rx_queue(self): try: while True: chunk = self.rx_queue.get_nowait() self._log(chunk) except queue.Empty: pass self.root.after(50, self._drain_rx_queue) # ---------------- COMMAND SENDING ---------------- def send_line(self, s: str): if not self.ser or not self.ser.is_open: messagebox.showwarning("Not connected", "Connect to a serial port first.") return msg = (s.strip() + "\n").encode() try: self.ser.write(msg) self._log_line(f"> {s.strip()}") except Exception as e: messagebox.showerror("Send error", str(e)) def send_dual_move(self): # Sends: "1 , 2 " try: m1 = float(self.m1_var.get().strip()) m2 = float(self.m2_var.get().strip()) except ValueError: messagebox.showerror("Bad input", "Enter numeric values for M1 and M2 (e.g., 0.25, -0.5).") return self.send_line(f"1 {m1}, 2 {m2}") def send_manual(self): s = self.manual_var.get().strip() if not s: return self.send_line(s) self.manual_var.set("") # ---------------- LOG ---------------- def clear_log(self): self.log.delete("1.0", "end") def _log(self, s: str): self.log.insert("end", s) if self.autoscroll_var.get(): self.log.see("end") def _log_line(self, s: str): self._log(s + "\n") # ---------------- CLOSE ---------------- def on_close(self): try: self.disconnect() finally: self.root.destroy() if __name__ == "__main__": root = tk.Tk() app = OctopusGUI(root) root.mainloop()