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:
2026-09-19 13:42:18 +03:00
co-authored by Claude Opus 4.6
parent ef5437ed36
commit 55f76d60df
3 changed files with 271 additions and 67 deletions
+246 -66
View File
@@ -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()