better-rtplot 0.4.9__tar.gz → 0.4.11__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.11}/PKG-INFO +1 -1
  2. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/pyproject.toml +1 -1
  3. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/client.py +30 -9
  4. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/server_browser.py +230 -6
  5. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/index.html +56 -2
  6. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/LICENSE +0 -0
  7. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/README.md +0 -0
  8. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/interactive_test.py +0 -0
  9. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/saved_plots/.gitignore +0 -0
  10. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/server.py +0 -0
  11. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/server_browser_gui.py +0 -0
  12. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/auto-render.min.js +0 -0
  13. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  14. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  15. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  16. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  17. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  18. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  19. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  20. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  21. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  22. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  23. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  24. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  25. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  26. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  27. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  28. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  29. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  30. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  31. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  32. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  33. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/katex.min.css +0 -0
  34. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/katex.min.js +0 -0
  35. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/uPlot.iife.min.js +0 -0
  36. {better_rtplot-0.4.9 → better_rtplot-0.4.11}/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.11
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.11"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "GPL V3.0"
@@ -22,7 +22,7 @@ socket = context.socket(zmq.PUB)
22
22
  control_socket = context.socket(zmq.PULL)
23
23
  control_socket.setsockopt(zmq.RCVHWM, 1000)
24
24
  #Non-blocking local state for controls, drained on each poll_controls() call
25
- _control_slider_values = {}
25
+ _control_values = {}
26
26
  _control_button_events = []
27
27
 
28
28
  # Cached payload of the most recent initialize_plots() call. The server
@@ -247,10 +247,27 @@ class Text:
247
247
  })
248
248
 
249
249
 
250
+ @dataclass
251
+ class TextInput:
252
+ id: str
253
+ label: str
254
+ value: str = ""
255
+ placeholder: Optional[str] = None
256
+ max_length: Optional[int] = None
257
+ height: Optional[float] = None
258
+
259
+ def to_dict(self):
260
+ return _drop_none({
261
+ "type": "text_input", "id": self.id, "label": self.label,
262
+ "value": self.value, "placeholder": self.placeholder,
263
+ "max_length": self.max_length, "height": self.height,
264
+ })
265
+
266
+
250
267
  @dataclass
251
268
  class ControlsRow:
252
269
  """A row of control widgets, rendered in place of a plot."""
253
- controls: List[Union[Button, Slider, Dial, Display, Text, dict]] = field(default_factory=list)
270
+ controls: List[Union[Button, Slider, Dial, Display, Text, TextInput, dict]] = field(default_factory=list)
254
271
 
255
272
  def to_dict(self):
