Files
MARTe-Integrated-Components/Docs/UDPS-C-Client.md
T
Martino FerrariandClaude Opus 4.6 7c5eb31a52 feat: standalone C/C++ UDPS client library
Consuming a UDPStreamer feed so far meant either linking MARTe2 (UDPSClient)
or writing Go (Common/Client/go/udpsprotocol). Common/Client/c fills the gap
for plain C/C++ integrators: two files depending on nothing but libc and BSD
sockets, covering the whole receive path — CONNECT, fragment reassembly,
CONFIG/DATA decoding with dequantisation, keepalives and silence-triggered
reconnect. No threads are spawned; udps_client_poll() does all the work and
runs every callback, so it drops into an existing event loop unsynchronised.

Verified against run_udp_producer.sh at 1 Msps: unicast (120 MiB, no loss) and
multicast with 12-fragment cycles (116k datagrams, no loss).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-22 16:51:40 +02:00

12 KiB
Raw Blame History

UDPS C Client Library

Common/Client/c/ is a standalone receiver for the UDPS streaming protocol: it connects to a UDPStreamer DataSource (or any other UDPS producer, such as DebugService), decodes the signals, and hands them to your callbacks as plain doubles.

It has no MARTe2 dependency and no third-party dependencies at all — just libc and BSD sockets. Two files, udps_client.h and udps_client.c, drop into any C or C++ project.

The wire format itself is specified in Protocol.md; this document covers the library. The producer side is documented in UDPStreamer.md.


Build

cd Common/Client/c
make            # libudpsclient.a + the udps_dump example
make cxxcheck   # verifies the header compiles and links from C++
make clean

Or just add the two files to your own build:

cc -std=c99 -O2 -c udps_client.c

Requirements: a C99 compiler and POSIX sockets. On glibc older than 2.17 add -lrt (clock_gettime lived in librt back then). The header is wrapped in extern "C", so C++ callers include it directly.


Quick start

#include "udps_client.h"
#include <stdio.h>

static void on_data(const udps_frame_t *f, void *user) {
    (void)user;
    /* Signals are in CONFIG order; values are already physical doubles. */
    printf("#%u  %s = %g\n", f->counter, f->signals[0].name, f->values[0].values[0]);
}

int main(void) {
    udps_client_config_t cfg;
    udps_client_t       *cli;

    udps_client_config_init(&cfg);
    cfg.server_addr = "127.0.0.1";
    cfg.server_port = 44500;

    cli = udps_client_create(&cfg);
    udps_client_set_callbacks(cli, NULL, on_data, NULL, NULL);

    for (;;) {
        udps_client_poll(cli, 200);   /* connects, receives, decodes, reconnects */
    }
    udps_client_destroy(cli);
    return 0;
}

udps_client_poll() is the only function that does work. It never spawns a thread, and every callback runs inside it — so if your program already has an event loop, call it from there and you need no synchronisation at all. A client must be used from one thread at a time.


Connection model

The library implements both transports of the protocol and picks one from the configuration:

Unicast (multicast_group == NULL) Multicast (multicast_group set)
CONNECT UDP datagram to server_addr:server_port over a TCP connection to server_addr:server_port
CONFIG UDP, back to the client's ephemeral port over the same TCP connection
DATA UDP, same ephemeral port UDP multicast on data_port
Keepalive ACK every keepalive_interval_s not needed (the TCP session is the liveness signal)

In multicast mode the group is joined before CONNECT is sent, because the server multicasts CONFIG as soon as it sees a client — a group joined afterwards would miss it.

The client reconnects on its own: if nothing arrives for silence_timeout_s it sends DISCONNECT, closes the sockets, waits reconnect_delay_s, and starts over. udps_client_poll() returns -1 when that happens, which is informational, not fatal.


Configuration

Always start from udps_client_config_init() — it fills in the defaults below — then override what you need. Strings are copied into the client, so they need not outlive udps_client_create().

