-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmonitor.py
More file actions
2410 lines (2146 loc) · 107 KB
/
Copy pathmonitor.py
File metadata and controls
2410 lines (2146 loc) · 107 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
USB COM Port Monitor
Small always-on-top desktop widget.
Usage: python monitor.py
Needs: pip install pyserial pywin32
"""
import tkinter as tk
import tkinter.font as tkfont
from tkinter import filedialog
import math
import time
import json
import os
import sys
import threading
try:
import serial.tools.list_ports
except ImportError:
print("We need pyserial library (and optionaly pywin32).");
try:
import win32file, pywintypes
HAS_WIN32 = True
except ImportError:
HAS_WIN32 = False
# ── virtual-desktop confinement (Windows) ──────────────────────────────────
# Borderless (override-redirect) windows carry no taskbar button, so Windows'
# Virtual Desktop Manager never tracks them and shows them on *every* virtual
# desktop. To confine the widget to one desktop it has to become trackable,
# which means a taskbar button: we force WS_EX_APPWINDOW on the main strip and
# make the rows window + terminals owned by it, so they follow its desktop
# without buttons of their own. One taskbar button total.
if sys.platform == "win32":
import ctypes
from ctypes import wintypes
_GWL_EXSTYLE = -20
_GWLP_HWNDPARENT = -8
_WS_EX_TOOLWINDOW = 0x00000080
_WS_EX_APPWINDOW = 0x00040000
_GA_ROOT = 2
_SW_HIDE = 0
_SW_SHOWNA = 8 # show, don't activate (keep focus where it is)
_WM_SETICON = 0x0080
_ICON_SMALL = 0
_ICON_BIG = 1
_IMAGE_ICON = 1
_LR_LOADFROMFILE = 0x0010
_SM_CXICON = 11
_SM_CYICON = 12
_SM_CXSMICON = 49
_SM_CYSMICON = 50
_GCLP_HICON = -14
_GCLP_HICONSM = -34
_user32 = ctypes.windll.user32
_user32.GetAncestor.restype = wintypes.HWND
_user32.GetAncestor.argtypes = (wintypes.HWND, ctypes.c_uint)
_user32.GetSystemMetrics.restype = ctypes.c_int
_user32.GetSystemMetrics.argtypes = (ctypes.c_int,)
_user32.LoadImageW.restype = ctypes.c_void_p
_user32.LoadImageW.argtypes = (ctypes.c_void_p, ctypes.c_wchar_p,
ctypes.c_uint, ctypes.c_int, ctypes.c_int,
ctypes.c_uint)
_user32.SendMessageW.restype = ctypes.c_void_p
_user32.SendMessageW.argtypes = (wintypes.HWND, ctypes.c_uint,
ctypes.c_void_p, ctypes.c_void_p)
# 64-bit-safe long-ptr accessors (fall back to the 32-bit names on Win32)
_get_long = getattr(_user32, "GetWindowLongPtrW", _user32.GetWindowLongW)
_set_long = getattr(_user32, "SetWindowLongPtrW", _user32.SetWindowLongW)
_set_class_long = getattr(_user32, "SetClassLongPtrW", _user32.SetClassLongW)
for _f in (_get_long, _set_class_long):
_f.restype = ctypes.c_void_p
_get_long.argtypes = (wintypes.HWND, ctypes.c_int)
_set_long.restype = ctypes.c_void_p
_set_long.argtypes = (wintypes.HWND, ctypes.c_int, ctypes.c_void_p)
_set_class_long.argtypes = (wintypes.HWND, ctypes.c_int, ctypes.c_void_p)
def _root_hwnd(hwnd):
return _user32.GetAncestor(hwnd, _GA_ROOT)
def make_taskbar_window(hwnd):
"""Give a top-level window a taskbar button (clear TOOLWINDOW, set
APPWINDOW) so the Virtual Desktop Manager tracks it and keeps it on a
single desktop. The window is briefly hidden/reshown so the shell picks
up the change."""
h = _root_hwnd(hwnd)
ex = (int(_get_long(h, _GWL_EXSTYLE) or 0)
& ~_WS_EX_TOOLWINDOW) | _WS_EX_APPWINDOW
_user32.ShowWindow(h, _SW_HIDE)
_set_long(h, _GWL_EXSTYLE, ex)
_user32.ShowWindow(h, _SW_SHOWNA)
def set_window_owner(hwnd, owner_hwnd):
"""Make `hwnd` an owned window of `owner_hwnd`, so it has no taskbar
button of its own and follows the owner across virtual desktops."""
_set_long(_root_hwnd(hwnd), _GWLP_HWNDPARENT, _root_hwnd(owner_hwnd))
def set_window_icon(hwnd, ico_path):
"""Load `ico_path` and attach it as the window's big + small icons (and
class icons), so the taskbar button shows it. Tk's iconbitmap sets the
window icon but the taskbar button created by WS_EX_APPWINDOW reads the
class icon, so push both explicitly."""
h = _root_hwnd(hwnd)
big = _user32.LoadImageW(None, ico_path, _IMAGE_ICON,
_user32.GetSystemMetrics(_SM_CXICON),
_user32.GetSystemMetrics(_SM_CYICON),
_LR_LOADFROMFILE)
small = _user32.LoadImageW(None, ico_path, _IMAGE_ICON,
_user32.GetSystemMetrics(_SM_CXSMICON),
_user32.GetSystemMetrics(_SM_CYSMICON),
_LR_LOADFROMFILE)
if big:
_user32.SendMessageW(h, _WM_SETICON, _ICON_BIG, big)
_set_class_long(h, _GCLP_HICON, big)
if small:
_user32.SendMessageW(h, _WM_SETICON, _ICON_SMALL, small)
_set_class_long(h, _GCLP_HICONSM, small)
# Run via python.exe the taskbar button inherits the interpreter's
# AppUserModelID (and its icon), ignoring our per-window icon — so give the
# process its own identity. Skip this for the frozen .exe: there the exe is
# already its own identity with an embedded icon, and an explicit AppID with
# no relaunch/icon info would break the pinned-to-taskbar icon. Must happen
# before the first window appears.
if not getattr(sys, "frozen", False):
try:
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
"com_monitor.widget")
except Exception: # noqa: BLE001
pass
else:
def make_taskbar_window(hwnd):
pass
def set_window_owner(hwnd, owner_hwnd):
pass
def set_window_icon(hwnd, ico_path):
pass
# ── tunables ──────────────────────────────────────────────────────────────────
REFRESH_MS = 500
NEW_DOT_S = 8 # seconds the dot/age text stays yellow
C_ROW_FLASH = "#8a6218" # amber peak colour for new-port flash
FLASH_ATTACK_S = 0.3 # seconds to reach peak brightness
FLASH_DECAY_K = 0.50 # controls decay speed (higher = faster early drop)
# brightness(t) = exp(-K * sqrt(t - attack)) ← ~0 by 60 s
BOLD_THRESHOLD = 0.1 # keep bold text while brightness is above this
# ── palette ───────────────────────────────────────────────────────────────────
C_BG = "#1a1a1a"
C_HDR = "#252525"
C_PORT = "#61dafb"
C_VIDPID = "#f0e68c"
C_FREE = "#7ec87e"
C_OPEN = "#e06c75"
C_NEW = "#ffcc44"
C_AGE = "#666666"
C_DESC = "#999999"
C_HEAD = "#444444"
C_SER = "#b0a0d0"
C_ROW_GONE = "#7a1a1a" # red peak for vanished-port row flash
C_GONE = "#a04848" # foreground for "gone" status
C_DIM = "#555555" # darkened text for vanished ports
C_WAIT = "#e6a500" # amber 'waiting' status (we want to open, port is locked)
FONT = ("Consolas", 10)
FONT_BOLD = ("Consolas", 10, "bold")
FONT_IT = ("Consolas", 10, "italic")
FONT_BOLD_IT = ("Consolas", 10, "bold italic")
FONT_HDR = ("Consolas", 9, "bold")
ALPHA_OPAQUE = 0.96
FADE_RAMP_S = 2.5 # seconds for chrome to fade to fully transparent
FADE_TICK_MS = 40 # animation tick while the fade ramp is running
COLS = [
("Port", "w", False),
("VID:PID", "w", False),
("Age", "e", False),
("", "c", False),
("Status", "w", False),
("Serial / Loc", "w", False),
("Description", "w", True),
]
# ── settings ──────────────────────────────────────────────────────────────────
# Anchor settings.json next to the program. In a PyInstaller --onefile build
# __file__ points at the temp _MEIxxxx extraction dir (wiped on exit), so use the
# directory of the actual .exe instead; otherwise use the script's own folder.
if getattr(sys, "frozen", False):
_APP_DIR = os.path.dirname(os.path.abspath(sys.executable))
else:
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
SETTINGS_FILE = os.path.join(_APP_DIR, "com_monitor.json")
def _resource_path(name: str) -> str:
"""Path to a bundled read-only resource (e.g. icon.ico). In a PyInstaller
--onefile build data files live in the temp extraction dir (sys._MEIPASS);
from source they sit next to this script."""
base = getattr(sys, "_MEIPASS", _APP_DIR)
return os.path.join(base, name)
DEFAULT_SETTINGS = {
"highlight_duration_s": 20.0, # row-flash fade duration
"show_removed_enable": True, # show disconnected ports at all
"show_removed_s": 60, # gated: 0 = keep forever this session, else seconds before purge
"always_on_top": True, # pin the window above all others
"always_on_top_timeout_s": 0, # 0 = never drop; else drop after idle
"show_on_all_desktops": True, # Windows: show on every virtual desktop
# (off = confine to one, adds a taskbar button)
"interaction_timeout_s": 2, # seconds with no mouse/focus before fading
"move_to_back_enable": False, # push window to back of Z-order on idle
"move_to_back_timeout_s": 300,
"normal_alpha": ALPHA_OPAQUE, # window opacity (rows keep this)
"window_x": None, # last-known position
"window_y": None,
"terminal_size": "640x400", # default terminal WxH (per-device geometry overrides)
"terminal_always_on_top": True, # terminal pin — independent of main window
"terminal_alpha": ALPHA_OPAQUE, # terminal window opacity
# per-device config keyed by "VID:PID:SERIAL"; each entry may carry a
# "name" and/or serial settings — either alone is fine.
"device_settings": {},
}
# serial/terminal portion of a device entry (the "name" key lives alongside
# these but is not a serial default).
DEFAULT_PORT_SETTINGS = {
"baud": 115200,
"data_bits": 8,
"parity": "N", # N/E/O/M/S
"stop_bits": 1, # 1, 1.5, 2
"line_ending": "\r\n", # "\r\n" | "\n" | "\r" | ""
"reconnect_on_unplug": True, # keep terminal open & reconnect when device returns
"signals": "controls", # title-bar modem lines: "none" | "controls" | "full"
"watch_enable": False, # master toggle for the file watcher
"watch_file": "", # path to monitor (also disabled when empty)
"watch_reconnect_s": 5.0, # seconds to release the port after a change
}
TERM_POLL_MS = 50 # serial-read poll cadence
TERM_WATCH_POLL_MS = 100 # file-watch poll cadence
TERM_BORDER = 4 # px resize-zone around the terminal
TERM_MIN_W = 280 # minimum terminal width
TERM_MIN_H = 160 # minimum terminal height
# ESP/Arduino auto-reset timing (mirrors esptool). UART = external USB-serial
# bridge (CH340/CP2102/…); USB = native USB-Serial-JTAG (ESP32-S2/S3/C3/…).
ESP_RESET_DELAY_S = 0.1 # EN-low / inter-step hold
ESP_BOOT_DELAY_S = 0.05 # GPIO0 hold after release (UART boot)
ESP_USB_RESET_DELAY_S = 0.2 # longer pulses for native-USB reset
def load_settings() -> dict:
# shallow-copy each default value so callers can mutate nested dicts
# (e.g. device_settings) without bleeding into DEFAULT_SETTINGS.
merged = {k: (dict(v) if isinstance(v, dict) else v)
for k, v in DEFAULT_SETTINGS.items()}
try:
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception:
return merged
for k, v in data.items():
if k in DEFAULT_SETTINGS:
merged[k] = v
return merged
def save_settings(s: dict) -> None:
try:
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(s, f, indent=2)
except Exception as e:
print(f"Failed to save settings: {e}")
# ── flash curve ─────────────────────────────────────────────────────────────
def _flash_brightness(t: float, k: float = FLASH_DECAY_K) -> float:
"""
0..1 flash intensity at t seconds after port appeared.
Shape: linear attack up to FLASH_ATTACK_S, then
exp(-K * sqrt(t - attack)) — fast initial drop, very slow tail.
Reaches ≈0 around 60 s for default K=0.65.
"""
if t <= 0:
return 0.0
if t < FLASH_ATTACK_S:
return t / FLASH_ATTACK_S # quick linear ramp to 1.0
return math.exp(-k * math.sqrt(t - FLASH_ATTACK_S))
def _blend(c1: str, c2: str, t: float) -> str:
"""Interpolate two #rrggbb colours: t=0 → c1, t=1 → c2."""
t = max(0.0, min(1.0, t))
r = int(int(c1[1:3], 16) + (int(c2[1:3], 16) - int(c1[1:3], 16)) * t)
g = int(int(c1[3:5], 16) + (int(c2[3:5], 16) - int(c1[3:5], 16)) * t)
b = int(int(c1[5:7], 16) + (int(c2[5:7], 16) - int(c1[5:7], 16)) * t)
return f"#{r:02x}{g:02x}{b:02x}"
# ── port-open detection ───────────────────────────────────────────────────────
def _is_open_win32(device: str) -> bool:
path = r"\\.\ "[:-1] + device
try:
h = win32file.CreateFile(
path,
win32file.GENERIC_READ | win32file.GENERIC_WRITE,
0, None, win32file.OPEN_EXISTING, 0, None,
)
win32file.CloseHandle(h)
return False
except pywintypes.error as e:
if e.winerror in (5, 32):
return True
return False
def _is_open_fallback(device: str) -> bool:
import serial
try:
s = serial.Serial(device, timeout=0)
s.close()
return False
except Exception:
return True
def is_open(device: str) -> bool:
return _is_open_win32(device) if HAS_WIN32 else _is_open_fallback(device)
# ── background open-probe ──────────────────────────────────────────────────────
# is_open() opens a handle to the port, which on a malfunctioning device can
# block inside the driver for up to ~30 s (a fixed usbser open timeout) before
# returning. Doing that on the Tk UI thread freezes the whole app. PortProbe
# runs is_open() on a worker thread instead and caches the answer for the UI to
# read without ever blocking: at most one probe per port is in flight at a time
# (so slow probes don't pile up every refresh), and while a probe is overdue the
# port reads as "error" until it finally returns. The status therefore wiggles
# free ↔ error on a flaky device, which is the intended, non-freezing behaviour.
PROBE_STUCK_S = 1.5 # a probe outstanding longer than this reads "error"
class PortProbe:
def __init__(self):
self._lock = threading.Lock()
self._state: dict[str, dict] = {} # port → {value,in_flight,started}
def status(self, port: str) -> str:
"""Latest cached open-state for `port` as one of
"open" | "free" | "error" | "unknown", kicking a fresh background probe
if none is in flight. Never blocks the caller."""
now = time.time()
with self._lock:
st = self._state.get(port)
if st is None:
st = self._state[port] = {"value": None, "in_flight": False,
"started": 0.0}
in_flight = st["in_flight"]
value = st["value"]
started = st["started"]
if not in_flight: # spawn one, don't pile up
st["in_flight"] = True
st["started"] = now
if not in_flight:
threading.Thread(target=self._run, args=(port,), daemon=True).start()
in_flight = True
if in_flight and value is None and (now - started) > PROBE_STUCK_S:
return "error" # first probe is stuck
if value is None:
return "unknown"
if in_flight and (now - started) > PROBE_STUCK_S:
return "error" # a refresh probe is stuck
return "open" if value else "free"
def _run(self, port: str):
try:
val = is_open(port)
except Exception: # noqa: BLE001
val = None
with self._lock:
st = self._state.get(port)
if st is not None:
st["value"] = val
st["in_flight"] = False
def forget(self, port: str):
"""Drop a port's cached state (call when its device record is purged)."""
with self._lock:
self._state.pop(port, None)
# ── per-device record ─────────────────────────────────────────────────────────
class Device:
"""One COM port currently or recently seen. Bundles its identity/data
(from list_ports), transient UI state (first-seen / flash / gone timers),
and connection state. Persisted config (name + serial settings) lives in
settings["device_settings"] keyed by VID:PID:Serial and is reached through
the owning ComMonitor — this object is the single access point for it."""
def __init__(self, app: "ComMonitor", dev_id: str, info: dict, now: float):
self.app = app
self.id = dev_id # e.g. "COM7"
self.info = info # vid/pid/description/serial_number/location
# transient UI state
self.first_seen = now
self.flash_start: "float | None" = None # None = not flashing
self.disappeared_at: "float | None" = None # None = present
# connection state, authoritative while a Terminal is bound to us
self.conn_state = "disconnected" # "connected" | "waiting" | "disconnected"
self.terminal: "Terminal | None" = None # this device's open terminal, if any
self.wanted = False # queued to open a terminal once free
# ── identity / data ──
@property
def present(self) -> bool: return self.disappeared_at is None
@property
def vid(self): return self.info.get("vid")
@property
def pid(self): return self.info.get("pid")
@property
def serial_number(self) -> str: return self.info.get("serial_number", "")
@property
def location(self) -> str: return self.info.get("location", "")
@property
def description(self) -> str: return self.info.get("description", "")
@property
def custom_key(self):
"""Settings key for this device's config. Serial is included verbatim —
an empty serial yields "VID:PID:" which still uniquely identifies the
"no-serial" variant of that VID:PID. Location is intentionally ignored."""
if self.vid is None or self.pid is None:
return None
return f"{self.vid:04X}:{self.pid:04X}:{self.serial_number or ''}"
# ── persisted config (this device's entry in settings["device_settings"]) ──
def _config(self) -> dict:
"""The stored config dict for this device, or {} if none/unkeyable.
Returns the live dict from settings so reads see edits immediately."""
k = self.custom_key
return self.app.settings.get("device_settings", {}).get(k, {}) if k else {}
@property
def name(self) -> str:
return self._config().get("name", "")
def port_settings(self) -> dict:
"""DEFAULT_PORT_SETTINGS merged with this device's serial overrides."""
s = dict(DEFAULT_PORT_SETTINGS)
s.update(self._config())
for k in ("name", "geometry"): # non-serial config keys
s.pop(k, None)
return s
@property
def geometry(self) -> "str | None":
"""Remembered terminal geometry "WxH+X+Y" for this device, or None."""
return self._config().get("geometry")
def save_geometry(self, geo: str):
self.update_config(geometry=geo)
def update_config(self, **fields):
"""Merge fields into this device's stored config, pruning empties.
Pass name="" (or any blank value) to drop that key."""
k = self.custom_key
if k is None:
return
store = self.app.settings.setdefault("device_settings", {})
entry = store.setdefault(k, {})
for kk, vv in fields.items():
if vv == "" or vv is None:
entry.pop(kk, None)
else:
entry[kk] = vv
if not entry: # nothing left → drop entry
store.pop(k, None)
def set_name(self, name: str):
self.update_config(name=name)
# ── main window ───────────────────────────────────────────────────────────────
class ComMonitor(tk.Tk):
def __init__(self):
super().__init__()
self.settings = load_settings()
# app/window icon — drives the taskbar button when confined to a desktop.
# default=… applies it to every toplevel (terminals, dialogs) too.
try:
self.iconbitmap(default=_resource_path("icon.ico"))
except tk.TclError:
pass
self.overrideredirect(True)
self.attributes("-topmost", self.settings["always_on_top"])
self.attributes("-alpha", self.settings["normal_alpha"])
self.configure(bg=C_BG)
self.devices: dict[str, Device] = {} # COM id → Device (present or recently gone)
self._probe = PortProbe() # off-thread is_open cache
self._initialized = False # skip flash for ports present at startup
self._row_widgets: list[list[tk.Widget]] = []
self._drag_ox = self._drag_oy = 0
# interaction-driven fade + on-change top-most state
self._known_ports: set[str] = set()
self._last_interaction = time.time()
self._top_until = 0.0 # window stays topmost while now < this
self._top_active = self.settings["always_on_top"]
self._bg_active = False
self._last_chrome_alpha = -1.0
self._editing_key = None # custom_key currently being edited
self._edit_entry = None # tk.Entry overlaying a serial cell
self._edit_lbl = None # serial Label hidden during edit
self._edit_desc_lbl = None # description Label hidden during edit
# per-device terminals + open-when-free intent live on each Device
self._flash_decay_k = self._compute_flash_decay_k()
# font handles used to pre-compute matching column widths in both grids
self._f_row = tkfont.Font(family="Consolas", size=10)
self._f_hdr = tkfont.Font(family="Consolas", size=9, weight="bold")
self._build_ui()
self.bind("<Configure>", self._sync_rows_geometry)
self._refresh()
self._animate_fade()
self._restore_window_position()
self._sync_rows_geometry()
self._rows_win.deiconify() # reveal once placed
self._apply_desktop_confinement()
# ── layout ────────────────────────────────────────────────────────────────
def _build_ui(self):
# Chrome window (self) holds the title bar and the column-header strip.
# Its -alpha animates from normal → 0 over the fade ramp.
bar = tk.Frame(self, bg=C_HDR, cursor="fleur")
bar.pack(fill=tk.X)
bar.bind("<ButtonPress-1>", self._drag_start)
bar.bind("<B1-Motion>", self._drag_move)
tk.Label(bar, text=" USB COM Monitor",
bg=C_HDR, fg="#666666", font=("Segoe UI", 8),
pady=4).pack(side=tk.LEFT)
tk.Button(bar, text=" ✕ ", bg=C_HDR, fg="#666666", bd=0,
relief="flat", font=("Segoe UI", 8),
activebackground="#c0392b", activeforeground="white",
command=self.destroy).pack(side=tk.RIGHT)
tk.Button(bar, text=" ⚙ ", bg=C_HDR, fg="#666666", bd=0,
relief="flat", font=("Segoe UI", 8),
activebackground=C_HDR, activeforeground="#aaaaaa",
command=self._open_settings).pack(side=tk.RIGHT)
self._hdr_grid = tk.Frame(self, bg=C_BG)
self._hdr_grid.pack(fill=tk.X, padx=2, pady=(3, 0))
for c, (name, anchor, stretch) in enumerate(COLS):
tk.Label(self._hdr_grid, text=name, bg=C_BG, fg=C_HEAD,
font=FONT_HDR, anchor=anchor, padx=3
).grid(row=0, column=c, sticky="ew")
if stretch:
self._hdr_grid.columnconfigure(c, weight=1)
tk.Frame(self._hdr_grid, bg="#333333", height=1).grid(
row=1, column=0, columnspan=len(COLS), sticky="ew", pady=(1, 0))
# Rows window — borderless Toplevel that always tracks the chrome
# window's position. Holds the data grid only; its -alpha stays at
# normal_alpha so rows never fade.
self._rows_win = tk.Toplevel(self, bg=C_BG)
self._rows_win.withdraw() # hidden until positioned
self._rows_win.overrideredirect(True)
self._rows_win.attributes("-topmost", self.settings["always_on_top"])
self._rows_win.attributes("-alpha", self.settings["normal_alpha"])
self._grid = tk.Frame(self._rows_win, bg=C_BG)
self._grid.pack(fill=tk.BOTH, expand=True, padx=2, pady=(0, 2))
for c, (_, _, stretch) in enumerate(COLS):
if stretch:
self._grid.columnconfigure(c, weight=1)
self._empty_lbl = tk.Label(
self._grid, text="(no COM ports detected)",
bg=C_BG, fg="#3a3a3a", font=FONT, anchor="w", padx=3)
# dragging anywhere in the rows area also moves the pair
self._grid.bind("<ButtonPress-1>", self._drag_start)
self._grid.bind("<B1-Motion>", self._drag_move)
for w in (self, self._rows_win):
w.bind("<Escape>", lambda _: self.destroy())
w.bind("<Control-q>", lambda _: self.destroy())
w.bind("<Button>", self._on_interact)
w.bind("<Enter>", self._on_interact)
w.bind("<FocusIn>", self._on_interact)
w.bind("<Motion>", self._on_interact)
# ── transparency ──────────────────────────────────────────────────────────
def _update_alpha(self):
normal = self.settings.get("normal_alpha", ALPHA_OPAQUE)
threshold = self.settings["interaction_timeout_s"]
idle = time.time() - self._last_interaction
f = min(1.0, (idle - threshold) / FADE_RAMP_S) if idle > threshold else 0.0
# rows window holds its opacity; only the chrome window fades
try:
self._rows_win.attributes("-alpha", normal)
except tk.TclError:
pass
chrome_alpha = normal * (1.0 - f)
if chrome_alpha != self._last_chrome_alpha:
self.attributes("-alpha", chrome_alpha)
self._last_chrome_alpha = chrome_alpha
def _animate_fade(self):
# poll on the fast tick so a motionless cursor resting over the window
# holds it solid (the 500 ms refresh poll alone lets the alpha dim
# between ticks).
if self._pointer_holds_visible():
self._last_interaction = time.time()
self._update_alpha()
self.after(FADE_TICK_MS, self._animate_fade)
def _pointer_holds_visible(self) -> bool:
"""True while the pointer should keep the window awake: over the rows
window, or over the chrome window *while it is still visible*, or a
widget is focused. Once the chrome has faded to alpha 0 the pointer over
its (invisible) strip is ignored, so it stays hidden until the pointer
reaches the rows."""
try:
px, py = self.winfo_pointerxy()
# rows window always holds it awake
r = self._rows_win
rx, ry = r.winfo_rootx(), r.winfo_rooty()
rw, rh = r.winfo_width(), r.winfo_height()
if rx <= px < rx + rw and ry <= py < ry + rh:
return True
# chrome window holds it only while still visible (alpha > 0)
if self._last_chrome_alpha > 0.0:
cx, cy = self.winfo_rootx(), self.winfo_rooty()
cw, ch = self.winfo_width(), self.winfo_height()
if cx <= px < cx + cw and cy <= py < cy + ch:
return True
return self._rows_win.focus_displayof() is not None
except tk.TclError:
return False
# ── two-window sync ───────────────────────────────────────────────────────
def _sync_rows_geometry(self, _event=None):
"""Keep the rows Toplevel glued directly under the chrome window."""
try:
self.update_idletasks()
x = self.winfo_rootx()
y = self.winfo_rooty() + self.winfo_height()
self._rows_win.geometry(f"+{x}+{y}") # position only — width auto
except tk.TclError:
pass
def _sync_column_widths(self):
"""Pre-compute the per-column pixel width needed for the widest text in
either grid (header text or any data cell) and apply it as `minsize` to
both grids. With both grids on the same minsize, columns line up and
both windows end up the same total width."""
cell_pad = 6 # padx=3 on each side
col_w = [0] * len(COLS)
for c, (name, _, _) in enumerate(COLS):
col_w[c] = self._f_hdr.measure(name) + cell_pad
for row in self._row_widgets:
for lbl in row:
try:
info = lbl.grid_info()
if int(info.get("columnspan", 1)) > 1:
continue # spanned cell doesn't constrain its column
col = int(info["column"])
text = lbl.cget("text")
except (tk.TclError, KeyError, ValueError):
continue
w = self._f_row.measure(text) + cell_pad
if w > col_w[col]:
col_w[col] = w
for c, w in enumerate(col_w):
self._hdr_grid.columnconfigure(c, minsize=w)
self._grid.columnconfigure(c, minsize=w)
# force both windows to the same overall width so they read as one
self.update_idletasks()
self._rows_win.update_idletasks()
target_w = max(self.winfo_reqwidth(), self._rows_win.winfo_reqwidth())
x = self.winfo_rootx()
y = self.winfo_rooty()
self.geometry(f"{target_w}x{self.winfo_reqheight()}+{x}+{y}")
self._sync_rows_geometry()
self._rows_win.geometry(
f"{target_w}x{self._rows_win.winfo_reqheight()}"
f"+{x}+{y + self.winfo_reqheight()}")
def _on_interact(self, _event=None):
"""Immediate wake — restart the interaction timer and restore visibility."""
self._last_interaction = time.time()
if self._bg_active:
self._lift_pair()
self._bg_active = False
self._update_alpha()
# ── paired Z-order helpers ───────────────────────────────────────────────
def _terminals(self):
"""Every currently-open Terminal (at most one per device)."""
return [d.terminal for d in self.devices.values() if d.terminal is not None]
def _update_terminal_alpha(self):
"""Apply terminal_alpha to every open terminal (live-preview + save)."""
a = float(self.settings.get("terminal_alpha", ALPHA_OPAQUE))
for t in self._terminals():
try:
t.attributes("-alpha", a)
except tk.TclError:
pass
def _lift_pair(self):
"""Bring the windows to the top of the stack while preserving their
relative order (chrome below, rows above, terminals on top)."""
try:
self.lift()
self._rows_win.lift()
for t in self._terminals():
t.lift()
except tk.TclError:
pass
def _lower_pair(self):
try:
for t in self._terminals():
t.lower()
self._rows_win.lower()
self.lower()
except tk.TclError:
pass
def _set_topmost_pair(self, on: bool):
# Note: the terminal keeps its own topmost state (toggled from its
# title bar) and is intentionally not pinned/unpinned here.
try:
self.attributes("-topmost", on)
self._rows_win.attributes("-topmost", on)
except tk.TclError:
pass
# ── drag ─────────────────────────────────────────────────────────────────
def _drag_start(self, e):
self._drag_ox = e.x_root - self.winfo_x()
self._drag_oy = e.y_root - self.winfo_y()
def _drag_move(self, e):
self.geometry(f"+{e.x_root - self._drag_ox}+{e.y_root - self._drag_oy}")
# ── helpers ───────────────────────────────────────────────────────────────
@staticmethod
def _age_str(s: float) -> str:
s = int(s)
if s < 60: return f"{s}s"
m, s = divmod(s, 60)
if m < 60: return f"{m}m{s:02d}s"
h, m = divmod(m, 60)
return f"{h}h{m:02d}m"
def _on_status_click(self, device: Device):
"""Click on a row's status cell. Each device has its own terminal:
surface it if open, otherwise open now (if free) or queue the wait.
If the terminal exists but is user-disconnected, the click also asks
it to reconnect — the row labelled 'free' should match the action."""
if device.terminal is not None:
device.terminal.lift()
device.terminal.focus_set()
if device.conn_state == "disconnected":
device.terminal.reconnect()
return
if device.wanted:
device.wanted = False # cancel wait
return
# Present → try to open now. The terminal's serial open is itself
# off-thread, so a busy/slow port won't freeze us: it falls back to
# "waiting" and the refresh loop grabs it once the cached probe says
# the port is free. Gone → queue and grab it when it returns free.
if device.present and self._probe.status(device.id) != "open":
self._open_terminal(device)
return
device.wanted = True
def _open_terminal(self, device: Device):
device.wanted = False
try:
device.terminal = Terminal(self, device)
device.terminal.lift()
self._confine_window(device.terminal)
except Exception as e: # noqa: BLE001
print(f"Failed to open terminal for {device.id}: {e}")
device.terminal = None
# ── inline edit of custom serial name ─────────────────────────────────────
def _begin_serial_edit(self, lbl, device: Device, desc_lbl=None):
"""Overlay an Entry on the serial cell so the user can type a custom
name keyed by VID:PID:Serial. While editing, row rebuilds are paused
(see `_editing_key` short-circuit in `_refresh`)."""
if self._editing_key is not None:
return
self._editing_key = device.custom_key
self._edit_lbl = lbl
self._edit_desc_lbl = desc_lbl
info = lbl.grid_info()
row, col = int(info["row"]), int(info["column"])
row_bg = lbl.cget("bg")
current = device.name
lbl.grid_remove()
if desc_lbl is not None:
desc_lbl.grid_remove()
entry = tk.Entry(self._grid, bg=row_bg, fg=C_SER, font=FONT_IT,
bd=0, relief="flat",
insertbackground=C_PORT,
highlightthickness=1,
highlightbackground=C_PORT,
highlightcolor=C_PORT)
entry.insert(0, current)
entry.grid(row=row, column=col, columnspan=2, sticky="ew")
entry.focus_set()
entry.select_range(0, tk.END)
entry.icursor(tk.END)
self._edit_entry = entry
def commit(_e=None):
if self._edit_entry is None:
return "break"
v = entry.get().strip()
device.set_name(v)
save_settings(self.settings)
self._end_serial_edit()
return "break"
def cancel(_e=None):
self._end_serial_edit()
return "break"
entry.bind("<Return>", commit)
entry.bind("<Escape>", cancel)
entry.bind("<FocusOut>", commit)
def _end_serial_edit(self):
if self._edit_entry is None:
return
try:
self._edit_entry.destroy()
except tk.TclError:
pass
self._edit_entry = None
# restore the hidden labels — the next _refresh tick will rebuild the
# row properly (italic + columnspan if a name was set, plain otherwise)
for w in (self._edit_lbl, self._edit_desc_lbl):
if w is not None:
try:
w.grid()
except tk.TclError:
pass
self._edit_lbl = None
self._edit_desc_lbl = None
self._editing_key = None
# ── settings ──────────────────────────────────────────────────────────────
def _compute_flash_decay_k(self) -> float:
# Solve exp(-K * sqrt(d - attack)) = 0.1 → K = 2.3 / sqrt(d - attack)
d = max(self.settings["highlight_duration_s"] - FLASH_ATTACK_S, 0.1)
return 2.3 / math.sqrt(d)
def _restore_window_position(self):
x = self.settings.get("window_x")
y = self.settings.get("window_y")
if x is None or y is None:
return
self.update_idletasks()
# clamp to current virtual screen so a saved off-screen pos is recoverable
sw = self.winfo_screenwidth()
sh = self.winfo_screenheight()
x = max(0, min(int(x), sw - 50))
y = max(0, min(int(y), sh - 50))
self.geometry(f"+{x}+{y}")
def _save_window_position(self):
try:
self.settings["window_x"] = self.winfo_x()
self.settings["window_y"] = self.winfo_y()
save_settings(self.settings)
except Exception:
pass
def destroy(self):
self._save_window_position()
for t in self._terminals():
t._close_quiet()
super().destroy()
def _apply_settings(self):
"""Re-apply settings to derived state after they've been edited."""
self._flash_decay_k = self._compute_flash_decay_k()
self._last_interaction = time.time()
self._set_topmost_pair(self.settings["always_on_top"])
self._top_active = self.settings["always_on_top"]
self._update_alpha()
self._update_terminal_alpha()
self._apply_desktop_confinement()
# ── virtual-desktop confinement ─────────────────────────────────────────
def _confine_to_desktop_enabled(self) -> bool:
return (sys.platform == "win32"
and not self.settings.get("show_on_all_desktops", True))
def _apply_desktop_confinement(self):
"""If enabled (Windows), give the main strip a taskbar button so the
Virtual Desktop Manager keeps it on a single desktop, and make the rows
window + any open terminals owned by it so they follow along without
adding buttons of their own. Default off → borderless, no taskbar
button, shown on all desktops (unchanged). Takes effect on Save;
turning it back off needs a restart."""
if not self._confine_to_desktop_enabled():
return
make_taskbar_window(self.winfo_id())
set_window_icon(self.winfo_id(), _resource_path("icon.ico"))
set_window_owner(self._rows_win.winfo_id(), self.winfo_id())
for t in self._terminals():
try:
set_window_owner(t.winfo_id(), self.winfo_id())
except tk.TclError:
pass
def _confine_window(self, win):
"""Make a newly-opened window (a terminal) owned by the main strip so it
shares its virtual desktop and adds no taskbar button of its own."""
if not self._confine_to_desktop_enabled():
return
try:
set_window_owner(win.winfo_id(), self.winfo_id())
except tk.TclError:
pass
def _open_settings(self):
SettingsDialog(self)
# ── refresh ───────────────────────────────────────────────────────────────
def _refresh(self):
now = time.time()
ports = sorted(serial.tools.list_ports.comports(), key=lambda p: p.device)
# update device records — active ports
current = {p.device for p in ports}
for p in ports:
info = {
"vid": p.vid, "pid": p.pid,
"description": p.description or "",
"serial_number": p.serial_number or "",
"location": p.location or "",
}
d = self.devices.get(p.device)
if d is None: # first time we've seen this port
d = self.devices[p.device] = Device(self, p.device, info, now)
if self._initialized: # don't flash ports seen at startup
# shift back so first render lands at peak brightness, not t=0
d.flash_start = now - FLASH_ATTACK_S
else:
d.info = info
if not d.present: # port came back → re-flash as new
d.disappeared_at = None
d.flash_start = now - FLASH_ATTACK_S
# mark newly-vanished ports (always tracked; show_removed only affects display)
for d in self.devices.values():
if d.id not in current and d.present:
d.disappeared_at = now
d.flash_start = None
# expire/purge: drop records gone too long. Keep any device that still
# has a terminal or a pending open, even once its row expires.