Skip to content
Draft
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
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,56 @@
All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in
[`CLAUDE.md`](./CLAUDE.md) → "Release Versioning".

## [0.27.0] — Unreleased, on branch `feat/playback-viewport-services`

> Numbered assuming #183 (`feat/dataset-qualified-names`, 0.26.0) merges first;
> renumber at release if the two land in the other order.

### Added — host services `pj.playback.v1` and `pj.viewport.v1` (MINOR, additions only)

Two new optional host services so a plugin (first consumer: the Assistant Agent
toolbox) can drive the app like a user — transport and zoom — without any new
executable surface crossing the ABI:

- **`pj.playback.v1`** (`PJ_playback_host_vtable_t`, `sdk::PlaybackHostView`,
`sdk::PlaybackHostService`): `play` / `pause` / `seek` / `set_playback_rate` /
`get_state` (ABI-frozen `PJ_playback_state_t` snapshot) / `to_display_time`
(absolute int64 ns → display-axis seconds, per-topic dataset offset;
current-frame semantics). All times are display-axis seconds.
- **`pj.viewport.v1`** (`PJ_viewport_host_vtable_t`, `sdk::ViewportHostView`,
`sdk::ViewportHostService`): `zoom_to_time_range` (X window in display-axis
seconds; per-plot Y preserved; XY/empty plots untouched) and `zoom_reset`
(fit). Both are **scoped to the tabs the calling plugin owns** — see
`pj.plot_tabs.v1` below; a plugin owning none has nothing to zoom, which is an
error rather than a silent no-op.
- **`pj.plot_tabs.v1`** (`PJ_plot_tab_host_vtable_t`, `sdk::PlotTabHostView`,
`sdk::PlotTabHostService`): a plugin composes plotting tabs of its own —
`create_tab` / `close_tab` / `list_tab_ids` / `tab_config` / `add_curve` /
`remove_curve` / `clear_tab`. Ids are plugin-chosen and namespaced per plugin
(the `pj.data_processors.v1` discipline), and every slot is scoped to the
caller's own tabs, so the user's tabs are neither disclosed nor mutable
through it. Curves are addressed by their parts — topic, field, dataset
source — rather than a joined path, because field paths contain `/` and
dataset names contain `:`, and an empty dataset source requires the pair to be
unique. `tab_config` reads back what a tab actually holds, so a caller can
report what was drawn instead of what it asked for.

The boundary these two draw is the VIEW. `pj.playback.v1` stays global by
nature: one time cursor is shared by every plot.

No existing struct or vtable slot was touched — every already-built plugin keeps
working with no recompile (`abidiff`: additions only). `pj.viewport.v1`'s two
slots keep their signatures; only their documented scope narrowed, and it has
never shipped.

### Fixed — `deserializePlotMarkers` accepts the empty buffer as an empty set

An empty buffer is the canonical proto encoding of an empty `PlotMarkers` set —
the "clear my markers" tombstone a producer publishes to a replace-only store —
but the decoder rejected `size == 0` as an error, making the tombstone
unrepresentable on the wire. It now decodes to an empty set; null-with-size,
truncated, and malformed payloads still error.

## [0.25.0]

