From ef5437ed3606da0c5a0e2efc5e8e73e8631dbb9f Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Sat, 19 Sep 2026 13:28:17 +0300 Subject: [PATCH] Add DASP device discovery to the connect dialog Send DISCOVER broadcast (type 0) to 255.255.255.255 and the Sedona multicast address 239.255.18.76 to find devices on the network. Responses are listed in a Treeview showing IP and platform ID. Selecting a device fills the Host field; double-click connects. Wait time is configurable (default 10s, range 1-60s). Co-Authored-By: Claude Opus 4.6 --- app_state.py | 2 +- connect_dialog.py | 124 ++++++++++++++++++++++++++++++++++++++++++++-- dasp.py | 47 ++++++++++++++++++ 3 files changed, 169 insertions(+), 4 deletions(-) diff --git a/app_state.py b/app_state.py index 4ce8b9b..bc29ef2 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.096" +VERSION = "0.0.0.097" 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 47813f6..4ac0569 100644 --- a/connect_dialog.py +++ b/connect_dialog.py @@ -1,8 +1,10 @@ """ Connection dialog for connecting to a live Sedona device. -Modal Toplevel with host, port, username, password fields. -Connect runs in a background thread; progress and errors shown inline. +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. """ import configparser @@ -20,6 +22,7 @@ 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") @@ -56,6 +59,49 @@ 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)) @@ -79,6 +125,77 @@ class ConnectDialog: y = parent.winfo_y() + (parent.winfo_height() - self._dlg.winfo_height()) // 2 self._dlg.geometry(f"+{x}+{y}") + # ── Discovery ────────────────────────────────────────────────── + + def _do_discover(self): + try: + port_int = int(self._port_var.get().strip()) + except ValueError: + port_int = 1876 + + try: + wait_time = max(1, min(60, int(self._wait_var.get().strip()))) + except ValueError: + wait_time = 10 + + self._btn_discover.config(state="disabled") + self._status_label.config(text="Discovering devices...", foreground="#666666") + + for item in self._dev_tree.get_children(): + self._dev_tree.delete(item) + + self._discover_thread = threading.Thread( + target=self._discover_worker, args=(port_int, wait_time), daemon=True) + self._discover_thread.start() + self._poll_discover() + + def _discover_worker(self, port, wait_time): + from dasp import DaspSocket + sock = None + try: + sock = DaspSocket() + devices = sock.discover(port=port, wait_time=wait_time) + self._discover_result = ("ok", devices) + except Exception as e: + self._discover_result = ("error", str(e)) + finally: + if sock is not None: + try: + sock.close() + except Exception: + pass + + def _poll_discover(self): + if self._discover_thread is not None and self._discover_thread.is_alive(): + self._dlg.after(100, self._poll_discover) + return + + status, data = self._discover_result + self._btn_discover.config(state="normal") + + if status == "ok": + if data: + for ip, platform_id in data: + self._dev_tree.insert("", tk.END, values=(ip, platform_id)) + self._status_label.config( + text=f"Found {len(data)} device(s)", foreground="#006600") + else: + 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): + sel = self._dev_tree.selection() + if not sel: + return + values = self._dev_tree.item(sel[0], "values") + if values: + self._host_var.set(values[0]) + + # ── Connect ──────────────────────────────────────────────────── + def _do_connect(self): host = self._host_var.get().strip() port = self._port_var.get().strip() @@ -96,6 +213,7 @@ class ConnectDialog: return self._btn_connect.config(state="disabled") + self._btn_discover.config(state="disabled") self._status_label.config(text="Connecting...", foreground="#666666") self._cancelled = False @@ -134,11 +252,11 @@ 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 if self._thread is not None and self._thread.is_alive(): - # Connection attempt in progress — clean up after it finishes import app_state as _as def _cleanup(): self._thread.join(timeout=5) diff --git a/dasp.py b/dasp.py index 2ea59cf..e20acb0 100644 --- a/dasp.py +++ b/dasp.py @@ -945,8 +945,55 @@ class DaspSocket: continue break + def discover(self, port=DEFAULT_PORT, wait_time=3): + """Send DASP DISCOVER broadcast and collect responding devices. + + Returns a list of (ip_address, platform_id) tuples. + """ + self._discovered = [] + self._discover_active = True + + msg = DaspMsg() + msg.msg_type = DISCOVER + msg.session_id = 0xFFFF + msg.seq_num = 0xFFFF + msg.include_message_len = False + data = msg.encode() + + with self._send_lock: + try: + self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + except OSError: + pass + try: + self._sock.sendto(data, ("255.255.255.255", port)) + except OSError: + pass + # also try the multicast address used by the Sedona spec + try: + self._sock.sendto(data, ("239.255.18.76", port)) + except OSError: + pass + + time.sleep(wait_time) + self._discover_active = False + + seen = set() + result = [] + for addr, pid in self._discovered: + if addr not in seen: + seen.add(addr) + result.append((addr, pid or "")) + return result + def _dispatch(self, msg, addr, raw=None): _trace("R", msg, raw) + + # Handle DISCOVER responses (from devices replying to our broadcast) + if msg.msg_type == DISCOVER and getattr(self, '_discover_active', False): + self._discovered.append((addr[0], msg.platform_id)) + return + session_id = msg.session_id with self._sessions_lock: session = self._sessions.get(session_id)