256
273
  return {"controls": [
@@ -536,7 +553,9 @@ def _send_initialize_with_handshake(cfg, timeout):
536
553
  elif evtype == "button":
537
554
  _control_button_events.append(event.get("id"))
538
555
  elif evtype == "slider":
539
- _control_slider_values[event.get("id")] = float(event.get("value", 0.0))
556
+ _control_values[event.get("id")] = float(event.get("value", 0.0))
557
+ elif evtype == "text":
558
+ _control_values[event.get("id")] = str(event.get("value", ""))
540
559
  # Unknown / resend_config: ignore during handshake.
541
560
  # Resend if the window elapsed without an ack; PUB/SUB will
542
561
  # drop the first send during slow-join but retries land.
@@ -575,8 +594,8 @@ def poll_controls():
575
594
  """Drain the return channel non-blocking and return current control state.
576
595
 
577
596
  Returns a ControlState(values, buttons) where:
578
- - values: dict of {slider_id: float} with the latest value of every
579
- slider the server has told us about since process start.
597
+ - values: dict of latest control values keyed by id. Sliders/dials
598
+ remain floats; text_input controls remain strings.
580
599
  - buttons: list of button ids that fired since the previous poll
581
600
  (cleared after this call).
582
601
 
@@ -587,7 +606,7 @@ def poll_controls():
587
606
  Reconnect button on a tab), it asks us to re-publish the cached
588
607
  initialize_plots() payload so the plot rewires without the user
589
608
  restarting their script. The request is consumed silently — the
590
- returned ControlState only ever contains slider/button events.
609
+ returned ControlState only ever contains control/button events.
591
610
  """
592
611
  global _control_button_events
593
612
  while True:
@@ -601,7 +620,9 @@ def poll_controls():
601
620
  if evtype == "button":
602
621
  _control_button_events.append(event.get("id"))
603
622
  elif evtype == "slider":
604
- _control_slider_values[event.get("id")] = float(event.get("value", 0.0))
623
+ _control_values[event.get("id")] = float(event.get("value", 0.0))
624
+ elif evtype == "text":
625
+ _control_values[event.get("id")] = str(event.get("value", ""))
605
626
  elif evtype == "resend_config":
606
627
  if plot_desc_dict is not None:
607
628
  socket.send_string(SENDING_PLOT_UPDATE)
@@ -615,7 +636,7 @@ def poll_controls():
615
636
 
616
637
  buttons = _control_button_events
617
638
  _control_button_events = []
618
- return ControlState(values=dict(_control_slider_values), buttons=buttons)
639
+ return ControlState(values=dict(_control_values), buttons=buttons)
619
640
 
620
641
 
621
642
  def save_snapshot(path, server_url=None, animate=False, timeout=5.0):
@@ -664,4 +685,4 @@ def save_snapshot(path, server_url=None, animate=False, timeout=5.0):
664
685
  abs_path = _os.path.abspath(path)
665
686
  with open(abs_path, "wb") as fh:
666
687
  fh.write(body)
667
- return abs_path
688
+ return abs_path
@@ -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
@@ -293,6 +375,7 @@ class Tab:
293
375
  layout: list = field(default_factory=list)
294
376
  control_rows: list = field(default_factory=list)
295
377
  slider_values: dict = field(default_factory=dict)
378
+ text_values: dict = field(default_factory=dict)
296
379
  display_values: dict = field(default_factory=dict)
297
380
  display_dirty: set = field(default_factory=set)
298
381
  fps: float = 0.0
@@ -302,6 +385,7 @@ class Tab:
302
385
  data_sock: Optional[zmq.Socket] = None
303
386
  ctrl_sock: Optional[zmq.Socket] = None
304
387
  receiver_task: Optional[asyncio.Task] = None
388
+ monitor_task: Optional[asyncio.Task] = None
305
389
 
306
390
  # Data buffer, lazy-allocated on first use (roughly 4 GB virtual,
307
391
  # but pages in only when written on Linux/Windows — a single tab
@@ -416,6 +500,7 @@ def parse_config(tab: Tab, json_config):
416
500
  trace_info = []
417
501
  control_rows = []
418
502
  slider_values = {}
503
+ text_values = {}
419
504
  layout = []
420
505
  num_datapoints_in_plot = DEFAULT_NUM_DATAPOINTS_IN_PLOT
421
506
 
@@ -431,6 +516,8 @@ def parse_config(tab: Tab, json_config):
431
516
  for element in row:
432
517
  if element.get("type") in ("slider", "dial") and "value" in element:
433
518
  slider_values[element["id"]] = float(element["value"])
519
+ elif element.get("type") == "text_input":
520
+ text_values[element["id"]] = str(element.get("value", ""))
434
521
  layout.append({"kind": "controls", "index": len(control_rows)})
435
522
  control_rows.append(row)
436
523
  continue
@@ -463,6 +550,17 @@ def parse_config(tab: Tab, json_config):
463
550
  }
464
551
  tab.display_dirty = {d for d in tab.display_dirty if d in active_display_ids}
465
552
  tab.slider_values = slider_values
553
+ active_text_ids = {
554
+ el["id"]
555
+ for row in control_rows
556
+ for el in row
557
+ if el.get("type") == "text_input"
558
+ }
559
+ tab.text_values = {
560
+ text_id: tab.text_values.get(text_id, default_value)
561
+ for text_id, default_value in text_values.items()
562
+ if text_id in active_text_ids
563
+ }
466
564
  tab.layout = layout
467
565
 
468
566
  num_traces = sum(traces_per_plot)
@@ -502,11 +600,20 @@ def build_config_message(tab: Tab, config_dict) -> dict:
502
600
  "controls": tab.control_rows,
503
601
  "layout": tab.layout,
504
602
  "slider_values": dict(tab.slider_values),
603
+ "text_values": dict(tab.text_values),
505
604
  "display_values": dict(tab.display_values),
506
605
  "row_layout": bool(NEW_SUBPLOT_IN_ROW),
507
606
  }
