diff --git a/README.md b/README.md index dd1cee4..59880b4 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,10 @@ Features: - Archive tab with all base DataToolView features (stacked plots, unit switching, log console) - Live tab with Bokeh streaming plots updated via OPC UA subscriptions - Per-channel unit conversion for live data -- Configurable history window, buffer size, and update interval via JsonEditor +- Configurable history window, buffer size, and update interval via the `live` + sub-config of `LiveDataToolViewConfig` +- Optional URL sync (`url_sync=True`) persists the selection / units / time + window in the browser URL (visible in the address bar of the screenshots below) ![Live Demo](docs/live_demo.gif) @@ -90,13 +93,23 @@ Features: *Real-time OPC UA streaming with stacked Bokeh plots* ```python -from opensemantic.lab.view import LiveDataToolView -from opensemantic.base.view._config import LiveDashboardConfig, PlotConfig +from opensemantic.base.view import DataToolPlotControlsConfig, UrlConfigMode +from opensemantic.lab.view import ( + LiveConfig, + LiveDataToolView, + LiveDataToolViewConfig, +) view = LiveDataToolView( controllers=[ctrl], - config=LiveDashboardConfig(lang="en", plot=PlotConfig(auto_fetch=True)), + config=LiveDataToolViewConfig( + lang="en", + plot=DataToolPlotControlsConfig(auto_fetch=True), + live=LiveConfig(buffer_size=500, update_interval_ms=500, history_seconds=30), + ), title="Live Dashboard", + url_sync=True, + url_mode=UrlConfigMode.PLAIN_KEYS, # human-readable flattened query params ) view.servable() ``` diff --git a/docs/generate_screenshots.py b/docs/generate_screenshots.py index 9ccf652..91c1663 100644 --- a/docs/generate_screenshots.py +++ b/docs/generate_screenshots.py @@ -79,6 +79,60 @@ def capture(page, frames, delay=500): frames.append(iio.imread(io.BytesIO(buf))) +def _address_bar(url, width, height=52): + """Render a synthetic browser address bar showing ``url`` (headless has no + chrome, so we draw one to showcase the URL-synced config).""" + import numpy as np + from PIL import Image, ImageDraw, ImageFont + + bar = Image.new("RGB", (width, height), (241, 243, 244)) + d = ImageDraw.Draw(bar) + cx = 20 + for _ in range(3): # back / forward / reload glyphs + d.ellipse( + [cx - 7, height // 2 - 7, cx + 7, height // 2 + 7], + outline=(150, 150, 150), + width=2, + ) + cx += 28 + x0 = cx + 6 + d.rounded_rectangle( + [x0, 9, width - 12, height - 9], + radius=(height - 18) // 2, + fill=(255, 255, 255), + outline=(205, 205, 205), + ) + try: + font = ImageFont.truetype("arial.ttf", 15) + except Exception: + font = ImageFont.load_default() + text, maxw = url, width - 12 - (x0 + 16) + if d.textlength(text, font=font) > maxw: + while len(text) > 12 and d.textlength(text + "…", font=font) > maxw: + text = text[:-1] + text += "…" + d.text((x0 + 14, height // 2 - 9), text, fill=(50, 50, 50), font=font) + return np.asarray(bar) + + +def _shot_with_address_bar(page): + """A page screenshot with a synthetic address bar (page.url) on top.""" + import numpy as np + + shot = iio.imread(io.BytesIO(page.screenshot())) + bar = _address_bar(page.url, shot.shape[1]) + if shot.shape[2] == 4: # match RGBA of the page screenshot + alpha = np.full(bar.shape[:2] + (1,), 255, dtype=bar.dtype) + bar = np.concatenate([bar, alpha], axis=2) + return np.vstack([bar, shot]) + + +def capture_with_address_bar(page, frames, delay=500): + """Like :func:`capture`, but prepend the synthetic address bar.""" + page.wait_for_timeout(delay) + frames.append(_shot_with_address_bar(page)) + + def start_server(): """Start the Panel server as a subprocess.""" # Clean up old DB so example creates fresh data @@ -100,10 +154,20 @@ def start_server(): def stop_server(proc): - """Stop the Panel server subprocess.""" - proc.terminate() + """Stop the Panel server subprocess (Windows-safe process-tree kill). + + On Windows, ``terminate()`` leaves the ``panel serve`` child alive (it keeps + the port and leaks servers across runs), so kill the whole tree. + """ + if os.name == "nt": + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(proc.pid)], + capture_output=True, + ) + else: + proc.terminate() try: - proc.wait(timeout=5) + proc.wait(timeout=8) except subprocess.TimeoutExpired: proc.kill() @@ -122,13 +186,24 @@ def main(): print("Waiting 30s for OPC UA data to accumulate...") page.wait_for_timeout(30000) - # Select all channels - auto-fetch finds accumulated data + # Select all channels - auto-fetch finds accumulated data. The + # selection is written into the URL-synced config, so the synthetic + # address bar in the screenshots shows the state being persisted. click_all_checkboxes(page) page.wait_for_timeout(5000) + # The picker window's end is captured at session start, before the + # OPC data accumulates, so the just-generated points fall after it. + # Reload the (now URL-synced) page: the fresh session's window ends + # at "now" - covering the persisted archive - and url_sync restores + # the selection, so auto-fetch renders the archive plot. This also + # showcases URL-config restoration end to end. + page.reload(timeout=20000) + page.wait_for_timeout(12000) + # Screenshot: archive tab with accumulated data archive_path = os.path.join(DOCS_DIR, "screenshot_archive_tab.png") - page.screenshot(path=archive_path) + iio.imwrite(archive_path, _shot_with_address_bar(page)) print("screenshot_archive_tab.png saved") # Switch to Live tab and start streaming @@ -139,13 +214,13 @@ def main(): # Wait for live data to accumulate page.wait_for_timeout(12000) live_path = os.path.join(DOCS_DIR, "screenshot_live_streaming.png") - page.screenshot(path=live_path) + iio.imwrite(live_path, _shot_with_address_bar(page)) print("screenshot_live_streaming.png saved") # Create live streaming GIF frames = [] for i in range(10): - capture(page, frames, 1500) + capture_with_address_bar(page, frames, 1500) gif_path = os.path.join(DOCS_DIR, "live_demo.gif") iio.imwrite(gif_path, frames, duration=1000, loop=0) diff --git a/docs/live_demo.gif b/docs/live_demo.gif index 18cead0..ce2b15a 100644 Binary files a/docs/live_demo.gif and b/docs/live_demo.gif differ diff --git a/docs/screenshot_archive_tab.png b/docs/screenshot_archive_tab.png index 34ee3a0..b9aec84 100644 Binary files a/docs/screenshot_archive_tab.png and b/docs/screenshot_archive_tab.png differ diff --git a/docs/screenshot_live_streaming.png b/docs/screenshot_live_streaming.png index c27608e..ce5eb27 100644 Binary files a/docs/screenshot_live_streaming.png and b/docs/screenshot_live_streaming.png differ diff --git a/examples/live_dashboard.py b/examples/live_dashboard.py index 5354a7c..f3c5b30 100644 --- a/examples/live_dashboard.py +++ b/examples/live_dashboard.py @@ -17,7 +17,7 @@ from opensemantic import compute_scoped_uuid from opensemantic.base.v1 import Database -from opensemantic.base.view._config import LiveConfig, LiveDashboardConfig, PlotConfig +from opensemantic.base.view import DataToolPlotControlsConfig, UrlConfigMode from opensemantic.characteristics.quantitative.v1 import ( ForcePerAreaUnit, Pressure, @@ -33,7 +33,11 @@ OpcUaDataType, OpcUaServer, ) -from opensemantic.lab.view import LiveDataToolView +from opensemantic.lab.view import ( + LiveConfig, + LiveDataToolView, + LiveDataToolViewConfig, +) pn.extension() @@ -112,16 +116,22 @@ async def generate_value(params): # -- Build dashboard using the client -- -config = LiveDashboardConfig( +config = LiveDataToolViewConfig( lang="en", - plot=PlotConfig(auto_fetch=True, row_limit=10000), + plot=DataToolPlotControlsConfig(auto_fetch=True, row_limit=10000), live=LiveConfig(buffer_size=500, update_interval_ms=500, history_seconds=30), ) +# URL-persist the config in PLAIN_KEYS mode (human-readable, flattened dot-path +# query params), so a selection / unit / time-window choice survives a reload, +# can be shared, and is legible in the address bar. Fine here since the tree is +# tiny; a large tree would favor JSON or COMPRESSED_BASE64. view = LiveDataToolView( controllers=[client], config=config, title="Live DataTool Dashboard", + url_sync=True, + url_mode=UrlConfigMode.PLAIN_KEYS, ) diff --git a/setup.cfg b/setup.cfg index 726883c..29db45f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,7 +48,7 @@ python_requires = >=3.10 # new major versions. This works if the required packages follow Semantic Versioning. # For more information, check out https://semver.org/. install_requires = - opensemantic.base>=0.42.7.post1000002003002 + opensemantic.base>=0.42.8.post1000002004004 opensemantic.characteristics.quantitative>=0.4.0.post1000002001001 [options.packages.find] diff --git a/src/opensemantic/lab/view/__init__.py b/src/opensemantic/lab/view/__init__.py index 701de90..43647c1 100644 --- a/src/opensemantic/lab/view/__init__.py +++ b/src/opensemantic/lab/view/__init__.py @@ -1,5 +1,13 @@ """Lab UI extensions with live OPC UA support.""" -from opensemantic.lab.view._live_dashboard import LiveDataToolView +from opensemantic.lab.view._live_dashboard import ( + LiveConfig, + LiveDataToolView, + LiveDataToolViewConfig, +) -__all__ = ["LiveDataToolView"] +__all__ = [ + "LiveDataToolView", + "LiveConfig", + "LiveDataToolViewConfig", +] diff --git a/src/opensemantic/lab/view/_live_dashboard.py b/src/opensemantic/lab/view/_live_dashboard.py index 18200aa..154e0b4 100644 --- a/src/opensemantic/lab/view/_live_dashboard.py +++ b/src/opensemantic/lab/view/_live_dashboard.py @@ -19,6 +19,7 @@ import panel as pn from bokeh.models import ColumnDataSource, DatetimeTickFormatter, Range1d from bokeh.plotting import figure as bk_figure +from pydantic import BaseModel, ConfigDict, Field from opensemantic.base.view._channel_utils import ( _t, @@ -27,12 +28,67 @@ group_channels_by_characteristic, resolve_value_type, ) -from opensemantic.base.view._config import LiveDashboardConfig -from opensemantic.base.view._datatool_dashboard import DataToolView +from opensemantic.base.view._datatool_dashboard import ( + DataToolView, + DataToolViewConfig, +) _logger = logging.getLogger(__name__) +class LiveConfig(BaseModel): + """Config of the live-subscription component (the rolling realtime plot).""" + + model_config = ConfigDict( + extra="allow", + json_schema_extra={ + "title": "LiveConfig", + "defaultProperties": [ + "buffer_size", + "update_interval_ms", + "history_seconds", + ], + }, + ) + + buffer_size: int = Field( + 1000, + title="Buffer size", + ge=10, + json_schema_extra={"title*": {"de": "Puffergroesse"}}, + ) + update_interval_ms: int = Field( + 500, + title="Update interval (ms)", + ge=50, + json_schema_extra={"title*": {"de": "Aktualisierungsintervall (ms)"}}, + ) + history_seconds: float = Field( + 10.0, + title="History window (s)", + gt=0, + json_schema_extra={"title*": {"de": "Verlaufsfenster (s)"}}, + ) + + +class LiveDataToolViewConfig(DataToolViewConfig): + """Config for :class:`LiveDataToolView`. + + Extends the channel-centered :class:`DataToolViewConfig` with a ``live`` + sub-config driving the realtime subscription tab, mirroring how the view + extends :class:`DataToolView` with a live tab. + """ + + model_config = ConfigDict( + json_schema_extra={ + "title": "LiveDataToolViewConfig", + "defaultProperties": ["controllers", "lang", "tree", "plot", "live"], + } + ) + + live: LiveConfig = Field(default_factory=LiveConfig, title="Live") + + def get_unit_enum_for_uuid(ch_uuid, selected): """Find the UnitEnum for a channel by UUID from the selected list.""" for ctrl, ch in selected: @@ -52,20 +108,32 @@ class LiveDataToolView(DataToolView): controllers List of DataToolController or OpcUaServer instances. config - LiveDashboardConfig with live subscription settings. + LiveDataToolViewConfig with live subscription settings. title View title. + embeddable + When True, skip the internal Panelini app so the cards can be embedded + into a host app via ``sidebar_cards`` / ``main_cards``. + url_sync + When True, bind the config to the browser URL. + url_mode + URL serialization mode (see ``UrlConfigMode``). """ + config_cls = LiveDataToolViewConfig + def __init__( self, controllers: Optional[List[Any]] = None, - config: Optional[LiveDashboardConfig] = None, + config: Optional[DataToolViewConfig] = None, title: str = "DataTool Dashboard", + embeddable: bool = False, + url_sync: bool = False, + url_mode=None, ): - self._live_config = config or LiveDashboardConfig() - # Live state - self._buffer_size = self._live_config.live.buffer_size + config = type(self)._coerce_config(config) + # Live state - the ``live`` sub-config drives the rolling buffers. + self._buffer_size = config.live.buffer_size self._live_buffers: Dict[str, Deque[Tuple[dt.datetime, Any]]] = defaultdict( lambda: deque(maxlen=self._buffer_size) ) @@ -75,8 +143,11 @@ def __init__( super().__init__( controllers=controllers, - config=self._live_config, + config=config, title=title, + embeddable=embeddable, + url_sync=url_sync, + url_mode=url_mode, ) @staticmethod @@ -182,7 +253,7 @@ def _start_live(self): self._periodic_callback = pn.state.add_periodic_callback( self._update_live_plot, - period=self._live_config.live.update_interval_ms, + period=self._config.live.update_interval_ms, ) def _stop_live(self): @@ -220,7 +291,7 @@ def _build_live_figures(self): from opensemantic.base.view._datatool_dashboard import COLORS groups = group_channels_by_characteristic( - self._selected, self._live_config.plot.grouping + self._selected, self._config.plot.grouping ) self._live_sources.clear() @@ -239,7 +310,7 @@ def _build_live_figures(self): axis_label = self._get_axis_label(group_key) now = dt.datetime.now(dt.timezone.utc) - history_s = self._live_config.live.history_seconds + history_s = self._config.live.history_seconds x_range = Range1d( start=now - dt.timedelta(seconds=history_s), end=now, @@ -283,7 +354,7 @@ def _build_live_figures(self): def _update_live_plot(self): """Periodic callback: stream data into Bokeh ColumnDataSources.""" - if not self._live_active or self._live_config is None: + if not self._live_active: return if not self._live_fig_built: @@ -291,9 +362,7 @@ def _update_live_plot(self): return now = dt.datetime.now(dt.timezone.utc) - history = getattr( - getattr(self._live_config, "live", None), "history_seconds", 30 - ) + history = self._config.live.history_seconds window_start = now - dt.timedelta(seconds=history) for ch_uuid, src in self._live_sources.items(): @@ -340,35 +409,26 @@ def _update_live_plot(self): # Update x-range on all figures (local time) now = dt.datetime.now() - history = getattr( - getattr(self._live_config, "live", None), "history_seconds", 30 - ) + history = self._config.live.history_seconds window_start = now - dt.timedelta(seconds=history) for fig in self._live_figures: fig.x_range.start = window_start fig.x_range.end = now - # -- Config change handling -- + # -- Config apply (config -> view) -- - def _on_config_editor_change(self, event): - if not event.new or not isinstance(event.new, dict): - return - try: - new_config = LiveDashboardConfig(**event.new) - except Exception as e: - _logger.debug("Incomplete live config, skipping: %s", e) - return - if new_config is None: - return + def _apply_config(self, old, new): + """Apply a config: retime/resize the live buffers, then the base state. - old_live = self._live_config.live - self._live_config = new_config - self._config = new_config + Runs inside ``set_config`` (guarded by ``_applying_config``). The live + sub-config drives the rolling buffers and the periodic callback; the + base handles selection / units / time / grouping and (re)plotting. + """ + old_live = getattr(old, "live", None) + new_live = new.live - # Handle live config changes - if old_live.buffer_size != new_config.live.buffer_size: - # Resize buffers - self._buffer_size = new_config.live.buffer_size + if old_live is None or old_live.buffer_size != new_live.buffer_size: + self._buffer_size = new_live.buffer_size new_buffers: Dict[str, Deque] = defaultdict( lambda: deque(maxlen=self._buffer_size) ) @@ -376,18 +436,18 @@ def _on_config_editor_change(self, event): new_buffers[k].extend(buf) self._live_buffers = new_buffers - if ( - old_live.update_interval_ms != new_config.live.update_interval_ms - and self._periodic_callback is not None - ): + interval_changed = ( + old_live is None + or old_live.update_interval_ms != new_live.update_interval_ms + ) + if interval_changed and self._periodic_callback is not None: self._periodic_callback.stop() self._periodic_callback = pn.state.add_periodic_callback( self._update_live_plot, - period=new_config.live.update_interval_ms, + period=new_live.update_interval_ms, ) - # Delegate base config changes - super()._on_config_editor_change(event) + super()._apply_config(old, new) def _on_controllers_changed(self): """Handle controllers change - also manages OPC UA lifecycle."""