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)