508
607
 
509
608
 
609
+ def refresh_config_message(tab: Tab):
610
+ """Rebuild the browser config payload using the tab's latest control state."""
611
+ if tab.config_dict is None:
612
+ tab.config_message = None
613
+ else:
614
+ tab.config_message = build_config_message(tab, tab.config_dict)
615
+
616
+
510
617
  ###############################
511
618
  # Binary payload construction #
512
619
  ###############################
@@ -618,9 +725,38 @@ def _open_tab_sockets(tab: Tab):
618
725
  # Close any prior sockets first (e.g. on retry).
619
726
  _close_tab_sockets(tab)
620
727
 
728
+ connect_label = tab.endpoint
729
+ connect_data_ep = None
730
+ if tab.mode == "connect":
731
+ try:
732
+ connect_data_ep, connect_label = _normalize_connect_target(tab.endpoint)
733
+ except Exception as exc: # noqa: BLE001
734
+ tab.status = "error"
735
+ tab.error = f"invalid endpoint: {exc}"
736
+ print(f"[{tab.id}] ZMQ open failed: {tab.error}")
737
+ return
738
+
739
+ tab.endpoint = connect_label
740
+ tab.status = "connecting"
741
+ tab.error = None
742
+ host_reachable, rtplot_ports_open, peer_err = _probe_connect_target(connect_label)
743
+ if not host_reachable:
744
+ tab.status = "error"
745
+ tab.error = peer_err
746
+ print(f"[{tab.id}] peer probe failed: {peer_err}")
747
+ return
748
+ if not rtplot_ports_open:
749
+ tab.status = "idle"
750
+ tab.error = None
751
+ print(
752
+ f"[{tab.id}] host reachable, rtplot ports closed;"
753
+ f" waiting for ZMQ peer: {connect_label}"
754
+ )
755
+
621
756
  last_exc: Exception | None = None
622
757
  attempts = 4 if tab.mode == "bind" else 1
623
758
  for attempt in range(attempts):
759
+ monitor = None
624
760
  try:
625
761
  data = zmq_ctx.socket(zmq.SUB)
626
762
  data.setsockopt_string(zmq.SUBSCRIBE, "")
@@ -635,14 +771,20 @@ def _open_tab_sockets(tab: Tab):
635
771
  tab.endpoint = f"*:{ZMQ_DEFAULT_PORT}"
636
772
  print(f"[{tab.id}] ZMQ: bound on tcp://*:{ZMQ_DEFAULT_PORT}")
637
773
  else:
638
- data_ep, label = _normalize_connect_target(tab.endpoint)
639
- data.connect(data_ep)
774
+ monitor = data.get_monitor_socket(
775
+ zmq.EVENT_CONNECTED
776
+ | zmq.EVENT_DISCONNECTED
777
+ | zmq.EVENT_CONNECT_DELAYED
778
+ | zmq.EVENT_CONNECT_RETRIED
779
+ )
780
+ data.connect(connect_data_ep)
640
781
  ctrl_ep, _ = _normalize_connect_target(
641
782
  _control_target_from_data(tab.endpoint), port=ZMQ_CONTROL_PORT
642
783
  )
643
784
  ctrl.connect(ctrl_ep)
644
- tab.endpoint = label
645
- print(f"[{tab.id}] ZMQ: connected to {data_ep} (ctrl {ctrl_ep})")
785
+ tab.endpoint = connect_label
786
+ tab.monitor_task = asyncio.create_task(zmq_monitor(tab, monitor))
787
+ print(f"[{tab.id}] ZMQ: connecting to {connect_data_ep} (ctrl {ctrl_ep})")
646
788
 
647
789
  tab.data_sock = data
648
790
  tab.ctrl_sock = ctrl
@@ -657,6 +799,11 @@ def _open_tab_sockets(tab: Tab):
657
799
  except Exception: pass
658
800
  try: ctrl.close(0)
659
801
  except Exception: pass
802
+ try:
803
+ if monitor is not None:
804
+ monitor.close(0)
805
+ except Exception:
806
+ pass
660
807
  if attempt < attempts - 1:
