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 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 13:28:17 +03:00
co-authored by Claude Opus 4.6
parent f3692e4587
commit ef5437ed36
3 changed files with 169 additions and 4 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser import configparser
import os 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") PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10 DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True DEFAULT_ADD_TIMESTAMP = True
+121 -3
View File
@@ -1,8 +1,10 @@
""" """
Connection dialog for connecting to a live Sedona device. Connection dialog for connecting to a live Sedona device.
Modal Toplevel with host, port, username, password fields. Modal Toplevel with host, port, username, password fields and device
Connect runs in a background thread; progress and errors shown inline. 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 import configparser
@@ -20,6 +22,7 @@ class ConnectDialog:
self._callback = on_connect_callback self._callback = on_connect_callback
self._thread = None self._thread = None
self._cancelled = False self._cancelled = False
self._discover_thread = None
self._dlg = tk.Toplevel(parent) self._dlg = tk.Toplevel(parent)
self._dlg.title("Connect to Device") self._dlg.title("Connect to Device")
@@ -56,6 +59,49 @@ class ConnectDialog:
ttk.Entry(frame, textvariable=self._pass_var, width=28, show="*").grid( ttk.Entry(frame, textvariable=self._pass_var, width=28, show="*").grid(
row=row, column=1, columnspan=2, sticky="ew", pady=4) 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 row += 1
self._status_label = ttk.Label(frame, text="", foreground="#666666") self._status_label = ttk.Label(frame, text="", foreground="#666666")
self._status_label.grid(row=row, column=0, columnspan=3, sticky="w", pady=(8, 4)) 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 y = parent.winfo_y() + (parent.winfo_height() - self._dlg.winfo_height()) // 2
self._dlg.geometry(f"+{x}+{y}") 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): def _do_connect(self):
host = self._host_var.get().strip() host = self._host_var.get().strip()
port = self._port_var.get().strip() port = self._port_var.get().strip()
@@ -96,6 +213,7 @@ class ConnectDialog:
return return
self._btn_connect.config(state="disabled") self._btn_connect.config(state="disabled")
self._btn_discover.config(state="disabled")
self._status_label.config(text="Connecting...", foreground="#666666") self._status_label.config(text="Connecting...", foreground="#666666")
self._cancelled = False self._cancelled = False
@@ -134,11 +252,11 @@ class ConnectDialog:
msg = rest[0] msg = rest[0]
self._status_label.config(text=f"Error: {msg}", foreground="#c00000") self._status_label.config(text=f"Error: {msg}", foreground="#c00000")
self._btn_connect.config(state="normal") self._btn_connect.config(state="normal")
self._btn_discover.config(state="normal")
def _do_cancel(self): def _do_cancel(self):
self._cancelled = True self._cancelled = True
if self._thread is not None and self._thread.is_alive(): 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 import app_state as _as
def _cleanup(): def _cleanup():
self._thread.join(timeout=5) self._thread.join(timeout=5)
+47
View File
@@ -945,8 +945,55 @@ class DaspSocket:
continue continue
break 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): def _dispatch(self, msg, addr, raw=None):
_trace("R", msg, raw) _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 session_id = msg.session_id
with self._sessions_lock: with self._sessions_lock:
session = self._sessions.get(session_id) session = self._sessions.get(session_id)