better-rtplot 0.4.9__tar.gz → 0.4.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/PKG-INFO +1 -1
  2. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/pyproject.toml +1 -1
  3. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/server_browser.py +188 -5
  4. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/index.html +15 -1
  5. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/LICENSE +0 -0
  6. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/README.md +0 -0
  7. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/client.py +0 -0
  8. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/interactive_test.py +0 -0
  9. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/saved_plots/.gitignore +0 -0
  10. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/server.py +0 -0
  11. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/server_browser_gui.py +0 -0
  12. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/auto-render.min.js +0 -0
  13. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  14. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  15. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  16. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  17. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  18. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  19. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  20. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  21. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  22. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  23. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  24. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  25. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  26. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  27. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  28. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  29. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  30. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  31. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  32. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  33. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/katex.min.css +0 -0
  34. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/katex/katex.min.js +0 -0
  35. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/uPlot.iife.min.js +0 -0
  36. {better_rtplot-0.4.9 → better_rtplot-0.4.10}/rtplot/static/uPlot.min.css +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: better-rtplot
3
- Version: 0.4.9
3
+ Version: 0.4.10
4
4
  Summary:
5
5
  License: GPL V3.0
6
6
  Author: jmontp
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.4.9"
3
+ version = "0.4.10"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "GPL V3.0"
@@ -23,6 +23,8 @@ import asyncio
23
23
  import dataclasses
24
24
  import json
25
25
  import os
26
+ import socket
27
+ import subprocess
26
28
  import struct
27
29
  import sys
28
30
  import time
@@ -36,6 +38,7 @@ from typing import Optional
36
38
  import numpy as np
37
39
  import zmq
38
40
  import zmq.asyncio
41
+ from zmq.utils.monitor import recv_monitor_message
39
42
 
40
43
  # pyzmq's asyncio integration needs event_loop.add_reader(), which the
41
44
  # Windows-default ProactorEventLoop (Python 3.8+) does not implement.
@@ -206,6 +209,7 @@ MSG_DELTA = 1
206
209
 
207
210
  ZMQ_DEFAULT_PORT = 5555
208
211
  ZMQ_CONTROL_PORT = ZMQ_DEFAULT_PORT + 1
212
+ TCP_PROBE_TIMEOUT = 0.5
209
213
 
210
214
  RECEIVED_PLOT_UPDATE = 0
211
215
  RECEIVED_DATA = 1
@@ -262,6 +266,84 @@ def _control_target_from_data(ip):
262
266
  return f"{raw}:{ZMQ_CONTROL_PORT}"
263
267
 
264
268
 
