Files
udpscope/docs/superpowers/specs/2026-10-31-source-plugins-design.md
T

8.0 KiB

Source plugins: framework and UDPS port (sub-project 1 of 4)

Date: 2026-10-31 Branch: feature/source-plugins

Context

udpscope reads exactly one kind of source, a UDPStreamer stream through deps/udpsclient. The goal is to accept several kinds of data source as plugins, each with its own config dialog and, where the hardware offers it, its own UI (signal generator, digital I/O, multiple timebases).

The work is split into four sub-projects, each with its own spec, plan and implementation:

  1. Plugin framework + UDPS client ported onto it (this document).
  2. Flat UDP plugin (standard MARTe2 UDPWriter stream; the wire format must be supplied or located before that spec is written -- none was found in ~/workspace or ~/Projects).
  3. MCC E-1608 plugin. The protocol code in MCC_E1608_DataSource is bound to MARTe2 (BasicTCPSocket, StreamString, SingleThreadService); it is ported to plain C++, not linked against MARTe2. AI streaming, DIO and DAC/generator panels.
  4. ABScope plugin. Needs a multi-timebase data path and a wire format: the ABScope design only sketches the UDP header and streamerd is unwritten.

Sub-project 1 changes no UDPS behaviour. It is a refactor plus the hosting machinery the later plugins need.

Decisions

Question Decision
What "plugin" means Compiled-in registry; the interface is kept clean enough for a dlopen loader to be added later (not built now).
Threading Acquisition keeps the single producer thread and calls source->poll(). UI actions reach the source through a thread-safe command queue drained inside poll(). CLAUDE.md's two-thread rule holds. (A per-source I/O thread was considered and deferred.)
Source UI Widget factory: a source returns GtkWidget* for its config form and each capability panel.
Timebases in the frame The neutral frame is single-timebase. Sub-project 4 extends interface and data path together; this may break the interface once, accepted.
CLI / sessions --server/--port/... keep meaning UDPS; --source NAME and --source-opt k=v added. Sessions keyed by kind + identity; legacy host_port.toml read as udps.

Design

1. The source interface (new src/source/)

  • SourceConfig: ordered string key/value map with typed getters. It is the stored form (a TOML table), the --source-opt form, and what a config widget reads and writes.
  • Frame: source-neutral, mirrors udps_frame_t: counter, lost, late, recv_time, publish_mode, num_samples, signals[] (the existing SignalInfo) and values[] (doubles). signals and values are pointers into the source's own buffers, valid only for the duration of on_data; Acquisition copies what it keeps. The hole, backfill and time-base logic needs counter/lost/late; a source with no counter supplies a running counter and lost = 0. Frame::value(sig, k, col) replaces udps_frame_value.
  • DataSource: the per-connection object.
    • open(), poll(timeout_ms), close().
    • stats() returns the counters now in StreamStats; connected() reports link state.
    • post(Command): thread-safe queue, drained inside poll(). Command is std::function<void()>.
    • set_datagram_tap(fn): optional; lets UDP-based sources feed the raw-packet capture.
    • Output goes to a SourceSink (on_config, on_data, on_event) called inside poll(), as udps_client does today.
  • SourceKind: factory and UI description of one plugin type: id, display_name, identity(cfg), make(cfg), make_config_form() and make_panels(source) (each {id, title, GtkWidget*}). make_config_form() takes no argument: the chooser calls form.write(cfg) to fill the form. A ConfigForm is a list of AdwPreferencesRow widgets plus write / read / valid / on_change closures, so the chooser can place the rows in its own group and gate its Connect button on valid().
  • SourceRegistry: filled by an explicit register_builtin_sources() call. No static-init self-registration (linkers can drop it). A future dlopen loader would only call register_kind().

2. Acquisition and the UDPS plugin

  • Acquisition takes a std::unique_ptr<DataSource> instead of building a udps_client_t and implements SourceSink; it creates the source lazily in start(), from the kind and SourceConfig it was given; the four trampolines go away.
  • on_config / on_data change only in the type they read. Time base, holes, backfill, decimation, trigger and math code are untouched.
  • run() calls source_->poll(50) and copies source_->stats() into ScopeModel::stats(). PacketCapture stays in Acquisition, fed through the datagram tap. Sources without datagrams (E-1608) leave the capture UI disabled, not hidden.
  • src/source/udps_source.cpp wraps deps/udpsclient unchanged: it maps udps_signal_t to SignalInfo and udps_frame_t to Frame, and carries the existing knobs (server, port, multicast, interface, recv buffer). Identity is host:port.
  • The udps_* types are used only in acquisition.cpp today, so no other file changes for this step beyond the Frame type.

3. UI hosting, config and persistence

  • Connect dialog (source_dialog.cpp): gains a "Source type" dropdown; choosing a kind swaps in that kind's make_config_form(). The UDPS form (server, port, multicast, interface) moves into the UDPS plugin. The dialog keeps the known-sources list, buffer size, relative time and capture ring size.
  • Capability panels: after the source is created the app calls make_panels() and adds each panel as a page of the existing side_stack (the side area's page stack, owned by the tab switcher). A panel is insensitive when disconnected (disabled, not hidden; the tab switcher owns visibility). A source with no panels adds no tab; UDPS has none. Panels call source->post(Command), never the source directly, so cross-thread safety lives in one place. A helper kit for common panels (DIO bank, generator form) lives in src/source/widgets/ but is only added when a plugin uses it, so it arrives with sub-project 3.
  • Config / CLI: Config gains source_kind (default udps) and a SourceConfig. --server/--port/--multicast/--interface populate the UDPS entries. --source NAME and repeatable --source-opt k=v work for any kind. --help lists the registered kinds.
  • Sessions: the source's config is stored as top-level TOML keys, source_kind and source_opt_<key>, written only for non-UDPS kinds (a UDPS session file is unchanged, so legacy host_port.toml files read as udps with nothing migrated). It is not a [source] table. A non-UDPS session file is named by a pseudo-host <kind>-<identity> with port 1.
  • Mocks: tools/mock_udpstreamer.py remains the UDPS mock. Each later plugin ships its own mock under tools/ so the headless self-check keeps working.

Verification

  • This is a pure refactor for UDPS: the existing headless self-check must read exactly as on main -- exactly three build warnings, no Gtk-CRITICAL (see docs/claude/verification.md).
  • New probe UDPSCOPE_PROBE_SOURCE_KINDS (UDPSCOPE_PROBE_SOURCES is already the chooser discovery probe). Its value is a delay in ms, and its presence also registers the stub probe kind. It extends the probe/dump in src/ui.cpp rather than adding a harness. It checks that the registry lists its kinds, that a config form round-trips its SourceConfig, and -- with a stub test-only kind carrying one panel -- that the panel's tab is built, insensitive while disconnected, and that a posted command is drained by poll().
  • Document the new probe lines in docs/claude/probe-readings.md; update architecture.md (a DataSource replaces the client in the thread description), build-and-run.md (new CLI options) and README.md.

Out of scope

  • Runtime .so loading, and a per-source I/O thread.
  • Multi-timebase data path (sub-project 4).
  • The flat UDP, E-1608 and ABScope plugins (sub-projects 2-4).
  • The DIO / generator helper widgets (arrive with sub-project 3).