better-rtplot 0.4.5__tar.gz → 0.4.7__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.5 → better_rtplot-0.4.7}/PKG-INFO +1 -1
  2. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/pyproject.toml +1 -1
  3. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/client.py +18 -0
  4. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/server_browser.py +72 -4
  5. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/index.html +48 -0
  6. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/LICENSE +0 -0
  7. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/README.md +0 -0
  8. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/interactive_test.py +0 -0
  9. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/saved_plots/.gitignore +0 -0
  10. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/server.py +0 -0
  11. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/server_browser_gui.py +0 -0
  12. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/auto-render.min.js +0 -0
  13. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  14. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  15. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  16. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  17. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  18. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  19. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  20. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  21. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  22. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  23. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  24. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  25. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  26. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  27. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  28. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  29. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  30. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  31. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  32. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  33. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/katex.min.css +0 -0
  34. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/katex/katex.min.js +0 -0
  35. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/rtplot/static/uPlot.iife.min.js +0 -0
  36. {better_rtplot-0.4.5 → better_rtplot-0.4.7}/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.5
3
+ Version: 0.4.7
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.5"
3
+ version = "0.4.7"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "GPL V3.0"
@@ -25,6 +25,13 @@ control_socket.setsockopt(zmq.RCVHWM, 1000)
25
25
  _control_slider_values = {}
26
26
  _control_button_events = []
27
27
 
28
+ # Cached payload of the most recent initialize_plots() call. The server
29
+ # uses this to recover from its own crashes: when its tab loses the
30
+ # config, it pushes a "resend_config" control event over the return
31
+ # channel, and poll_controls() re-PUBs this dict so the plot reattaches
32
+ # without the user restarting their script. None until first call.
33
+ plot_desc_dict = None
34
+
28
35
  #Global variable to keep track of last connected address
29
36
  # default is fixed publisher mode, therefore you don't connect
30
37
  # to an address
@@ -506,6 +513,13 @@ def poll_controls():
506
513
  (cleared after this call).
507
514
 
508
515
  Call this from your tight loop before computing the next sample.
