Add Device > Discover menu with standalone discovery dialog
Discover dialog shows a device list with Port, Wait Time fields and a Discover button. Selecting a device and clicking Connect (or double- clicking) opens a credentials prompt for username/password, then connects. The Connect dialog is reverted to its original simple form. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+246
-66
@@ -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("<<TreeviewSelect>>", self._on_device_selected)
|
||||
self._dev_tree.bind("<Double-1>", 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("<Double-1>", 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("<Escape>", 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("<Return>", lambda e: self._do_connect())
|
||||
self._dlg.bind("<Escape>", 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()
|
||||
|
||||
Reference in New Issue
Block a user