better-rtplot 0.3.0__tar.gz → 0.4.1__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.
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.1
2
+ Name: better-rtplot
3
+ Version: 0.4.1
4
+ Summary:
5
+ License: GPL V3.0
6
+ Author: jmontp
7
+ Author-email: jmontp@umich.edu
8
+ Requires-Python: >=3.9,<3.13
9
+ Classifier: License :: Other/Proprietary License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Provides-Extra: browser
16
+ Provides-Extra: server
17
+ Requires-Dist: aiohttp (>=3.9.0) ; extra == "browser"
18
+ Requires-Dist: numpy (>=1.23.5)
19
+ Requires-Dist: pyqtgraph (>=0.13.0) ; extra == "server"
20
+ Requires-Dist: pyside6 (>6.4.0) ; extra == "server"
21
+ Requires-Dist: pyzmq (>=25.0.0)
22
+ Description-Content-Type: text/markdown
23
+
24
+ # rtplot — real-time plotting over ZMQ
25
+
26
+ **rtplot** pushes live data from a Python script to a browser plot —
27
+ locally or across a network — in a few lines of code. The plot page
28
+ also hosts interactive widgets (buttons, sliders, dials, numeric / text
29
+ displays) that feed values back to the sender in real time.
30
+
31
+ Typical use: a robot or data-acquisition script runs on a Raspberry Pi,
32
+ and you watch signals and tweak gains from a laptop on the same Wi-Fi.
33
+
34
+ ---
35
+
36
+ ## How it works
37
+
38
+ Two processes talking over ZMQ, plus a browser viewer:
39
+
40
+ ```mermaid
41
+ flowchart TD
42
+ script["Your Python script<br/>send_array() · poll_controls()"]
43
+ subgraph server["rtplot-server"]
44
+ browser["browser tab<br/>localhost:8050"]
45
+ end
46
+ script -- "data :5555" --> server
47
+ server -- "controls :5556" --> script
48
+ ```
49
+
50
+ Sender and server don't have to be on the same machine — see the
51
+ [networking guide](docs/networking.md).
52
+
53
+ ---
54
+
55
+ ## Install
56
+
57
+ **Server** — grab the prebuilt binary from the
58
+ [Releases page](https://github.com/jmontp/rtplot/releases):
59
+
60
+ | Platform | Asset |
61
+ |---|---|
62
+ | Windows | `rtplot-server-<version>-windows-x64.exe` |
63
+ | Linux | `rtplot-server-<version>-linux-x86_64.tar.gz` |
64
+ | macOS (Apple Silicon) | `rtplot-server-<version>-macos-arm64.tar.gz` |
65
+
66
+ No Python needed on the viewing machine. On Windows the binary opens
67
+ a small Tk status window showing the listening URL.
68
+
69
+ **Client** — pip install in the env that runs your script:
70
+
71
+ ```bash
72
+ pip install better-rtplot
73
+ ```
74
+
75
+ (If you'd rather run the server from Python too, use
76
+ `pip install "better-rtplot[browser]"` — see the
77
+ [API reference](docs/api.md#install-detail).)
78
+
79
+ ---
80
+
81
+ ## Your first plot
82
+
83
+ ```mermaid
84
+ flowchart TD
85
+ t1["terminal 1: start the server"]
86
+ t2["terminal 2: run your script"]
87
+ br["browser: open localhost:8050"]
88
+ t1 --> t2 --> br
89
+ ```
90
+
91
+ **Terminal 1 — start the server.** Run the prebuilt `rtplot-server`
92
+ binary you downloaded above. Open `http://localhost:8050` in a
93
+ browser — the page is blank until data arrives.
94
+
95
+ **Terminal 2 — run your sender script.** Save as `my_plot.py`:
96
+
97
+ ```python
98
+ from rtplot import client
99
+ import time
100
+
101
+ client.local_plot()
102
+ client.initialize_plots(["my signal"])
103
+
104
+ for i in range(1000):
105
+ client.send_array(i * 0.01)
106
+ time.sleep(0.01)
107
+ ```
108
+
109
+ ```bash
110
+ python my_plot.py
111
+ ```
112
+
113
+ A rising line now draws itself in the browser tab.
114
+
115
+ ---
116
+
117
+ ## Highlights
118
+
119
+ - **Fast.** Binary WebSocket deltas up to 1 kHz; the browser coalesces
120
+ samples into one repaint per `requestAnimationFrame`, so rendering
121
+ tracks your monitor refresh rate regardless of sample rate.
122
+ - **Browser-based.** aiohttp + uPlot, no desktop GUI toolkit, works
123
+ over SSH port forwarding.
124
+ - **Remote-friendly.** Sender or plot host can bind. Live Bind /
125
+ Connect buttons retarget without restart.
126
+ - **Config lives with the data.** The sender declares plot layout.
127
+ - **Interactive controls.** Buttons, sliders, dials, displays — polled
128
+ from your loop, no threads, no callbacks.
129
+ - **Static HTML snapshots.** `save_snapshot("out.html")` writes a
130
+ self-contained ~65 KB file with the current trace embedded.
131
+
132
+ ---
133
+
134
+ ## Where to go next
135
+
136
+ - **[API reference](docs/api.md)** — every `rtplot.client` function,
137
+ the plot-layout schema, interactive controls, snapshots, browser UI,
138
+ and `rtplot-server` CLI flags.
139
+ - **[Networking guide](docs/networking.md)** — Mode A vs. Mode B,
140
+ viewing from a phone or second laptop, the WSL2 wrinkle, Cloudflare
141
+ Tunnel, Tailscale.
142
+ - **[Examples](examples/README.md)** — runnable scripts with embedded
143
+ HTML snapshots you can open offline.
144
+
145
+ ---
146
+
147
+ Issues and feature requests:
148
+ [github.com/jmontp/rtplot/issues](https://github.com/jmontp/rtplot/issues).
149
+
@@ -0,0 +1,125 @@
1
+ # rtplot — real-time plotting over ZMQ
2
+
3
+ **rtplot** pushes live data from a Python script to a browser plot —
4
+ locally or across a network — in a few lines of code. The plot page
5
+ also hosts interactive widgets (buttons, sliders, dials, numeric / text
6
+ displays) that feed values back to the sender in real time.
7
+
8
+ Typical use: a robot or data-acquisition script runs on a Raspberry Pi,
9
+ and you watch signals and tweak gains from a laptop on the same Wi-Fi.
10
+
11
+ ---
12
+
13
+ ## How it works
14
+
15
+ Two processes talking over ZMQ, plus a browser viewer:
16
+
17
+ ```mermaid
18
+ flowchart TD
19
+ script["Your Python script<br/>send_array() · poll_controls()"]
20
+ subgraph server["rtplot-server"]
21
+ browser["browser tab<br/>localhost:8050"]
22
+ end
23
+ script -- "data :5555" --> server
24
+ server -- "controls :5556" --> script
25
+ ```
26
+
27
+ Sender and server don't have to be on the same machine — see the
28
+ [networking guide](docs/networking.md).
29
+
30
+ ---
31
+
32
+ ## Install
33
+
34
+ **Server** — grab the prebuilt binary from the
35
+ [Releases page](https://github.com/jmontp/rtplot/releases):
36
+
37
+ | Platform | Asset |
38
+ |---|---|
39
+ | Windows | `rtplot-server-<version>-windows-x64.exe` |
40
+ | Linux | `rtplot-server-<version>-linux-x86_64.tar.gz` |
41
+ | macOS (Apple Silicon) | `rtplot-server-<version>-macos-arm64.tar.gz` |
42
+
43
+ No Python needed on the viewing machine. On Windows the binary opens
44
+ a small Tk status window showing the listening URL.
45
+
46
+ **Client** — pip install in the env that runs your script:
47
+
48
+ ```bash
49
+ pip install better-rtplot
50
+ ```
51
+
52
+ (If you'd rather run the server from Python too, use
53
+ `pip install "better-rtplot[browser]"` — see the
54
+ [API reference](docs/api.md#install-detail).)
55
+
56
+ ---
57
+
58
+ ## Your first plot
59
+
60
+ ```mermaid
61
+ flowchart TD
62
+ t1["terminal 1: start the server"]
63
+ t2["terminal 2: run your script"]
64
+ br["browser: open localhost:8050"]
65
+ t1 --> t2 --> br
66
+ ```
67
+
68
+ **Terminal 1 — start the server.** Run the prebuilt `rtplot-server`
69
+ binary you downloaded above. Open `http://localhost:8050` in a
70
+ browser — the page is blank until data arrives.
71
+
72
+ **Terminal 2 — run your sender script.** Save as `my_plot.py`:
73
+
74
+ ```python
75
+ from rtplot import client
76
+ import time
77
+
78
+ client.local_plot()
79
+ client.initialize_plots(["my signal"])
80
+
81
+ for i in range(1000):
82
+ client.send_array(i * 0.01)
83
+ time.sleep(0.01)
84
+ ```
85
+
86
+ ```bash
87
+ python my_plot.py
88
+ ```
89
+
90
+ A rising line now draws itself in the browser tab.
91
+
92
+ ---
93
+
94
+ ## Highlights
95
+
96
+ - **Fast.** Binary WebSocket deltas up to 1 kHz; the browser coalesces
97
+ samples into one repaint per `requestAnimationFrame`, so rendering
98
+ tracks your monitor refresh rate regardless of sample rate.
99
+ - **Browser-based.** aiohttp + uPlot, no desktop GUI toolkit, works
100
+ over SSH port forwarding.
101
+ - **Remote-friendly.** Sender or plot host can bind. Live Bind /
102
+ Connect buttons retarget without restart.
103
+ - **Config lives with the data.** The sender declares plot layout.
104
+ - **Interactive controls.** Buttons, sliders, dials, displays — polled
105
+ from your loop, no threads, no callbacks.
106
+ - **Static HTML snapshots.** `save_snapshot("out.html")` writes a
107
+ self-contained ~65 KB file with the current trace embedded.
108
+
109
+ ---
110
+
111
+ ## Where to go next
112
+
113
+ - **[API reference](docs/api.md)** — every `rtplot.client` function,
114
+ the plot-layout schema, interactive controls, snapshots, browser UI,
115
+ and `rtplot-server` CLI flags.
116
+ - **[Networking guide](docs/networking.md)** — Mode A vs. Mode B,
117
+ viewing from a phone or second laptop, the WSL2 wrinkle, Cloudflare
118
+ Tunnel, Tailscale.
119
+ - **[Examples](examples/README.md)** — runnable scripts with embedded
120
+ HTML snapshots you can open offline.
121
+
122
+ ---
123
+
124
+ Issues and feature requests:
125
+ [github.com/jmontp/rtplot/issues](https://github.com/jmontp/rtplot/issues).
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.3.0"
3
+ version = "0.4.1"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "GPL V3.0"
@@ -2,6 +2,8 @@ import zmq
2
2
  import numpy as np
3
3
  import time
4
4
  from collections import OrderedDict, namedtuple
5
+ from dataclasses import dataclass, field
6
+ from typing import List, Optional, Tuple, Union
5
7
 
6
8
  ###################
7
9
  # ZMQ Networking #
@@ -85,19 +87,177 @@ SENDING_DISPLAY = "4"
85
87
  #Lightweight result type returned by poll_controls()
86
88
  ControlState = namedtuple("ControlState", ["values", "buttons"])
87
89
 
90
+
91
+ ##########################################
92
+ # Typed plot / controls configuration API #
93
+ ##########################################
94
+ #
95
+ # These dataclasses are an alternative to the dict-based configuration
96
+ # accepted by initialize_plots(). Both APIs coexist — the typed form
97
+ # serializes to the exact same on-the-wire dict, so the server is
98
+ # unaware of which one the caller used.
99
+ #
100
+ # client.initialize_plots([
101
+ # client.Plot(names=["signal"], yrange=(-6, 6), title="demo"),
102
+ # client.ControlsRow([client.Button("reset", "Reset")]),
103
+ # ])
104
+ #
105
+ # is equivalent to the classic:
106
+ #
107
+ # client.initialize_plots([
108
+ # {"names": ["signal"], "yrange": [-6, 6], "title": "demo"},
109
+ # {"controls": [{"type": "button", "id": "reset", "label": "Reset"}]},
110
+ # ])
111
+
112
+
113
+ def _drop_none(d):
114
+ # Wire format treats missing keys as "use default"; None would
115
+ # JSON-serialize to null which the server wouldn't handle.
116
+ return {k: v for k, v in d.items() if v is not None}
117
+
118
+
119
+ def _range_to_list(r):
120
+ # yrange/xrange go over the wire as JSON lists.
121
+ return list(r) if r is not None else None
122
+
123
+
124
+ @dataclass
125
+ class Plot:
126
+ """A single plot with optional styling.
127
+
128
+ All fields beyond ``names`` are optional and mirror the styled-plot
129
+ dict keys documented in ``docs/api.md``.
130
+ """
131
+ names: List[str]
132
+ colors: Optional[List[str]] = None
133
+ line_style: Optional[List[str]] = None
134
+ line_width: Optional[float] = None
135
+ title: Optional[str] = None
136
+ xlabel: Optional[str] = None
137
+ ylabel: Optional[str] = None
138
+ yrange: Optional[Tuple[float, float]] = None
139
+ xrange: Optional[int] = None
140
+ height: Optional[float] = None
141
+
142
+ def to_dict(self):
143
+ return _drop_none({
144
+ "names": list(self.names),
145
+ "colors": list(self.colors) if self.colors is not None else None,
146
+ "line_style": list(self.line_style) if self.line_style is not None else None,
147
+ "line_width": self.line_width,
148
+ "title": self.title,
149
+ "xlabel": self.xlabel,
150
+ "ylabel": self.ylabel,
151
+ "yrange": _range_to_list(self.yrange),
152
+ "xrange": self.xrange,
153
+ "height": self.height,
154
+ })
155
+
156
+
157
+ @dataclass
158
+ class Button:
159
+ id: str
160
+ label: str
161
+ color: Optional[str] = None
162
+ height: Optional[float] = None
163
+
164
+ def to_dict(self):
165
+ return _drop_none({
166
+ "type": "button", "id": self.id, "label": self.label,
167
+ "color": self.color, "height": self.height,
168
+ })
169
+
170
+
171
+ @dataclass
172
+ class Slider:
173
+ id: str
174
+ label: str
175
+ min: float
176
+ max: float
177
+ value: float = 0.0
178
+ step: Optional[float] = None
179
+ format: Optional[str] = None
180
+ color: Optional[str] = None
181
+ height: Optional[float] = None
182
+
183
+ def to_dict(self):
184
+ return _drop_none({
185
+ "type": "slider", "id": self.id, "label": self.label,
186
+ "min": self.min, "max": self.max, "value": self.value,
187
+ "step": self.step, "format": self.format,
188
+ "color": self.color, "height": self.height,
189
+ })
190
+
191
+
192
+ @dataclass
193
+ class Dial:
194
+ id: str
195
+ label: str
196
+ min: float
197
+ max: float
198
+ value: float = 0.0
199
+ step: Optional[float] = None
200
+ sensitivity: Optional[float] = None
201
+ format: Optional[str] = None
202
+ color: Optional[str] = None
203
+ height: Optional[float] = None
204
+
205
+ def to_dict(self):
206
+ return _drop_none({
207
+ "type": "dial", "id": self.id, "label": self.label,
208
+ "min": self.min, "max": self.max, "value": self.value,
209
+ "step": self.step, "sensitivity": self.sensitivity,
210
+ "format": self.format, "color": self.color,
211
+ "height": self.height,
212
+ })
213
+
214
+
215
+ @dataclass
216
+ class Display:
217
+ id: str
218
+ label: str
219
+ format: Optional[str] = None
220
+ height: Optional[float] = None
221
+
222
+ def to_dict(self):
223
+ return _drop_none({
224
+ "type": "display", "id": self.id, "label": self.label,
225
+ "format": self.format, "height": self.height,
226
+ })
227
+
228
+
229
+ @dataclass
230
+ class Text:
231
+ id: str
232
+ label: str
233
+ value: str = ""
234
+ height: Optional[float] = None
235
+
236
+ def to_dict(self):
237
+ return _drop_none({
238
+ "type": "text", "id": self.id, "label": self.label,
239
+ "value": self.value, "height": self.height,
240
+ })
241
+
242
+
243
+ @dataclass
244
+ class ControlsRow:
245
+ """A row of control widgets, rendered in place of a plot."""
246
+ controls: List[Union[Button, Slider, Dial, Display, Text, dict]] = field(default_factory=list)
247
+
248
+ def to_dict(self):
249
+ return {"controls": [
250
+ c.to_dict() if hasattr(c, "to_dict") else c
251
+ for c in self.controls
252
+ ]}
253
+
254
+
88
255
  def local_plot():
89
256
  """Send data to a plot in the same computer"""
90
257
 
91
258
  local_address = "tcp://127.0.0.1:5555"
92
259
  configure_ip(ip = local_address)
93
260
 
94
- def plot_to_neurobionics_tv():
95
- """Send data to a plot in the same computer"""
96
-
97
- tv_computer_address = "tcp://141.212.77.23:5555"
98
- configure_ip(ip = tv_computer_address)
99
-
100
-
101
261
  def configure_port(new_port:int):
102
262
  """Rebind the local publisher on ``new_port`` (bind mode only).
103
263
 
@@ -277,6 +437,11 @@ def initialize_plots(plot_descriptions=1):
277
437
  plot_desc_dict = OrderedDict()
278
438
  plot_desc_dict["plot0"] = plot_descriptions
279
439
 
440
+ #Process typed inputs (Plot / ControlsRow) passed alone
441
+ elif hasattr(plot_descriptions, "to_dict"):
442
+ plot_desc_dict = OrderedDict()
443
+ plot_desc_dict["plot0"] = plot_descriptions.to_dict()
444
+
280
445
  #Process lists of things
281
446
  elif isinstance(plot_descriptions, list):
282
447
 
@@ -291,10 +456,14 @@ def initialize_plots(plot_descriptions=1):
291
456
  for i,plot_desc in enumerate(plot_descriptions):
292
457
  plot_desc_dict["plot{}".format(i)] = {"names":plot_desc}
293
458
 
294
- #Process list of dics
295
- elif isinstance(plot_descriptions[0],dict):
459
+ #Process list of dicts or typed objects (Plot / ControlsRow),
460
+ # including mixed lists — anything exposing .to_dict() is normalized
461
+ # to the wire dict form.
462
+ elif isinstance(plot_descriptions[0],dict) or hasattr(plot_descriptions[0], "to_dict"):
296
463
  plot_desc_dict = OrderedDict()
297
464
  for i,plot_desc in enumerate(plot_descriptions):
465
+ if hasattr(plot_desc, "to_dict"):
466
+ plot_desc = plot_desc.to_dict()
298
467
  plot_desc_dict["plot{}".format(i)] = plot_desc
299
468
 
300
469
  #Throw error
@@ -787,6 +787,8 @@ INDEX_HTML = """<!doctype html>
787
787
  .ctrl-item-tall > .ctrl-val { align-self: center; }
788
788
  .ctrl-btn:hover { background: #f0f0f0; }
789
789
  .ctrl-btn:active { background: #e2e2e2; }
790
+ .ctrl-btn-colored:hover { filter: brightness(0.93); }
791
+ .ctrl-btn-colored:active { filter: brightness(0.85); }
790
792
  .ctrl-slider .ctrl-rangeinput { flex: 1; min-width: 120px; }
791
793
  .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; }
792
794
  .ctrl-numinput::-webkit-outer-spin-button,
@@ -1093,6 +1095,7 @@ INDEX_HTML = """<!doctype html>
1093
1095
  min, max, step, initial: value,
1094
1096
  commit, applyLocal,
1095
1097
  sensitivity, hasMin, hasMax,
1098
+ color: el.color,
1096
1099
  });
1097
1100
  if (widget && widget.node) item.appendChild(widget.node);
1098
1101
 
@@ -1127,7 +1130,7 @@ INDEX_HTML = """<!doctype html>
1127
1130
  return item;
1128
1131
  }
1129
1132
 
1130
- function buildSliderWidget({ min, max, step, initial, commit, applyLocal, hasMin, hasMax }) {
1133
+ function buildSliderWidget({ min, max, step, initial, commit, applyLocal, hasMin, hasMax, color }) {
1131
1134
  // HTML range inputs can't represent unbounded values, so fall back
1132
1135
  // to sane defaults when the user omits min/max on a slider.
1133
1136
  const rangeMin = hasMin ? min : 0;
@@ -1137,6 +1140,7 @@ INDEX_HTML = """<!doctype html>
1137
1140
  range.className = 'ctrl-rangeinput';
1138
1141
  range.min = rangeMin;
1139
1142
  range.max = rangeMax;
1143
+ if (color != null) range.style.accentColor = resolveColor(color);
1140
1144
  range.step = step;
1141
1145
  range.value = initial;
1142
1146
  // Live preview: update the number box (and any other mirrors) on every
@@ -1149,7 +1153,7 @@ INDEX_HTML = """<!doctype html>
1149
1153
  };
1150
1154
  }
1151
1155
 
1152
- function buildDialWidget({ min, max, initial, commit, applyLocal, sensitivity, hasMin, hasMax }) {
1156
+ function buildDialWidget({ min, max, initial, commit, applyLocal, sensitivity, hasMin, hasMax, color }) {
1153
1157
  const svgNS = 'http://www.w3.org/2000/svg';
1154
1158
  const size = 100;
1155
1159
  const svg = document.createElementNS(svgNS, 'svg');
@@ -1185,6 +1189,7 @@ INDEX_HTML = """<!doctype html>
1185
1189
 
1186
1190
  const indicator = document.createElementNS(svgNS, 'line');
1187
1191
  indicator.classList.add('dial-indicator');
1192
+ if (color != null) indicator.style.stroke = resolveColor(color);
1188
1193
  svg.appendChild(indicator);
1189
1194
 
1190
1195
  // Unified rotation math: the indicator advances by 2π radians for
@@ -1270,6 +1275,12 @@ INDEX_HTML = """<!doctype html>
1270
1275
  const b = document.createElement('button');
1271
1276
  b.className = 'ctrl-btn';
1272
1277
  b.textContent = el.label || el.id;
1278
+ if (el.color != null) {
1279
+ const c = resolveColor(el.color);
1280
+ b.style.backgroundColor = c;
1281
+ b.style.borderColor = c;
1282
+ b.classList.add('ctrl-btn-colored');
1283
+ }
1273
1284
  b.addEventListener('click', () => sendCtrl({ type: 'control_button', id: el.id }));
1274
1285
  item.appendChild(b);
1275
1286
  } else if (el.type === 'slider') {