516
+
517
+ Also transparently handles ``resend_config`` requests from the server:
518
+ if the server crashed and reconnected (or the user clicked the
519
+ Reconnect button on a tab), it asks us to re-publish the cached
520
+ initialize_plots() payload so the plot rewires without the user
521
+ restarting their script. The request is consumed silently — the
522
+ returned ControlState only ever contains slider/button events.
509
523
  """
510
524
  global _control_button_events
511
525
  while True:
@@ -520,6 +534,10 @@ def poll_controls():
520
534
  _control_button_events.append(event.get("id"))
521
535
  elif evtype == "slider":
522
536
  _control_slider_values[event.get("id")] = float(event.get("value", 0.0))
537
+ elif evtype == "resend_config":
538
+ if plot_desc_dict is not None:
539
+ socket.send_string(SENDING_PLOT_UPDATE)
540
+ socket.send_json(plot_desc_dict)
523
541
 
524
542
  buttons = _control_button_events
525
543
  _control_button_events = []
@@ -306,6 +306,12 @@ class Tab:
306
306
  data_rate_hz: float = 0.0
307
307
  _last_rx_ts: float = 0.0
308
308
 
309
+ # When the most recent client-supplied config failed to parse we
310
+ # stash the reason + a unix timestamp so the browser can surface
311
+ # "your last config was rejected at HH:MM:SS" instead of just
312
+ # silently rendering nothing. Cleared on the next successful parse.
313
+ last_config_error: Optional[dict] = None
314
+
309
315
  def ensure_buffer(self):
310
316
  if self.buffer is None:
311
317
  self.buffer = np.zeros((INITIAL_NUM_TRACES, MAX_LOCAL_STORAGE))
@@ -344,6 +350,7 @@ def tab_public(t: Tab) -> dict:
344
350
  "endpoint": t.endpoint,
345
351
  "status": t.status,
346
352
  "error": t.error,
353
+ "last_config_error": t.last_config_error,
347
354
  }
348
355
 
349
356
 
@@ -711,8 +718,30 @@ async def zmq_receiver(tab: Tab):
711
718
  tab._last_rx_ts = now
712
719
 
713
720
  if category == RECEIVED_PLOT_UPDATE:
714
- cfg = await sock.recv_json(object_pairs_hook=OrderedDict)
715
- parse_config(tab, cfg)
721
+ # Decode + parse are wrapped so a malformed config doesn't kill
722
+ # the receiver task. We stash the failure on the tab so the
723
+ # browser can show "your last config was rejected at HH:MM:SS"
724
+ # instead of looking like nothing happened. Existing valid
725
+ # state (if any) is preserved on parse failure.
726
+ try:
727
+ cfg = await sock.recv_json(object_pairs_hook=OrderedDict)
728
+ except Exception as exc: # noqa: BLE001
729
+ msg = f"Could not decode config JSON: {type(exc).__name__}: {exc}"
730
+ print(f"[{tab.id}] {msg}")
731
+ tab.last_config_error = {"message": msg, "timestamp": time.time()}
732
+ await broadcast_tab(tab.id)
733
+ continue
734
+
735
+ try:
736
+ parse_config(tab, cfg)
737
+ except Exception as exc: # noqa: BLE001
738
+ msg = f"Configuration rejected: {type(exc).__name__}: {exc}"
739
+ print(f"[{tab.id}] {msg}")
740
+ tab.last_config_error = {"message": msg, "timestamp": time.time()}
741
+ await broadcast_tab(tab.id)
742
+ continue
743
+
744
+ tab.last_config_error = None # clear: this one was good
716
745
  tab.config_dict = cfg
717
746
  tab.config_message = build_config_message(tab, cfg)
718
747
  tab.initialized = True
@@ -921,6 +950,10 @@ async def create_connect_tab(
921
950
  tabs[t.id] = t
922
951
  _open_tab_sockets(t)
923
952
  t.receiver_task = asyncio.create_task(zmq_receiver(t))
953
+ # If the client at the other end is already running and only called
954
+ # initialize_plots() once at its startup, this nudges it to resend
955
+ # the cached config so the new tab populates without a script restart.
956
+ asyncio.create_task(_request_config_resend(t))
924
957
  if persist:
925
958
  save_persisted_tabs()
926
959
  await broadcast_tab_list()
@@ -956,13 +989,47 @@ async def rename_tab(tab_id: str, new_name: str):
956
989
  await broadcast_tab(tab_id)
957
990
 
958
991
 
992
+ async def _request_config_resend(tab: "Tab"):
993
+ """Nudge a still-running client to re-send its initialize_plots() payload.
994
+
995
+ The common scenario: the server crashed (or was restarted) mid-session
996
+ and lost the plot config it received the first time the client called
997
+ ``initialize_plots()``. The client is still happily streaming data,
998
+ but the new server has nothing to plot it on. This sends a
999
+ ``{"type": "resend_config"}`` control event over the tab's PUSH
1000
+ socket; the client's poll_controls() loop intercepts it and re-sends
1001
+ the cached config so the plot picks back up without the user having
1002
+ to restart their script.
1003
+
1004
+ Retries on a brief schedule because the client's PULL socket may need
1005
+ a moment to auto-reconnect to the freshly bound PUSH socket — a
1006
+ one-shot DONTWAIT send to a peer-less PUSH gets silently dropped.
1007
+ Stops early once we see ``tab.initialized`` flip to True (the client
1008
+ has answered) so we don't spam.
1009
+ """
1010
+ for i, delay in enumerate((0.4, 1.0, 2.0)):
1011
+ await asyncio.sleep(delay)
1012
+ if tab.ctrl_sock is None:
1013
+ return
1014
+ if i > 0 and tab.initialized:
1015
+ return
1016
+ try:
1017
+ await tab.ctrl_sock.send_json(
1018
+ {"type": "resend_config"}, flags=zmq.DONTWAIT
1019
+ )
1020
+ except (zmq.Again, zmq.ZMQError):
1021
+ pass
1022
+
1023
+
959
1024
  async def reconnect_tab(tab_id: str):
960
1025
  """Close and reopen ``tab_id``'s sockets, restart its receiver task.
961
1026
 
962
1027
  The tab's buffer and plot config are preserved — if the sender on
963
1028
  the other side is still running the same stream we avoid a visible
