This commit is contained in:
Martino Ferrari
2026-05-15 17:42:14 +02:00
commit e3389f932b
40 changed files with 7622 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
# UDPStreamer Wire Protocol
This document specifies the binary protocol used between UDPStreamer (server) and any
compatible client (the included Go WebUI, a Python script, etc.).
All multi-byte integers are **little-endian**.
---
## Packet Header (17 bytes, packed)
Every datagram begins with a 17-byte header:
```
Offset Size Type Field
────── ──── ────── ────────────────────────────────────────────────────
0 4 uint32 magic = 0x53504455 ('UDPS' LE)
4 1 uint8 type see Packet Types below
5 4 uint32 counter per-update sequence number
(same across all fragments of one update)
9 2 uint16 fragmentIdx 0-based index of this fragment
11 2 uint16 totalFragments number of fragments for this update
13 4 uint32 payloadBytes bytes of payload following this header
```
**Total header size:** 17 bytes
**Magic:** `0x55 0x44 0x50 0x53` (`UDPS`)
---
## Packet Types
| Value | Direction | Name | Description |
|-------|-----------|------|-------------|
| 0 | Server → Client | DATA | Signal data (may be fragmented) |
| 1 | Server → Client | CONFIG | Signal metadata sent on connect |
| 2 | Client → Server | ACK | Acknowledge a data counter (reserved) |
| 3 | Client → Server | CONNECT | Request a session |
| 4 | Client → Server | DISCONNECT | End the session |
---
## Session Flow
```
Client Server
────── ──────
CONNECT (type=3) →
← CONFIG (type=1)
← DATA (type=0) ┐
← DATA (type=0) │ repeated every RT cycle
← DATA (type=0) ┘
DISCONNECT (type=4) →
```
1. Client sends a 17-byte CONNECT packet (`payloadBytes = 0`).
2. Server responds immediately with one or more CONFIG fragments describing all signals.
3. Server sends DATA fragments on every `Synchronise()` call while a client is connected.
4. Client sends DISCONNECT to terminate cleanly. A new CONNECT replaces an existing session.
---
## CONFIG Payload
The CONFIG payload is sent as one or more fragmented packets (`type = 1`).
After reassembly the layout is:
```
Offset Size Type Field
────── ──── ─────── ────────────────────────────────────
0 4 uint32 numSignals
── for each signal (136 bytes) ──────────────────────────────
0 64 char[64] name null-terminated
64 1 uint8 typeCode see Type Codes
65 1 uint8 quantType see Quantization Types
66 1 uint8 numDimensions 0 = scalar, 1 = 1-D array, 2 = matrix
67 4 uint32 numRows 0 or 1 for scalar/1-D
71 4 uint32 numCols number of elements along fastest axis
75 8 float64 rangeMin
83 8 float64 rangeMax
91 1 uint8 timeMode see Time Modes
92 8 float64 samplingRate Hz (0 if PacketTime)
100 4 uint32 timeSignalIdx index of the time-reference signal;
0xFFFFFFFF = PacketTime (no reference)
104 32 char[32] unit null-terminated physical unit string
── (total per signal: 136 bytes) ────────────────────────────
```
### Type Codes
| Code | C type | Bytes/element |
|------|--------|---------------|
| 0 | uint8 | 1 |
| 1 | int8 | 1 |
| 2 | uint16 | 2 |
| 3 | int16 | 2 |
| 4 | uint32 | 4 |
| 5 | int32 | 4 |
| 6 | uint64 | 8 |
| 7 | int64 | 8 |
| 8 | float32 | 4 |
| 9 | float64 | 8 |
### Quantization Type Codes (wire side)
| Code | Wire type | Description |
|------|-----------|-------------|
| 0 | — | No quantization; raw type as above |
| 1 | uint8 | Linear map `[rangeMin, rangeMax]``[0, 255]` |
| 2 | int8 | Linear map `[rangeMin, rangeMax]``[-127, 127]` |
| 3 | uint16 | Linear map `[rangeMin, rangeMax]``[0, 65535]` |
| 4 | int16 | Linear map `[rangeMin, rangeMax]``[-32767, 32767]` |
### Time Mode Codes
| Code | Name | Meaning |
|------|------|---------|
| 0 | PacketTime | HRT timestamp at `Synchronise()` — see DATA payload |
| 1 | FullArray | `timeSignalIdx` signal has same `numElements`; element `[k]` time = `timeSignal[k]` |
| 2 | FirstSample | `timeSignalIdx` is scalar; `t[k] = t[0] + k / samplingRate` |
| 3 | LastSample | `timeSignalIdx` is scalar; `t[k] = t[N-1] - (N-1-k) / samplingRate` |
---
## DATA Payload
After reassembly, the DATA payload layout is:
```
Offset Size Type Field
────── ──── ────── ────────────────────────────────────────────────────
0 8 uint64 hrtTimestamp hardware reference timer count at Synchronise()
── for each signal (in config order) ────────────────────────────────────
varies N×sz — signal data N = numRows×numCols, sz = element size
(wire size if quantized, raw size otherwise)
```
Signal data for quantized signals uses the wire element size (see Quantization Type Codes),
not the original MARTe2 type size.
### Dequantization
To recover physical values from quantized integers:
```
// uint16 → float
span = rangeMax - rangeMin
physical = rangeMin + (wire_uint16 / 65535.0) × span
// int16 → float
physical = rangeMin + ((wire_int16 + 32767) / 65534.0) × span
```
---
## Fragmentation
When a payload exceeds `MaxPayloadSize` bytes, it is split into fragments:
```
chunkSize = MaxPayloadSize - 17 // usable bytes per datagram
numFragments = ceil(payloadSize / chunkSize)
```
Fragment `i` carries bytes `[i × chunkSize .. min((i+1) × chunkSize, payloadSize))`.
All fragments share the same `counter`; `fragmentIdx` and `totalFragments` allow
the client to reassemble them in any order.
**Example:** `MaxPayloadSize = 1400`, payload = 8016 B
`chunkSize = 1383`, `numFragments = ceil(8016/1383) = 6`
---
## Minimal Python Client Example
```python
import socket, struct, time
MAGIC = 0x53504455
HDR_FMT = '<IBHHI' # magic, type, counter, fragIdx, totalFrags, payloadBytes
HDR_SIZE = 17
def build_connect():
return struct.pack(HDR_FMT, MAGIC, 3, 0, 0, 1, 0)
def parse_header(data):
return struct.unpack_from(HDR_FMT, data)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('', 44900))
sock.sendto(build_connect(), ('127.0.0.1', 44500))
sock.settimeout(5.0)
fragments = {}
while True:
data, _ = sock.recvfrom(65536)
magic, ptype, counter, frag_idx, total_frags, payload_bytes = parse_header(data)
payload = data[HDR_SIZE:]
if ptype == 1: # CONFIG
print(f"CONFIG fragment {frag_idx+1}/{total_frags}")
elif ptype == 0: # DATA
fragments.setdefault(counter, {})[frag_idx] = payload
if len(fragments[counter]) == total_frags:
full = b''.join(fragments.pop(counter)[i] for i in range(total_frags))
hrt = struct.unpack_from('<Q', full)[0]
print(f"DATA counter={counter} hrt={hrt} payload={len(full)}B")
```
+104
View File
@@ -0,0 +1,104 @@
# SineArrayGAM
`SineArrayGAM` is a helper GAM bundled with the UDPStreamer library. It fills a `float32`
output array with a continuous sinusoidal waveform, maintaining phase continuity across
RT cycles. It is intended for testing and demonstrating the UDPStreamer's packed-burst
signal capability.
> **Library:** The class is compiled into `UDPStreamer.so` alongside the UDPStreamer
> DataSource. A `SineArrayGAM.so → UDPStreamer.so` symlink is required for MARTe2's
> auto-loader (created automatically by `Test/MARTeApp/run.sh`).
---
## Configuration
```
+Ch1GAM = {
Class = SineArrayGAM
Frequency = 1000.0 // Signal frequency [Hz] (default: 1.0)
Amplitude = 1.0 // Peak amplitude (default: 1.0)
Offset = 0.0 // DC offset (default: 0.0)
Phase = 0.0 // Initial phase [radians] (default: 0.0)
SamplingRate = 1000000.0 // Sample rate [Hz] (default: 1000000.0)
// Must match the SamplingRate declared in the
// UDPStreamer signal config.
OutputSignals = {
Ch1 = {
DataSource = DDB
Type = float32
NumberOfDimensions = 1
NumberOfElements = 1000 // N samples per RT cycle
}
}
}
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `Frequency` | float64 | 1.0 | Signal frequency in Hz |
| `Amplitude` | float64 | 1.0 | Peak amplitude (half peak-to-peak) |
| `Offset` | float64 | 0.0 | DC offset added to every sample |
| `Phase` | float64 | 0.0 | Phase shift in radians |
| `SamplingRate` | float64 | 1 000 000.0 | Sample rate in Hz; must be > 0 |
### Output signal
Exactly **one** output signal of type **`float32`** is required. The signal must have
`NumberOfDimensions = 1` and `NumberOfElements = N` where N ≥ 1.
---
## Waveform formula
Each `Execute()` call fills elements `[0 .. N-1]` using:
```
v[k] = Amplitude × sin(2π × Frequency × (offset_total + k) / SamplingRate + Phase) + Offset
```
where `offset_total` is a cumulative sample counter that increments by `N` after each call.
This guarantees a gapless, phase-continuous waveform across RT cycles.
---
## Bandwidth and fragmentation
For a 1 MSps burst at 10 kHz RT rate with 1000 samples per cycle:
```
N = SamplingRate / RT_rate = 1 000 000 / 1000 = 1000 samples per cycle
Wire bytes = 1000 × 4 (float32) = 4000 B per channel per cycle
At 10 kHz: 4000 × 10 000 = 40 MB/s raw (UDP, before quantization)
```
Using `QuantizedType = uint16` halves the wire bandwidth to 20 MB/s.
With `MaxPayloadSize = 1400` the 4 000-byte payload spans 3 UDP datagrams
(`ceil(4008 / 1383) = 3`).
---
## Example: two-channel quadrature pair
```
+Ch1GAM = {
Class = SineArrayGAM
Frequency = 1000.0; Amplitude = 1.0; Phase = 0.0; SamplingRate = 10000000.0
OutputSignals = {
Ch1 = { DataSource = DDB; Type = float32; NumberOfDimensions = 1; NumberOfElements = 1000 }
}
}
+Ch2GAM = {
Class = SineArrayGAM
Frequency = 1000.0; Amplitude = 1.0; Phase = 1.5708; SamplingRate = 10000000.0
OutputSignals = {
Ch2 = { DataSource = DDB; Type = float32; NumberOfDimensions = 1; NumberOfElements = 1000 }
}
}
```
This produces two signals 90° out of phase, useful for verifying IQ-style signal chains.
+359
View File
@@ -0,0 +1,359 @@
# Tutorial: Streaming Signals with UDPStreamer
This tutorial walks you through:
1. Setting up the environment
2. Running the demo application
3. Visualising signals in the browser
4. Adding UDPStreamer to your own MARTe2 application
5. Streaming high-frequency packed signals
**Prerequisites:** MARTe2 and MARTe2-components must already be built.
See the [MARTe2 installation guide](https://vcis.f4e.europa.eu/marte2-docs/) if needed.
---
## 1. Environment Setup
Edit `marte_env.sh` in the repository root to point at your MARTe2 installations:
```bash
# marte_env.sh (key variables)
export MARTe2_DIR="$HOME/workspace/MARTe2"
export MARTe2_Components_DIR="$HOME/workspace/MARTe2-components"
```
Then source it in your shell:
```bash
cd /path/to/MARTe_IO_components
source marte_env.sh
```
Verify the environment is correct:
```bash
echo $MARTe2_DIR
ls $MARTe2_DIR/Build/x86-linux/App/MARTeApp.ex # should exist
```
---
## 2. Running the Demo Application
The demo is in `Test/MARTeApp/`. It runs a 10 kHz MARTe2 application that streams:
- **Counter** and **Time** — scalar counters from the Linux timer
- **Sine1** — 1 Hz sine wave (float32, amplitude 10, quantized to uint16 on wire)
- **Sine2** — 0.3 Hz sine wave (float32, amplitude 5, raw float32 on wire)
- **Ch1**, **Ch2** — 1 kHz sine bursts packed as 1000 samples/packet (10 MSps)
Start everything with one command:
```bash
cd Test/MARTeApp
./run.sh --webui
```
The script will:
1. Build the UDPStreamer shared library.
2. Build the Go WebUI binary (first run only).
3. Start the WebUI relay on `http://localhost:8080`.
4. Launch the MARTe2 application.
Press `Ctrl+C` to stop both processes.
---
## 3. Visualising Signals in the Browser
Open `http://localhost:8080` in any modern browser.
### Add your first plot
1. Click **+ Add Plot** in the toolbar.
2. A blank plot panel appears with a "Drop signals here" hint.
### Plot a signal
1. In the left sidebar find **Sine1** (listed as `Sine1 · f32`).
2. Click and drag it onto the plot panel.
3. The sine wave appears immediately.
### Overlay multiple signals
Drag **Sine2** onto the same plot — it is added as a second trace.
### Adjust the time window
Use the **Window** dropdown in the top bar to change the rolling display window
(1 s, 5 s, 10 s, 30 s, 60 s).
### Plot layout
Use the layout buttons (`1×1`, `2×1`, `2×2`, …) to split the screen into multiple
plot panels. Each panel is independent — drag different signals onto each.
### High-frequency signals
Drag **Ch1** (shown as `Ch1 · [1000] f32`) onto a plot. Each UDP packet carries
1000 samples at 10 MSps; the WebUI reconstructs per-sample timestamps and displays
the continuous waveform.
### Export data
Click **⬇** on any plot to download the visible window as a CSV file.
---
## 4. Adding UDPStreamer to Your Own Application
### Step 1 — Declare the DataSource
Add UDPStreamer to the `+Data` section of your MARTe2 configuration:
```
+Data = {
Class = ReferenceContainer
DefaultDataSource = DDB
+DDB = { Class = GAMDataSource }
+Streamer = {
Class = UDPStreamer
Port = 44500
MaxPayloadSize = 1400
Signals = {
Voltage = {
Type = float32
Unit = "V"
RangeMin = -10.0
RangeMax = 10.0
QuantizedType = uint16 // 16-bit quantized on wire
}
Current = {
Type = float32
Unit = "A"
}
}
}
+Timings = { Class = TimingDataSource }
}
```
### Step 2 — Route signals with IOGAM
Use IOGAM to copy signals from your inter-GAM DDB into the Streamer:
```
+StreamerGAM = {
Class = IOGAM
InputSignals = {
Voltage = { DataSource = DDB; Type = float32 }
Current = { DataSource = DDB; Type = float32 }
}
OutputSignals = {
Voltage = { DataSource = Streamer; Type = float32 }
Current = { DataSource = Streamer; Type = float32 }
}
}
```
Add `StreamerGAM` at the **end** of the thread's `Functions` list so it runs after
your control GAMs have written their outputs.
### Step 3 — Add the library to LD_LIBRARY_PATH
In your run script, add the UDPStreamer build directory:
```bash
export LD_LIBRARY_PATH="/path/to/Build/x86-linux/Components/DataSources/UDPStreamer:$LD_LIBRARY_PATH"
```
### Step 4 — Start the WebUI and connect
```bash
# From the Client/WebUI directory:
./udpstreamer-webui --streamer 127.0.0.1:44500 --listen :8080 --clientport 44900
```
Open `http://localhost:8080`, drag your signals onto a plot, and you're done.
---
## 5. Streaming High-Frequency Packed Signals
This section shows how to stream 1000 samples per RT cycle at 1 MSps.
### Overview
At 1 kHz RT rate with 1000 samples per cycle the effective sample rate is 1 MSps.
Each UDP packet carries a burst of 1000 samples; the client reconstructs timestamps
using the anchor timestamp and `SamplingRate`.
### Step 1 — Generate burst data with SineArrayGAM
`SineArrayGAM` (bundled in `UDPStreamer.so`) produces a continuous float32 array:
```
+Ch1GAM = {
Class = SineArrayGAM
Frequency = 1000.0 // 1 kHz signal
Amplitude = 1.0
Phase = 0.0
SamplingRate = 1000000.0 // must match Streamer config below
OutputSignals = {
Ch1 = {
DataSource = DDB
Type = float32
NumberOfDimensions = 1
NumberOfElements = 1000
}
}
}
```
### Step 2 — Add a time reference signal
Add a scalar time signal that will anchor the first sample's timestamp:
```
+TimerGAM = {
Class = IOGAM
InputSignals = {
Time = { DataSource = Timer; Type = uint32; Frequency = 1000 }
}
OutputSignals = {
Time = { DataSource = DDB; Type = uint32 }
}
}
```
### Step 3 — Configure UDPStreamer for packed signals
```
+Streamer = {
Class = UDPStreamer
Port = 44500
MaxPayloadSize = 1400
Signals = {
Time = { Type = uint32; Unit = "us" } // time reference (scalar)
Ch1 = {
Type = float32
NumberOfDimensions = 1
NumberOfElements = 1000
Unit = "V"
TimeMode = FirstSample // Time = timestamp of first sample
TimeSignal = Time
SamplingRate = 1000000.0 // Hz
}
}
}
```
### Step 4 — Wire everything in the thread
```
+Thread1 = {
Class = RealTimeThread
Functions = { TimerGAM Ch1GAM StreamerGAM }
}
```
Where `StreamerGAM` is the IOGAM that copies `Time` and `Ch1` from DDB to Streamer.
### Step 5 — Fragmentation note
A single 1000-element float32 channel plus a uint32 time signal produces:
```
payload = 8 B (HRT) + 4 B (Time/uint32) + 4000 B (float32×1000) = 4012 B
```
With `MaxPayloadSize = 1400`:
```
fragments = ceil(4012 / 1383) = 3 datagrams per cycle
```
At 1 kHz that is 3000 UDP datagrams/second per channel — well within typical LAN capacity.
### Step 6 — Create the SineArrayGAM symlink
MARTe2 tries to `dlopen("SineArrayGAM.so")` the first time it encounters the class.
Create the symlink in your build directory:
```bash
UDPSTREAMER_LIB=/path/to/Build/x86-linux/Components/DataSources/UDPStreamer
ln -sf "${UDPSTREAMER_LIB}/UDPStreamer.so" "${UDPSTREAMER_LIB}/SineArrayGAM.so"
```
---
## 6. Writing a Custom UDP Client
A minimal Python client that receives and prints signal data:
```python
import socket, struct
MAGIC = 0x53504455
HDR = struct.Struct('<IBHHI') # 17 bytes: magic, type, counter, fragIdx, total, payloadBytes
SERVER = ('127.0.0.1', 44500)
MY_PORT = 44900
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('', MY_PORT))
sock.settimeout(5.0)
# Send CONNECT
sock.sendto(HDR.pack(MAGIC, 3, 0, 0, 1, 0), SERVER)
print("CONNECT sent")
reassembly = {}
while True:
data, _ = sock.recvfrom(65536)
if len(data) < 17:
continue
magic, ptype, counter, frag_idx, total_frags, payload_bytes = HDR.unpack_from(data)
if magic != MAGIC:
continue
payload = data[17:17 + payload_bytes]
# Accumulate fragments
bucket = reassembly.setdefault((ptype, counter), {})
bucket[frag_idx] = payload
if len(bucket) < total_frags:
continue
full = b''.join(bucket[i] for i in range(total_frags))
del reassembly[(ptype, counter)]
if ptype == 1: # CONFIG
num_sigs = struct.unpack_from('<I', full)[0]
print(f"CONFIG: {num_sigs} signals")
elif ptype == 0: # DATA
hrt = struct.unpack_from('<Q', full)[0]
print(f"DATA counter={counter} hrt={hrt} payload={len(full)}B")
```
For a full-featured client with CONFIG parsing and dequantization see
[`Client/WebUI/protocol.go`](../Client/WebUI/protocol.go) (Go)
and the [Protocol reference](Protocol.md).
---
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| `Failed dlopen(): UDPStreamer.so` | Library not in `LD_LIBRARY_PATH` | Add build dir to `LD_LIBRARY_PATH` |
| `Failed dlopen(): WaveformSin.so` | Symlink missing | Run `run.sh`; it creates the symlinks automatically |
| `Failed dlopen(): SineArrayGAM.so` | Symlink missing | Create `SineArrayGAM.so → UDPStreamer.so` in the build dir |
| WebUI shows "No data for Xs" | UDPStreamer not running / wrong port | Check port numbers; check MARTe2 logs |
| Plots only show ~167 ms of HF data | Browser buffer too small | Use `TEMPORAL_CAP` in JS (already 600 000 in current WebUI) |
| Fragmentation error / missing data | MTU too small | Reduce `MaxPayloadSize` to 1200 or smaller |
+200
View File
@@ -0,0 +1,200 @@
# UDPStreamer DataSource
`UDPStreamer` is a MARTe2 output DataSource that streams signals from a real-time application
to a single connected UDP client. It is fully asynchronous from the RT thread: the RT cycle
only performs a fast spinlock + memcpy, while all network I/O runs on a dedicated background
thread.
## Key Features
- **Zero-copy RT path** — `Synchronise()` only locks, copies signal memory, and posts a semaphore.
- **Single-client model** — one client at a time; a new CONNECT replaces the previous session.
- **Packet fragmentation** — large payloads are split into ≤ `MaxPayloadSize`-byte datagrams,
each with a header carrying fragment index and total count so the client can reassemble them.
- **Signal quantization** — `float32`/`float64` signals can be linearly quantized to
`uint8`, `int8`, `uint16`, or `int16` on the wire, reducing bandwidth significantly.
- **Temporal arrays** — signals with `NumberOfElements > 1` can carry per-sample time
metadata via `TimeMode` and `TimeSignal`, enabling high-frequency burst transmission
(e.g. 1 000 samples per RT cycle at 1 MSps).
---
## Configuration
```
+Streamer = {
Class = UDPStreamer
// Network
Port = 44500 // UDP port the server listens on (default: 44500)
MaxPayloadSize = 1400 // Maximum bytes per UDP datagram (default: 1400)
// Must be > 17 (header size). Tune for MTU.
// Background thread (optional)
CPUMask = 0x2 // CPU affinity mask for the network thread
StackSize = 1048576 // Stack size in bytes (default: 1 MiB)
Signals = {
// ── Scalar signal ────────────────────────────────────────────────────
Time = {
Type = uint32
Unit = "us" // Optional: physical unit string (informational)
}
// ── Float signal with quantization ───────────────────────────────────
Pressure = {
Type = float32
Unit = "Pa"
RangeMin = 0.0 // Required when QuantizedType is set
RangeMax = 1000000.0 // Required when QuantizedType is set
QuantizedType = uint16 // none | uint8 | int8 | uint16 | int16
}
// ── Temporal array (packed burst) ────────────────────────────────────
Channel1 = {
Type = float32
NumberOfDimensions = 1
NumberOfElements = 1000 // N samples per RT cycle
Unit = "V"
TimeMode = FirstSample // see Time Modes below
TimeSignal = Time // name of a scalar signal in this DataSource
SamplingRate = 1000000.0 // Hz — used by client to reconstruct timestamps
}
}
}
```
### Top-level Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `Port` | uint16 | 44500 | UDP server port |
| `MaxPayloadSize` | uint32 | 1400 | Max payload bytes per UDP datagram (min 18) |
| `CPUMask` | uint32 | 0 (any) | Background thread CPU affinity |
| `StackSize` | uint32 | 1 048 576 | Background thread stack size in bytes |
### Per-signal Parameters
| Parameter | Type | Default | Applies to |
|-----------|------|---------|------------|
| `Unit` | string | `""` | Any type — informational, forwarded to client in CONFIG |
| `RangeMin` | float64 | 0.0 | float32/float64 with `QuantizedType` |
| `RangeMax` | float64 | 1.0 | float32/float64 with `QuantizedType` |
| `QuantizedType` | string | `none` | float32/float64 only |
| `TimeMode` | string | `PacketTime` | Signals with `NumberOfElements > 1` |
| `TimeSignal` | string | — | Required when `TimeMode``PacketTime` |
| `SamplingRate` | float64 | 0.0 | Required when `TimeMode` = `FirstSample` or `LastSample` |
### Quantization Types
| Value | Wire type | Bit depth | Notes |
|-------|-----------|-----------|-------|
| `none` | same as source | — | Raw copy, no quantization |
| `uint8` | uint8 | 8-bit | Maps `[RangeMin, RangeMax]``[0, 255]` |
| `int8` | int8 | 8-bit | Maps `[RangeMin, RangeMax]``[-127, 127]` |
| `uint16` | uint16 | 16-bit | Maps `[RangeMin, RangeMax]``[0, 65 535]` |
| `int16` | int16 | 16-bit | Maps `[RangeMin, RangeMax]``[-32 767, 32 767]` |
Quantization formula (unsigned, e.g. uint16):
```
normalized = clamp((value - RangeMin) / (RangeMax - RangeMin), 0.0, 1.0)
wire_value = (uint16)(normalized × 65535)
```
### Time Modes
| Value | Meaning | Requirements |
|-------|---------|--------------|
| `PacketTime` | The HRT counter captured at `Synchronise()` time is used as the packet timestamp. No per-signal time metadata. | — |
| `FullArray` | `TimeSignal` carries one timestamp per element (same `NumberOfElements`). | `TimeSignal` must have the same `NumberOfElements`. |
| `FirstSample` | `TimeSignal` is a scalar giving the timestamp of element `[0]`. Elements `[1..N-1]` are inferred at `1/SamplingRate` intervals. | Scalar `TimeSignal`; `SamplingRate > 0`. |
| `LastSample` | Same as `FirstSample` but `TimeSignal` is the timestamp of element `[N-1]`. | Scalar `TimeSignal`; `SamplingRate > 0`. |
---
## Broker
UDPStreamer uses `MemoryMapSynchronisedOutputBroker` for output signals. This broker is
called automatically by the MARTe2 scheduler after all GAMs in the thread have executed.
> **Note:** Input signals are not supported — `GetBrokerName()` returns `""` for
> `InputSignals`.
---
## Lifecycle
```
PrepareNextState() ← opens UDP server socket, starts background thread
[RT thread, each cycle]
GAMs execute ← write into Streamer signal memory via broker
Synchronise() ← spinlock + memcpy to readyBuffer + post dataSem
[Background thread]
Poll serverSocket ← receive CONNECT / DISCONNECT / ACK
Wait dataSem ← woken by Synchronise()
QuantizeAndSerialize() ← build wire payload
SendFragmented() ← send DATA fragments to client
```
---
## Performance Notes
- The RT path (`Synchronise()`) performs only: `FastLock()` + `memcpy` + `FastUnLock()` + `EventSem.Post()`.
No socket calls, no heap allocation.
- `readyBuffer` and `wireBuffer` are allocated once in `AllocateMemory()`.
- If no client is connected the background thread skips serialisation entirely.
- Packet loss is tolerated silently. ACK tracking is reserved for future use.
---
## Example: minimal scalar streaming
```
+Data = {
Class = ReferenceContainer
+Streamer = {
Class = UDPStreamer
Port = 44500
Signals = {
Counter = { Type = uint32 }
Voltage = { Type = float32; Unit = "V"; RangeMin = -10.0; RangeMax = 10.0; QuantizedType = uint16 }
}
}
}
```
## Example: high-frequency burst
```
+Streamer = {
Class = UDPStreamer
Port = 44500
MaxPayloadSize = 1400
Signals = {
T0 = { Type = uint32; Unit = "us" }
Ch1 = {
Type = float32
NumberOfDimensions = 1
NumberOfElements = 1000 // 1000 samples per RT cycle
Unit = "V"
TimeMode = FirstSample
TimeSignal = T0
SamplingRate = 1000000.0 // 1 MSps
}
}
}
```
With `MaxPayloadSize = 1400`, a single 1000-element float32 signal produces:
```
payload = 8 B (HRT timestamp) + 4 B (T0/uint32) + 4000 B (float32×1000) = 4012 B
fragments = ceil(4012 / 1383) = 3
```
+188
View File
@@ -0,0 +1,188 @@
# WebUI Client
The WebUI is a Go binary that acts as a bridge between UDPStreamer and any web browser.
It receives UDP packets from UDPStreamer, reassembles fragmented data, and re-publishes
decoded signal values over a WebSocket to the browser. The browser renders live plots
using [Plotly.js](https://plotly.com/javascript/).
```
MARTe2 RT app
│ UDP (binary protocol)
udpstreamer-webui (Go)
│ WebSocket (JSON)
Browser (index.html + Plotly.js)
```
---
## Building
```bash
cd Client/WebUI
go build -o udpstreamer-webui ./...
```
Requires Go ≥ 1.21. The only external dependency is `gorilla/websocket`
(declared in `go.mod`; fetched automatically by `go build`).
---
## Running
```bash
./udpstreamer-webui \
--streamer 127.0.0.1:44500 \ # address:port of the UDPStreamer server
--listen :8080 \ # HTTP / WebSocket listen address
--clientport 44900 # local UDP port for receiving from streamer
```
Open `http://localhost:8080` in any modern browser.
### Flags
| Flag | Default | Description |
|------|---------|-------------|
| `--streamer` | `127.0.0.1:44500` | UDP address of the UDPStreamer DataSource |
| `--listen` | `:8080` | HTTP server bind address |
| `--clientport` | `44900` | Local UDP port for receiving data |
---
## Browser UI
### Layout
```
┌─────────────────────────────────────────────────────────┐
│ MARTe2 UDP Streamer ● Streaming Window: [5 s▾] ⏸ │ ← top bar
├──────────┬──────────────────────────────────────────────┤
│ Signals │ Plots [1×1][2×1][2×2]… [+Add] │
│──────────│──────────────────────────────────────────────│
│ Counter │ ┌─────────────────┐ ┌──────────────────┐ │
│ Time │ │ Plot 1 │ │ Plot 2 │ │
│ Sine1 │ │ (drop signals) │ │ (drop signals) │ │
│ Sine2 │ │ │ │ │ │
│ Ch1[1000]│ └─────────────────┘ └──────────────────┘ │
│ Ch2[1000]│ │
└──────────┴──────────────────────────────────────────────┘
```
### Signal Sidebar
Signals received in the CONFIG packet are listed in the sidebar:
- **Scalar signals** — appear as single draggable items (e.g. `Sine1 · f32`).
- **Temporal arrays** — high-frequency burst signals with `TimeMode ≠ PacketTime`.
Displayed as a single draggable item showing element count: `Ch1 · [1000] f32`.
- **Spatial arrays** — `TimeMode = PacketTime` arrays are shown as an expandable
group; individual elements (`Ch1[0]`, `Ch1[1]`, …) can be dragged independently.
### Adding Plots
1. Click **+ Add Plot** in the toolbar.
2. Drag a signal from the sidebar onto a plot panel.
3. Multiple signals can be overlaid on the same plot.
4. Click the **×** badge to remove a trace.
### Plot Controls
| Control | Action |
|---------|--------|
| **⏸ / ▶** (per plot) | Pause / resume that plot; paused plots allow zoom and pan |
| **⬇** (per plot) | Export all visible traces to CSV |
| **🗑** (per plot) | Delete the plot |
| **⏸ Pause All** (top bar) | Pause all plots simultaneously |
| **Window** (top bar) | Adjust the rolling time window (1 s 60 s) |
| Layout buttons | Switch between 1×1, 2×1, 1×2, 2×2, 3×1, … grid layouts |
| Sidebar **←** | Collapse the signal list to maximise plot area |
### Status LED
| Colour | Meaning |
|--------|---------|
| Red (solid) | No WebSocket connection to browser |
| Orange (pulsing) | WebSocket connected but no data received in > 1 s |
| Green (pulsing) | Data is being received normally |
---
## Architecture (Go side)
### Goroutines
```
main()
├── hub.Run() ← event loop: register/unregister WS clients, batch data at 30 Hz
├── udpClient.Run() ← reconnects on silence; parses UDP packets; feeds hub.dataCh
└── http.ListenAndServe()
├── GET / ← serves embedded index.html
└── GET /ws ← upgrades to WebSocket; launches per-client read/write pumps
```
### WebSocket Message Format
All messages are JSON. Two types are sent from server to browser:
#### `config` message
Sent immediately when a browser client connects (if a CONFIG has been received from
UDPStreamer) and whenever UDPStreamer sends a new CONFIG packet.
```json
{
"type": "config",
"signals": [
{
"name": "Sine1",
"typeCode": 8,
"quantType": 3,
"numDimensions": 0,
"numRows": 1,
"numCols": 1,
"rangeMin": -10.0,
"rangeMax": 10.0,
"timeMode": 0,
"samplingRate": 0.0,
"timeSignalIdx": 4294967295,
"unit": "V"
}
]
}
```
#### `data` message
Sent at ≤ 30 Hz, batching all UDP packets received since the last tick.
Each signal carries its own time axis to support mixed scalar + temporal-array signals.
```json
{
"type": "data",
"signals": {
"Sine1": { "t": [1747123456.001, 1747123456.002], "v": [3.14, 2.71] },
"Counter": { "t": [1747123456.001, 1747123456.002], "v": [12340, 12341] },
"Ch1": { "t": [1747123456.000, 1747123456.0001, ...], "v": [0.12, 0.13, ...] }
}
}
```
- `t` — Unix timestamp in seconds (float64) for each sample.
- `v` — physical value (after dequantization if applicable).
- For **temporal arrays**, `t` and `v` have `N × batchSize` entries
(up to 2000 per 30 Hz tick after server-side decimation).
- For **spatial arrays** the keys are `"Ch1[0]"`, `"Ch1[1]"`, etc.
---
## Reconnection Behaviour
- **UDP reconnect:** The Go client reconnects to UDPStreamer automatically after 5 s
of silence. This handles MARTe2 restarts transparently.
- **WebSocket keepalive:** The server sends a WebSocket ping every 30 s. The browser
auto-responds; if no pong is received within 10 s the connection is closed and the
browser reconnects with exponential backoff (starting at 1 s, capped at 30 s).
- **Buffer preservation:** Browser-side signal buffers are only reset when the signal
layout changes (name, type, or dimensions differ). A reconnect with the same CONFIG
keeps existing data visible in the plots.