269
+ def _host_port_from_endpoint(endpoint, default_port=ZMQ_DEFAULT_PORT):
270
+ """Parse ``tcp://host:port`` or ``host[:port]`` into ``(host, port)``."""
271
+ raw = endpoint.strip()
272
+ if raw.startswith("tcp://"):
273
+ raw = raw[len("tcp://"):]
274
+ if raw.count(":") >= 1:
275
+ host, port_str = raw.rsplit(":", 1)
276
+ else:
277
+ host, port_str = raw, str(default_port)
278
+ try:
279
+ port = int(port_str)
280
+ except ValueError:
281
+ port = default_port
282
+ return host, port
283
+
284
+
285
+ def _probe_tcp_endpoint(endpoint, timeout=TCP_PROBE_TIMEOUT):
286
+ """Return ``(open, reachable, error)`` for a TCP endpoint.
287
+
288
+ ``Connection refused`` still proves the host answered at the network
289
+ layer, so it is reachable even though that particular port is closed.
290
+ """
291
+ host, port = _host_port_from_endpoint(endpoint)
292
+ try:
293
+ with socket.create_connection((host, port), timeout=timeout):
294
+ return True, True, None
295
+ except ConnectionRefusedError as exc:
296
+ return False, True, str(exc)
297
+ except OSError as exc:
298
+ return False, False, str(exc)
299
+
300
+
301
+ def _ping_host(host):
302
+ """Best-effort host reachability fallback for firewalled TCP ports."""
303
+ if not host or host == "*":
304
+ return False
305
+ if sys.platform == "win32":
306
+ cmd = ["ping", "-n", "1", "-w", "700", host]
307
+ else:
308
+ cmd = ["ping", "-c", "1", "-W", "1", host]
309
+ try:
310
+ return subprocess.run(
311
+ cmd,
312
+ stdout=subprocess.DEVNULL,
313
+ stderr=subprocess.DEVNULL,
314
+ timeout=1.5,
315
+ check=False,
316
+ ).returncode == 0
317
+ except (OSError, subprocess.TimeoutExpired):
318
+ return False
319
+
320
+
321
+ def _probe_connect_target(endpoint):
322
+ """Classify a connect-mode target for the browser status dot.
323
+
324
+ Returns ``(host_reachable, rtplot_ports_open, error)``. A reachable host
325
+ with closed rtplot ports is still a usable tab: ZeroMQ can connect now and
326
+ auto-complete the connection later when the sender starts listening.
327
+ """
328
+ host, data_port = _host_port_from_endpoint(endpoint)
329
+ data_open, data_reachable, data_err = _probe_tcp_endpoint(endpoint)
330
+ ctrl_endpoint = _control_target_from_data(endpoint)
331
+ ctrl_open, ctrl_reachable, ctrl_err = _probe_tcp_endpoint(
332
+ ctrl_endpoint, timeout=TCP_PROBE_TIMEOUT
333
+ )
334
+ host_reachable = data_reachable or ctrl_reachable
335
+ if not host_reachable and _ping_host(host):
336
+ host_reachable = True
337
+ if not host_reachable:
338
+ return (
339
+ False,
340
+ False,
341
+ f"{host} unreachable (data {data_port}: {data_err}; "
342
+ f"control {data_port + 1}: {ctrl_err})",
343
+ )
344
+ return True, bool(data_open and ctrl_open), None
345
+
346
+
265
347
  ###############################
266
348
  # Tab model #
267
349
  ###############################
@@ -274,7 +356,7 @@ class Tab:
274
356
  name: str
275
357
  mode: str # "bind" or "connect"
276
358
  endpoint: str # user-visible label, e.g. "*:5555" or "pi1:5555"
277
- status: str = "idle" # idle | streaming | error
359
+ status: str = "idle" # idle | connecting | connected | streaming | error
278
360
  error: Optional[str] = None
279
361
 
280
362
  # Plot state
@@ -302,6 +384,7 @@ class Tab:
302
384
  data_sock: Optional[zmq.Socket] = None
303
385
  ctrl_sock: Optional[zmq.Socket] = None
304
386
  receiver_task: Optional[asyncio.Task] = None
387
+ monitor_task: Optional[asyncio.Task] = None
305
388
 
306
389
  # Data buffer, lazy-allocated on first use (roughly 4 GB virtual,
307
390
  # but pages in only when written on Linux/Windows — a single tab
@@ -618,9 +701,38 @@ def _open_tab_sockets(tab: Tab):
618
701
  # Close any prior sockets first (e.g. on retry).
619
702
  _close_tab_sockets(tab)
620
703
 
704
+ connect_label = tab.endpoint
705
+ connect_data_ep = None
706
+ if tab.mode == "connect":
707
+ try:
708
+ connect_data_ep, connect_label = _normalize_connect_target(tab.endpoint)
709
+ except Exception as exc: # noqa: BLE001
710
+ tab.status = "error"
711
+ tab.error = f"invalid endpoint: {exc}"
712
+ print(f"[{tab.id}] ZMQ open failed: {tab.error}")
713
+ return
714
+
715
+ tab.endpoint = connect_label
716
+ tab.status = "connecting"
717
+ tab.error = None
718
+ host_reachable, rtplot_ports_open, peer_err = _probe_connect_target(connect_label)
719
+ if not host_reachable:
720
+ tab.status = "error"
721
+ tab.error = peer_err
722
+ print(f"[{tab.id}] peer probe failed: {peer_err}")
723
+ return
724
+ if not rtplot_ports_open:
725
+ tab.status = "idle"
726
+ tab.error = None
727
+ print(
728
+ f"[{tab.id}] host reachable, rtplot ports closed;"
729
+ f" waiting for ZMQ peer: {connect_label}"
730
+ )
731
+
621
732
  last_exc: Exception | None = None
622
733
  attempts = 4 if tab.mode == "bind" else 1
623
734
  for attempt in range(attempts):