964
- flash to empty. A new config from the sender will replace the state
965
- via the normal RECEIVED_PLOT_UPDATE path.
1029
+ flash to empty. After the sockets come back up we ask the (likely
1030
+ still-running) client to re-send its plot config so the connection
1031
+ recovers cleanly even if the server itself restarted in the
1032
+ meantime.
966
1033
  """
967
1034
  t = tabs.get(tab_id)
968
1035
  if t is None:
@@ -976,6 +1043,7 @@ async def reconnect_tab(tab_id: str):
976
1043
  t.data_rate_hz = 0.0
977
1044
  t._last_rx_ts = 0.0
978
1045
  t.receiver_task = asyncio.create_task(zmq_receiver(t))
1046
+ asyncio.create_task(_request_config_resend(t))
979
1047
  await broadcast_tab(tab_id)
980
1048
 
981
1049
 
@@ -67,6 +67,13 @@
67
67
  .plot-wrap { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 8px; flex: 1 1 auto; min-width: 320px; }
68
68
  .plot-title { font-size: 13px; font-weight: 600; margin: 0 0 4px 4px; color: #333; }
69
69
  #empty-state { padding: 28px 32px; color: #888; font-size: calc(14px * var(--ui-scale)); font-style: italic; }
70
+ /* Persistent banner the server raises when a client-supplied plot
71
+ config fails to parse — the user used to be left guessing why
72
+ nothing rendered; now they get the reason + a local timestamp. */
73
+ .config-error-banner { margin: 12px 12px 0 12px; padding: 10px 14px; background: #fce8e8; color: #8a1a1a; border: 1px solid #f0c2c2; border-left: 4px solid #d24a4a; border-radius: 4px; font-size: calc(13px * var(--ui-scale)); }
74
+ .config-error-banner .ce-title { font-weight: 600; margin-right: 8px; }
75
+ .config-error-banner .ce-when { color: #6b1818; opacity: 0.8; font-family: monospace; font-size: calc(12px * var(--ui-scale)); }
76
+ .config-error-banner .ce-msg { display: block; margin-top: 4px; font-family: monospace; font-size: calc(12px * var(--ui-scale)); white-space: pre-wrap; word-break: break-word; }
70
77
  :root { --ctrl-unit-h: 38px; --ui-scale: 1; }
71
78
  .ctrl-row { display: flex; gap: 12px; align-items: center; padding: 10px 14px; background: #fff; border: 1px solid #ddd; border-radius: 4px; flex-wrap: wrap; }
72
79
  .ctrl-item { display: flex; align-items: center; gap: 6px; }
@@ -187,6 +194,7 @@
187
194
  <div id="res-unavail" class="res-unavail" style="display:none;">psutil not installed &mdash; pip install psutil for live stats</div>
188
195
  </div>
189
196
  </div>
197
+ <div id="config-error-banner" class="config-error-banner" style="display:none"></div>
190
198
  <div id="plots" class="row"></div>
191
199
  <script src="/static/uPlot.iife.min.js"></script>
192
200
  <script src="/static/katex/katex.min.js"></script>
@@ -210,6 +218,43 @@
210
218
  return 'rgb(0,0,0)';
211
219
  }
212
220
 
221
+ const configErrorBanner = document.getElementById('config-error-banner');
222
+
223
+ function fmtLocalTimestamp(unixSeconds) {
224
+ const d = new Date(unixSeconds * 1000);
225
+ const pad = (n) => String(n).padStart(2, '0');
226
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
227
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
228
+ }
229
+
230
+ function applyConfigErrorBanner() {
231
+ const t = activeTab && knownTabs[activeTab];
232
+ const err = t && t.last_config_error;
233
+ if (!err) {
234
+ configErrorBanner.style.display = 'none';
235
+ configErrorBanner.innerHTML = '';
236
+ return;
237
+ }
238
+ // Build with textContent on the leaf nodes so user-controlled
239
+ // strings (the error message) can't inject HTML.
240
+ configErrorBanner.innerHTML = '';
241
+ const head = document.createElement('div');
242
+ const title = document.createElement('span');
243
+ title.className = 'ce-title';
244
+ title.textContent = 'Last configuration was rejected';
245
+ head.appendChild(title);
246
+ const when = document.createElement('span');
247
+ when.className = 'ce-when';
248
+ when.textContent = `at ${fmtLocalTimestamp(err.timestamp)}`;
249
+ head.appendChild(when);
250
+ configErrorBanner.appendChild(head);
251
+ const body = document.createElement('span');
252
+ body.className = 'ce-msg';
253
+ body.textContent = err.message || '';
254
+ configErrorBanner.appendChild(body);
255
+ configErrorBanner.style.display = '';
256
+ }
257
+
213
258
  // ---- LaTeX rendering ($...$ inline, $$...$$ display) ----
214
259
  // KaTeX's auto-render walks an element's text nodes looking for the
215
260
  // configured delimiters, so plain labels without `$` are completely
@@ -1111,6 +1156,7 @@
1111
1156
  showEmptyState('Loading tab \u2026');
1112
1157
  const t = knownTabs[id];
1113
1158
  setZmqMode(t.mode, t.endpoint);
1159
+ applyConfigErrorBanner();
1114
1160
  sendCtrl({ type: 'tab_subscribe', id: id });
1115
1161
  }
1116
1162
 
@@ -1183,6 +1229,7 @@
1183
1229
  if (activeTab) {
1184
1230
  const t = knownTabs[activeTab];
1185
1231
  setZmqMode(t.mode, t.endpoint);
1232
+ applyConfigErrorBanner();
1186
1233
  sendCtrl({ type: 'tab_subscribe', id: activeTab });
1187
1234
  showEmptyState('Loading tab \u2026');
1188
1235
  }
@@ -1193,6 +1240,7 @@
1193
1240
  renderTabs();
1194
1241
  if (msg.tab.id === activeTab) {
1195
1242
  setZmqMode(msg.tab.mode, msg.tab.endpoint);
1243
+ applyConfigErrorBanner();
1196
1244
  }
1197
1245
  }
1198
1246
  } else if (msg.type === 'tab_removed') {
File without changes
File without changes