661
808
  wait = 0.15 * (attempt + 1)
662
809
  print(
@@ -692,6 +839,62 @@ async def _cancel_task(task):
692
839
  pass
693
840
 
694
841
 
842
+ async def zmq_monitor(tab: Tab, monitor_sock):
843
+ """Track transport-level ZMQ state for connect-mode tabs."""
844
+ try:
845
+ while True:
846
+ evt = await recv_monitor_message(monitor_sock)
847
+ event = evt.get("event")
848
+ if event == zmq.EVENT_CONNECTED:
849
+ if tab.status in ("connecting", "idle") and not tab.initialized:
850
+ tab.status = "connected"
851
+ tab.error = None
852
+ await broadcast_tab(tab.id)
853
+ elif event == zmq.EVENT_DISCONNECTED:
854
+ if tab.mode == "connect":
855
+ host_reachable, rtplot_ports_open, peer_err = _probe_connect_target(tab.endpoint)
856
+ if host_reachable and not rtplot_ports_open:
857
+ tab.status = "idle"
858
+ tab.error = None
859
+ elif host_reachable:
860
+ tab.status = "idle"
861
+ tab.error = None
862
+ else:
863
+ tab.status = "error"
864
+ tab.error = peer_err
865
+ await broadcast_tab(tab.id)
866
+ elif event == zmq.EVENT_CONNECT_RETRIED:
867
+ if tab.mode == "connect" and tab.status == "error":
868
+ host_reachable, _rtplot_ports_open, peer_err = _probe_connect_target(tab.endpoint)
869
+ if host_reachable:
870
+ tab.status = "idle"
871
+ tab.error = None
872
+ else:
873
+ tab.error = peer_err
874
+ await broadcast_tab(tab.id)
875
+ elif event == zmq.EVENT_CONNECT_DELAYED:
876
+ if tab.mode == "connect" and tab.status == "error":
877
+ host_reachable, _rtplot_ports_open, peer_err = _probe_connect_target(tab.endpoint)
878
+ if host_reachable:
879
+ tab.status = "idle"
880
+ tab.error = None
881
+ else:
882
+ tab.error = peer_err
883
+ await broadcast_tab(tab.id)
884
+ except asyncio.CancelledError:
885
+ raise
886
+ except Exception as exc: # noqa: BLE001
887
+ if tab.mode == "connect" and tab.status != "streaming":
888
+ tab.status = "error"
889
+ tab.error = f"ZMQ monitor error: {exc}"
890
+ await broadcast_tab(tab.id)
891
+ finally:
892
+ try:
893
+ monitor_sock.close(0)
894
+ except Exception: # noqa: BLE001
895
+ pass
896
+
897
+
695
898
  async def send_control_event(tab: Tab, event):
696
899
  """Forward a control event to the user's Python process, best-effort."""
697
900
  if tab.ctrl_sock is None:
@@ -773,7 +976,7 @@ async def zmq_receiver(tab: Tab):
773
976
 
774
977
  tab.last_config_error = None # clear: this one was good
775
978
  tab.config_dict = cfg
776
- tab.config_message = build_config_message(tab, cfg)
979
+ refresh_config_message(tab)
777
980
  tab.initialized = True
778
981
  fps = None
779
982
  tab.fps = 0.0
@@ -797,6 +1000,10 @@ async def zmq_receiver(tab: Tab):
797
1000
  await send_control_event(
798
1001
  tab, {"type": "slider", "id": sid, "value": svalue}
799
1002
  )
1003
+ for text_id, text_value in tab.text_values.items():
1004
+ await send_control_event(
1005
+ tab, {"type": "text", "id": text_id, "value": text_value}
1006
+ )
800
1007
 
801
1008
  elif category == RECEIVED_DATA:
802
1009
  arr = await _recv_array_async(sock)
@@ -1003,6 +1210,7 @@ async def delete_tab(tab_id: str):
1003
1210
  if t is None:
1004
1211
  return
1005
1212
  await _cancel_task(t.receiver_task)
1213
+ await _cancel_task(t.monitor_task)
1006
1214
  _close_tab_sockets(t)
1007
1215
  # Move any viewers off this tab back to bind_me on the browser side;
1008
1216
  # server tells them via tab_removed + they re-subscribe.
@@ -1091,6 +1299,8 @@ async def reconnect_tab(tab_id: str):
1091
1299
  if t is None:
1092
1300
  return
1093
1301
  await _cancel_task(t.receiver_task)
1302
+ await _cancel_task(t.monitor_task)
1303
+ t.monitor_task = None
1094
1304
  # _open_tab_sockets closes first, then reopens. Status gets set to
1095
1305
  # "idle" on success or "error" + a message on bind/connect failure.
1096
1306
  _open_tab_sockets(t)
@@ -1160,6 +1370,7 @@ async def handle_ws(request):
1160
1370
  },
