Skip to content

Commit ef986d0

Browse files
committed
v1.6.0
- Probing is done in separate background thread, prevents gui from hanging on faulty device - Terminal is also in a separate thread. prevent gui from hanging when opening terminal on faulty device
1 parent 0423ddd commit ef986d0

2 files changed

Lines changed: 175 additions & 40 deletions

File tree

CHANGELOG.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
v1.6.0
2+
- Probing is done in separate background thread, prevents gui from hanging on faulty device
3+
- Terminal is also in a separate thread. prevent gui from hanging when opening terminal on faulty device
4+
15
v1.5.1
26
- Taskbar button fix when pinned
37

monitor.py

Lines changed: 171 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import json
1616
import os
1717
import sys
18+
import threading
1819

1920
try:
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 ─────────────────────────────────────────────────────────
344406
class 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

Comments
 (0)