735
+ monitor = None
624
736
  try:
625
737
  data = zmq_ctx.socket(zmq.SUB)
626
738
  data.setsockopt_string(zmq.SUBSCRIBE, "")
@@ -635,14 +747,20 @@ def _open_tab_sockets(tab: Tab):
635
747
  tab.endpoint = f"*:{ZMQ_DEFAULT_PORT}"
636
748
  print(f"[{tab.id}] ZMQ: bound on tcp://*:{ZMQ_DEFAULT_PORT}")
637
749
  else:
638
- data_ep, label = _normalize_connect_target(tab.endpoint)
639
- data.connect(data_ep)
750
+ monitor = data.get_monitor_socket(
751
+ zmq.EVENT_CONNECTED
752
+ | zmq.EVENT_DISCONNECTED
753
+ | zmq.EVENT_CONNECT_DELAYED
754
+ | zmq.EVENT_CONNECT_RETRIED
755
+ )
756
+ data.connect(connect_data_ep)
640
757
  ctrl_ep, _ = _normalize_connect_target(
641
758
  _control_target_from_data(tab.endpoint), port=ZMQ_CONTROL_PORT
642
759
  )
643
760
  ctrl.connect(ctrl_ep)
644
- tab.endpoint = label
645
- print(f"[{tab.id}] ZMQ: connected to {data_ep} (ctrl {ctrl_ep})")
761
+ tab.endpoint = connect_label
762
+ tab.monitor_task = asyncio.create_task(zmq_monitor(tab, monitor))
763
+ print(f"[{tab.id}] ZMQ: connecting to {connect_data_ep} (ctrl {ctrl_ep})")
646
764
 
647
765
  tab.data_sock = data
648
766
  tab.ctrl_sock = ctrl
@@ -657,6 +775,11 @@ def _open_tab_sockets(tab: Tab):
657
775
  except Exception: pass
658
776
  try: ctrl.close(0)
659
777
  except Exception: pass
778
+ try:
779
+ if monitor is not None:
780
+ monitor.close(0)
781
+ except Exception:
782
+ pass
660
783
  if attempt < attempts - 1:
661
784
  wait = 0.15 * (attempt + 1)