1161
1371
  )
1162
1372
  if t.config_message is not None:
1373
+ refresh_config_message(t)
1163
1374
  await ws_send_text(ws, t.config_message)
1164
1375
  if t.display_values:
1165
1376
  await ws_send_text(
@@ -1220,9 +1431,21 @@ async def handle_ws(request):
1220
1431
  t = tabs.get(tid)
1221
1432
  if sid and t is not None:
1222
1433
  t.slider_values[sid] = value
1434
+ refresh_config_message(t)
1223
1435
  await send_control_event(
1224
1436
  t, {"type": "slider", "id": sid, "value": value}
1225
1437
  )
1438
+ elif ptype == "control_text":
1439
+ text_id = payload.get("id")
1440
+ tid = ws_tab.get(ws, BIND_ME_ID)
1441
+ t = tabs.get(tid)
1442
+ if text_id and t is not None:
1443
+ value = str(payload.get("value", ""))
1444
+ t.text_values[text_id] = value
1445
+ refresh_config_message(t)
1446
+ await send_control_event(
1447
+ t, {"type": "text", "id": text_id, "value": value}
1448
+ )
1226
1449
  elif msg.type == WSMsgType.ERROR:
1227
1450
  break
1228
1451
  finally:
@@ -1467,6 +1690,7 @@ async def on_cleanup(app):
1467
1690
  await _cancel_task(app.get(key))
1468
1691
  for t in list(tabs.values()):
1469
1692
  await _cancel_task(t.receiver_task)
1693
+ await _cancel_task(t.monitor_task)
1470
1694
  _close_tab_sockets(t)
1471
1695
  for ws in list(ws_clients):
1472
1696
  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; }
