diff --git a/app_state.py b/app_state.py index bc29ef2..32d376d 100644 --- a/app_state.py +++ b/app_state.py @@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it. import configparser import os -VERSION = "0.0.0.097" +VERSION = "0.0.0.098" PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") DEFAULT_GRID_SIZE = 10 DEFAULT_ADD_TIMESTAMP = True diff --git a/connect_dialog.py b/connect_dialog.py index 4ac0569..1bef16a 100644 --- a/connect_dialog.py +++ b/connect_dialog.py @@ -1,10 +1,9 @@ """ -Connection dialog for connecting to a live Sedona device. +Connection and discovery dialogs for live Sedona devices. -Modal Toplevel with host, port, username, password fields and device -discovery. Connect runs in a background thread; progress and errors -shown inline. The Discover button sends a DASP broadcast and lists -responding devices so the user can pick one. +ConnectDialog — manual host/port/user/password entry (Device > Connect). +DiscoverDialog — DASP broadcast discovery with device list; selecting a +device opens a credentials prompt, then connects (Device > Discover). """ import configparser @@ -15,6 +14,10 @@ from tkinter import ttk import app_state +# ──────────────────────────────────────────────────────────────────── +# ConnectDialog (Device > Connect…) +# ──────────────────────────────────────────────────────────────────── + class ConnectDialog: """Modal dialog for entering device connection parameters.""" @@ -22,7 +25,6 @@ class ConnectDialog: self._callback = on_connect_callback self._thread = None self._cancelled = False - self._discover_thread = None self._dlg = tk.Toplevel(parent) self._dlg.title("Connect to Device") @@ -59,49 +61,6 @@ class ConnectDialog: ttk.Entry(frame, textvariable=self._pass_var, width=28, show="*").grid( row=row, column=1, columnspan=2, sticky="ew", pady=4) - # ── Discovery section ────────────────────────────────────── - row += 1 - sep = ttk.Separator(frame, orient="horizontal") - sep.grid(row=row, column=0, columnspan=3, sticky="ew", pady=(12, 4)) - - row += 1 - disc_header = ttk.Frame(frame) - disc_header.grid(row=row, column=0, columnspan=3, sticky="ew", pady=(0, 4)) - - ttk.Label(disc_header, text="Discovered Devices:").pack(side=tk.LEFT) - - self._btn_discover = ttk.Button( - disc_header, text="Discover", command=self._do_discover, width=10) - self._btn_discover.pack(side=tk.RIGHT) - - self._wait_var = tk.StringVar(value="10") - wait_entry = ttk.Entry(disc_header, textvariable=self._wait_var, width=4, - justify="center") - wait_entry.pack(side=tk.RIGHT, padx=(0, 4)) - ttk.Label(disc_header, text="Wait (s):").pack(side=tk.RIGHT, padx=(8, 2)) - - row += 1 - list_frame = ttk.Frame(frame) - list_frame.grid(row=row, column=0, columnspan=3, sticky="nsew", pady=(0, 4)) - frame.rowconfigure(row, weight=1) - - columns = ("ip", "platform") - self._dev_tree = ttk.Treeview( - list_frame, columns=columns, show="headings", height=5, selectmode="browse") - self._dev_tree.heading("ip", text="IP Address") - self._dev_tree.heading("platform", text="Platform ID") - self._dev_tree.column("ip", width=140, minwidth=100) - self._dev_tree.column("platform", width=180, minwidth=100) - self._dev_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - - scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self._dev_tree.yview) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - self._dev_tree.configure(yscrollcommand=scrollbar.set) - - self._dev_tree.bind("<>", self._on_device_selected) - self._dev_tree.bind("", lambda e: self._do_connect()) - - # ── Status and buttons ───────────────────────────────────── row += 1 self._status_label = ttk.Label(frame, text="", foreground="#666666") self._status_label.grid(row=row, column=0, columnspan=3, sticky="w", pady=(8, 4)) @@ -125,6 +84,166 @@ class ConnectDialog: y = parent.winfo_y() + (parent.winfo_height() - self._dlg.winfo_height()) // 2 self._dlg.geometry(f"+{x}+{y}") + def _do_connect(self): + host = self._host_var.get().strip() + port = self._port_var.get().strip() + user = self._user_var.get().strip() + password = self._pass_var.get() + + if not host: + self._status_label.config(text="Host is required", foreground="#c00000") + return + + try: + port_int = int(port) + except ValueError: + self._status_label.config(text="Invalid port number", foreground="#c00000") + return + + self._btn_connect.config(state="disabled") + self._status_label.config(text="Connecting...", foreground="#666666") + + self._cancelled = False + self._thread = threading.Thread( + target=self._connect_worker, + args=(host, port_int, user, password), + daemon=True + ) + self._thread.start() + self._poll_connect() + + def _connect_worker(self, host, port, user, password): + try: + result = self._callback(host, port, user, password) + if self._cancelled: + return + self._result = ("ok", result, host, port, user) + except Exception as e: + if self._cancelled: + return + self._result = ("error", str(e), host, port, user) + + def _poll_connect(self): + if self._cancelled: + return + if self._thread is not None and self._thread.is_alive(): + self._dlg.after(100, self._poll_connect) + return + + status, *rest = self._result + if status == "ok": + _, host, port, user = rest + _save_last_connection(host, str(port), user) + self._dlg.destroy() + else: + msg = rest[0] + self._status_label.config(text=f"Error: {msg}", foreground="#c00000") + self._btn_connect.config(state="normal") + + def _do_cancel(self): + self._cancelled = True + if self._thread is not None and self._thread.is_alive(): + import app_state as _as + def _cleanup(): + self._thread.join(timeout=5) + if _as.device_session is not None and _as.live_mode: + try: + _as.device_session.disconnect() + except Exception: + pass + _as.live_mode = False + _as.device_session = None + _as.connection_status = "offline" + threading.Thread(target=_cleanup, daemon=True).start() + self._dlg.destroy() + + +# ──────────────────────────────────────────────────────────────────── +# DiscoverDialog (Device > Discover…) +# ──────────────────────────────────────────────────────────────────── + +class DiscoverDialog: + """Modal dialog that broadcasts DASP DISCOVER, lists responding + devices, and lets the user pick one to connect to.""" + + def __init__(self, parent, on_connect_callback): + self._parent = parent + self._callback = on_connect_callback + self._discover_thread = None + + self._dlg = tk.Toplevel(parent) + self._dlg.title("Discover Devices") + self._dlg.resizable(False, False) + self._dlg.transient(parent) + self._dlg.grab_set() + + frame = ttk.Frame(self._dlg, padding=16) + frame.pack(fill=tk.BOTH, expand=True) + + # ── Header row: label + wait + discover button ───────────── + row = 0 + hdr = ttk.Frame(frame) + hdr.grid(row=row, column=0, sticky="ew", pady=(0, 8)) + + ttk.Label(hdr, text="Discovered Devices:").pack(side=tk.LEFT) + + self._btn_discover = ttk.Button( + hdr, text="Discover", command=self._do_discover, width=10) + self._btn_discover.pack(side=tk.RIGHT) + + self._wait_var = tk.StringVar(value="10") + ttk.Entry(hdr, textvariable=self._wait_var, width=4, justify="center").pack( + side=tk.RIGHT, padx=(0, 4)) + ttk.Label(hdr, text="Wait (s):").pack(side=tk.RIGHT, padx=(8, 2)) + + _, port, _ = _load_last_connection() + self._port_var = tk.StringVar(value=port) + ttk.Entry(hdr, textvariable=self._port_var, width=6, justify="center").pack( + side=tk.RIGHT, padx=(0, 4)) + ttk.Label(hdr, text="Port:").pack(side=tk.RIGHT, padx=(8, 2)) + + # ── Device list ──────────────────────────────────────────── + row += 1 + list_frame = ttk.Frame(frame) + list_frame.grid(row=row, column=0, sticky="nsew", pady=(0, 4)) + frame.rowconfigure(row, weight=1) + + columns = ("ip", "platform") + self._dev_tree = ttk.Treeview( + list_frame, columns=columns, show="headings", height=6, selectmode="browse") + self._dev_tree.heading("ip", text="IP Address") + self._dev_tree.heading("platform", text="Platform ID") + self._dev_tree.column("ip", width=150, minwidth=100) + self._dev_tree.column("platform", width=220, minwidth=120) + self._dev_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self._dev_tree.yview) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self._dev_tree.configure(yscrollcommand=scrollbar.set) + + self._dev_tree.bind("", lambda e: self._do_connect_selected()) + + # ── Status + buttons ─────────────────────────────────────── + row += 1 + self._status_label = ttk.Label(frame, text="", foreground="#666666") + self._status_label.grid(row=row, column=0, sticky="w", pady=(8, 4)) + + row += 1 + btn_frame = ttk.Frame(frame) + btn_frame.grid(row=row, column=0, sticky="e", pady=(8, 0)) + + ttk.Button(btn_frame, text="Cancel", command=self._dlg.destroy).pack(side=tk.RIGHT) + self._btn_connect = ttk.Button( + btn_frame, text="Connect", command=self._do_connect_selected) + self._btn_connect.pack(side=tk.RIGHT, padx=(0, 8)) + + self._dlg.bind("", lambda e: self._dlg.destroy()) + + self._dlg.update_idletasks() + x = parent.winfo_x() + (parent.winfo_width() - self._dlg.winfo_width()) // 2 + y = parent.winfo_y() + (parent.winfo_height() - self._dlg.winfo_height()) // 2 + self._dlg.geometry(f"+{x}+{y}") + # ── Discovery ────────────────────────────────────────────────── def _do_discover(self): @@ -180,46 +299,104 @@ class ConnectDialog: self._status_label.config( text=f"Found {len(data)} device(s)", foreground="#006600") else: - self._status_label.config( - text="No devices found", foreground="#666666") + self._status_label.config(text="No devices found", foreground="#666666") else: self._status_label.config( text=f"Discovery error: {data}", foreground="#c00000") - def _on_device_selected(self, event): + # ── Connect selected device ──────────────────────────────────── + + def _do_connect_selected(self): sel = self._dev_tree.selection() if not sel: + self._status_label.config(text="Select a device first", foreground="#c00000") return values = self._dev_tree.item(sel[0], "values") - if values: - self._host_var.set(values[0]) + host = values[0] + port = self._port_var.get().strip() + self._dlg.destroy() + _CredentialsDialog(self._parent, self._callback, host, port) - # ── Connect ──────────────────────────────────────────────────── + +# ──────────────────────────────────────────────────────────────────── +# _CredentialsDialog (username / password prompt after discovery) +# ──────────────────────────────────────────────────────────────────── + +class _CredentialsDialog: + """Small modal that asks for username and password, then connects.""" + + def __init__(self, parent, on_connect_callback, host, port): + self._callback = on_connect_callback + self._host = host + self._port = port + self._thread = None + self._cancelled = False + + _, _, last_user = _load_last_connection() + + self._dlg = tk.Toplevel(parent) + self._dlg.title(f"Connect to {host}") + self._dlg.resizable(False, False) + self._dlg.transient(parent) + self._dlg.grab_set() + + frame = ttk.Frame(self._dlg, padding=16) + frame.pack(fill=tk.BOTH, expand=True) + + row = 0 + ttk.Label(frame, text="Host:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4) + ttk.Label(frame, text=f"{host}:{port}").grid(row=row, column=1, sticky="w", pady=4) + + row += 1 + ttk.Label(frame, text="Username:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4) + self._user_var = tk.StringVar(value=last_user) + user_entry = ttk.Entry(frame, textvariable=self._user_var, width=24) + user_entry.grid(row=row, column=1, sticky="ew", pady=4) + + row += 1 + ttk.Label(frame, text="Password:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4) + self._pass_var = tk.StringVar() + ttk.Entry(frame, textvariable=self._pass_var, width=24, show="*").grid( + row=row, column=1, sticky="ew", pady=4) + + row += 1 + self._status_label = ttk.Label(frame, text="", foreground="#666666") + self._status_label.grid(row=row, column=0, columnspan=2, sticky="w", pady=(8, 4)) + + row += 1 + btn_frame = ttk.Frame(frame) + btn_frame.grid(row=row, column=0, columnspan=2, sticky="e", pady=(8, 0)) + + ttk.Button(btn_frame, text="Cancel", command=self._do_cancel).pack(side=tk.RIGHT) + self._btn_connect = ttk.Button(btn_frame, text="Connect", command=self._do_connect) + self._btn_connect.pack(side=tk.RIGHT, padx=(0, 8)) + + user_entry.focus_set() + self._dlg.bind("", lambda e: self._do_connect()) + self._dlg.bind("", lambda e: self._do_cancel()) + + self._dlg.update_idletasks() + x = parent.winfo_x() + (parent.winfo_width() - self._dlg.winfo_width()) // 2 + y = parent.winfo_y() + (parent.winfo_height() - self._dlg.winfo_height()) // 2 + self._dlg.geometry(f"+{x}+{y}") def _do_connect(self): - host = self._host_var.get().strip() - port = self._port_var.get().strip() user = self._user_var.get().strip() password = self._pass_var.get() - if not host: - self._status_label.config(text="Host is required", foreground="#c00000") - return - try: - port_int = int(port) + port_int = int(self._port) except ValueError: - self._status_label.config(text="Invalid port number", foreground="#c00000") + self._status_label.config(text="Invalid port", foreground="#c00000") return self._btn_connect.config(state="disabled") - self._btn_discover.config(state="disabled") self._status_label.config(text="Connecting...", foreground="#666666") self._cancelled = False self._thread = threading.Thread( target=self._connect_worker, - args=(host, port_int, user, password), + args=(self._host, port_int, user, password), daemon=True ) self._thread.start() @@ -252,7 +429,6 @@ class ConnectDialog: msg = rest[0] self._status_label.config(text=f"Error: {msg}", foreground="#c00000") self._btn_connect.config(state="normal") - self._btn_discover.config(state="normal") def _do_cancel(self): self._cancelled = True @@ -272,6 +448,10 @@ class ConnectDialog: self._dlg.destroy() +# ──────────────────────────────────────────────────────────────────── +# Persistence helpers +# ──────────────────────────────────────────────────────────────────── + def _load_last_connection(): """Reads last connection params from editor.properties.""" config = configparser.ConfigParser() diff --git a/main.py b/main.py index 623bf2b..0a592b3 100644 --- a/main.py +++ b/main.py @@ -1960,6 +1960,27 @@ def do_device_connect(): connect_dialog.ConnectDialog(app_state.window, _connect_to_device) +def do_device_discover(): + """Opens the discover dialog to scan for devices on the network.""" + if app_state.current_file_path and not app_state.live_mode: + if app_state.is_dirty: + answer = messagebox.askyesnocancel( + "Unsaved Changes", + "The current file has unsaved changes.\n\n" + "Save before connecting to a device?", + ) + if answer is None: + return + if answer: + sax_file.execute_direct_overwrite_save() + sax_file.reset_to_startup_state() + sax_file.refresh_treeview_from_dom(None) + wiresheet.render_components([]) + app_state.window.title(app_state.base_window_title()) + + connect_dialog.DiscoverDialog(app_state.window, _connect_to_device) + + def _connect_to_device(host, port, user, password): """Called from the connect dialog's background thread.""" ds = device_session.DeviceSession() @@ -1979,6 +2000,7 @@ def _on_connected(ds, root_comp): """Main-thread callback after successful connect.""" app_state.window.title(app_state.base_window_title_live()) menu_device.entryconfig("Connect...", state="disabled") + menu_device.entryconfig("Discover...", state="disabled") menu_device.entryconfig("Disconnect", state="normal") menu_device.entryconfig("Download to SAX...", state="normal") app_state.set_status(f"Connected to {app_state.device_host}:{app_state.device_port}") @@ -2044,6 +2066,7 @@ def do_device_disconnect(): app_state.window.title(app_state.base_window_title()) menu_device.entryconfig("Connect...", state="normal") + menu_device.entryconfig("Discover...", state="normal") menu_device.entryconfig("Disconnect", state="disabled") menu_device.entryconfig("Download to SAX...", state="disabled") app_state.set_status("Disconnected") @@ -2222,6 +2245,7 @@ menu_bar = tk.Menu(window) menu_device = tk.Menu(menu_bar, tearoff=0) menu_device.add_command(label="Connect...", command=do_device_connect) +menu_device.add_command(label="Discover...", command=do_device_discover) menu_device.add_command(label="Disconnect", command=do_device_disconnect, state="disabled") menu_device.add_separator() menu_device.add_command(label="Download to SAX...", command=do_download_to_sax, state="disabled")