Field Default Meaning
server_addr — (required) Server IPv4 address; a hostname is resolved if it is not a dotted quad.
server_port — (required) Server UDP port, or the TCP control port in multicast mode.
multicast_group NULL IPv4 group to join. Non-NULL selects the multicast transport.
interface_addr NULL Local IPv4 address (not a name, e.g. "127.0.0.1") of the interface to join on. Defaults to the default route, which silently receives nothing if the server sends elsewhere.
data_port server_port + 1 Multicast data port. Must match the producer's DataPort.
silence_timeout_s 1.0 Reconnect after this long without data. 0 disables the check — use it for streams that are idle by design.
reconnect_delay_s 2.0 Wait between reconnection attempts.
keepalive_interval_s 15.0 Unicast ACK period. The server evicts silent clients after its ClientTimeout (30 s by default). 0 disables.
recv_buffer_bytes 4 MiB SO_RCVBUF. The Linux default (~208 KiB) is overrun by fast producers and the kernel drops datagrams silently.
max_packet_bytes 1 MiB Ceiling on one reassembled payload; a reassembly buffer of this size is allocated per in-flight update (4 at most).

API

Lifecycle

void            udps_client_config_init(udps_client_config_t *cfg);
udps_client_t  *udps_client_create(const udps_client_config_t *cfg);
void            udps_client_set_callbacks(udps_client_t *c, udps_config_cb, udps_data_cb,
                                          udps_event_cb, void *user);
int             udps_client_poll(udps_client_t *c, int timeout_ms);
void            udps_client_destroy(udps_client_t *c);

udps_client_create() returns NULL on a bad address or an invalid configuration; no socket is opened until the first poll. udps_client_poll() returns the number of packets processed, 0 on timeout, or -1 if the session broke — pass a negative timeout_ms to block. destroy sends DISCONNECT before closing.

Callbacks

void on_config(const udps_signal_t *signals, uint32_t n, uint8_t publish_mode, void *user);
void on_data  (const udps_frame_t *frame, void *user);
void on_event (udps_event_t event, const char *detail, void *user);

on_config fires on every CONFIG packet: the signal set can change at runtime, so treat it as a reset of everything you cached. on_event reports UDPS_EVENT_CONNECTED, UDPS_EVENT_DISCONNECTED and UDPS_EVENT_ERROR with a human-readable detail.

The frame and everything it points at are owned by the client and are valid only until on_data returns. The decode buffers are reused by the next packet. Copy what you keep.

Inspection

int                  udps_client_is_connected(const udps_client_t *c);
const udps_signal_t *udps_client_signals(const udps_client_t *c, uint32_t *n);
uint8_t              udps_client_publish_mode(const udps_client_t *c);
void                 udps_client_stats(const udps_client_t *c, udps_stats_t *out);
const char          *udps_client_last_error(const udps_client_t *c);

Helpers

uint32_t    udps_signal_num_elements(const udps_signal_t *s);
const char *udps_type_name(uint8_t type_code);
int         udps_parse_header(const void *buf, size_t len, udps_header_t *out);
int         udps_parse_config(const void *payload, size_t len, udps_signal_t *sigs,
                              uint32_t max_signals, uint32_t *n, uint8_t *publish_mode);
double      udps_frame_value(const udps_frame_t *f, uint32_t sig, uint32_t sample, uint32_t elem);
double      udps_frame_element_time(const udps_frame_t *f, uint32_t sig, uint32_t elem);

udps_parse_header and udps_parse_config are stateless and socket-free, so captured or replayed traffic can be decoded without a client.


Reading a frame

typedef struct {
    uint32_t counter;      /* gaps in this sequence are lost datagrams */
    uint64_t hrt;          /* producer's high-resolution timer at send */
    double   recv_time;    /* CLOCK_REALTIME seconds at arrival */
    uint8_t  publish_mode;
    uint32_t num_samples;  /* batched RT cycles; 1 unless Accumulate */
    uint32_t num_signals;
    const udps_signal_t        *signals;  /* CONFIG order */
    const udps_signal_values_t *values;   /* same order */
} udps_frame_t;

values[i].values is an array of values[i].count physical doubles. Quantised signals are already expanded back onto [range_min, range_max], and integer types are widened — the decoded form does not depend on the wire type, so a consumer need not branch on type_code at all.

