1515import json
1616import os
1717import sys
18+ import threading
1819
1920try :
2021 import serial .tools .list_ports
@@ -340,6 +341,67 @@ def is_open(device: str) -> bool:
340341 return _is_open_win32 (device ) if HAS_WIN32 else _is_open_fallback (device )
341342
342343
344+ # ── background open-probe ──────────────────────────────────────────────────────
345+ # is_open() opens a handle to the port, which on a malfunctioning device can
346+ # block inside the driver for up to ~30 s (a fixed usbser open timeout) before
347+ # returning. Doing that on the Tk UI thread freezes the whole app. PortProbe
348+ # runs is_open() on a worker thread instead and caches the answer for the UI to
349+ # read without ever blocking: at most one probe per port is in flight at a time
350+ # (so slow probes don't pile up every refresh), and while a probe is overdue the
351+ # port reads as "error" until it finally returns. The status therefore wiggles
352+ # free ↔ error on a flaky device, which is the intended, non-freezing behaviour.
353+ PROBE_STUCK_S = 1.5 # a probe outstanding longer than this reads "error"
354+
355+
356+ class PortProbe :
357+ def __init__ (self ):
358+ self ._lock = threading .Lock ()
359+ self ._state : dict [str , dict ] = {} # port → {value,in_flight,started}
360+
361+ def status (self , port : str ) -> str :
362+ """Latest cached open-state for `port` as one of
363+ "open" | "free" | "error" | "unknown", kicking a fresh background probe
364+ if none is in flight. Never blocks the caller."""
365+ now = time .time ()
366+ with self ._lock :
367+ st = self ._state .get (port )
368+ if st is None :
369+ st = self ._state [port ] = {"value" : None , "in_flight" : False ,
370+ "started" : 0.0 }
371+ in_flight = st ["in_flight" ]
372+ value = st ["value" ]
373+ started = st ["started" ]
374+ if not in_flight : # spawn one, don't pile up
375+ st ["in_flight" ] = True
376+ st ["started" ] = now
377+ if not in_flight :
378+ threading .Thread (target = self ._run , args = (port ,), daemon = True ).start ()
379+ in_flight = True
380+ if in_flight and value is None and (now - started ) > PROBE_STUCK_S :
381+ return "error" # first probe is stuck
382+ if value is None :
383+ return "unknown"
384+ if in_flight and (now - started ) > PROBE_STUCK_S :
385+ return "error" # a refresh probe is stuck
386+ return "open" if value else "free"
387+
388+ def _run (self , port : str ):
389+ try :
390+ val = is_open (port )
391+ except Exception : # noqa: BLE001
392+ val = None
393+ with self ._lock :
394+ st = self ._state .get (port )
395+ if st is not None :
396+ st ["value" ] = val
397+ st ["in_flight" ] = False
398+
399+ def forget (self , port : str ):
400+ """Drop a port's cached state (call when its device record is purged)."""
401+ with self ._lock :
402+ self ._state .pop (port , None )
403+
404+
343405# ── per-device record ─────────────────────────────────────────────────────────
344406class Device :
345407 """One COM port currently or recently seen. Bundles its identity/data
@@ -449,6 +511,7 @@ def __init__(self):
449511 self .configure (bg = C_BG )
450512
451513 self .devices : dict [str , Device ] = {} # COM id → Device (present or recently gone)
514+ self ._probe = PortProbe () # off-thread is_open cache
452515 self ._initialized = False # skip flash for ports present at startup
453516 self ._row_widgets : list [list [tk .Widget ]] = []
454517 self ._drag_ox = self ._drag_oy = 0
@@ -732,12 +795,13 @@ def _on_status_click(self, device: Device):
732795 if device .wanted :
733796 device .wanted = False # cancel wait
734797 return
735- # free and present right now → open immediately
736- if device .present and not is_open (device .id ):
798+ # Present → try to open now. The terminal's serial open is itself
799+ # off-thread, so a busy/slow port won't freeze us: it falls back to
800+ # "waiting" and the refresh loop grabs it once the cached probe says
801+ # the port is free. Gone → queue and grab it when it returns free.
802+ if device .present and self ._probe .status (device .id ) != "open" :
737803 self ._open_terminal (device )
738804 return
739- # OPEN (held elsewhere) or gone → queue and grab it as soon as it's
740- # present and free again
741805 device .wanted = True
742806
743807 def _open_terminal (self , device : Device ):
@@ -950,10 +1014,12 @@ def _refresh(self):
9501014 or (now - d .disappeared_at ) > show_removed )
9511015 if expired :
9521016 del self .devices [dev_id ]
1017+ self ._probe .forget (dev_id )
9531018
9541019 # ports queued to open: grab each as soon as it's present AND free
1020+ # (free per the cached background probe — never block the UI thread)
9551021 for d in self .devices .values ():
956- if d .wanted and d .present and not is_open (d .id ):
1022+ if d .wanted and d .present and self . _probe . status (d .id ) == "free" :
9571023 self ._open_terminal (d )
9581024
9591025 # reconcile each open terminal's connection with its device's presence
@@ -969,7 +1035,8 @@ def _refresh(self):
9691035 t ._info ("[device removed — waiting for it to return]" )
9701036 else :
9711037 t ._close_quiet ()
972- elif t .state == "waiting" and d .present and not is_open (d .id ):
1038+ elif (t .state == "waiting" and d .present
1039+ and self ._probe .status (d .id ) == "free" ):
9731040 # device available again → reconnect (manual disconnect won't reach here)
9741041 t .reconnect ()
9751042
@@ -1073,11 +1140,18 @@ def _refresh(self):
10731140 age_s = now - d .first_seen
10741141 cs = d .conn_state
10751142 # our terminal owns the port only while actually connected;
1076- # waiting/disconnected releases it so the row reflects that
1077- occupied = (cs == "connected"
1078- or (cs == "disconnected" and is_open (d .id )))
1079- waiting = (cs == "waiting"
1080- or (cs == "disconnected" and d .wanted ))
1143+ # waiting/connecting/disconnected releases it so the row
1144+ # reflects that. For a released port the OPEN/free state
1145+ # comes from the cached background probe (never a blocking
1146+ # is_open here); a probe stuck on a flaky device reads
1147+ # "error" rather than freezing the UI.
1148+ probe = (self ._probe .status (d .id )
1149+ if cs == "disconnected" and not d .wanted else None )
1150+ occupied = (cs == "connected" or probe == "open" )
1151+ waiting = (cs == "waiting"
1152+ or (cs == "disconnected" and d .wanted ))
1153+ connecting = (cs == "connecting" )
1154+ errored = (probe == "error" )
10811155
10821156 flash_t = now - d .flash_start if d .flash_start is not None else None
10831157 if flash_t is not None :
@@ -1089,12 +1163,19 @@ def _refresh(self):
10891163 row_font = FONT
10901164
10911165 fresh = age_s < NEW_DOT_S
1092- dot_c = C_NEW if fresh else (C_OPEN if occupied else C_FREE )
1166+ dot_c = C_NEW if fresh else (
1167+ C_OPEN if occupied else
1168+ C_GONE if errored else
1169+ C_WAIT if (waiting or connecting ) else C_FREE )
10931170 age_c = C_NEW if fresh else C_AGE
1094- if waiting :
1171+ if connecting :
1172+ stat_t , stat_c = "connecting" , C_WAIT
1173+ elif waiting :
10951174 stat_t , stat_c = "waiting" , C_WAIT
10961175 elif occupied :
10971176 stat_t , stat_c = "OPEN" , C_OPEN
1177+ elif errored :
1178+ stat_t , stat_c = "error" , C_GONE
10981179 else :
10991180 stat_t , stat_c = "free" , C_FREE
11001181 port_c = C_PORT
@@ -1402,6 +1483,13 @@ def __init__(self, parent: ComMonitor, device: "Device"):
14021483 self ._poll_id = None
14031484 self ._closing = False
14041485 self ._conn_btn = None
1486+ # async open: opening a serial port can block ~30 s in the driver on a
1487+ # malfunctioning device, so it runs on a worker thread. While the worker
1488+ # is out the state is "connecting"; it drops its result here for the
1489+ # 50 ms poll (on the UI thread) to pick up. _open_token guards against a
1490+ # stale worker's result landing after the user cancelled/reconnected.
1491+ self ._open_result : "tuple | None" = None
1492+ self ._open_token = 0
14051493 # modem-control state: RTS/DTR are outputs we drive, CTS/DSR/DCD are
14061494 # inputs we read. Widget refs are filled by _build_signals. ESP auto-reset
14071495 # modes idle the lines de-asserted so merely opening or closing the port
@@ -1441,7 +1529,7 @@ def __init__(self, parent: ComMonitor, device: "Device"):
14411529 self .update_idletasks ()
14421530 if not saved_geo : # first open → place above main
14431531 self ._position_above_main ()
1444- self ._set_state ( "connected" if self . _open_serial () else "waiting" )
1532+ self ._begin_open () # async; poll picks up result
14451533 self ._schedule_poll ()
14461534 self ._schedule_watch_poll ()
14471535
@@ -1867,10 +1955,13 @@ def _toggle_connection(self):
18671955 # Manual user action wins over any watch hold in progress.
18681956 self ._watch_cancel_hold ()
18691957 if self .state == "disconnected" :
1870- # user wants a connection: take it now, else wait for the device
1871- self ._set_state ("connected" if self ._open_serial () else "waiting" )
1958+ # user wants a connection: open asynchronously (→ "connecting", then
1959+ # the poll resolves it to connected/waiting without freezing the UI)
1960+ self ._begin_open ()
18721961 else :
1873- # connected or waiting → user opts out; no auto-reconnect
1962+ # connected / waiting / connecting → user opts out; no auto-reconnect.
1963+ # Bumping the token discards any in-flight open worker's result.
1964+ self ._open_token += 1
18741965 self ._close_serial ()
18751966 self ._set_state ("disconnected" )
18761967 self ._info ("[disconnected]" )
@@ -1881,6 +1972,7 @@ def _update_conn_btn(self):
18811972 # label = the action a click performs; colour = the current state
18821973 text , fg = {
18831974 "connected" : (" Disconnect " , C_FREE ),
1975+ "connecting" : (" Cancel " , C_WAIT ),
18841976 "waiting" : (" Cancel " , C_WAIT ),
18851977 "disconnected" : (" Connect " , C_OPEN ),
18861978 }.get (self .state , (" Connect " , C_OPEN ))
@@ -1937,29 +2029,50 @@ def _resize_move(self, e):
19372029 y = y0 + (h0 - h )
19382030 self .geometry (f"{ w } x{ h } +{ x } +{ y } " )
19392031
1940- def _open_serial (self ) -> bool :
2032+ def _begin_open (self ):
2033+ """Open the serial port on a worker thread so a slow/blocking driver
2034+ open (up to ~30 s on a malfunctioning device) never freezes the UI.
2035+ Sets state "connecting"; _poll picks up the worker's result and finishes
2036+ the connection (or falls back to "waiting") on the UI thread."""
2037+ if self .state == "connecting" :
2038+ return # one open in flight
2039+ s = dict (self .dev .port_settings ())
2040+ self ._open_token += 1
2041+ token = self ._open_token
2042+ self ._open_result = None
2043+ self ._set_state ("connecting" )
2044+ self ._info ("[connecting…]" )
2045+
2046+ def worker ():
2047+ try :
2048+ ser = serial .Serial (
2049+ self .dev .id ,
2050+ baudrate = s ["baud" ], bytesize = s ["data_bits" ],
2051+ parity = s ["parity" ], stopbits = s ["stop_bits" ], timeout = 0 )
2052+ self ._open_result = (token , ser , None )
2053+ except (serial .SerialException , OSError , ValueError ) as e :
2054+ self ._open_result = (token , None , e )
2055+
2056+ threading .Thread (target = worker , daemon = True ).start ()
2057+
2058+ def _finish_open (self , ser , err ):
2059+ """Apply a worker's open result on the UI thread."""
19412060 s = self .dev .port_settings ()
1942- try :
1943- self .serial = serial .Serial (
1944- self .dev .id ,
1945- baudrate = s ["baud" ],
1946- bytesize = s ["data_bits" ],
1947- parity = s ["parity" ],
1948- stopbits = s ["stop_bits" ],
1949- timeout = 0 )
1950- # Drive RTS/DTR immediately, then again after 100 ms — some USB-serial
1951- # adapters drop line-state commands issued in the first few ms after
1952- # enumeration, so a second write ensures the buttons' values stick.
1953- self ._apply_modem_outputs ()
1954- self .after (100 , self ._apply_modem_outputs )
1955- self ._info (f"[connected @ { s ['baud' ]} "
1956- f"{ s ['data_bits' ]} { s ['parity' ]} { s ['stop_bits' ]} ]" )
1957- self ._update_signal_widgets ()
1958- return True
1959- except (serial .SerialException , OSError , ValueError ) as e :
2061+ if err is not None or ser is None :
19602062 self .serial = None
1961- self ._info (f"[open failed: { e } ]" )
1962- return False
2063+ self ._info (f"[open failed: { err } ]" )
2064+ self ._set_state ("waiting" )
2065+ return
2066+ self .serial = ser
2067+ # Drive RTS/DTR immediately, then again after 100 ms — some USB-serial
2068+ # adapters drop line-state commands issued in the first few ms after
2069+ # enumeration, so a second write ensures the buttons' values stick.
2070+ self ._apply_modem_outputs ()
2071+ self .after (100 , self ._apply_modem_outputs )
2072+ self ._info (f"[connected @ { s ['baud' ]} "
2073+ f"{ s ['data_bits' ]} { s ['parity' ]} { s ['stop_bits' ]} ]" )
2074+ self ._set_state ("connected" )
2075+ self ._update_signal_widgets ()
19632076
19642077 def _close_serial (self ):
19652078 if self .serial is not None :
@@ -1976,6 +2089,20 @@ def _poll(self):
19762089 self ._poll_id = None
19772090 if self ._closing :
19782091 return
2092+ # pick up an async open result (if any). Ignore a stale worker whose
2093+ # token no longer matches (user cancelled / reconnected meanwhile); if
2094+ # it managed to open the port, close that orphaned handle.
2095+ res = self ._open_result
2096+ if res is not None :
2097+ self ._open_result = None
2098+ token , ser , err = res
2099+ if token == self ._open_token and self .state == "connecting" :
2100+ self ._finish_open (ser , err )
2101+ elif ser is not None :
2102+ try :
2103+ ser .close ()
2104+ except Exception : # noqa: BLE001
2105+ pass
19792106 if self .serial is not None :
19802107 try :
19812108 n = self .serial .in_waiting
@@ -2027,11 +2154,14 @@ def _open_port_settings(self):
20272154 PortSettingsDialog (self )
20282155
20292156 def reconnect (self ):
2030- """Reopen the serial port with whatever port_settings are now in effect."""
2157+ """Reopen the serial port with whatever port_settings are now in effect.
2158+ The open is async (→ "connecting", resolved by the poll), so a slow
2159+ driver open can't freeze the UI here either."""
2160+ self ._open_token += 1 # discard any in-flight open
20312161 self ._close_serial ()
20322162 # The watch path/timeout may have just changed; re-seed on the next poll.
20332163 self ._watch_baseline = None
2034- self ._set_state ( "connected" if self . _open_serial () else "waiting" )
2164+ self ._begin_open ( )
20352165 self ._refresh_title ()
20362166 self ._build_signals ()
20372167
@@ -2062,6 +2192,7 @@ def _close_quiet(self):
20622192 if self ._closing :
20632193 return
20642194 self ._closing = True
2195+ self ._open_token += 1 # orphan any in-flight open
20652196 self .dev .conn_state = "disconnected" # release the device's state
20662197 self .dev .terminal = None # unbind from the device
20672198 save_settings (self .parent .settings ) # persist geometry
0 commit comments