@@ -94,6 +95,8 @@
94
95
  .ctrl-numinput { width: 72px; font-family: monospace; font-size: calc(13px * var(--ui-scale)); padding: 4px 6px; border: 1px solid #b8b8b8; border-radius: 3px; background: #fff; color: #222; text-align: right; -moz-appearance: textfield; }
95
96
  .ctrl-numinput::-webkit-outer-spin-button,
96
97
  .ctrl-numinput::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
98
+ .ctrl-textinput { min-width: 200px; width: min(320px, 100%); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: calc(13px * var(--ui-scale)); padding: 7px 10px; border: 1px solid #b8b8b8; border-radius: 3px; background: #fff; color: #222; text-align: left; }
99
+ .ctrl-textinput:focus { outline: none; border-color: #2a5db0; box-shadow: 0 0 0 2px rgba(42, 93, 176, 0.14); }
97
100
  .ctrl-nudgebtn { width: 26px; height: 26px; font-size: calc(15px * var(--ui-scale)); font-weight: 600; line-height: 1; padding: 0; border: 1px solid #b8b8b8; background: #f7f7f7; color: #333; cursor: pointer; border-radius: 3px; }
98
101
  .ctrl-nudgebtn:hover { background: #e9e9e9; }
99
102
  .ctrl-nudgebtn:active { background: #dcdcdc; }
@@ -414,7 +417,7 @@
414
417
  let socket = null;
415
418
  let pendingFrame = false;
416
419
  let lastStatus = { fps: 0, statusByte: 0, nonPlot: 0, peers: 0, dirty: true };
417
- const controlElements = { displays: {}, displayFormats: {}, displayKinds: {}, sliders: {} };
420
+ const controlElements = { displays: {}, displayFormats: {}, displayKinds: {}, sliders: {}, textInputs: {} };
418
421
 
419
422
  function sendCtrl(msg) {
420
423
  if (socket && socket.readyState === WebSocket.OPEN) {
@@ -683,6 +686,34 @@
683
686
  item.appendChild(val);
684
687
  controlElements.displays[el.id] = val;
685
688
  controlElements.displayKinds[el.id] = 'text';
689
+ } else if (el.type === 'text_input') {
690
+ item.classList.add('ctrl-text-input', 'flex');
691
+ if (el.label) {
692
+ const lbl = document.createElement('label');
693
+ lbl.textContent = el.label;
694
+ item.appendChild(lbl);
695
+ }
696
+ const input = document.createElement('input');
697
+ input.type = 'text';
698
+ input.className = 'ctrl-textinput';
699
+ input.value = (el.value !== undefined && el.value !== null) ? String(el.value) : '';
700
+ if (el.placeholder !== undefined && el.placeholder !== null) {
701
+ input.placeholder = String(el.placeholder);
702
+ }
703
+ if (el.max_length !== undefined && Number.isFinite(Number(el.max_length))) {
704
+ input.maxLength = Number(el.max_length);
705
+ }
706
+ const commit = () => sendCtrl({ type: 'control_text', id: el.id, value: input.value });
707
+ input.addEventListener('change', commit);
708
+ input.addEventListener('blur', commit);
709
+ input.addEventListener('keydown', (e) => {
710
+ if (e.key === 'Enter') {
711
+ e.preventDefault();
712
+ commit();
713
+ }
714
+ });
715
+ item.appendChild(input);
716
+ controlElements.textInputs[el.id] = input;
686
717
  }
687
718
  return item;
688
719
  }
@@ -721,6 +752,14 @@
721
752
  }
722
753
  }
723
754
 
755
+ function applyTextValues(values) {
756
+ if (!values) return;
757
+ for (const [id, val] of Object.entries(values)) {
758
+ const input = controlElements.textInputs[id];
759
+ if (input) input.value = (val !== undefined && val !== null) ? String(val) : '';
760
+ }
761
+ }
762
+
724
763
  function applyDisplayValues(values) {
725
764
  if (!values) return;
726
765
  for (const [id, val] of Object.entries(values)) {
@@ -751,6 +790,7 @@
751
790
  controlElements.displayFormats = {};
752
791
  controlElements.displayKinds = {};
753
792
  controlElements.sliders = {};
793
+ controlElements.textInputs = {};
754
794
  }
755
795
 
756
796
  function showEmptyState(text) {
@@ -858,6 +898,7 @@
858
898
  });
859
899
  totalTraces = traceOffset;
860
900
  applySliderValues(cfg.slider_values);
901
+ applyTextValues(cfg.text_values);
861
902
  applyDisplayValues(cfg.display_values);
862
903
  // Render LaTeX in everything we just built: uPlot's title,
863
904
  // x/y axis labels, and legend (all rendered as HTML), plus the
@@ -987,6 +1028,19 @@
987
1028
  }
988
1029
 
989
1030
  // ---- Tab bar rendering ----
1031
+ function tabStatusTitle(t) {
1032
+ if (t.error) return t.error;
1033
+ if (t.status === 'streaming') return 'Receiving rtplot data';
1034
+ if (t.status === 'connected') return 'ZMQ connected; waiting for plot config/data';
1035
+ if (t.status === 'connecting') return 'Checking device connection';
1036
+ if (t.status === 'idle') {
1037
+ return t.mode === 'bind'
1038
+ ? 'Listening for a sender'
1039
+ : 'Host reachable; rtplot ports are not connected';
1040
+ }
1041
+ return 'Status unknown';
1042
+ }
1043
+
990
1044
  function renderTabs() {
991
1045
  tabbar.innerHTML = '';
992
1046
  // Stable order: bind_me first, then others in insertion order.
@@ -1002,7 +1056,7 @@
1002
1056
 
1003
1057
  const dot = document.createElement('span');
1004
1058
  dot.className = 'tab-dot ' + (t.status || 'idle');
1005
- if (t.error) dot.title = t.error;
1059
+ dot.title = tabStatusTitle(t);
1006
1060
  el.appendChild(dot);
1007
1061
 
1008
1062
  // Stacked labels: name on top, endpoint on a muted subline so
File without changes
File without changes