- name
- plotjuggler-plugin
- description
- Write or modify a PlotJuggler plugin (DataSource, MessageParser, Toolbox, or Dialog) or native functional parser module using the plotjuggler_sdk. Use this whenever the task is to implement, extend, debug, or build a PlotJuggler extension — importing a file format, streaming live data, parsing message payloads, mapping a custom type to a canonical object or scalars, adding a data-processing toolbox, or building a plugin configuration dialog — even if the user does not say the word "plugin". It gives the fast path, correct CMake, and load-bearing ABI/lifetime rules. Do NOT use it for changing the SDK's own ABI/protocol (that is a maintainer task with different rules — see below).
# Writing a PlotJuggler plugin
PlotJuggler has four **plugin families**. Subclass a family's small C++20 base
class and export it from a shared library (`.so`/`.dll`/`.dylib`).
`plotjuggler_sdk` supplies each base class. A one-line macro emits the entry
point, version symbol, exception trampolines and symbol folding.
**You never touch the raw ABI.** Override a few virtual methods and ship the library.
There is also a lighter extension shape: a **functional parser module** is one
C++17 source compiled as a native shared library, with no plugin-family
boilerplate. Write a MessageParser plugin when you own a whole encoding. Write a
parser module when you only need one or a few custom message types rendered as
canonical objects or scalars. Start at `references/parser-module.md`.
This skill is the author-oriented fast path. The repo's `pj_plugins/docs/` guides
are the full reference; this steers you to the right one and front-loads the
things that silently break a plugin.
## Are you writing a plugin, or changing the SDK?
- **Writing a plugin *against* the SDK** → this skill. The macros handle ABI
versioning, tail slots, `struct_size`, weak linkage, exception safety. You do
**not** need to read the ABI-stability rules.
- **Changing the SDK's own ABI/protocol** (adding vtable slots, editing a struct,
bumping a `PJ_*_PROTOCOL_VERSION`, touching `pj_base/abi/baseline.abi`) → this is a
**maintainer** task. Stop and follow `pj_plugins/docs/ARCHITECTURE.md` §0a and
the "Release Versioning" contract in the root `CLAUDE.md`. Different rules apply.
## Step 1 — How do you get the SDK?
A plugin author obtains `plotjuggler_sdk` one of three ways. **All three end at the
same link line** — `target_link_libraries(my_plugin PRIVATE
plotjuggler_sdk::plugin_sdk)` — only the acquisition step differs:
- **Conan package** (`plotjuggler_sdk`) — the common external route; the official
plugins use it. Pin the compatibility range explicitly:
`plotjuggler_sdk/[>=X.Y.Z <(X+1).0.0]`, where `X.Y.Z` is the version that
introduced the newest SDK feature you actually use (pre-1.0 the upper bound is
`<1.0.0` — the SDK guarantees no breaks within `0.x`). Tip from the official
repo: keep the version in a single `SDK_VERSION` file your `conanfile.py` reads.
Then `find_package(plotjuggler_sdk REQUIRED COMPONENTS plugin_sdk)`.
- **pixi / conda package** (`plotjuggler_sdk`) — same `find_package` + target once
the package is in your environment.
- **git submodule / vendored source** — `add_subdirectory(plotjuggler_sdk)`; the
same `plotjuggler_sdk::plugin_sdk` alias exists in-tree, and
`pj_configure_plugin` / `pj_embed_file` are available too, so the CMake below is identical.
(Editing the SDK repo itself is this world: the raw umbrella target is
`pj_plugin_sdk`; bare `pj_base` lacks the MessageParser and dialog SDK headers,
so prefer the umbrella. The `pj_plugins/docs/*-guide.md` snippets show
per-piece in-tree targets.)
## Step 2 — Pick the family
| Your goal | Family | Read |
|---|---|---|
| Turn a **file** or a **live source** (socket, serial, hardware) into topics | **DataSource** | `references/data-source.md` |
| Decode a **byte payload** on a topic into named fields (JSON, protobuf, ROS, custom) | **MessageParser** | `references/message-parser.md` |
| Map **one or a few message types** to canonical objects or scalars without owning the encoding | **Functional parser module** | `references/parser-module.md` |
| A **tool** that reads existing data, transforms it, and writes new topics | **Toolbox** | `references/toolbox.md` |
| A **configuration UI** (for a source/parser/toolbox, or standalone) | **Dialog** | `references/dialog.md` |
A single `.so` can host **more than one family** — the supported pattern is an
owner + its embedded Dialog (one `PJ_*_PLUGIN(...)` macro per family at file
scope). Don't ship *unrelated* families in one DSO: catalog discovery reads one
primary family descriptor per library.
If your data is object-like (image, point cloud, occupancy grid, transforms,
markers…) rather than scalar time series, also read `references/builtin-objects.md`
**before** writing — it changes what you emit and how.
## Step 3 — The fast path per family
Subclass the base, override the minimum, register with the macro. The macro's
second argument is the **manifest JSON literal** (see Step 5).
| Family | Subclass | `#include` | Override (minimum) | Register |
|---|---|---|---|---|
| DataSource — file import | `PJ::FileSourceBase` | `pj_base/sdk/data_source_patterns.hpp` | `extraCapabilities()`, `importData()` | `PJ_DATA_SOURCE_PLUGIN(C, m)` |
| DataSource — live stream | `PJ::StreamSourceBase` | `pj_base/sdk/data_source_patterns.hpp` | `extraCapabilities()`, `onStart/onPoll/onStop()` | `PJ_DATA_SOURCE_PLUGIN(C, m)` |
| DataSource — manual | `PJ::DataSourcePluginBase` | `pj_base/sdk/data_source_plugin_base.hpp` | `capabilities()`, `start/stop()`, `currentState()` | `PJ_DATA_SOURCE_PLUGIN(C, m)` |
| MessageParser | `PJ::MessageParserPluginBase` | ⚠ `pj_plugins/sdk/message_parser_plugin_base.hpp` | `registerSchemaHandler()` in ctor/`bindSchema()` | `PJ_MESSAGE_PARSER_PLUGIN(C, m)` |
| Toolbox | `PJ::ToolboxPluginBase` | `pj_base/sdk/toolbox_plugin_base.hpp` | `capabilities()` (+ data ops) | `PJ_TOOLBOX_PLUGIN(C, m)` |
| Dialog | `PJ::DialogPluginTyped` | `pj_plugins/sdk/dialog_plugin_typed.hpp` + `.../widget_data.hpp` | `manifest()`, `ui_content()`, `widget_data()`, event handlers | `PJ_DIALOG_PLUGIN(C, m)` |
> ⚠ **Header-location trap.** Three base classes live under `pj_base/sdk/`, but
> `MessageParserPluginBase` lives under **`pj_plugins/sdk/`**, and the Dialog SDK
> lives under `pj_plugins/sdk/` too (installed there from the `dialog_protocol`
> module). Do not substitute a `pj_base/sdk/` path for either one; use the paths
> in the table.
The quickest correct start is to copy a working example from this repo and edit it:
`examples/sdk_consumer/` (minimal external DataSource with the full CMake),
`pj_plugins/examples/mock_*.cpp` (DataSource, parser, toolbox, source+dialog), and
`pj_plugins/dialog_protocol/examples/mock_dialog.cpp` (standalone dialog). Each
family reference embeds an API skeleton for when the repo isn't at hand (domain
placeholders like `openSocket()` are yours to fill in).
## Step 4 — The SDK already has it: do not write these
Before writing a helper, consult [Existing SDK utilities](../../../docs/sdk-utilities.md)
and read its header. The table covers source-provider, numeric/time, streaming,
dialog and testing support, including helpers owned by pj-official-plugins.
Providers also follow [the provider contract](../../../docs/provider-guide.md)
and link `plotjuggler_sdk::source` alongside `plugin_sdk`.
## Step 5 — Build it
One CMakeLists.txt serves every acquisition channel from Step 1:
```cmake
cmake_minimum_required(VERSION 3.22)
project(my_plugin LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Conan / pixi / installed package:
find_package(plotjuggler_sdk REQUIRED COMPONENTS plugin_sdk)
# Submodule / vendored source instead: add_subdirectory(plotjuggler_sdk)
add_library(my_plugin SHARED my_plugin.cpp)
target_link_libraries(my_plugin PRIVATE plotjuggler_sdk::plugin_sdk) # same in all worlds
pj_configure_plugin(my_plugin
FAMILIES data_source # data_source | message_parser | toolbox | dialog
# (list several: `data_source dialog` for an embedded dialog)
MANIFEST_FILE ${CMAKE_CURRENT_SOURCE_DIR}/manifest.json
MANIFEST_HEADER generated/my_manifest.hpp # -> #include "my_manifest.hpp"
MANIFEST_VAR kMyManifest # -> PJ_DATA_SOURCE_PLUGIN(MyPlugin, kMyManifest)
)
# Dialog UI kept as a real Qt Designer file:
# pj_embed_file(my_plugin FILE ui/my_dialog.ui HEADER generated/my_dialog_ui.hpp VAR_NAME kMyDialogUi)
```
The `plotjuggler_sdk::plugin_sdk` component supplies the common author surface —
base + parser SDK + dialog SDK + these CMake helpers. Compiled provider support
requires `plotjuggler_sdk::source` as well. You do **not** link a separate
dialog target downstream (`pj_dialog_sdk` is an in-tree name).
**`pj_configure_plugin` is required in practice.** It:
1. Validates `manifest.json`; `id`, `name` and `version` are required.
2. Isolates plugin symbols from the host with hidden visibility everywhere
and `-Wl,-Bsymbolic-functions` on Linux/ELF.
3. Writes a human-readable `<target>.pjmanifest.json` sidecar for tooling.
Runtime discovery reads the JSON embedded by `PJ_*_PLUGIN`, not this sidecar.
4. With `MANIFEST_HEADER`, generates the `constexpr` header passed to
`PJ_*_PLUGIN(Class, kMyManifest)`. `manifest.json` remains the single source.
5. On Linux, links a version-script allowlist exporting **only** ABI entry points.
The post-link check fails the build if an entry point is missing or a
`STB_GNU_UNIQUE` symbol leaks.
MessageParser manifests **must** include `"encoding": ["json", …]`.
The host routes payloads by these case-sensitive names.
DataSource may add `"file_extensions": [".csv"]`.
`pj_emit_plugin_manifest` (the pre-0.25 name) still works but is deprecated.
### `min_sdk_required` — the host floor, not the build pin
`min_sdk_required` declares the oldest **host** SDK contract your plugin
functions on. It is independent of the SDK you build against: `pj_base` /
`pj_source` are static libraries compiled into your plugin, so adopting a new
helper (`parseIso8601Utc`, `sliderToWindow`, a codec) **never** raises the
floor. Only **host-contract** surfaces do — runtime-host tail slots, named
services, wire contracts — and the authority for which release introduced each
is `feature_floors.json`, shipped in the package at
`share/plotjuggler_sdk/feature_floors.json`.
When you adopt a surface newer than your floor, pick one:
- **Raise `min_sdk_required`** to that surface's release — mandatory for a
non-negotiated (hard protocol) surface, and right whenever the feature is
essential to what your plugin is for. The reverse is NOT symmetric: the
checker sees only runtime host surfaces, so a floor above anything it
matched may still be correct (compile-time SDK APIs are outside the check)
— never lower a floor just because the checker matched nothing.
- **Declare the degraded range** — when the surface is runtime-negotiated and
the wrapper's error return is acceptable (ignore it and carry on). Two
manifest fields, required together and forbidden when nothing exceeds the
floor:
```json
"min_sdk_required": "0.28.0",
"suggested_sdk_version": "0.32.0",
"floor_test": "McapDatasetMetadata.MetadataPublishDegradesCleanlyOnFloorLevelHost"
```
`suggested_sdk_version` is the full-feature floor (exactly the max introducing
release over every host surface you use — the checker validates it), so hosts
can tell users which PlotJuggler unlocks your whole feature set. `floor_test`
names one gtest, existing in your `tests/`, proving the DEGRADED PATH works
against a floor-level host — drive it with a `struct_size`-truncated host
vtable and poison the slots past the truncation so a missing guard fails the
test. Helper-level granularity is acceptable when the full entry point needs a
live host; the test comment must then state the routing fact (which entry
point routes through the helper). Neither field is ever a gate:
`min_sdk_required` remains the only admission criterion, and
`min_plotjuggler_version` is deprecated.
Check your own plugin with the tool the SDK package ships next to the table:
```bash
python3 <sdk>/share/plotjuggler_sdk/feature_floor_check.py \
--table <sdk>/share/plotjuggler_sdk/feature_floors.json \
--manifest my_plugin/manifest.json --sources my_plugin
```
## Step 6 — The rules that silently break a plugin
These cut across all families. The family references add family-specific traps
(dialog `buttonBox` naming, parser topic-scoping, toolbox `notifyDataChanged`
coalescing, etc.) — read them.
1. **Never let an exception cross the ABI boundary.** Return
`PJ::unexpected("clear reason")` / `PJ::okStatus()` from fallible methods. The
macro's trampolines are only a safety net: fallible entry points convert a
stray exception's `what()` into the ABI error, non-fallible ones can only
swallow it, and an exception escaping a `noexcept` slot terminates the
process. Explicit `Status` returns are the only predictable diagnostics.
2. **Keep the data plane on the host's callback thread.** `writeHost()`,
`toolboxHost()`, and `pushMessage()` are single-threaded — a background I/O
thread must buffer under a mutex and hand data off inside `onPoll()`/`poll()`.
The authoritative source is the thread tag on each slot in the protocol
headers: slots tagged `[thread-safe]` — `reportMessage()`,
`isStopRequested()`, `notifyState()`, `requestStop()`, and the toolbox's
`notifyDataChanged()` — may be called from any thread (e.g. reporting a
connection loss from an I/O callback). Everything else stays on the callback
thread.
3. **Timestamps are `int64` nanoseconds since the Unix epoch.** Not seconds, not
row indices. Extract or synthesize a real absolute nanosecond value. Use
`std::chrono::system_clock` for receive-time stamps — **never**
`high_resolution_clock`, which is since-boot on some platforms. (Only data with
genuinely no time axis — e.g. a CSV with no time column — may fall back to a
documented synthetic index-as-time mode.)
4. **`saveConfig()`/`loadConfig()` must be a complete, deterministic round-trip**
with no ambient state (no `QSettings`, no cwd assumptions). Persist every field.
A DataSource must run `loadConfig(saved) → start()` headless, with no dialog.
A parser must decode from its config alone.
Dialogs and toolboxes restore from the layout *before data arrives*.
Tolerate older configs by migrating or defaulting missing keys.
Do not *fail* `loadConfig()` merely because referenced data is not loaded yet.
Re-resolve in `onDataChanged()`.
5. **String lifetimes across the ABI.** SDK overrides return `std::string` — the
base class buffers it for the ABI, one buffer per slot, invalidated at the
next call of the *same* method on the same instance. So never hold the host's
view of a previous return, and if you ever hand out a raw `const char*`
yourself, it must stay valid until your next call.
6. **Arrow buffers use RAII holders.** Wrap read out-params in
`PJ::sdk::ArrowSchemaHolder` / `ArrowArrayHolder`; wrap write streams in
`PJ::sdk::ArrowStreamHolder`. On a **successful** append the host takes
ownership (the holder disarms); on **failure** you still own it (the holder
releases). Never release twice.
7. **Guard host access.** Check `writeHostBound()` / `runtimeHostBound()` (or the
toolbox equivalents) before using a host view.
8. **Object-like data → a builtin type + its codec.** Images, point clouds,
grids, transforms, markers go through the builtin object vocabulary, not raw
scalar fields. See `references/builtin-objects.md`.
## Verify
- Builds clean and produces a shared library.
- `PJ_*_PLUGIN` macro present exactly once per family; manifest literal has the
required keys (+ `encoding` for a parser).
- Load it in PlotJuggler, or preferably test the **real built `.so`** through
the host loaders (`DataSourceLibrary::load(path)`,
`MessageParserLibrary::load(path)`, …). Inject the path as a compile definition.
Bind SDK test helpers as host services: `pj_base/sdk/testing/parser_write_recorder.hpp`
and `pj_plugins/testing/toolbox_test_store.hpp`. Assert on the writes.
عرض على GitHub