Files
udpscope/docs/superpowers/specs/2026-11-02-flat-udp-source-design.md

8.1 KiB

Flat UDP source plugin (sub-project 2 of 4)

Date: 2026-11-02 Branch: to be cut from feature/source-plugins

Context

Sub-project 1 added the DataSource / SourceKind / SourceRegistry framework. Sub-project 3 added the E-1608. This sub-project adds flatudp: a receiver for the MARTe2 UDPSender datasource (MARTe2-components/Source/Components/DataSources/UDP/UDPSender.{h,cpp}).

The wire format

Read from UDPSender::Synchronise() and MemoryDataSourceI:

  • One Write of the datasource memory per Synchronise(): one datagram is one cycle.
  • The memory is the signals concatenated in declaration order, no padding, in the sender's native byte order. signalOffsets[s] is the running sum of size * elements.
  • There is no header, counter, timestamp or signal description. In IndependentThread mode the first signal is a uint8 Trigger; it is sent like any other signal.
  • The user must therefore enter the exact signal list by hand.

Decisions

Question Decision
Time base The user declares the nominal cycle rate in Hz (rate, required). The existing pipeline stamps the frames: anchored at arrival, samples spaced by the declared cadence, corrected to the measured cadence when the arrivals are more than 25% off it; --relative-time gives a purely counted clock. There is no time_mode switch: acquisition.cpp has no stamping path that ignores the declared cadence, and sampling_rate = 0 would size the ring and the ingest decimation for 1 kHz (model.cpp:60). Loss is never reported (lost = 0).
Signal list entry A row editor: name entry, type dropdown, element count and a remove button per row, plus an "Add signal" row.
Network Port and optional bind address, plus an optional multicast group and interface. No source-address filter.
Byte order little (default) or big.
Out of scope Auto-detecting the list, importing a MARTe2 .cfg, a source filter, loss detection.

Config keys

Persisted in the session, settable with --source-opt. Kind id: flatudp.

Key Default Meaning
port 44488 UDP port to listen on (the sender's own default).
bind empty Local address to bind; empty is any.
multicast empty Group to join; empty is plain unicast.
iface empty Local interface address for the join.
rate none Nominal cycle rate in Hz. Required; sizes the ring and the decimation and spaces samples within a frame.
endian little little or big.
signals none name:type[count],..., e.g. Trigger:uint8,Ia:float32,Wave:float32[16]. A persistence format only: the form builds and parses it.

Types: uint8/16/32/64, int8/16/32/64, float32, float64. Count 1 is a scalar signal; N is a vector signal. Expected datagram size is the sum of size * count over the rows.

Validity (form and --source-opt alike): at least one signal; names non-empty and unique; count 1-4096; total size at most 65507 bytes; rate finite and positive.

Architecture

New files under src/source/flatudp/:

  • flatudp_signals.{hpp,cpp} -- the signals string codec: parse, format, validate, size arithmetic. No GTK, so a probe can round-trip it.
  • flatudp_source.{hpp,cpp} -- the DataSource.
  • flatudp_kind.{hpp,cpp} -- the SourceKind, the form, and the registration in register_builtin_sources().

Threading is unchanged: the source lives on the producer thread and poll() does all the work.

Data flow

poll(timeout_ms) waits on a non-blocking UDP socket and drains it.

  • A datagram of exactly the expected size is decoded row by row to doubles (byte-swapped when endian=big).
  • Batching. When every row is scalar, decoded datagrams are queued and delivered block at a time in one Frame (num_samples = block), where block = clamp(round(rate / 100), 1, 1024), about 100 frames per second as the E-1608 does. A partial block is flushed after 100 ms so a slow or stopped sender does not hold samples back. When any row is a vector, a vector signal carries one sample per Frame, so each datagram is its own frame (num_samples = 1).
  • Frame: counter a running frame count, lost = 0, late = false, recv_time the arrival clock at delivery.
  • set_datagram_tap is honoured for every datagram, valid or not, and has_datagrams() is true, so packet capture works.
  • on_config is called from the first poll() with one SignalInfo per row (rows = count, cols = 1, time_mode = kTimePacket, sampling_rate = rate).

Errors and state

  • A wrong-size datagram is dropped, never decoded. Each distinct size raises one Error event ("datagram of N bytes, expected M, check the signal list") and increments stale.
  • Connected is raised on the first valid datagram and Disconnected after 2 s of silence; a later datagram raises Connected again.
  • A bind or multicast-join failure raises an Error and the source stays disconnected.

Connect form

Built from AdwPreferencesRows, with the destroy-notify guard used by the E-1608 form.

  • Network group: port, bind, multicast, interface.
  • Timing: a rate spin.
  • Byte order combo.
  • Signals group: one row per signal, "Add signal" at the bottom, and a live subtitle with the implied size ("41 bytes per datagram"). valid() is false while the list breaks a validity rule.
  • The signals string is rebuilt from the rows on every change; write() parses it back.

Verification

No unit suite; the self-check is the contract.

  • tools/mock_flatudp.py: emits packed structs for a given signal spec and rate; options for big-endian and for one wrong-size datagram.
  • UDPSCOPE_PROBE_FLATUDP in src/ui_probe.cpp: stream names and count; a hand-computed decode of known values including a vector signal and a uint64; the declared rate reaching the model; the wrong-size error reported once and counted in stale; disconnect after silence; the form's validity rules; the signals codec round trip. A multicast loopback check runs if the sandbox allows it.
  • Baselines unchanged: exactly 3 build warnings, gtk warnings: 0, no Gtk-CRITICAL.

Documentation

README (the kind and its limits: no loss detection, the signal list is the user's responsibility), docs/claude/build-and-run.md (options and mock usage), docs/claude/architecture.md (source plugins), docs/claude/probe-readings.md and docs/claude/verification.md (the new probe lines).

Amendment 2026-11-02: how a vector signal is shown (decided with the user during Task 2)

Task 2 found that treating every vector row as one channel of count consecutive samples zero-pads the scalar channels of a mixed list (the model spans a frame by its largest element block, and Frame::value is 0 past a scalar's one value) and declares the wrong rate for the vector channel. Both readings of a vector are useful depending on the sender, so each vector row picks one:

  • channels (default). Wave:float32[16] becomes 16 scalar channels named Wave[0] .. Wave[15], one sample per cycle each. All such lists are batched like an all-scalar list.
  • waveform. Wave:float32[16]w is ONE channel receiving a block of count consecutive samples per cycle: SignalInfo{rows=count, cols=1, sampling_rate=rate*count}, one datagram per Frame (num_samples = 1).

Rules:

  • The signals string takes an optional trailing w after the count; format emits it only for a waveform row.
  • A waveform row needs count > 1.
  • A list with a waveform row must contain only waveform rows, all with the same count; otherwise it is invalid, with a message the form shows. (No zero-padding, no holding scalars across a block.)
  • The channel total is capped at 256: scalars count 1, a channels row counts count, a waveform row counts 1. Over the cap is invalid.
  • block batching applies whenever the list has no waveform row; with a waveform row it is 1.
  • The form shows a "Vector as" dropdown (Channels / Waveform) on rows whose count is above 1, and the channel total in the subtitle.
  • The earlier "Data flow" text saying every vector row forces num_samples = 1 applies to waveform rows only.