Element count. count is the signal's element count (num_rows × num_cols), except for a scalar signal in Accumulate mode, where the producer batches several RT cycles into one packet and count == num_samples — one value per cycle. Arrays are not batched: they appear once and apply to the whole packet. udps_frame_value(f, sig, sample, elem) applies that rule for you.

Timestamps. The protocol does not put a timestamp on every element; how to date them depends on the signal's time_mode (see Protocol.md):

time_mode Where the time comes from
UDPS_TIME_PACKET No per-element time. Use recv_time.
UDPS_TIME_FULL_ARRAY The signal at time_signal_idx holds one timestamp per element — read it like any other signal.
UDPS_TIME_FIRST_SAMPLE / UDPS_TIME_LAST_SAMPLE The signal at time_signal_idx is a scalar stamping element 0 (or N1); the rest follow at 1/sampling_rate.

The time signal is a raw producer-side counter (µs, or ns when it is a uint64), not wall clock, so plotting it against real time needs a one-off calibration against recv_time — that is what the Go hub does. udps_frame_element_time() skips all that and returns an arrival-anchored estimate: good enough for a quick plot, but when a time signal exists, it is the accurate source.


Diagnosing loss

udps_stats_t s;
udps_client_stats(cli, &s);
Counter Meaning
packets_received, bytes_received Accepted datagrams and TCP frames.
frames_delivered DATA packets decoded and passed to on_data.
config_updates CONFIG packets applied.
counter_gaps Missing packet counters — datagrams lost on the wire or in the kernel.
fragments_dropped Duplicate, stale or unplaceable fragments; a non-zero value with counter_gaps means fragmented updates are arriving incomplete.
reconnects Sessions re-established after a silence timeout.

Persistent loss on a fast stream is almost always the receive buffer: raise recv_buffer_bytes (and net.core.rmem_max, which caps it). A fragmented producer is more fragile than one sending whole cycles, because losing any fragment discards the whole update — if you control the producer, sizing MaxPayloadSize above one cycle removes that failure mode entirely.


Example program

example/udps_dump.c connects, prints the signal table on CONFIG, then a throttled summary of each frame, and a receive-statistics report on Ctrl-C.

# unicast
./udps_dump --host 127.0.0.1 --port 44500

# multicast
./udps_dump --host 127.0.0.1 --port 44500 --multicast 239.0.0.1 --iface 127.0.0.1

# quieter, and stop after 500 frames
./udps_dump --host 127.0.0.1 --port 44500 --interval 5 --frames 500
Flag Meaning
--host ADDR Server address (default 127.0.0.1).
--port N Server UDP port, or TCP control port in multicast mode (default 44500).
--multicast GROUP Join GROUP for data instead of using unicast.
--iface ADDR Local interface address for the multicast join.
--data-port N Multicast data port (default --port + 1).
--silence SEC Reconnect after SEC without data; 0 disables.
--interval SEC Seconds between printouts (default 1).
--frames N Exit after N frames.

Against the repository's own producer (./run_udp_producer.sh -n 2, two 1 Msps channels of 1000-element float32 arrays at 1 kHz) the output looks like:

CONFIG: 3 signal(s), publish mode strict
  #   name                     type     shape      unit     rate[Hz]   time-mode
  0   TimeArray                uint64   1x1000     ns       0          packet
  1   Ch1                      float32  1x1000     V        0          full-array
  2   Ch2                      float32  1x1000     V        0          full-array

frame #2289897  t=1787409851.723466  samples=1
  Ch1     n=1000   first=-6.9e-10  last=-0.00628  min=-1     max=1     V
  Ch2     n=1000   first=0.5       last=0.49975   min=-0.5   max=0.5   V

Limitations

  • IPv4 only, matching the protocol and the producer.
  • One thread per client; there is no internal locking.
  • The receive path allocates only when a CONFIG grows the signal set or a frame grows the decode arena, so a steady stream is allocation-free — but this is not a hard real-time component.
  • DATA arriving before the first CONFIG is dropped: without descriptors it cannot be decoded. This is normal for a few packets after joining a multicast group.