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

6.0 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 Both. time_mode=rate (default): the user declares the cycle rate in Hz; frames are spaced 1/rate and no loss is ever reported. time_mode=arrival: each datagram is stamped with its receive time.
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 Cycle rate in Hz. Required when time_mode=rate.
time_mode rate rate or arrival.
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 > 0 when time_mode=rate.

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) and delivered as one Frame: num_samples = 1, counter a running count, lost = 0, late = false, recv_time the arrival clock.
  • set_datagram_tap is honoured and has_datagrams() is true, so packet capture works.
  • on_config is called once on open with one SignalInfo per row (rows = count, cols = 1).

Time

  • rate: every signal has time_mode = kTimePacket and sampling_rate = rate, as the E-1608 does. Spacing comes from the declared rate; jitter does not appear in the trace.
  • arrival: sampling_rate = 0, so the existing arrival-clock path stamps each frame.
  • To confirm in the plan: that acquisition.cpp's packet path with sampling_rate = 0 really stamps on arrival and does not degenerate. If it does not, the plan adds the small change there.

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 becomes true after the first valid datagram and false after 2 s of silence.
  • 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 group: rate spin and time-mode combo. The rate spin is disabled, not hidden, in arrival mode.
  • 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; rate-mode spacing; 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).