better-rtplot 0.4.8__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.8 → better_rtplot-0.4.10}/PKG-INFO +1 -1
  2. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/pyproject.toml +1 -1
  3. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/client.py +74 -6
  4. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/server_browser.py +194 -5
  5. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/index.html +15 -1
  6. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/LICENSE +0 -0
  7. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/README.md +0 -0
  8. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/interactive_test.py +0 -0
  9. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/saved_plots/.gitignore +0 -0
  10. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/server.py +0 -0
  11. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/server_browser_gui.py +0 -0
  12. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/auto-render.min.js +0 -0
  13. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  14. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  15. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  16. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  17. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  18. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  19. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  20. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  21. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  22. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  23. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  24. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  25. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  26. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  27. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  28. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  29. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  30. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  31. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  32. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  33. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/katex.min.css +0 -0
  34. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/katex/katex.min.js +0 -0
  35. {better_rtplot-0.4.8 → better_rtplot-0.4.10}/rtplot/static/uPlot.iife.min.js +0 -0
  36. {better_rtplot-0.4.8 → 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.8
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.8"
3
+ version = "0.4.10"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "GPL V3.0"
@@ -414,8 +414,17 @@ def send_array(A, flags=0, copy=True, track=False):
414
414
  socket.send(A, flags, copy=copy, track=track)
415
415
 
416
416
 
417
- def initialize_plots(plot_descriptions=1):
418
- """Send a json description of desired plot.
417
+ def initialize_plots(plot_descriptions=1, handshake_timeout=2.0):
418
+ """Send a json description of desired plot and block until the
419
+ server acknowledges it.
420
+
421
+ Without the handshake, a fast script that calls ``initialize_plots()``
422
+ and immediately starts firing ``send_array()`` can lose the config
423
+ frame to ZMQ's PUB/SUB slow-joiner window — the server then drops
424
+ every subsequent data frame because no plot is declared yet and the
425
+ browser just stays blank. We resend the config on a short schedule
426
+ until the server PUSHes back a ``config_ack``, guaranteeing the
427
+ plot is live by the time this call returns.
419
428
 
420
429
  Inputs
421
430
  ------
@@ -426,6 +435,11 @@ def initialize_plots(plot_descriptions=1):
426
435
  - list of str: one plot, one trace per name
427
436
  - list of list of str: one plot per sublist
428
437
  - list of dict: multiple plots, each with full styling
438
+ handshake_timeout:
439
+ Max seconds to wait for the config_ack. On timeout we print a
440
+ warning and return (so an old server without the handshake
441
+ feature, or a server on a flaky network, doesn't block the
442
+ caller forever). Set to 0 to skip the handshake entirely.
429
443
  """
430
444
  global plot_desc_dict
431
445
 
@@ -477,11 +491,65 @@ def initialize_plots(plot_descriptions=1):
477
491
  else:
478
492
  raise TypeError("Incorrect usage of initialize_plots, verify github for usage")
479
493
 
480
- #Send the category
481
- socket.send_string(SENDING_PLOT_UPDATE)
494
+ _send_initialize_with_handshake(plot_desc_dict, handshake_timeout)
482
495
 
483
- #Send the description
484
- socket.send_json(plot_desc_dict)
496
+
497
+ def _send_initialize_with_handshake(cfg, timeout):
498
+ """Publish ``cfg`` and block until the server acks it.
499
+
500
+ Any control events (button presses, slider updates) that happen to
501
+ arrive during the handshake are stashed in the module-level caches
502
+ so the next ``poll_controls()`` call still sees them — we don't
503
+ want the handshake to swallow real user interaction.
504
+ """
505
+ global _control_button_events
506
+
507
+ def _send_once():
508
+ socket.send_string(SENDING_PLOT_UPDATE)
509
+ socket.send_json(cfg)
510
+
511
+ _send_once()
512
+
513
+ if timeout is None or timeout <= 0:
514
+ return # opt-out: fire-and-forget like the pre-handshake behavior
515
+
516
+ deadline = time.time() + timeout
517
+ resend_interval = 0.2
518
+ last_send = time.time()
519
+
520
+ poller = zmq.Poller()
521
+ poller.register(control_socket, zmq.POLLIN)
522
+
523
+ while time.time() < deadline:
524
+ remaining_ms = max(10, int((deadline - time.time()) * 1000))
525
+ next_resend_ms = max(1, int((last_send + resend_interval - time.time()) * 1000))
526
+ wait_ms = min(remaining_ms, next_resend_ms)
527
+ events = dict(poller.poll(wait_ms))
528
+ if control_socket in events:
529
+ try:
530
+ event = control_socket.recv_json(flags=zmq.NOBLOCK)
531
+ except (zmq.Again, zmq.ZMQError):
532
+ continue
533
+ evtype = event.get("type")
534
+ if evtype == "config_ack":
535
+ return
536
+ elif evtype == "button":
537
+ _control_button_events.append(event.get("id"))
538
+ elif evtype == "slider":
539
+ _control_slider_values[event.get("id")] = float(event.get("value", 0.0))
540
+ # Unknown / resend_config: ignore during handshake.
541
+ # Resend if the window elapsed without an ack; PUB/SUB will
542
+ # drop the first send during slow-join but retries land.
543
+ if time.time() - last_send >= resend_interval:
544
+ _send_once()
545
+ last_send = time.time()
546
+
547
+ print(
548
+ "rtplot.client: initialize_plots() timed out waiting for server ack"
549
+ f" after {timeout:.1f}s — the plot config may have been dropped."
550
+ " If you're on an older server (rtplot < 0.4.9) this warning is"
551
+ " expected and harmless."
552
+ )
485
553
 
486
554
 
487
555
  def set_display(display_id: str, value):
@@ -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:
@@ -780,6 +959,12 @@ async def zmq_receiver(tab: Tab):
780
959
  last_time = now
781
960
  tab.status = "streaming"
782
961
  tab.error = None
962
+ # Blocking-handshake ack: tells initialize_plots() on the
963
+ # client side that the config made it past PUB/SUB's
964
+ # slow-joiner window so it can stop resending and return.
965
+ # Old clients ignore unknown event types, so this is safe
966
+ # to emit unconditionally.
967
+ await send_control_event(tab, {"type": "config_ack"})
783
968
  await broadcast_text_tab(tab.id, tab.config_message)
784
969
  await broadcast_tab(tab.id)
785
970
  snap = make_snapshot_message(tab)
@@ -997,6 +1182,7 @@ async def delete_tab(tab_id: str):
997
1182
  if t is None:
998
1183
  return
999
1184
  await _cancel_task(t.receiver_task)
1185
+ await _cancel_task(t.monitor_task)
1000
1186
  _close_tab_sockets(t)
1001
1187
  # Move any viewers off this tab back to bind_me on the browser side;
1002
1188
  # server tells them via tab_removed + they re-subscribe.
@@ -1085,6 +1271,8 @@ async def reconnect_tab(tab_id: str):
1085
1271
  if t is None:
1086
1272
  return
1087
1273
  await _cancel_task(t.receiver_task)
1274
+ await _cancel_task(t.monitor_task)
1275
+ t.monitor_task = None
1088
1276
  # _open_tab_sockets closes first, then reopens. Status gets set to
1089
1277
  # "idle" on success or "error" + a message on bind/connect failure.
1090
1278
  _open_tab_sockets(t)
@@ -1461,6 +1649,7 @@ async def on_cleanup(app):
1461
1649
  await _cancel_task(app.get(key))
1462
1650
  for t in list(tabs.values()):
1463
1651
  await _cancel_task(t.receiver_task)
1652
+ await _cancel_task(t.monitor_task)
1464
1653
  _close_tab_sockets(t)
1465
1654
  for ws in list(ws_clients):
1466
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