### Feature: plugin-authoring CMake helpers ship with the SDK (MINOR)
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.25.0
0.27.0
9 changes: 9 additions & 0 deletions docs/dialog-sdk-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,3 +510,12 @@ public:
}
};
```

## Conditional field enabling (`pj_enable_when`)

Declarative, host-driven enable/disable: give any widget the string dynamic
property `pj_enable_when` = `"<comboObjectName>:<index>[,<index>...]"` and it
stays enabled only while that combo sits on one of the listed indices. Works
inside modal sub-dialogs (where the plugin cannot push widget data); rules
re-assert after every widget-data apply. Malformed/unresolvable rules are
ignored. See dialog-plugin-guide.md → "Optional: conditional field enabling".
2 changes: 2 additions & 0 deletions pj_base/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ if(PJ_BUILD_TESTS)
tests/parser_module_abi_test.cpp
tests/parser_module_manifest_test.cpp
tests/data_processors_api_test.cpp
tests/playback_viewport_api_test.cpp
tests/plot_tabs_api_test.cpp
tests/settings_store_host_test.cpp
tests/parser_runtime_host_test.cpp
tests/data_source_protocol_test.cpp
Expand Down
5 changes: 4 additions & 1 deletion pj_base/include/pj_base/builtin/plot_markers_codec.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ inline constexpr std::string_view kSchemaPlotMarkers = "PJ.PlotMarkers";

/// Decodes canonical PJ.PlotMarkers wire bytes into sdk::PlotMarkers.
///
/// Returns an error for null, empty, truncated, or malformed payloads.
/// An EMPTY buffer decodes to an empty set — the canonical "no markers"
/// tombstone a producer publishes to clear its output (the object store is
/// replace-only; markers are overwritten, never deleted). Returns an error for
/// null-with-size, truncated, or malformed payloads.
[[nodiscard]] Expected<sdk::PlotMarkers> deserializePlotMarkers(const uint8_t* data, size_t size);

} // namespace PJ
206 changes: 206 additions & 0 deletions pj_base/include/pj_base/plugin_data_api.h
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,15 @@ typedef struct {
* Every populator (see sdk::fillError) MUST clear both new slots when
* writing to avoid stale pointers in reused error structs.
*/
/* Shared host-service error-code convention. Codes are domain-specific in
* general, but every PJ host service distinguishes at least these two classes,
* and aggregators (e.g. a kind router probing multiple backends) rely on the
* distinction: REJECTED means "this request is not mine / not valid" (safe to
* try another backend), INTERNAL means a real failure on a request the backend
* owns (must be surfaced, never masked by a fallback). */
#define PJ_ERROR_CODE_REJECTED 1
#define PJ_ERROR_CODE_INTERNAL 2

typedef struct {
int32_t code; /* 0 = success; otherwise domain-specific */
char domain[PJ_ERROR_DOMAIN_MAX]; /* null-terminated; truncated if too long */
Expand Down Expand Up @@ -1046,6 +1055,203 @@ typedef struct {
const PJ_settings_store_vtable_t* vtable;
} PJ_settings_store_t;

/**
* Playback host service ("pj.playback.v1", protocol_version 1).
*
* Optional programmatic control of the host's playback cursor (the vertical
* time tracker the playback slider drives). All slots are [main-thread].
*
* TIME UNIT: every time in this service is DISPLAY-AXIS SECONDS — the numbers
* the plot X axes and the playback slider show — NOT absolute nanoseconds.
* Display time is derived from absolute time via per-dataset, user-editable
* offsets, so any converted value is valid for the CURRENT frame only:
* re-query after the user edits a source offset or the time reference.
*
* ABI-APPENDABLE: new slots may be added at the tail; struct_size gates read.
*/

/* ABI-FROZEN: layout permanent; changes = ABI break (add a get_state_v2 slot instead). */
typedef struct {
bool is_playing;
double current_time_s; /* display-axis seconds */
double range_min_s; /* playback range in display-axis seconds */
double range_max_s;
double playback_rate; /* speed multiplier; 1.0 = real time */
} PJ_playback_state_t;

typedef struct PJ_playback_host_vtable_t {
uint32_t protocol_version; /* = 1 */
uint32_t struct_size; /* = sizeof(PJ_playback_host_vtable_t) */

/* [main-thread] Start advancing the playback cursor. Idempotent. */
bool (*play)(void* ctx, PJ_error_t* out_error) PJ_NOEXCEPT;

/* [main-thread] Stop advancing the playback cursor. Idempotent. */
bool (*pause)(void* ctx, PJ_error_t* out_error) PJ_NOEXCEPT;

/* [main-thread] Move the cursor to `time_s` (display-axis seconds); the host
* clamps into [range_min_s, range_max_s]. Play/pause state is unchanged.
* Non-finite time is an error. */
bool (*seek)(void* ctx, double time_s, PJ_error_t* out_error) PJ_NOEXCEPT;

/* [main-thread] Set the playback-speed multiplier (> 0; the host may clamp).
* Non-finite or non-positive rate is an error. */
bool (*set_playback_rate)(void* ctx, double rate, PJ_error_t* out_error) PJ_NOEXCEPT;

/* [main-thread] Snapshot the current playback state. During live streaming
* the range (and a cursor glued to its tip) advances on every ingest tick. */
bool (*get_state)(void* ctx, PJ_playback_state_t* out_state, PJ_error_t* out_error) PJ_NOEXCEPT;

/* [main-thread] Convert an ABSOLUTE nanosecond timestamp (the unit the data
* read/write surfaces speak) into display-axis seconds, using the display
* offset of the dataset that owns `topic`. An empty topic uses the host's
* representative dataset. Current-frame semantics (see the service
* doc-comment). An unknown topic is an error. */
bool (*to_display_time)(
void* ctx, PJ_string_view_t topic, int64_t absolute_ns, double* out_display_s, PJ_error_t* out_error)
PJ_NOEXCEPT;
} PJ_playback_host_vtable_t;

typedef struct {
void* ctx;
const PJ_playback_host_vtable_t* vtable;
} PJ_playback_host_t;

/**
* Viewport host service ("pj.viewport.v1", protocol_version 1).
*
* Optional zoom control, SCOPED to the tabs the calling plugin owns (see
* "pj.plot_tabs.v1"). A host that grants a plugin its own tabs must confine
* these slots to them: the user's plots are not a plugin's to reframe. A
* plugin owning no tab therefore has nothing to zoom, which is an error, not a
* silent no-op.
*
* That makes this service dependent on the other in practice, even though the
* registry treats the two as independently optional: a host offering this one
* WITHOUT "pj.plot_tabs.v1" leaves every plugin with nothing it may zoom, so
* these two are registered together or not at all.
*
* Note the boundary is the VIEW, not the transport: "pj.playback.v1" stays
* global by nature, since one time cursor is shared by every plot.
*
* All slots are [main-thread]. Times are display-axis seconds (same convention
* as "pj.playback.v1"). ABI-APPENDABLE: new slots may be added at the tail;
* struct_size gates read.
*/
typedef struct PJ_viewport_host_vtable_t {
uint32_t protocol_version; /* = 1 */
uint32_t struct_size; /* = sizeof(PJ_viewport_host_vtable_t) */

/* [main-thread] Set the visible X window of every time-series plot the
* calling plugin owns to [t0_s, t1_s] (display-axis seconds). Each plot keeps
* its own Y range; XY plots and empty plots are untouched. Requires finite
* t0_s < t1_s. Owning no such plot is an error, and the host is expected to
* say which kind — no tab at all, or a tab with nothing to zoom — since the
* caller's remedy differs. */
bool (*zoom_to_time_range)(void* ctx, double t0_s, double t1_s, PJ_error_t* out_error) PJ_NOEXCEPT;

/* [main-thread] Reset every plot the calling plugin owns to fit its data.
* Like zoom_to_time_range, owning nothing is an error. */
bool (*zoom_reset)(void* ctx, PJ_error_t* out_error) PJ_NOEXCEPT;
} PJ_viewport_host_vtable_t;

typedef struct {
void* ctx;
const PJ_viewport_host_vtable_t* vtable;
} PJ_viewport_host_t;

/**
* Plot-tab host service ("pj.plot_tabs.v1", protocol_version 1).
*
* Lets a plugin compose plotting tabs OF ITS OWN: create one, place and remove
* curves in it, read back what it holds, close it. Every slot is scoped to the
* calling binding — a tab this plugin did not create is rejected exactly as an
* unknown id is, so the service never discloses, mutates or even confirms the
* existence of the user's tabs. That scoping is the host's job, not the
* plugin's: it is enforced where ownership is known, and ownership is derived
* from `ctx`, never passed in.
*
* Ids are chosen by the plugin and namespaced per plugin by the host (the
* "pj.data_processors.v1" discipline), so an id is unique within this binding
* and cannot collide with another plugin's. The resemblance stops there, and
* the difference matters: a data processor's id survives a session reload
* because the host replays it from a persisted recipe, whereas a tab is a VIEW
* and this service promises nothing of the sort. Treat an id as live only for
* as long as `list_tab_ids` still returns it, and re-read rather than assume
* after anything that could have rebuilt the workspace.
*
* A tab created here is the host's to present: it carries whatever permanent
* mark the host uses for model-authored views, and the plugin cannot suppress
* it. Whether such a tab is saved with the workspace is likewise the host's
* policy, not this service's contract.
*
* All slots are [main-thread]. ABI-APPENDABLE: new slots may be added at the
* tail; struct_size gates read.
*/
typedef struct PJ_plot_tab_host_vtable_t {
uint32_t protocol_version; /* = 1 */
uint32_t struct_size; /* = sizeof(PJ_plot_tab_host_vtable_t) */

/* [main-thread] Create (or replace, upsert by id) a tab owned by this plugin,
* holding one empty plot. An empty `title` lets the host name it. All string
* arguments are borrowed for the duration of the call. */
bool (*create_tab)(void* ctx, PJ_string_view_t id, PJ_string_view_t title, PJ_error_t* out_error) PJ_NOEXCEPT;

/* [main-thread] Close one of this plugin's tabs. Unknown id is an error.
* Closing a tab discards the VIEW only: any derived series or markers drawn
* in it are data and outlive it. */
bool (*close_tab)(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) PJ_NOEXCEPT;

/* [main-thread] Enumerate the ids of THIS plugin's live tabs.
* Count-then-fill: pass capacity 0 to read *out_count, then call again with a
* buffer of that size. On success the first min(capacity, *out_count) entries
* of out_ids are filled and point into host storage valid only until the next
* call on this vtable. Owning no tab is success with *out_count == 0. */
bool (*list_tab_ids)(
void* ctx, PJ_string_view_t* out_ids, uint64_t capacity, uint64_t* out_count,
PJ_error_t* out_error) PJ_NOEXCEPT;

/* [main-thread] Read back what a tab actually holds, as JSON
* {"title":"...","curves":[{"topic":"...","field":"...","dataset":"..."}]}.
* This is how a caller reports what was drawn instead of what it asked for:
* a curve the host could not resolve is simply absent. "dataset" is always
* the RESOLVED source name, even where the caller left it empty, so the
* report says which run a curve actually came from. The host emits valid
* JSON: any character needing escaping in a topic or field is escaped here.
* *out_config_json is borrowed, valid only until the next call on this
* vtable. Unknown id is an error. */
bool (*tab_config)(
void* ctx, PJ_string_view_t id, PJ_string_view_t* out_config_json, PJ_error_t* out_error) PJ_NOEXCEPT;

/* [main-thread] Draw one curve in a tab of this plugin's. The series is named
* by its parts, not a joined path, because field paths legitimately contain
* '/' and dataset names contain ':' — splitting a joined form is guesswork the
* host should not have to do. An empty `dataset_source` means the topic/field
* must be unique across loaded datasets; the host refuses an ambiguous one
* with the qualified candidates rather than picking. Adding a curve already
* present is not an error. */
bool (*add_curve)(
void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t field,
PJ_string_view_t dataset_source, PJ_error_t* out_error) PJ_NOEXCEPT;

/* [main-thread] Take one curve back out. `dataset_source` resolves by the
* same rule as in add_curve — empty means "the topic/field must be unique" —
* so a curve can be removed with whatever form was used to add it, or with
* the resolved name tab_config reports. A curve that is not there is an
* error, so a mistaken path is visible rather than silently accepted. */
bool (*remove_curve)(
void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t field,
PJ_string_view_t dataset_source, PJ_error_t* out_error) PJ_NOEXCEPT;

/* [main-thread] Remove every curve from a tab, keeping the tab itself. */
bool (*clear_tab)(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) PJ_NOEXCEPT;
} PJ_plot_tab_host_vtable_t;

typedef struct {
void* ctx;
const PJ_plot_tab_host_vtable_t* vtable;
} PJ_plot_tab_host_t;

#ifdef __cplusplus
}
#endif
Expand Down
Loading
Loading