662
785
  print(
@@ -692,6 +815,62 @@ async def _cancel_task(task):
692
815
  pass
693
816
 
694
817
 
818
+ async def zmq_monitor(tab: Tab, monitor_sock):
819
+ """Track transport-level ZMQ state for connect-mode tabs."""
820
+ try:
821
+ while True:
822
+ evt = await recv_monitor_message(monitor_sock)
823
+ event = evt.get("event")
824
+ if event == zmq.EVENT_CONNECTED:
825
+ if tab.status in ("connecting", "idle") and not tab.initialized:
826
+ tab.status = "connected"
827
+ tab.error = None
828
+ await broadcast_tab(tab.id)
829
+ elif event == zmq.EVENT_DISCONNECTED:
830
+ if tab.mode == "connect":
831
+ host_reachable, rtplot_ports_open, peer_err = _probe_connect_target(tab.endpoint)
832
+ if host_reachable and not rtplot_ports_open:
833
+ tab.status = "idle"
834
+ tab.error = None
835
+ elif host_reachable:
836
+ tab.status = "idle"
837
+ tab.error = None
838
+ else:
839
+ tab.status = "error"
840
+ tab.error = peer_err
841
+ await broadcast_tab(tab.id)
842
+ elif event == zmq.EVENT_CONNECT_RETRIED:
843
+ if tab.mode == "connect" and tab.status == "error":
844
+ host_reachable, _rtplot_ports_open, peer_err = _probe_connect_target(tab.endpoint)
845
+ if host_reachable:
846
+ tab.status = "idle"
847
+ tab.error = None
848
+ else:
849
+ tab.error = peer_err
850
+ await broadcast_tab(tab.id)
851
+ elif event == zmq.EVENT_CONNECT_DELAYED:
852
+ if tab.mode == "connect" and tab.status == "error":
853
+ host_reachable, _rtplot_ports_open, peer_err = _probe_connect_target(tab.endpoint)
854
+ if host_reachable:
855
+ tab.status = "idle"
856
+ tab.error = None
857
+ else:
858
+ tab.error = peer_err
859
+ await broadcast_tab(tab.id)
860
+ except asyncio.CancelledError:
861
+ raise
862
+ except Exception as exc: # noqa: BLE001
863
+ if tab.mode == "connect" and tab.status != "streaming":
864
+ tab.status = "error"
865
+ tab.error = f"ZMQ monitor error: {exc}"
866
+ await broadcast_tab(tab.id)
867
+ finally:
868
+ try:
869
+ monitor_sock.close(0)
870
+ except Exception: # noqa: BLE001
871
+ pass
872
+
873
+
695
874
  async def send_control_event(tab: Tab, event):
696
875
  """Forward a control event to the user's Python process, best-effort."""
697
876
  if tab.ctrl_sock is None:
@@ -1003,6 +1182,7 @@ async def delete_tab(tab_id: str):
1003
1182
  if t is None:
1004
1183
  return
1005
1184
  await _cancel_task(t.receiver_task)
1185
+ await _cancel_task(t.monitor_task)
1006
1186
  _close_tab_sockets(t)
1007
1187
  # Move any viewers off this tab back to bind_me on the browser side;
1008
1188
  # server tells them via tab_removed + they re-subscribe.
@@ -1091,6 +1271,8 @@ async def reconnect_tab(tab_id: str):
1091
1271
  if t is None:
1092
1272
  return
1093
1273
  await _cancel_task(t.receiver_task)
1274
+ await _cancel_task(t.monitor_task)
1275
+ t.monitor_task = None
1094
1276
  # _open_tab_sockets closes first, then reopens. Status gets set to
1095
1277
  # "idle" on success or "error" + a message on bind/connect failure.
1096
1278
  _open_tab_sockets(t)
@@ -1467,6 +1649,7 @@ async def on_cleanup(app):
1467
1649
  await _cancel_task(app.get(key))
1468
1650
  for t in list(tabs.values()):
1469
1651
  await _cancel_task(t.receiver_task)
1652
+ await _cancel_task(t.monitor_task)
1470
1653
  _close_tab_sockets(t)
1471
1654
  for ws in list(ws_clients):
1472
1655
  try:
@@ -15,6 +15,7 @@
15
15
  .tab.active { background: #fff; color: #111; border-color: #bbb; font-weight: 600; }
16
16
  .tab .tab-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #bbb; flex: 0 0 auto; }
17
17
  .tab .tab-dot.idle { background: #bbb; }
18
+ .tab .tab-dot.connected { background: #e8b13a; box-shadow: 0 0 3px rgba(232,177,58,0.5); }
18
19
  .tab .tab-dot.streaming { background: #2ebf4e; box-shadow: 0 0 3px rgba(46,191,78,0.5); }
19
20
  .tab .tab-dot.error { background: #e05252; }
20
21
  .tab .tab-dot.connecting { background: #e8b13a; animation: pulse 1s ease-in-out infinite alternate; }
@@ -987,6 +988,19 @@
987
988
  }
988
989
 
989
990
  // ---- Tab bar rendering ----
991
+ function tabStatusTitle(t) {
992
+ if (t.error) return t.error;
993
+ if (t.status === 'streaming') return 'Receiving rtplot data';
994
+ if (t.status === 'connected') return 'ZMQ connected; waiting for plot config/data';
995
+ if (t.status === 'connecting') return 'Checking device connection';
996
+ if (t.status === 'idle') {
997
+ return t.mode === 'bind'
998
+ ? 'Listening for a sender'
999
+ : 'Host reachable; rtplot ports are not connected';
1000
+ }
1001
+ return 'Status unknown';
1002
+ }
1003
+
990
1004
  function renderTabs() {
991
1005
  tabbar.innerHTML = '';
992
1006
  // Stable order: bind_me first, then others in insertion order.
@@ -1002,7 +1016,7 @@
1002
1016
 
1003
1017
  const dot = document.createElement('span');
1004
1018
  dot.className = 'tab-dot ' + (t.status || 'idle');
1005
- if (t.error) dot.title = t.error;
1019
+ dot.title = tabStatusTitle(t);
1006
1020
  el.appendChild(dot);
1007
1021
 
1008
1022
  // Stacked labels: name on top, endpoint on a muted subline so
File without changes
File without changes