better-rtplot 0.4.16__tar.gz → 0.6.0__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.16 → better_rtplot-0.6.0}/PKG-INFO +5 -2
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/README.md +2 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/pyproject.toml +1 -1
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/client.py +169 -13
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/server_browser.py +208 -18
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/index.html +177 -81
- better_rtplot-0.6.0/rtplot/static/presentation.css +168 -0
- better_rtplot-0.6.0/rtplot/static/ui-view.css +24 -0
- better_rtplot-0.6.0/rtplot/static/ui-view.js +180 -0
- better_rtplot-0.6.0/rtplot/ui_state.py +163 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/LICENSE +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/interactive_test.py +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/saved_plots/.gitignore +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/server.py +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/server_browser_gui.py +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/auto-render.min.js +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/katex.min.css +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/katex.min.js +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/uPlot.iife.min.js +0 -0
- {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/uPlot.min.css +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: better-rtplot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary:
|
|
5
5
|
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
6
7
|
Author: jmontp
|
|
7
8
|
Author-email: jmontp@umich.edu
|
|
8
9
|
Requires-Python: >=3.9,<3.13
|
|
@@ -131,6 +132,8 @@ A rising line now draws itself in the browser tab.
|
|
|
131
132
|
- **Remote-friendly.** Sender or plot host can bind. Live Bind /
|
|
132
133
|
Connect buttons retarget without restart.
|
|
133
134
|
- **Config lives with the data.** The sender declares plot layout.
|
|
135
|
+
- **Responsive presentation.** Native phone sizing, essential actions, compact navigation, and explicit semantic styles. See the [presentation guide](docs/presentation.md) and [before/after screenshots](docs/presentation-screenshots/README.md).
|
|
136
|
+
- **Sections and runtime state.** Optional collapsible sections, persistent status controls, and presentation updates that preserve streaming history. See the [section API](docs/sections.md) and [workflow example](examples/06_sections/README.md).
|
|
134
137
|
- **Interactive controls.** Buttons, sliders, dials, displays — polled
|
|
135
138
|
from your loop, no threads, no callbacks.
|
|
136
139
|
- **LaTeX labels.** Use `$...$` or `\(...\)` in plot and control text;
|
|
@@ -107,6 +107,8 @@ A rising line now draws itself in the browser tab.
|
|
|
107
107
|
- **Remote-friendly.** Sender or plot host can bind. Live Bind /
|
|
108
108
|
Connect buttons retarget without restart.
|
|
109
109
|
- **Config lives with the data.** The sender declares plot layout.
|
|
110
|
+
- **Responsive presentation.** Native phone sizing, essential actions, compact navigation, and explicit semantic styles. See the [presentation guide](docs/presentation.md) and [before/after screenshots](docs/presentation-screenshots/README.md).
|
|
111
|
+
- **Sections and runtime state.** Optional collapsible sections, persistent status controls, and presentation updates that preserve streaming history. See the [section API](docs/sections.md) and [workflow example](examples/06_sections/README.md).
|
|
110
112
|
- **Interactive controls.** Buttons, sliders, dials, displays — polled
|
|
111
113
|
from your loop, no threads, no callbacks.
|
|
112
114
|
- **LaTeX labels.** Use `$...$` or `\(...\)` in plot and control text;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import zmq
|
|
2
2
|
import numpy as np
|
|
3
3
|
import time
|
|
4
|
+
import uuid
|
|
5
|
+
import warnings
|
|
6
|
+
from .ui_state import META_KEY, declarations, merge_state, presentation_requested
|
|
4
7
|
from collections import OrderedDict, namedtuple
|
|
5
8
|
from dataclasses import dataclass, field
|
|
6
9
|
from typing import List, Optional, Tuple, Union
|
|
@@ -24,6 +27,7 @@ control_socket.setsockopt(zmq.RCVHWM, 1000)
|
|
|
24
27
|
#Non-blocking local state for controls, drained on each poll_controls() call
|
|
25
28
|
_control_values = {}
|
|
26
29
|
_control_button_events = []
|
|
30
|
+
_last_control_poll = time.monotonic()
|
|
27
31
|
|
|
28
32
|
# Cached payload of the most recent initialize_plots() call. The server
|
|
29
33
|
# uses this to recover from its own crashes: when its tab loses the
|
|
@@ -91,6 +95,16 @@ SENDING_PLOT_UPDATE = "0"
|
|
|
91
95
|
SENDING_DATA = "1"
|
|
92
96
|
SENDING_DISPLAY = "4"
|
|
93
97
|
SENDING_TEXT_INPUT = "5"
|
|
98
|
+
SENDING_UI_STATE = "6"
|
|
99
|
+
_session = uuid.uuid4().hex
|
|
100
|
+
_generation = 0
|
|
101
|
+
_ui_revision = 0
|
|
102
|
+
_ui_state = {"controls": {}, "sections": {}}
|
|
103
|
+
_view = {}
|
|
104
|
+
_control_registry = {}
|
|
105
|
+
_server_capabilities = set()
|
|
106
|
+
_source_epoch = None
|
|
107
|
+
_last_ui_publish = 0.0
|
|
94
108
|
|
|
95
109
|
#Lightweight result type returned by poll_controls()
|
|
96
110
|
ControlState = namedtuple("ControlState", ["values", "buttons"])
|
|
@@ -147,6 +161,8 @@ class Plot:
|
|
|
147
161
|
yrange: Optional[Tuple[float, float]] = None
|
|
148
162
|
xrange: Optional[int] = None
|
|
149
163
|
height: Optional[float] = None
|
|
164
|
+
section: Optional[str] = None
|
|
165
|
+
min_height: Optional[int] = None
|
|
150
166
|
|
|
151
167
|
def to_dict(self):
|
|
152
168
|
return _drop_none({
|
|
@@ -161,6 +177,7 @@ class Plot:
|
|
|
161
177
|
"yrange": _range_to_list(self.yrange),
|
|
162
178
|
"xrange": self.xrange,
|
|
163
179
|
"height": self.height,
|
|
180
|
+
"section": self.section, "min_height": self.min_height,
|
|
164
181
|
})
|
|
165
182
|
|
|
166
183
|
|
|
@@ -169,6 +186,7 @@ class PlotRow:
|
|
|
169
186
|
"""A group of plots with its own column count in the browser."""
|
|
170
187
|
plots: List[Union[Plot, dict]]
|
|
171
188
|
columns: int
|
|
189
|
+
section: Optional[str] = None
|
|
172
190
|
|
|
173
191
|
def to_dict(self):
|
|
174
192
|
if isinstance(self.columns, bool) or not isinstance(self.columns, int) or self.columns < 1:
|
|
@@ -183,7 +201,7 @@ class PlotRow:
|
|
|
183
201
|
or any(k in p for k in ("plots", "controls", "non_plot_labels"))
|
|
184
202
|
for p in plots):
|
|
185
203
|
raise ValueError("PlotRow can contain only plot descriptions")
|
|
186
|
-
return {"columns": self.columns, "plots": plots}
|
|
204
|
+
return _drop_none({"columns": self.columns, "plots": plots, "section": self.section})
|
|
187
205
|
|
|
188
206
|
|
|
189
207
|
@dataclass
|
|
@@ -192,11 +210,12 @@ class Button:
|
|
|
192
210
|
label: str
|
|
193
211
|
color: Optional[str] = None
|
|
194
212
|
height: Optional[float] = None
|
|
213
|
+
appearance: Optional[str] = None
|
|
195
214
|
|
|
196
215
|
def to_dict(self):
|
|
197
216
|
return _drop_none({
|
|
198
217
|
"type": "button", "id": self.id, "label": self.label,
|
|
199
|
-
"color": self.color, "height": self.height,
|
|
218
|
+
"color": self.color, "height": self.height, "appearance": self.appearance,
|
|
200
219
|
})
|
|
201
220
|
|
|
202
221
|
|
|
@@ -250,11 +269,12 @@ class Display:
|
|
|
250
269
|
label: str
|
|
251
270
|
format: Optional[str] = None
|
|
252
271
|
height: Optional[float] = None
|
|
272
|
+
appearance: Optional[str] = None
|
|
253
273
|
|
|
254
274
|
def to_dict(self):
|
|
255
275
|
return _drop_none({
|
|
256
276
|
"type": "display", "id": self.id, "label": self.label,
|
|
257
|
-
"format": self.format, "height": self.height,
|
|
277
|
+
"format": self.format, "height": self.height, "appearance": self.appearance,
|
|
258
278
|
})
|
|
259
279
|
|
|
260
280
|
|
|
@@ -264,11 +284,12 @@ class Text:
|
|
|
264
284
|
label: str
|
|
265
285
|
value: str = ""
|
|
266
286
|
height: Optional[float] = None
|
|
287
|
+
appearance: Optional[str] = None
|
|
267
288
|
|
|
268
289
|
def to_dict(self):
|
|
269
290
|
return _drop_none({
|
|
270
291
|
"type": "text", "id": self.id, "label": self.label,
|
|
271
|
-
"value": self.value, "height": self.height,
|
|
292
|
+
"value": self.value, "height": self.height, "appearance": self.appearance,
|
|
272
293
|
})
|
|
273
294
|
|
|
274
295
|
|
|
@@ -294,11 +315,43 @@ class ControlsRow:
|
|
|
294
315
|
"""A row of control widgets, rendered in place of a plot."""
|
|
295
316
|
controls: List[Union[Button, Slider, Dial, Display, Text, TextInput, dict]] = field(default_factory=list)
|
|
296
317
|
|
|
318
|
+
section: Optional[str] = None
|
|
319
|
+
title: Optional[str] = None
|
|
320
|
+
exclusive: Optional[bool] = None
|
|
321
|
+
|
|
297
322
|
def to_dict(self):
|
|
298
|
-
return {"controls": [
|
|
323
|
+
return _drop_none({"controls": [
|
|
299
324
|
c.to_dict() if hasattr(c, "to_dict") else c
|
|
300
325
|
for c in self.controls
|
|
301
|
-
]}
|
|
326
|
+
], "section": self.section, "title": self.title, "exclusive": self.exclusive})
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@dataclass
|
|
330
|
+
class Section:
|
|
331
|
+
id: str
|
|
332
|
+
title: str
|
|
333
|
+
description: str = ""
|
|
334
|
+
collapsible: bool = False
|
|
335
|
+
collapsed: bool = False
|
|
336
|
+
visible: bool = True
|
|
337
|
+
navigation: str = "primary"
|
|
338
|
+
density: str = "normal"
|
|
339
|
+
show_heading: bool = True
|
|
340
|
+
|
|
341
|
+
def to_dict(self):
|
|
342
|
+
return dict(vars(self))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@dataclass
|
|
346
|
+
class View:
|
|
347
|
+
sections: List[Union[Section, dict]]
|
|
348
|
+
persistent_section: Optional[str] = None
|
|
349
|
+
essential_controls: Optional[List[str]] = None
|
|
350
|
+
|
|
351
|
+
def to_dict(self):
|
|
352
|
+
return _drop_none({"sections": [s.to_dict() if hasattr(s, "to_dict") else s
|
|
353
|
+
for s in self.sections],
|
|
354
|
+
"persistent_section": self.persistent_section, "essential_controls": self.essential_controls})
|
|
302
355
|
|
|
303
356
|
|
|
304
357
|
def local_plot():
|
|
@@ -448,6 +501,8 @@ def send_array(A, flags=0, copy=True, track=False):
|
|
|
448
501
|
shape = A.shape,
|
|
449
502
|
)
|
|
450
503
|
|
|
504
|
+
_publish_ui_if_due()
|
|
505
|
+
|
|
451
506
|
#Send category
|
|
452
507
|
socket.send_string(SENDING_DATA)
|
|
453
508
|
#Send json description
|
|
@@ -456,7 +511,7 @@ def send_array(A, flags=0, copy=True, track=False):
|
|
|
456
511
|
socket.send(A, flags, copy=copy, track=track)
|
|
457
512
|
|
|
458
513
|
|
|
459
|
-
def initialize_plots(plot_descriptions=1, handshake_timeout=2.0):
|
|
514
|
+
def initialize_plots(plot_descriptions=1, handshake_timeout=2.0, *, view=None, ui_state=None):
|
|
460
515
|
"""Send a json description of desired plot and block until the
|
|
461
516
|
server acknowledges it.
|
|
462
517
|
|
|
@@ -483,8 +538,15 @@ def initialize_plots(plot_descriptions=1, handshake_timeout=2.0):
|
|
|
483
538
|
warning and return (so an old server without the handshake
|
|
484
539
|
feature, or a server on a flaky network, doesn't block the
|
|
485
540
|
caller forever). Set to 0 to skip the handshake entirely.
|
|
541
|
+
view:
|
|
542
|
+
Optional View or dictionary declaring sections and a persistent section.
|
|
543
|
+
Requires a browser server >= 0.5.0; unconfirmed support emits a warning.
|
|
544
|
+
ui_state:
|
|
545
|
+
Optional initial controls/sections presentation overrides. Subsequent
|
|
546
|
+
set_ui_state() patches preserve plots and values; None resets overrides.
|
|
486
547
|
"""
|
|
487
|
-
global plot_desc_dict
|
|
548
|
+
global plot_desc_dict, _generation, _ui_revision, _ui_state, _view
|
|
549
|
+
global _control_registry, _server_capabilities, _source_epoch, _control_button_events, _last_control_poll
|
|
488
550
|
|
|
489
551
|
#Process int inputs
|
|
490
552
|
if isinstance(plot_descriptions,int):
|
|
@@ -534,7 +596,24 @@ def initialize_plots(plot_descriptions=1, handshake_timeout=2.0):
|
|
|
534
596
|
else:
|
|
535
597
|
raise TypeError("Incorrect usage of initialize_plots, verify github for usage")
|
|
536
598
|
|
|
599
|
+
_view, _control_registry = declarations(plot_desc_dict, view)
|
|
600
|
+
_ui_state = merge_state({"controls": {}, "sections": {}}, ui_state or {}, _control_registry, _view)
|
|
601
|
+
_generation += 1
|
|
602
|
+
_ui_revision = 0
|
|
603
|
+
_source_epoch = None
|
|
604
|
+
_server_capabilities = set()
|
|
605
|
+
_control_button_events = []
|
|
606
|
+
_last_control_poll = time.monotonic()
|
|
537
607
|
_send_initialize_with_handshake(plot_desc_dict, handshake_timeout)
|
|
608
|
+
if (view is not None or ui_state is not None) and "ui_state_v1" not in _server_capabilities:
|
|
609
|
+
warnings.warn("Sections/UI state require rtplot server >= 0.5.0. This server did not "
|
|
610
|
+
"confirm support: layout may be flat and disabled states are NOT enforced.",
|
|
611
|
+
RuntimeWarning, stacklevel=2)
|
|
612
|
+
|
|
613
|
+
if presentation_requested(plot_desc_dict, _view) and "presentation_v1" not in _server_capabilities:
|
|
614
|
+
warnings.warn("Presentation options require rtplot browser server >= 0.6.0. This server did not "
|
|
615
|
+
"confirm support; essential placement, groups, and styling may be ignored.",
|
|
616
|
+
RuntimeWarning, stacklevel=2)
|
|
538
617
|
|
|
539
618
|
|
|
540
619
|
def _send_initialize_with_handshake(cfg, timeout):
|
|
@@ -549,7 +628,7 @@ def _send_initialize_with_handshake(cfg, timeout):
|
|
|
549
628
|
|
|
550
629
|
def _send_once():
|
|
551
630
|
socket.send_string(SENDING_PLOT_UPDATE)
|
|
552
|
-
socket.send_json(cfg)
|
|
631
|
+
socket.send_json(_wire_config(cfg))
|
|
553
632
|
|
|
554
633
|
_send_once()
|
|
555
634
|
|
|
@@ -575,8 +654,9 @@ def _send_initialize_with_handshake(cfg, timeout):
|
|
|
575
654
|
continue
|
|
576
655
|
evtype = event.get("type")
|
|
577
656
|
if evtype == "config_ack":
|
|
578
|
-
|
|
579
|
-
|
|
657
|
+
if _accept_ack(event):
|
|
658
|
+
return
|
|
659
|
+
elif evtype == "button" and _current_event(event):
|
|
580
660
|
_control_button_events.append(event.get("id"))
|
|
581
661
|
elif evtype == "slider":
|
|
582
662
|
_control_values[event.get("id")] = float(event.get("value", 0.0))
|
|
@@ -597,6 +677,65 @@ def _send_initialize_with_handshake(cfg, timeout):
|
|
|
597
677
|
)
|
|
598
678
|
|
|
599
679
|
|
|
680
|
+
def _wire_config(cfg):
|
|
681
|
+
# Old servers already skip non_plot_labels entries. Metadata adds no traces.
|
|
682
|
+
return {**cfg, META_KEY: {"non_plot_labels": [], "rtplot": {
|
|
683
|
+
"session": _session, "generation": _generation, "view": _view,
|
|
684
|
+
"revision": _ui_revision, "state": _ui_state,
|
|
685
|
+
}}}
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _accept_ack(event):
|
|
689
|
+
global _server_capabilities, _source_epoch, _control_button_events
|
|
690
|
+
if event.get("session") is not None and (event.get("session"), event.get("generation")) != (_session, _generation):
|
|
691
|
+
return False
|
|
692
|
+
_server_capabilities = set(event.get("capabilities", []))
|
|
693
|
+
epoch = event.get("source_epoch")
|
|
694
|
+
if epoch != _source_epoch:
|
|
695
|
+
_control_button_events = []
|
|
696
|
+
_source_epoch = epoch
|
|
697
|
+
return True
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _current_event(event):
|
|
701
|
+
if "ui_state_v1" not in _server_capabilities:
|
|
702
|
+
return True
|
|
703
|
+
return (event.get("session"), event.get("generation"), event.get("source_epoch")) == (_session, _generation, _source_epoch)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _publish_ui_if_due(force=False):
|
|
707
|
+
global _last_ui_publish
|
|
708
|
+
if "ui_state_v1" not in _server_capabilities:
|
|
709
|
+
return
|
|
710
|
+
now = time.monotonic()
|
|
711
|
+
if not force and now - _last_ui_publish < 1.0:
|
|
712
|
+
return
|
|
713
|
+
socket.send_string(SENDING_UI_STATE, zmq.SNDMORE)
|
|
714
|
+
socket.send_json({"session": _session, "generation": _generation,
|
|
715
|
+
"revision": _ui_revision, "state": _ui_state})
|
|
716
|
+
_last_ui_publish = now
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def set_ui_state(patch):
|
|
720
|
+
"""Patch control/section presentation state without reinitializing plots.
|
|
721
|
+
|
|
722
|
+
None removes a property override (or all overrides for a target ID).
|
|
723
|
+
Returns whether the merged state changed. Call poll_controls regularly
|
|
724
|
+
while idle to maintain liveness and recover dropped presentation updates.
|
|
725
|
+
"""
|
|
726
|
+
global _ui_state, _ui_revision
|
|
727
|
+
if "ui_state_v1" not in _server_capabilities:
|
|
728
|
+
raise RuntimeError("set_ui_state requires an acknowledged rtplot server >= 0.5.0; "
|
|
729
|
+
"disabled-state enforcement is unavailable")
|
|
730
|
+
merged = merge_state(_ui_state, patch, _control_registry, _view)
|
|
731
|
+
changed = merged != _ui_state
|
|
732
|
+
if changed:
|
|
733
|
+
_ui_state = merged
|
|
734
|
+
_ui_revision += 1
|
|
735
|
+
_publish_ui_if_due(force=changed)
|
|
736
|
+
return changed
|
|
737
|
+
|
|
738
|
+
|
|
600
739
|
def set_display(display_id: str, value):
|
|
601
740
|
"""Push a single display box value to the browser.
|
|
602
741
|
|
|
@@ -646,7 +785,16 @@ def poll_controls():
|
|
|
646
785
|
restarting their script. The request is consumed silently — the
|
|
647
786
|
returned ControlState only ever contains control/button events.
|
|
648
787
|
"""
|
|
649
|
-
global _control_button_events
|
|
788
|
+
global _control_button_events, _source_epoch, _last_control_poll
|
|
789
|
+
now = time.monotonic()
|
|
790
|
+
stale_poll = "ui_state_v1" in _server_capabilities and now - _last_control_poll > 5.0
|
|
791
|
+
_last_control_poll = now
|
|
792
|
+
if stale_poll:
|
|
793
|
+
# An idle/disconnected application's PULL socket can still hold actions
|
|
794
|
+
# sent before the server detected the outage. Drain them without replay.
|
|
795
|
+
_control_button_events = []
|
|
796
|
+
_source_epoch = None
|
|
797
|
+
_publish_ui_if_due(force=stale_poll)
|
|
650
798
|
while True:
|
|
651
799
|
try:
|
|
652
800
|
event = control_socket.recv_json(flags=zmq.NOBLOCK)
|
|
@@ -655,6 +803,13 @@ def poll_controls():
|
|
|
655
803
|
except zmq.ZMQError:
|
|
656
804
|
break
|
|
657
805
|
evtype = event.get("type")
|
|
806
|
+
if stale_poll and evtype in ("config_ack", "button", "slider", "text"):
|
|
807
|
+
continue
|
|
808
|
+
if evtype == "config_ack":
|
|
809
|
+
_accept_ack(event)
|
|
810
|
+
continue
|
|
811
|
+
if evtype in ("button", "slider", "text") and not _current_event(event):
|
|
812
|
+
continue
|
|
658
813
|
if evtype == "button":
|
|
659
814
|
_control_button_events.append(event.get("id"))
|
|
660
815
|
elif evtype == "slider":
|
|
@@ -664,7 +819,8 @@ def poll_controls():
|
|
|
664
819
|
elif evtype == "resend_config":
|
|
665
820
|
if plot_desc_dict is not None:
|
|
666
821
|
socket.send_string(SENDING_PLOT_UPDATE)
|
|
667
|
-
|
|
822
|
+
_control_button_events = []
|
|
823
|
+
socket.send_json(_wire_config(plot_desc_dict))
|
|
668
824
|
# Surface a one-line confirmation so the user can see in
|
|
669
825
|
# their script log that the recovery handshake worked.
|
|
670
826
|
# If they never see this after clicking Reconnect, the
|