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.
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/PKG-INFO +1 -1
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/pyproject.toml +1 -1
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/client.py +30 -9
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/server_browser.py +230 -6
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/index.html +56 -2
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/LICENSE +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/README.md +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/interactive_test.py +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/saved_plots/.gitignore +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/server.py +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/server_browser_gui.py +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/auto-render.min.js +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/katex.min.css +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/katex.min.js +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/uPlot.iife.min.js +0 -0
- {better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/uPlot.min.css +0 -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
|
-
|
|
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
|
-
|
|
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
|
|
579
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
639
|
-
|
|
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 =
|
|
645
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2
RENAMED
|
File without changes
|
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2
RENAMED
|
File without changes
|
{better_rtplot-0.4.9 → better_rtplot-0.4.11}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|