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>
288 lines
12 KiB
Markdown
288 lines
12 KiB
Markdown
# 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 `double`s.
|
||
|
||
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](Protocol.md); this document covers the
|
||
library. The producer side is documented in [UDPStreamer.md](UDPStreamer.md).
|
||
|
||
---
|
||
|
||
## Build
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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
|
||
|
||
```c
|
||
#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
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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 `double`s. 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](Protocol.md#time-mode-codes)):
|
||
|
||
| `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 N−1); 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
|
||
|
||
```c
|
||
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.
|
||
|
||
```bash
|
||
# 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.
|