WebUI: per-signal vscale toolbar, active-signal highlighting, zoom fix

- Per-signal, per-plot vertical scale state (sigVScale keyed by plotId:signalKey)
  so the same signal in two plots has fully independent vscale config
- Active signal redrawn on top of all series with 2× line width for clear
  visual identification; badge click toggles selection and opens/closes the
  embedded vscale toolbar (click same badge again to deselect)
- Vscale configurator moved from floating popup to a slim toolbar strip
  anchored inside the plot card, with an × close button
- Trigger dropdown shows one entry per array signal with [0…N-1] label;
  opening it shows an index-picker dialog to choose the element
- Zoom resampling: when server returns no data for a zoomed range, fall
  back to the local circular buffer instead of returning empty arrays

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-05-27 15:42:00 +02:00
parent b15e637f14
commit 3dd0d863fa
13 changed files with 1508 additions and 231 deletions
@@ -50,13 +50,17 @@ namespace MARTe {
/**
* @brief Publishing mode for the background sender thread.
*
* - Strict: send one UDP packet on every Synchronise() call (legacy behaviour).
* - Auto: buffer successive ticks and only flush when the HRT-based flush
* interval has elapsed. Controlled by MinRefreshRate (Hz).
* - Strict: send one packet every Synchronise() call (default).
* - Accumulate: buffer N successive snapshots and flush either when the next
* snapshot would exceed MaxPayloadSize or when 1/MinRefreshRate
* has elapsed. Scalars expand to arrays; the first scalar with
* Unit="us"/"ns" is auto-promoted as the FullArray time reference.
* - Decimate: send one packet every Ratio Synchronise() calls.
*/
typedef enum {
UDPStreamerPublishStrict = 0u, /**< Send on every RT cycle (default) */
UDPStreamerPublishAuto = 1u /**< Rate-limited: flush at MinRefreshRate Hz */
UDPStreamerPublishStrict = 0u, /**< Send on every RT cycle (default) */
UDPStreamerPublishAccumulate = 1u, /**< Accumulate until size/time limit; then flush */
UDPStreamerPublishDecimate = 2u /**< Send 1 packet every Ratio cycles */
} UDPStreamerPublishMode;
/**
@@ -100,6 +104,7 @@ struct UDPStreamerSignalInfo {
uint32 srcByteSize; /**< Bytes in MARTe2 memory */
uint32 wireByteSize; /**< Bytes on the wire (may differ when quantized) */
uint32 bufferOffset; /**< Byte offset in the flat MemoryDataSourceI memory buffer */
bool accumulated; /**< True when this scalar was expanded to flushCount elements in Auto accumulation mode */
};
/**
@@ -145,59 +150,134 @@ static const uint8 UDPS_TYPECODE_FLOAT64 = 9u;
static const uint8 UDPS_TYPECODE_UNKNOWN = 255u;
/**
* @brief A DataSource that streams MARTe2 signals to a single UDP client.
* @brief A DataSource that streams MARTe2 signals to UDP clients.
*
* @details This output DataSource accepts signals from GAMs and forwards them
* asynchronously to a connected UDP client. A dedicated background thread handles
* all network I/O so that the real-time thread is only blocked for a fast spinlock
* asynchronously over the network. A dedicated background thread handles all
* network I/O so that the real-time thread is only blocked by a fast spinlock
* and a memcpy during Synchronise().
*
* Protocol:
* - Client sends CONNECT → server replies with CONFIG describing all signals.
* - Server sends DATA packets (fragmented if needed) on every RT cycle.
* - Client sends ACK packets (optional, for monitoring loss).
* - Client sends DISCONNECT to terminate the session.
* Two operating modes are selected by the presence or absence of MulticastGroup:
*
* Signal configuration syntax:
* @par Unicast mode (default — no MulticastGroup)
* The server opens a single UDP socket on Port. The client initiates the session
* by sending a CONNECT packet to that port. The server replies with a CONFIG
* packet on the same socket and subsequently sends DATA packets directly to the
* client's address. Any number of clients may connect sequentially (one at a
* time); a new CONNECT evicts the previous client.
*
* @par Multicast mode (MulticastGroup specified)
* The server opens a TCP listener on Port for control traffic and a UDP socket
* aimed at MulticastGroup:DataPort for data traffic. The client:
* 1. Connects to Port via TCP and sends a CONNECT packet.
* 2. Receives the CONFIG packet over TCP.
* 3. Joins the multicast group (MulticastGroup:DataPort) to receive DATA packets.
* Multiple clients may receive data simultaneously by joining the same group.
* A new TCP CONNECT evicts the previous client from the control channel; the
* multicast data stream continues regardless.
*
* @par Packet protocol (both modes)
* - Client → Server: CONNECT (initiates session), DISCONNECT (terminates session),
* ACK (optional, for packet-loss monitoring).
* - Server → Client: CONFIG (signal metadata), DATA (signal values, possibly
* fragmented into multiple datagrams if payload exceeds MaxPayloadSize).
*
* @par Top-level configuration parameters
* | Parameter | Type | Default | Description |
* |-----------------|---------|---------|-------------|
* | Port | uint16 | 44500 | TCP control port (multicast) or UDP server port (unicast). Values ≤ 1024 produce a warning. |
* | MulticastGroup | string | *(absent)* | **Enables multicast mode.** IPv4 multicast address, e.g. `"239.0.0.1"`. Must be in 224.0.0.0/4. Absent or empty = unicast. |
* | DataPort | uint16 | Port+1 | UDP port for multicast DATA datagrams. Ignored in unicast mode. Must be non-zero and differ from Port. |
* | MaxPayloadSize | uint32 | 1400 | Maximum bytes of signal payload per UDP datagram (excluding the 17-byte header). Larger signals are fragmented. |
* | PublishingMode | string | Strict | `Strict`: send one packet every Synchronise() call. `Auto`: rate-limited; flush only when MinRefreshRate interval has elapsed. |
* | MinRefreshRate | float64 | — | Required when PublishingMode = Auto. Flush frequency in Hz (e.g. 120.0). |
* | MaxBatchSize | uint32 | 1 | Optional when PublishingMode = Auto. Number of RT cycles to accumulate before flushing one packet. Scalar signals are expanded to arrays of MaxBatchSize elements; the first scalar with Unit="us" or "ns" is auto-promoted as the per-sample FullArray timestamp reference for all other scalars. When omitted or 1, the most-recent single value is sent at MinRefreshRate. |
* | CPUMask | uint32 | 0xFFFFFFFF | CPU affinity bitmask for the background thread. |
* | StackSize | uint32 | (MARTe2 default) | Stack size in bytes for the background thread. |
*
* @par Per-signal configuration parameters
* | Parameter | Type | Default | Description |
* |------------------|---------|-------------|-------------|
* | Type | string | — | MARTe2 type name (uint8, int16, float32, float64, …). Mandatory. |
* | NumberOfDimensions | uint8 | 0 | 0 = scalar, 1 = array, 2 = matrix. |
* | NumberOfElements | uint32 | 1 | Total element count (rows × cols for matrices). |
* | Unit | string | "" | Physical unit label, e.g. `"Pa"` or `"m/s"`. Transmitted in CONFIG for display purposes. |
* | RangeMin | float64 | 0.0 | Minimum of the physical range. Required when QuantizedType is not `none`. |
* | RangeMax | float64 | 1.0 | Maximum of the physical range. Required when QuantizedType is not `none`. |
* | QuantizedType | string | none | Wire quantization for float signals: `none` \| `uint8` \| `int8` \| `uint16` \| `int16`. Reduces wire bandwidth at the cost of precision. Only valid for float32/float64 signals. |
* | TimeMode | string | PacketTime | How the signal's time axis is encoded (see below). |
* | TimeSignal | string | — | Name of the signal that carries timestamps. Required when TimeMode ≠ PacketTime. |
* | SamplingRate | float64 | — | Signal sampling rate in Hz. Required when TimeMode = FirstSample or LastSample. |
*
* @par TimeMode values
* | Value | Description |
* |--------------|-------------|
* | PacketTime | Uses the HRT counter captured at Synchronise() time as the single packet timestamp. No dedicated time signal needed. |
* | FullArray | TimeSignal has the same NumberOfElements as this signal; one timestamp per element. |
* | FirstSample | TimeSignal is a scalar = timestamp of the first element; subsequent elements are spaced by 1/SamplingRate. |
* | LastSample | TimeSignal is a scalar = timestamp of the last element; elements are spaced backwards by 1/SamplingRate. |
*
* @par Example — unicast mode
* <pre>
* +UDPStreamer1 = {
* Class = UDPStreamer
* Port = 44500 // Optional (default 44500)
* MaxPayloadSize = 1400 // Optional (default 1400); max payload bytes per UDP datagram
* CPUMask = 0x2 // Optional, affinity for background thread
* StackSize = 1048576 // Optional, stack size for background thread
* PublishingMode = "Auto" // Optional: "Strict" (default) | "Auto"
* MinRefreshRate = 120 // Optional (Hz); only used when PublishingMode = "Auto"
* +Streamer = {
* Class = UDPStreamer
* Port = 44500
* MaxPayloadSize = 1400
* PublishingMode = "Strict"
* Signals = {
* Time = {
* Type = uint64
* }
* Pressure = {
* Type = float32
* NumberOfDimensions = 1
* NumberOfElements = 100
* Unit = "Pa" // Optional
* RangeMin = 0.0 // Optional (required for quantization)
* RangeMax = 1000000.0 // Optional (required for quantization)
* QuantizedType = uint16 // Optional: none|uint8|int8|uint16|int16
* TimeMode = LastSample // Optional: PacketTime|FullArray|FirstSample|LastSample
* TimeSignal = Time // Required when TimeMode != PacketTime
* SamplingRate = 10000.0 // Required when TimeMode = FirstSample or LastSample
* Type = float32
* NumberOfDimensions = 1
* NumberOfElements = 100
* Unit = "Pa"
* RangeMin = 0.0
* RangeMax = 1000000.0
* QuantizedType = uint16
* TimeMode = LastSample
* TimeSignal = Time
* SamplingRate = 10000.0
* }
* Temperature = {
* Type = float64
* Unit = "K"
* Type = float64
* Unit = "degC"
* TimeMode = PacketTime
* }
* }
* }
* </pre>
*
* Notes:
* - QuantizedType is only valid for float32/float64 signals.
* - TimeMode = PacketTime uses the HRT counter captured in Synchronise().
* - TimeMode = FullArray requires TimeSignal to have the same NumberOfElements.
* - TimeMode = FirstSample/LastSample requires a scalar TimeSignal and SamplingRate.
* @par Example — multicast mode
* <pre>
* +Streamer = {
* Class = UDPStreamer
* Port = 44500 // TCP control port
* MulticastGroup = "239.0.0.1" // Enables multicast mode
* DataPort = 44501 // UDP data port (default: Port+1)
* MaxPayloadSize = 1400
* PublishingMode = "Auto"
* MinRefreshRate = 60
* Signals = {
* Time = {
* Type = uint64
* }
* Voltage = {
* Type = float32
* NumberOfDimensions = 1
* NumberOfElements = 1000
* Unit = "V"
* RangeMin = -10.0
* RangeMax = 10.0
* QuantizedType = int16
* TimeMode = FirstSample
* TimeSignal = Time
* SamplingRate = 100000.0
* }
* }
* }
* </pre>
*/
class UDPStreamer : public MemoryDataSourceI, public EmbeddedServiceMethodBinderI {
public:
@@ -214,8 +294,11 @@ public:
virtual ~UDPStreamer();
/**
* @brief Parses top-level configuration parameters (Port, MaxPayloadSize, CPUMask, StackSize).
* @return true if all mandatory parameters are valid.
* @brief Parses top-level configuration parameters.
* @details Reads Port, MaxPayloadSize, CPUMask, StackSize, PublishingMode,
* MinRefreshRate, MulticastGroup, and DataPort from the configuration node.
* Sets useMulticast and dataPort when MulticastGroup is present and valid.
* @return true if all mandatory parameters are valid and consistent.
*/
virtual bool Initialise(StructuredDataI &data);
@@ -312,6 +395,14 @@ private:
*/
void HandleTCPConnect(const uint8 *buf, uint32 size);
/**
* @brief Serializes an accumulated batch into wireBuffer.
* @param src Flat buffer [numSamples × totalSrcBytes], slot 0 = oldest.
* @param timestamps HRT counters per slot; timestamps[0] is the packet timestamp.
* @param numSamples Actual number of filled slots to serialize.
*/
void SerializeAccumulated(const uint8 *src, const uint64 *timestamps, uint32 numSamples);
/**
* @brief Maps a MARTe2 TypeDescriptor to the UDPS_TYPECODE_* constants.
*/
@@ -325,6 +416,20 @@ private:
UDPStreamerPublishMode publishMode; /**< Strict or Auto publishing mode */
float64 minRefreshRate; /**< Minimum flush rate (Hz) for Auto mode */
uint64 flushPeriodTicks; /**< HRT ticks per flush interval (computed from minRefreshRate) */
/* Accumulate mode — dynamic batch parameters */
uint32 maxBatchCount; /**< Max snapshots that fit in MaxPayloadSize (Accumulate) */
uint32 singleCycleWireBytes; /**< Wire bytes for all accumulated signals per snapshot */
uint32 fixedWireBytes; /**< Wire bytes for non-accumulated signals (arrays, once per packet) */
volatile uint64 lastPublishTs; /**< HRT counter of last successful flush (Accumulate mode) */
uint8 *accumBuffer; /**< Heap: [maxBatchCount × totalSrcBytes] linear fill */
uint64 *accumTimestamps; /**< Heap: [maxBatchCount] HRT counter per snapshot */
uint32 accumFill; /**< Slots filled in accumBuffer (0..maxBatchCount) */
uint64 *readyTimestamps; /**< Heap: [maxBatchCount] HRT for completed ready batch */
uint64 *scratchTimestamps; /**< Heap: [maxBatchCount] background-thread local copy */
uint32 readyFill; /**< Snapshot count in the ready batch */
/* Decimate mode */
uint32 decimateRatio; /**< Send 1 packet every decimateRatio Synchronise() calls */
uint32 decimateCounter; /**< Current decimate cycle counter */
/* Signal metadata */
uint32 numSigs; /**< Number of signals */