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
Writeof the datasource memory perSynchronise(): 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 ofsize * elements. - There is no header, counter, timestamp or signal description. In IndependentThread mode the
first signal is a
uint8Trigger; 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}-- thesignalsstring codec: parse, format, validate, size arithmetic. No GTK, so a probe can round-trip it.flatudp_source.{hpp,cpp}-- theDataSource.flatudp_kind.{hpp,cpp}-- theSourceKind, the form, and the registration inregister_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
blockat a time in oneFrame(num_samples = block), whereblock = 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 perFrame, so each datagram is its own frame (num_samples = 1). Frame:countera running frame count,lost = 0,late = false,recv_timethe arrival clock at delivery.set_datagram_tapis honoured for every datagram, valid or not, andhas_datagrams()is true, so packet capture works.on_configis called from the firstpoll()with oneSignalInfoper 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
Errorevent ("datagram of N bytes, expected M, check the signal list") and incrementsstale. Connectedis raised on the first valid datagram andDisconnectedafter 2 s of silence; a later datagram raisesConnectedagain.- A bind or multicast-join failure raises an
Errorand 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
signalsstring 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_FLATUDPinsrc/ui_probe.cpp: stream names and count; a hand-computed decode of known values including a vector signal and auint64; the declared rate reaching the model; the wrong-size error reported once and counted instale; disconnect after silence; the form's validity rules; thesignalscodec round trip. A multicast loopback check runs if the sandbox allows it.- Baselines unchanged: exactly 3 build warnings,
gtk warnings: 0, noGtk-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 namedWave[0]..Wave[15], one sample per cycle each. All such lists are batched like an all-scalar list.waveform.Wave:float32[16]wis ONE channel receiving a block ofcountconsecutive samples per cycle:SignalInfo{rows=count, cols=1, sampling_rate=rate*count}, one datagram perFrame(num_samples = 1).
Rules:
- The
signalsstring takes an optional trailingwafter the count;formatemits 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
channelsrow countscount, a waveform row counts 1. Over the cap is invalid. blockbatching 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 = 1applies to waveform rows only.