Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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()
```
Expand Down
89 changes: 82 additions & 7 deletions docs/generate_screenshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand All @@ -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
Expand All @@ -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)
Expand Down
Binary file modified docs/live_demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshot_archive_tab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshot_live_streaming.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 14 additions & 4 deletions examples/live_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,7 +33,11 @@
OpcUaDataType,
OpcUaServer,
)
from opensemantic.lab.view import LiveDataToolView
from opensemantic.lab.view import (
LiveConfig,
LiveDataToolView,
LiveDataToolViewConfig,
)

pn.extension()

Expand Down Expand Up @@ -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,
)


Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
12 changes: 10 additions & 2 deletions src/opensemantic/lab/view/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
Loading
Loading