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:
+19
-5
@@ -82,9 +82,14 @@ func (c *wsClient) readPump() {
|
|||||||
case "addSource":
|
case "addSource":
|
||||||
label, _ := env["label"].(string)
|
label, _ := env["label"].(string)
|
||||||
addr, _ := env["addr"].(string)
|
addr, _ := env["addr"].(string)
|
||||||
|
mcastGroup, _ := env["multicastGroup"].(string)
|
||||||
|
dataPortF, _ := env["dataPort"].(float64)
|
||||||
if addr != "" {
|
if addr != "" {
|
||||||
select {
|
select {
|
||||||
case c.hub.commandCh <- hubCmd{op: "wsAddSource", label: label, addr: addr}:
|
case c.hub.commandCh <- hubCmd{
|
||||||
|
op: "wsAddSource", label: label, addr: addr,
|
||||||
|
multicastGroup: mcastGroup, dataPort: int(dataPortF),
|
||||||
|
}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +149,8 @@ type hubCmd struct {
|
|||||||
addr string
|
addr string
|
||||||
state string
|
state string
|
||||||
sigs []SignalInfo
|
sigs []SignalInfo
|
||||||
|
multicastGroup string
|
||||||
|
dataPort int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hub is the central broker between UDP clients and WebSocket clients.
|
// Hub is the central broker between UDP clients and WebSocket clients.
|
||||||
@@ -474,7 +481,9 @@ func (h *Hub) Run() {
|
|||||||
|
|
||||||
case "wsAddSource":
|
case "wsAddSource":
|
||||||
if h.sm != nil {
|
if h.sm != nil {
|
||||||
go func(label, addr string) { h.sm.Add(label, addr, "", 0) }(cmd.label, cmd.addr)
|
go func(label, addr, mcastGroup string, dataPort int) {
|
||||||
|
h.sm.Add(label, addr, mcastGroup, dataPort)
|
||||||
|
}(cmd.label, cmd.addr, cmd.multicastGroup, cmd.dataPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "wsRemoveSource":
|
case "wsRemoveSource":
|
||||||
@@ -718,9 +727,10 @@ func (h *Hub) buildDataMessageForSource(src *sourceHubState, batch []DataSample)
|
|||||||
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
||||||
out[pfx+sig.Name] = sigData{T: decimT, V: decimV}
|
out[pfx+sig.Name] = sigData{T: decimT, V: decimV}
|
||||||
|
|
||||||
case n > 1 && sig.TimeMode == TimeModeFullArray:
|
case sig.TimeMode == TimeModeFullArray:
|
||||||
// The time signal has the same N elements as the data signal.
|
|
||||||
// Each element pair (timeSig[k], dataSig[k]) is one (t, v) sample.
|
// Each element pair (timeSig[k], dataSig[k]) is one (t, v) sample.
|
||||||
|
// This handles both standard N-element FullArray signals and
|
||||||
|
// Accumulate-mode scalars (n=1) auto-assigned FullArray time mode.
|
||||||
hasTimeSig := sig.TimeSignalIdx != NoTimeSignal && int(sig.TimeSignalIdx) < len(sigs)
|
hasTimeSig := sig.TimeSignalIdx != NoTimeSignal && int(sig.TimeSignalIdx) < len(sigs)
|
||||||
var timeSigName string
|
var timeSigName string
|
||||||
timerToSec := 1e-6
|
timerToSec := 1e-6
|
||||||
@@ -918,7 +928,9 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []DataS
|
|||||||
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
||||||
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
|
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
|
||||||
|
|
||||||
case n > 1 && sig.TimeMode == TimeModeFullArray:
|
case sig.TimeMode == TimeModeFullArray:
|
||||||
|
// Handles both standard N-element FullArray signals and
|
||||||
|
// Accumulate-mode scalars (n=1) with auto-assigned FullArray time mode.
|
||||||
hasTimeSig := sig.TimeSignalIdx != NoTimeSignal && int(sig.TimeSignalIdx) < len(sigs)
|
hasTimeSig := sig.TimeSignalIdx != NoTimeSignal && int(sig.TimeSignalIdx) < len(sigs)
|
||||||
var timeSigName string
|
var timeSigName string
|
||||||
timerToSec := 1e-6
|
timerToSec := 1e-6
|
||||||
@@ -957,10 +969,12 @@ func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []DataS
|
|||||||
allV = append(allV, vals[k])
|
allV = append(allV, vals[k])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if writeRing {
|
||||||
ringT, ringV := lttbDecimate(allT, allV, maxRingPoints)
|
ringT, ringV := lttbDecimate(allT, allV, maxRingPoints)
|
||||||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||||||
rb.write(ringT, ringV)
|
rb.write(ringT, ringV)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
||||||
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
|
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, arg := range sourceArgs {
|
for _, arg := range sourceArgs {
|
||||||
label, addr := ParseSourceArg(arg)
|
label, addr, mcastGroup, dataPort := ParseSourceArgFull(arg)
|
||||||
sm.Add(label, addr, "", 0)
|
sm.Add(label, addr, mcastGroup, dataPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
sub, err := fs.Sub(staticFiles, "static")
|
sub, err := fs.Sub(staticFiles, "static")
|
||||||
|
|||||||
+99
-20
@@ -34,6 +34,11 @@ const (
|
|||||||
TimeModeFullArray uint8 = 1 // TimeSignal has same N elements; not expanded here
|
TimeModeFullArray uint8 = 1 // TimeSignal has same N elements; not expanded here
|
||||||
TimeModeFirstSample uint8 = 2 // TimeSignal scalar = time of element [0]
|
TimeModeFirstSample uint8 = 2 // TimeSignal scalar = time of element [0]
|
||||||
TimeModeLastSample uint8 = 3 // TimeSignal scalar = time of element [N-1]
|
TimeModeLastSample uint8 = 3 // TimeSignal scalar = time of element [N-1]
|
||||||
|
|
||||||
|
// PublishMode values – must match UDPStreamerPublishMode enum in UDPStreamer.h
|
||||||
|
PublishModeStrict uint8 = 0 // one packet per Synchronise() call
|
||||||
|
PublishModeAccumulate uint8 = 1 // variable batch; DATA has [8 HRT][4 numSamples][signals...]
|
||||||
|
PublishModeDecimate uint8 = 2 // one packet every Ratio calls
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── Packet header (17 bytes, little-endian, packed) ─────────────────────────
|
// ─── Packet header (17 bytes, little-endian, packed) ─────────────────────────
|
||||||
@@ -216,16 +221,17 @@ func nullTermString(b []byte) string {
|
|||||||
// ─── CONFIG payload parser ────────────────────────────────────────────────────
|
// ─── CONFIG payload parser ────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ParseConfig decodes a fully-reassembled CONFIG payload.
|
// ParseConfig decodes a fully-reassembled CONFIG payload.
|
||||||
func ParseConfig(payload []byte) ([]SignalInfo, error) {
|
// Returns the signal list, the publishing mode byte (PublishMode*), and any error.
|
||||||
|
func ParseConfig(payload []byte) ([]SignalInfo, uint8, error) {
|
||||||
if len(payload) < 4 {
|
if len(payload) < 4 {
|
||||||
return nil, fmt.Errorf("config payload too short")
|
return nil, 0, fmt.Errorf("config payload too short")
|
||||||
}
|
}
|
||||||
numSigs := binary.LittleEndian.Uint32(payload[0:4])
|
numSigs := binary.LittleEndian.Uint32(payload[0:4])
|
||||||
offset := 4
|
offset := 4
|
||||||
sigs := make([]SignalInfo, 0, numSigs)
|
sigs := make([]SignalInfo, 0, numSigs)
|
||||||
for i := uint32(0); i < numSigs; i++ {
|
for i := uint32(0); i < numSigs; i++ {
|
||||||
if offset+SigDescSize > len(payload) {
|
if offset+SigDescSize > len(payload) {
|
||||||
return nil, fmt.Errorf("config payload truncated at signal %d", i)
|
return nil, 0, fmt.Errorf("config payload truncated at signal %d", i)
|
||||||
}
|
}
|
||||||
raw := payload[offset : offset+SigDescSize]
|
raw := payload[offset : offset+SigDescSize]
|
||||||
si := SignalInfo{
|
si := SignalInfo{
|
||||||
@@ -245,7 +251,12 @@ func ParseConfig(payload []byte) ([]SignalInfo, error) {
|
|||||||
sigs = append(sigs, si)
|
sigs = append(sigs, si)
|
||||||
offset += SigDescSize
|
offset += SigDescSize
|
||||||
}
|
}
|
||||||
return sigs, nil
|
// Trailing publish-mode byte (added after signal descriptors).
|
||||||
|
publishMode := PublishModeStrict
|
||||||
|
if offset < len(payload) {
|
||||||
|
publishMode = payload[offset]
|
||||||
|
}
|
||||||
|
return sigs, publishMode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DATA payload parser ──────────────────────────────────────────────────────
|
// ─── DATA payload parser ──────────────────────────────────────────────────────
|
||||||
@@ -257,25 +268,15 @@ type DataSample struct {
|
|||||||
Values map[string][]float64 // key = signal name, value = []float64 with NumElements entries
|
Values map[string][]float64 // key = signal name, value = []float64 with NumElements entries
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseData decodes a fully-reassembled DATA payload using the provided signal config.
|
// parseElems reads n elements for sig from payload at offset, advancing offset.
|
||||||
// arrivalTime is the wall-clock time at which the packet was received.
|
// Returns the slice of float64 values and the new offset.
|
||||||
func ParseData(payload []byte, sigs []SignalInfo, arrivalTime time.Time) (DataSample, error) {
|
func parseElems(payload []byte, offset, n int, sig SignalInfo) ([]float64, int, error) {
|
||||||
if len(payload) < 8 {
|
|
||||||
return DataSample{}, fmt.Errorf("data payload too short")
|
|
||||||
}
|
|
||||||
hrt := binary.LittleEndian.Uint64(payload[0:8])
|
|
||||||
offset := 8
|
|
||||||
|
|
||||||
vals := make(map[string][]float64, len(sigs))
|
|
||||||
for _, sig := range sigs {
|
|
||||||
n := sig.NumElements()
|
|
||||||
elems := make([]float64, n)
|
elems := make([]float64, n)
|
||||||
|
|
||||||
if sig.QuantType == QuantNone {
|
if sig.QuantType == QuantNone {
|
||||||
sz := rawTypeSize(sig.TypeCode)
|
sz := rawTypeSize(sig.TypeCode)
|
||||||
needed := n * sz
|
needed := n * sz
|
||||||
if offset+needed > len(payload) {
|
if offset+needed > len(payload) {
|
||||||
return DataSample{}, fmt.Errorf("data payload truncated for signal %q", sig.Name)
|
return nil, offset, fmt.Errorf("data payload truncated for signal %q", sig.Name)
|
||||||
}
|
}
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
elems[i] = readRawElement(payload, offset+i*sz, sig.TypeCode)
|
elems[i] = readRawElement(payload, offset+i*sz, sig.TypeCode)
|
||||||
@@ -285,7 +286,7 @@ func ParseData(payload []byte, sigs []SignalInfo, arrivalTime time.Time) (DataSa
|
|||||||
sz := quantSize(sig.QuantType)
|
sz := quantSize(sig.QuantType)
|
||||||
needed := n * sz
|
needed := n * sz
|
||||||
if offset+needed > len(payload) {
|
if offset+needed > len(payload) {
|
||||||
return DataSample{}, fmt.Errorf("data payload truncated (quant) for signal %q", sig.Name)
|
return nil, offset, fmt.Errorf("data payload truncated (quant) for signal %q", sig.Name)
|
||||||
}
|
}
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
var raw uint16
|
var raw uint16
|
||||||
@@ -298,7 +299,85 @@ func ParseData(payload []byte, sigs []SignalInfo, arrivalTime time.Time) (DataSa
|
|||||||
}
|
}
|
||||||
offset += needed
|
offset += needed
|
||||||
}
|
}
|
||||||
|
return elems, offset, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseData decodes a fully-reassembled DATA payload using the provided signal config
|
||||||
|
// and publishing mode. arrivalTime is the wall-clock time at which the packet arrived.
|
||||||
|
//
|
||||||
|
// For PublishModeAccumulate the payload format is:
|
||||||
|
//
|
||||||
|
// [8 HRT][4 numSamples][for each signal: accumulated scalars → numSamples elems; arrays → NumElements elems]
|
||||||
|
//
|
||||||
|
// The function returns one DataSample per accumulated snapshot so the hub can
|
||||||
|
// process each slot independently with its own timestamp.
|
||||||
|
func ParseData(payload []byte, sigs []SignalInfo, publishMode uint8, arrivalTime time.Time) ([]DataSample, error) {
|
||||||
|
if len(payload) < 8 {
|
||||||
|
return nil, fmt.Errorf("data payload too short")
|
||||||
|
}
|
||||||
|
hrt := binary.LittleEndian.Uint64(payload[0:8])
|
||||||
|
offset := 8
|
||||||
|
|
||||||
|
if publishMode == PublishModeAccumulate {
|
||||||
|
if len(payload) < 12 {
|
||||||
|
return nil, fmt.Errorf("accumulate data payload too short (missing numSamples)")
|
||||||
|
}
|
||||||
|
numSamples := int(binary.LittleEndian.Uint32(payload[8:12]))
|
||||||
|
offset = 12
|
||||||
|
if numSamples == 0 {
|
||||||
|
return []DataSample{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse per-signal data blocks (all slots for a signal are contiguous).
|
||||||
|
accumVals := make(map[string][]float64, len(sigs)) // scalars: numSamples values
|
||||||
|
fixedVals := make(map[string][]float64, len(sigs)) // arrays: NumElements values
|
||||||
|
|
||||||
|
for _, sig := range sigs {
|
||||||
|
n := sig.NumElements()
|
||||||
|
if n == 1 {
|
||||||
|
// Accumulated scalar: read numSamples back-to-back elements.
|
||||||
|
elems, newOff, err := parseElems(payload, offset, numSamples, sig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
offset = newOff
|
||||||
|
accumVals[sig.Name] = elems
|
||||||
|
} else {
|
||||||
|
// Fixed array (non-accumulated): one set of NumElements values.
|
||||||
|
elems, newOff, err := parseElems(payload, offset, n, sig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
offset = newOff
|
||||||
|
fixedVals[sig.Name] = elems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build one DataSample per slot.
|
||||||
|
samples := make([]DataSample, numSamples)
|
||||||
|
for k := 0; k < numSamples; k++ {
|
||||||
|
vals := make(map[string][]float64, len(sigs))
|
||||||
|
for sigName, av := range accumVals {
|
||||||
|
vals[sigName] = []float64{av[k]}
|
||||||
|
}
|
||||||
|
for sigName, fv := range fixedVals {
|
||||||
|
vals[sigName] = fv // shared read-only reference; hub does not modify
|
||||||
|
}
|
||||||
|
samples[k] = DataSample{HRTTimestamp: hrt, WallTime: arrivalTime, Values: vals}
|
||||||
|
}
|
||||||
|
return samples, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strict / Decimate: single snapshot, one element set per signal.
|
||||||
|
vals := make(map[string][]float64, len(sigs))
|
||||||
|
for _, sig := range sigs {
|
||||||
|
n := sig.NumElements()
|
||||||
|
elems, newOff, err := parseElems(payload, offset, n, sig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
offset = newOff
|
||||||
vals[sig.Name] = elems
|
vals[sig.Name] = elems
|
||||||
}
|
}
|
||||||
return DataSample{HRTTimestamp: hrt, WallTime: arrivalTime, Values: vals}, nil
|
return []DataSample{{HRTTimestamp: hrt, WallTime: arrivalTime, Values: vals}}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-5
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -135,9 +136,33 @@ func (sm *SourceManager) Load(path string) error {
|
|||||||
|
|
||||||
// ParseSourceArg parses "label@host:port" or "host:port".
|
// ParseSourceArg parses "label@host:port" or "host:port".
|
||||||
func ParseSourceArg(s string) (label, addr string) {
|
func ParseSourceArg(s string) (label, addr string) {
|
||||||
s = strings.TrimSpace(s)
|
label, addr, _, _ = ParseSourceArgFull(s)
|
||||||
if idx := strings.Index(s, "@"); idx >= 0 {
|
return
|
||||||
return strings.TrimSpace(s[:idx]), strings.TrimSpace(s[idx+1:])
|
}
|
||||||
}
|
|
||||||
return "", s
|
// ParseSourceArgFull parses "[label@]host:port[/multicastGroup:dataPort]".
|
||||||
|
// Examples:
|
||||||
|
//
|
||||||
|
// "Streamer@127.0.0.1:44500/239.0.0.1:44503" → label="Streamer", addr="127.0.0.1:44500", group="239.0.0.1", port=44503
|
||||||
|
// "127.0.0.1:44501" → label="", addr="127.0.0.1:44501", group="", port=0
|
||||||
|
func ParseSourceArgFull(s string) (label, addr, multicastGroup string, dataPort int) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
rest := s
|
||||||
|
if idx := strings.Index(s, "@"); idx >= 0 {
|
||||||
|
label = strings.TrimSpace(s[:idx])
|
||||||
|
rest = strings.TrimSpace(s[idx+1:])
|
||||||
|
}
|
||||||
|
if idx := strings.Index(rest, "/"); idx >= 0 {
|
||||||
|
addr = strings.TrimSpace(rest[:idx])
|
||||||
|
mcastPart := strings.TrimSpace(rest[idx+1:])
|
||||||
|
if lastColon := strings.LastIndex(mcastPart, ":"); lastColon >= 0 {
|
||||||
|
multicastGroup = strings.TrimSpace(mcastPart[:lastColon])
|
||||||
|
dataPort, _ = strconv.Atoi(strings.TrimSpace(mcastPart[lastColon+1:]))
|
||||||
|
} else {
|
||||||
|
multicastGroup = mcastPart
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addr = rest
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+607
-63
@@ -39,6 +39,12 @@ function getSigStyle(key) {
|
|||||||
if (!sigStyle[key]) sigStyle[key] = { color: getTraceColor(key), width: 1.5, dash: 'solid', marker: 'none', markerSize: 4 };
|
if (!sigStyle[key]) sigStyle[key] = { color: getTraceColor(key), width: 1.5, dash: 'solid', marker: 'none', markerSize: 4 };
|
||||||
return sigStyle[key];
|
return sigStyle[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-signal vertical scale state: key → {mode, divValue, offset, _resolvedDiv, _resolvedOffset}
|
||||||
|
const sigVScale = {};
|
||||||
|
// Active signal per plot: plotId → key
|
||||||
|
const plotActiveSignal = {};
|
||||||
|
|
||||||
function setSigStyle(key, updates) {
|
function setSigStyle(key, updates) {
|
||||||
const s = getSigStyle(key);
|
const s = getSigStyle(key);
|
||||||
Object.assign(s, updates);
|
Object.assign(s, updates);
|
||||||
@@ -53,6 +59,121 @@ function setSigStyle(key, updates) {
|
|||||||
plots.forEach(p => { if (p.traces.includes(key)) { createUPlot(p); p.needsRedraw = true; } });
|
plots.forEach(p => { if (p.traces.includes(key)) { createUPlot(p); p.needsRedraw = true; } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── VScale helpers ─────────────────────────────────────────────────────── */
|
||||||
|
// vsKey: compound key "plotId:signalKey" so same signal in different plots is independent.
|
||||||
|
function getVScale(plotId, key) {
|
||||||
|
const vsKey = plotId + ':' + key;
|
||||||
|
if (!sigVScale[vsKey]) sigVScale[vsKey] = { mode: 'auto', divValue: 1, offset: 0, screenPos: 0, _resolvedDiv: null, _resolvedOffset: null };
|
||||||
|
return sigVScale[vsKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSignalMeta(key) {
|
||||||
|
const colon = key.indexOf(':');
|
||||||
|
if (colon < 0) return null;
|
||||||
|
const src = sourcesMap[key.slice(0, colon)];
|
||||||
|
if (!src) return null;
|
||||||
|
return src.signals.find(s => s.name === key.slice(colon + 1)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the effective {divValue, offset, screenPos} for a signal given its raw data array.
|
||||||
|
// y_norm = (y_raw - offset) / divValue + screenPos
|
||||||
|
// divValue: units per division offset: raw value at screen center screenPos: divisions from center
|
||||||
|
// Also caches the resolved values in vs._resolvedDiv/_resolvedOffset for Y-axis label use.
|
||||||
|
function resolveVScale(plotId, key, rawY) {
|
||||||
|
const vs = getVScale(plotId, key);
|
||||||
|
const screenPos = vs.screenPos || 0;
|
||||||
|
if (vs.mode === 'range') {
|
||||||
|
const meta = findSignalMeta(key);
|
||||||
|
if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) {
|
||||||
|
const divValue = (meta.rangeMax - meta.rangeMin) / 8;
|
||||||
|
const offset = (meta.rangeMin + meta.rangeMax) / 2;
|
||||||
|
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
|
||||||
|
return { divValue, offset, screenPos };
|
||||||
|
}
|
||||||
|
// Fall through to auto if no range
|
||||||
|
}
|
||||||
|
if (vs.mode === 'manual') {
|
||||||
|
const divValue = Math.max(vs.divValue || 1, 1e-30);
|
||||||
|
const offset = vs.offset != null ? vs.offset : 0;
|
||||||
|
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
|
||||||
|
return { divValue, offset, screenPos };
|
||||||
|
}
|
||||||
|
// Auto: fit data in central 6 of 8 divisions, centered at screenPos
|
||||||
|
let min = Infinity, max = -Infinity;
|
||||||
|
for (let i = 0; i < rawY.length; i++) {
|
||||||
|
const v = rawY[i];
|
||||||
|
if (v != null && isFinite(v)) { if (v < min) min = v; if (v > max) max = v; }
|
||||||
|
}
|
||||||
|
if (!isFinite(min)) { min = -1; max = 1; }
|
||||||
|
if (min === max) { min -= 1; max += 1; }
|
||||||
|
const divValue = Math.max((max - min) / 6, 1e-30);
|
||||||
|
const offset = (max + min) / 2;
|
||||||
|
vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
|
||||||
|
return { divValue, offset, screenPos };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply vscale normalization to a list of raw Y arrays (one per trace in p.traces).
|
||||||
|
// Returns normalized arrays where y_norm = (y_raw - offset) / divValue + screenPos.
|
||||||
|
function applyVScaleNorm(p, yArrays) {
|
||||||
|
return yArrays.map((rawY, ki) => {
|
||||||
|
const key = p.traces[ki];
|
||||||
|
const { divValue, offset, screenPos } = resolveVScale(p.id, key, rawY);
|
||||||
|
const out = new Float64Array(rawY.length);
|
||||||
|
for (let i = 0; i < rawY.length; i++) {
|
||||||
|
const v = rawY[i];
|
||||||
|
out[i] = (v == null || !isFinite(v)) ? NaN : (v - offset) / divValue + screenPos;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the active (Y-axis-labelled) signal for a plot and update badge highlights.
|
||||||
|
function setActiveSig(plotId, key) {
|
||||||
|
if (key === null || key === undefined) {
|
||||||
|
delete plotActiveSignal[plotId];
|
||||||
|
} else {
|
||||||
|
plotActiveSignal[plotId] = key;
|
||||||
|
}
|
||||||
|
const c = document.getElementById('badges-' + plotId);
|
||||||
|
if (c) c.querySelectorAll('.sig-badge').forEach(b =>
|
||||||
|
b.classList.toggle('sig-badge-active', key != null && b.dataset.key === key));
|
||||||
|
const p = plots.find(q => q.id === plotId);
|
||||||
|
if (p && p.uplot) p.uplot.redraw(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark plots containing key dirty and refresh badge vscale text.
|
||||||
|
function refreshPlotForKey(key) {
|
||||||
|
plots.forEach(p => {
|
||||||
|
if (p.traces.includes(key)) {
|
||||||
|
p.needsRedraw = true;
|
||||||
|
_updateBadgeVScaleInfo(p.id, key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format a numeric value concisely for badge/axis display.
|
||||||
|
function _fmtVal(v) {
|
||||||
|
if (v == null || !isFinite(v)) return '?';
|
||||||
|
const abs = Math.abs(v);
|
||||||
|
if (abs === 0) return '0';
|
||||||
|
if (abs >= 1e4 || abs < 1e-3) return v.toExponential(1);
|
||||||
|
return parseFloat(v.toPrecision(3)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the vscale info text inside a badge.
|
||||||
|
function _updateBadgeVScaleInfo(plotId, key) {
|
||||||
|
const c = document.getElementById('badges-' + plotId); if (!c) return;
|
||||||
|
const b = c.querySelector('[data-key="' + CSS.escape(key) + '"]'); if (!b) return;
|
||||||
|
const infoEl = b.querySelector('.vscale-info'); if (!infoEl) return;
|
||||||
|
const vs = sigVScale[plotId + ':' + key];
|
||||||
|
if (!vs) { infoEl.textContent = ''; return; }
|
||||||
|
const divValue = vs._resolvedDiv || vs.divValue || 1;
|
||||||
|
const sp = vs.screenPos || 0;
|
||||||
|
let txt = _fmtVal(divValue) + '/div';
|
||||||
|
if (sp !== 0) txt += ' ' + (sp >= 0 ? '+' : '') + sp.toFixed(1) + 'div';
|
||||||
|
infoEl.textContent = txt;
|
||||||
|
}
|
||||||
|
|
||||||
// Sync: shared uPlot cursor crosshair across all live plots
|
// Sync: shared uPlot cursor crosshair across all live plots
|
||||||
const LIVE_SYNC = uPlot.sync('live');
|
const LIVE_SYNC = uPlot.sync('live');
|
||||||
const TRIG_SYNC = uPlot.sync('trig');
|
const TRIG_SYNC = uPlot.sync('trig');
|
||||||
@@ -692,32 +813,83 @@ function buildDataFromFetched(p, fetchedSignals, targetPts) {
|
|||||||
if (!sd || !sd.t.length) { yArrays.push(new Float64Array(sharedT.length)); continue; }
|
if (!sd || !sd.t.length) { yArrays.push(new Float64Array(sharedT.length)); continue; }
|
||||||
yArrays.push(resampleLinear(sd.t, sd.v, sharedT));
|
yArrays.push(resampleLinear(sd.t, sd.v, sharedT));
|
||||||
}
|
}
|
||||||
return [sharedT, ...yArrays];
|
return [sharedT, ...applyVScaleNorm(p, yArrays)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════════════════
|
||||||
uPlot helpers
|
uPlot helpers
|
||||||
════════════════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════════════════ */
|
||||||
// Format Unix seconds → HH:MM:SS.mmm (used for live x-axis ticks)
|
// ─── Time formatting helpers ──────────────────────────────────────────────────
|
||||||
function fmtLiveTick(u, vals) {
|
|
||||||
return vals.map(v => {
|
// Returns the span (in seconds) of the currently visible x-axis across all plots.
|
||||||
if (v == null) return '';
|
// Falls back to windowSec when no uPlot instances exist yet.
|
||||||
|
function currentXSpan() {
|
||||||
|
for (const p of plots) {
|
||||||
|
if (p.uplot) {
|
||||||
|
const s = p.uplot.scales.x;
|
||||||
|
if (s && s.min != null && s.max != null) return Math.abs(s.max - s.min);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return windowSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format a signed duration (seconds) auto-selecting s / ms / µs / ns based on
|
||||||
|
// refSpan (e.g. the visible x-range or the value itself).
|
||||||
|
// sign = '+' prefix only when showSign is true (default false for ΔT display).
|
||||||
|
function fmtDuration(sec, refSpan, showSign) {
|
||||||
|
const abs = Math.abs(sec);
|
||||||
|
const sign = showSign ? (sec < 0 ? '−' : '+') : (sec < 0 ? '−' : '');
|
||||||
|
if (refSpan < 1e-6) { // nanosecond range
|
||||||
|
return sign + (abs * 1e9).toFixed(1) + ' ns';
|
||||||
|
} else if (refSpan < 1e-3) { // microsecond range
|
||||||
|
return sign + (abs * 1e6).toFixed(3) + ' µs';
|
||||||
|
} else if (refSpan < 1) { // millisecond range
|
||||||
|
return sign + (abs * 1e3).toFixed(3) + ' ms';
|
||||||
|
} else { // second range
|
||||||
|
return sign + abs.toFixed(6) + ' s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format a Unix-seconds timestamp → HH:MM:SS.fraction
|
||||||
|
// The number of sub-second digits adapts to the visible x-range span.
|
||||||
|
function fmtLiveTime(v, span) {
|
||||||
const d = new Date(v * 1000);
|
const d = new Date(v * 1000);
|
||||||
const hh = String(d.getHours()).padStart(2, '0');
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
const frac = v - Math.floor(v); // sub-second part, full float64 precision
|
||||||
|
if (span < 1e-6) {
|
||||||
|
// show 9 decimal places (ns precision)
|
||||||
|
const ns = Math.round(frac * 1e9);
|
||||||
|
return hh + ':' + mm + ':' + ss + '.' + String(ns).padStart(9, '0');
|
||||||
|
} else if (span < 1e-3) {
|
||||||
|
// show 6 decimal places (µs precision)
|
||||||
|
const us = Math.round(frac * 1e6);
|
||||||
|
return hh + ':' + mm + ':' + ss + '.' + String(us).padStart(6, '0');
|
||||||
|
} else if (span < 1) {
|
||||||
|
// show 3 decimal places (ms precision) — tick labels only show ms
|
||||||
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
||||||
return hh + ':' + mm + ':' + ss + '.' + ms;
|
return hh + ':' + mm + ':' + ss + '.' + ms;
|
||||||
});
|
} else {
|
||||||
|
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
||||||
|
return hh + ':' + mm + ':' + ss + '.' + ms;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Format relative seconds → ±Xms or ±Xs (used for trigger x-axis ticks)
|
|
||||||
|
// Format Unix seconds → HH:MM:SS.fraction (used for live x-axis ticks)
|
||||||
|
// Precision adapts to the visible x-range (via u.scales.x.{min,max}).
|
||||||
|
function fmtLiveTick(u, vals) {
|
||||||
|
const span = (u.scales.x && u.scales.x.max != null)
|
||||||
|
? Math.abs(u.scales.x.max - u.scales.x.min) : windowSec;
|
||||||
|
return vals.map(v => v == null ? '' : fmtLiveTime(v, span));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format relative seconds → auto-scaled unit (used for trigger x-axis ticks)
|
||||||
|
// Unit (s/ms/µs/ns) is determined by the visible x-range span.
|
||||||
function fmtTrigTick(u, vals) {
|
function fmtTrigTick(u, vals) {
|
||||||
return vals.map(v => {
|
const span = (u.scales.x && u.scales.x.max != null)
|
||||||
if (v == null) return '';
|
? Math.abs(u.scales.x.max - u.scales.x.min) : 1;
|
||||||
const abs = Math.abs(v), sign = v < 0 ? '-' : '+';
|
return vals.map(v => v == null ? '' : fmtDuration(v, span, true));
|
||||||
if (abs < 1) return sign + (abs * 1000).toFixed(1) + 'ms';
|
|
||||||
return sign + abs.toFixed(3) + 's';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw the trigger marker: dashed vertical line at t=0, plus a horizontal threshold
|
// Draw the trigger marker: dashed vertical line at t=0, plus a horizontal threshold
|
||||||
@@ -745,7 +917,16 @@ function drawTriggerMarker(u, p) {
|
|||||||
ctx.fillText('T', px + 3, bbox.top + 2);
|
ctx.fillText('T', px + 3, bbox.top + 2);
|
||||||
// Horizontal threshold line — only on plots that contain the trigger signal
|
// Horizontal threshold line — only on plots that contain the trigger signal
|
||||||
if (p && trig.signal && p.traces.includes(trig.signal)) {
|
if (p && trig.signal && p.traces.includes(trig.signal)) {
|
||||||
const y = u.valToPos(trig.threshold, 'y', true);
|
// Normalize the raw threshold to this plot's vscale for the trigger signal.
|
||||||
|
const tvs = p ? sigVScale[p.id + ':' + trig.signal] : null;
|
||||||
|
let threshNorm = trig.threshold;
|
||||||
|
if (tvs) {
|
||||||
|
const dv = tvs._resolvedDiv || tvs.divValue || 1;
|
||||||
|
const ofs = tvs._resolvedOffset != null ? tvs._resolvedOffset : (tvs.offset || 0);
|
||||||
|
const sp = tvs.screenPos || 0;
|
||||||
|
threshNorm = (trig.threshold - ofs) / dv + sp;
|
||||||
|
}
|
||||||
|
const y = u.valToPos(threshNorm, 'y', true);
|
||||||
if (y >= bbox.top && y <= bbox.top + bbox.height) {
|
if (y >= bbox.top && y <= bbox.top + bbox.height) {
|
||||||
const py = Math.round(y);
|
const py = Math.round(y);
|
||||||
ctx.strokeStyle = 'rgba(203,166,247,0.45)';
|
ctx.strokeStyle = 'rgba(203,166,247,0.45)';
|
||||||
@@ -778,6 +959,72 @@ function interpAtTime(u, si, t) {
|
|||||||
return v0 + (t - t0) / (t1 - t0) * (v1 - v0);
|
return v0 + (t - t0) / (t1 - t0) * (v1 - v0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redraw the active signal's line on top of all series with a wider stroke, so it
|
||||||
|
// visually appears in the foreground regardless of series draw order.
|
||||||
|
function drawActiveSeries(u, p) {
|
||||||
|
if (!u.bbox) return;
|
||||||
|
const activeKey = plotActiveSignal[p.id];
|
||||||
|
if (!activeKey) return;
|
||||||
|
const idx = p.traces.indexOf(activeKey);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const xs = u.data[0];
|
||||||
|
const ys = u.data[idx + 1]; // +1 because index 0 is time
|
||||||
|
if (!xs || !ys) return;
|
||||||
|
const style = getSigStyle(activeKey);
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const { ctx } = u;
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = style.color;
|
||||||
|
ctx.lineWidth = style.width * 2 * dpr;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
let started = false;
|
||||||
|
for (let i = 0; i < xs.length; i++) {
|
||||||
|
const yv = ys[i];
|
||||||
|
if (yv == null || !isFinite(yv)) { started = false; continue; }
|
||||||
|
const xPx = u.valToPos(xs[i], 'x', true);
|
||||||
|
const yPx = u.valToPos(yv, 'y', true);
|
||||||
|
if (!started) { ctx.moveTo(xPx, yPx); started = true; }
|
||||||
|
else ctx.lineTo(xPx, yPx);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw offset position markers (right-pointing triangles) on the left edge of the plot
|
||||||
|
// for each signal. Active signal marker is larger and outlined in white.
|
||||||
|
function drawOffsetMarkers(u, p) {
|
||||||
|
if (!u.bbox) return;
|
||||||
|
const { ctx, bbox } = u;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
p.traces.forEach(key => {
|
||||||
|
const vs = sigVScale[p.id + ':' + key];
|
||||||
|
const screenPos = vs ? (vs.screenPos || 0) : 0;
|
||||||
|
const yCtr = u.valToPos(screenPos, 'y', true);
|
||||||
|
const mH = (plotActiveSignal[p.id] === key ? 7 : 5) * dpr;
|
||||||
|
const mW = (plotActiveSignal[p.id] === key ? 10 : 7) * dpr;
|
||||||
|
if (yCtr < bbox.top - mH * 2 || yCtr > bbox.top + bbox.height + mH * 2) return;
|
||||||
|
const isActive = plotActiveSignal[p.id] === key;
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = getSigStyle(key).color;
|
||||||
|
// Right-pointing triangle: tip at left edge of plot area, body extends left into Y-axis area
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(bbox.left + dpr, yCtr);
|
||||||
|
ctx.lineTo(bbox.left - mW + dpr, yCtr - mH);
|
||||||
|
ctx.lineTo(bbox.left - mW + dpr, yCtr + mH);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
if (isActive) {
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.75)';
|
||||||
|
ctx.lineWidth = dpr;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Draw custom marker shapes (square, cross, diamond) for all series in a plot.
|
// Draw custom marker shapes (square, cross, diamond) for all series in a plot.
|
||||||
// Circle markers are handled natively by uPlot's points option.
|
// Circle markers are handled natively by uPlot's points option.
|
||||||
function drawSeriesMarkers(u, p) {
|
function drawSeriesMarkers(u, p) {
|
||||||
@@ -855,10 +1102,20 @@ function drawCursorLines(u, p) {
|
|||||||
if (p) {
|
if (p) {
|
||||||
const DSZ = 5; // diamond half-size in px
|
const DSZ = 5; // diamond half-size in px
|
||||||
p.traces.forEach((key, idx) => {
|
p.traces.forEach((key, idx) => {
|
||||||
const v = interpAtTime(u, idx + 1, val);
|
const vNorm = interpAtTime(u, idx + 1, val);
|
||||||
if (v === null) return;
|
if (vNorm === null) return;
|
||||||
const cy = u.valToPos(v, 'y', true);
|
const cy = u.valToPos(vNorm, 'y', true);
|
||||||
if (cy < bbox.top || cy > bbox.top + bbox.height) return;
|
if (cy < bbox.top || cy > bbox.top + bbox.height) return;
|
||||||
|
// Un-transform normalized value back to real units for display
|
||||||
|
// y_norm = (y_raw - offset) / divValue + screenPos → y_raw = (y_norm - screenPos) * divValue + offset
|
||||||
|
const vs = sigVScale[p.id + ':' + key];
|
||||||
|
let vReal = vNorm;
|
||||||
|
if (vs) {
|
||||||
|
const dv = vs._resolvedDiv || vs.divValue || 1;
|
||||||
|
const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
|
||||||
|
const sp = vs.screenPos || 0;
|
||||||
|
vReal = (vNorm - sp) * dv + ofs;
|
||||||
|
}
|
||||||
const tc = getSigStyle(key).color;
|
const tc = getSigStyle(key).color;
|
||||||
// Diamond marker at intersection
|
// Diamond marker at intersection
|
||||||
ctx.fillStyle = tc;
|
ctx.fillStyle = tc;
|
||||||
@@ -871,13 +1128,13 @@ function drawCursorLines(u, p) {
|
|||||||
ctx.lineTo(px - DSZ, cy);
|
ctx.lineTo(px - DSZ, cy);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
// Value text next to diamond
|
// Value text next to diamond (real units)
|
||||||
const str = Math.abs(v) >= 10000 ? v.toExponential(2) : parseFloat(v.toPrecision(4)).toString();
|
const str = Math.abs(vReal) >= 10000 ? vReal.toExponential(2) : parseFloat(vReal.toPrecision(4)).toString();
|
||||||
ctx.fillStyle = tc;
|
ctx.fillStyle = tc;
|
||||||
ctx.font = '11px monospace';
|
ctx.font = '11px monospace';
|
||||||
const currentAlign = ctx.textAlign;
|
const currentAlign = ctx.textAlign;
|
||||||
ctx.textAlign = "left"; // horizontal alignment
|
ctx.textAlign = 'left';
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText(str, px + DSZ + 4, cy);
|
ctx.fillText(str, px + DSZ + 4, cy);
|
||||||
ctx.textAlign = currentAlign;
|
ctx.textAlign = currentAlign;
|
||||||
});
|
});
|
||||||
@@ -955,20 +1212,41 @@ function makeUPlotOpts(p, inTrigMode) {
|
|||||||
}
|
}
|
||||||
return { time: false, auto: false, min: xMin, max: xMax };
|
return { time: false, auto: false, min: xMin, max: xMax };
|
||||||
})(),
|
})(),
|
||||||
y: { auto: true },
|
y: { auto: false, min: -4.5, max: 4.5 },
|
||||||
},
|
},
|
||||||
series: seriesArr,
|
series: seriesArr,
|
||||||
axes: [
|
axes: [
|
||||||
{
|
{
|
||||||
stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 },
|
stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 },
|
||||||
values: xVals, size: 36, space: 90
|
values: xVals, size: 36,
|
||||||
|
// Always produce exactly 10 horizontal divisions (11 evenly-spaced tick lines).
|
||||||
|
splits: (u, _ai, sMin, sMax) => {
|
||||||
|
const n = 10, span = sMax - sMin;
|
||||||
|
if (span === 0) return [sMin];
|
||||||
|
return Array.from({ length: n + 1 }, (_, i) => sMin + span * i / n);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, size: 60,
|
||||||
|
// Fixed 9 splits at integer divisions [-4..4] matching the normalized Y scale.
|
||||||
|
splits: () => [-4, -3, -2, -1, 0, 1, 2, 3, 4],
|
||||||
|
// Labels show real-unit values of the active signal for the plot.
|
||||||
|
// y_norm = (y_raw - offset) / divValue + screenPos → y_raw = (y_norm - screenPos) * divValue + offset
|
||||||
|
values: (u, vals) => {
|
||||||
|
const activeKey = plotActiveSignal[p.id];
|
||||||
|
const vs = activeKey ? sigVScale[p.id + ':' + activeKey] : null;
|
||||||
|
if (!vs) return vals.map(v => v == null ? '' : v.toFixed(1));
|
||||||
|
const divValue = vs._resolvedDiv || vs.divValue || 1;
|
||||||
|
const offset = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0);
|
||||||
|
const screenPos = vs.screenPos || 0;
|
||||||
|
return vals.map(v => v == null ? '' : _fmtVal((v - screenPos) * divValue + offset));
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ stroke: '#7f849c', grid: { stroke: '#313244', width: 1 }, ticks: { stroke: '#313244', width: 1 }, size: 50 },
|
|
||||||
],
|
],
|
||||||
legend: { show: false },
|
legend: { show: false },
|
||||||
padding: [4, 4, 0, 0],
|
padding: [4, 4, 0, 0],
|
||||||
hooks: {
|
hooks: {
|
||||||
draw: [u => { drawCursorLines(u, p); drawSeriesMarkers(u, p); drawTriggerMarker(u, p); }],
|
draw: [u => { drawActiveSeries(u, p); drawOffsetMarkers(u, p); drawCursorLines(u, p); drawSeriesMarkers(u, p); drawTriggerMarker(u, p); }],
|
||||||
// Two-hook zoom detection: setSelect flags that the NEXT setScale is user-initiated.
|
// Two-hook zoom detection: setSelect flags that the NEXT setScale is user-initiated.
|
||||||
// uPlot fires setSelect → then immediately setScale (when drag.setScale:true).
|
// uPlot fires setSelect → then immediately setScale (when drag.setScale:true).
|
||||||
// All programmatic setScale calls happen without a preceding setSelect, so the
|
// All programmatic setScale calls happen without a preceding setSelect, so the
|
||||||
@@ -1062,6 +1340,59 @@ function createUPlot(p) {
|
|||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
}, true); // capture:true so we fire before uPlot's own handlers
|
}, true); // capture:true so we fire before uPlot's own handlers
|
||||||
|
|
||||||
|
// ── Offset marker drag ─────────────────────────────────────────────────────
|
||||||
|
// Detect mousedown near the left edge of the plot area (marker triangle zone).
|
||||||
|
// Dragging moves the marker AND the signal together by changing screenPos.
|
||||||
|
p.div.addEventListener('mousedown', e => {
|
||||||
|
if (e.button !== 0 || !p.uplot || !p.uplot.bbox) return;
|
||||||
|
const canvas = p.uplot.ctx.canvas;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const plotLeftCss = rect.left + p.uplot.bbox.left / dpr;
|
||||||
|
const markerZone = 12; // CSS px hit area to left/right of plot edge
|
||||||
|
|
||||||
|
if (e.clientX > plotLeftCss + 3 || e.clientX < plotLeftCss - markerZone) return;
|
||||||
|
|
||||||
|
// Find which marker was hit (closest to screenPos canvas position per signal).
|
||||||
|
let hitKey = null, hitDist = Infinity;
|
||||||
|
p.traces.forEach(key => {
|
||||||
|
const vs = sigVScale[p.id + ':' + key];
|
||||||
|
const screenPos = vs ? (vs.screenPos || 0) : 0;
|
||||||
|
const yDev = p.uplot.valToPos(screenPos, 'y', true);
|
||||||
|
const yCss = rect.top + yDev / dpr;
|
||||||
|
const dist = Math.abs(e.clientY - yCss);
|
||||||
|
if (dist < 14 && dist < hitDist) { hitDist = dist; hitKey = key; }
|
||||||
|
});
|
||||||
|
if (!hitKey) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setActiveSig(p.id, hitKey);
|
||||||
|
|
||||||
|
const vs = getVScale(p.id, hitKey);
|
||||||
|
const startY = e.clientY;
|
||||||
|
const startScreenPos = vs.screenPos || 0;
|
||||||
|
const overRect = p.uplot.over.getBoundingClientRect();
|
||||||
|
|
||||||
|
const onMove = ev => {
|
||||||
|
const dy = ev.clientY - startY; // positive = down in canvas = lower y_norm
|
||||||
|
// Y scale spans 9 divisions over plot height; drag up → higher screenPos.
|
||||||
|
const dNorm = -dy / overRect.height * 9;
|
||||||
|
vs.screenPos = Math.max(-4, Math.min(4, startScreenPos + dNorm));
|
||||||
|
// Keep "Position" input in sync if the vscale menu is open for this signal.
|
||||||
|
if (_vsMenuKey === hitKey) {
|
||||||
|
document.getElementById('vscale-pos').value = parseFloat(vs.screenPos.toPrecision(4));
|
||||||
|
}
|
||||||
|
refreshPlotForKey(hitKey);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}, true);
|
||||||
|
|
||||||
// Pan support: Shift+left-drag pans the current view (synced across all plots).
|
// Pan support: Shift+left-drag pans the current view (synced across all plots).
|
||||||
// Works in both zoomed mode (xRange set) and rolling mode (freezes the window first).
|
// Works in both zoomed mode (xRange set) and rolling mode (freezes the window first).
|
||||||
let _panActive = false, _panAnchorX = 0, _panAnchorMin = 0, _panAnchorMax = 0;
|
let _panActive = false, _panAnchorX = 0, _panAnchorMin = 0, _panAnchorMax = 0;
|
||||||
@@ -1191,7 +1522,9 @@ function buildLiveData(p) {
|
|||||||
if (p.xRange) {
|
if (p.xRange) {
|
||||||
const zd = zoomData[p.id];
|
const zd = zoomData[p.id];
|
||||||
if (zd && Math.abs(zd.t0 - t0) < 1e-9 && Math.abs(zd.t1 - t1) < 1e-9) {
|
if (zd && Math.abs(zd.t0 - t0) < 1e-9 && Math.abs(zd.t1 - t1) < 1e-9) {
|
||||||
return buildDataFromFetched(p, zd.signals, Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2));
|
const fetched = buildDataFromFetched(p, zd.signals, Math.max(LTTB_MIN, ((p.uplot ? p.uplot.width : p.div.clientWidth) || 600) * 2));
|
||||||
|
// Only use server data if it actually has samples; otherwise fall through to local buffer.
|
||||||
|
if (fetched[0] && fetched[0].length > 0) return fetched;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1249,7 +1582,7 @@ function buildLiveData(p) {
|
|||||||
yArrays.push(resampleLinear(sl.t, sl.v, sharedT));
|
yArrays.push(resampleLinear(sl.t, sl.v, sharedT));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [sharedT, ...yArrays];
|
return [sharedT, ...applyVScaleNorm(p, yArrays)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTrigData(p) {
|
function buildTrigData(p) {
|
||||||
@@ -1301,7 +1634,7 @@ function buildTrigData(p) {
|
|||||||
yArrays.push(resampleLinear(relT, sl.v, sharedT));
|
yArrays.push(resampleLinear(relT, sl.v, sharedT));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [sharedT, ...yArrays];
|
return [sharedT, ...applyVScaleNorm(p, yArrays)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════════════════
|
||||||
@@ -1486,26 +1819,24 @@ function updateCursorReadout() {
|
|||||||
ro.classList.toggle('visible', active);
|
ro.classList.toggle('visible', active);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
|
||||||
// Format depends on mode: live = HH:MM:SS.mmm, trigger = ±Xms
|
// Use the current visible x-range to pick the display unit.
|
||||||
|
const span = currentXSpan();
|
||||||
|
|
||||||
|
// Format a cursor position: trigger mode → signed relative duration;
|
||||||
|
// live mode → absolute wall time with span-appropriate precision.
|
||||||
const fmt = v => {
|
const fmt = v => {
|
||||||
if (v === null) return '—';
|
if (v === null) return '—';
|
||||||
if (trig.enabled && trig.snapshot) {
|
if (trig.enabled && trig.snapshot) return fmtDuration(v, span, true);
|
||||||
const abs = Math.abs(v), sign = v < 0 ? '-' : '+';
|
return fmtLiveTime(v, span);
|
||||||
return abs < 1 ? sign + (abs * 1000).toFixed(3) + 'ms' : sign + abs.toFixed(6) + 's';
|
|
||||||
}
|
|
||||||
const d = new Date(v * 1000);
|
|
||||||
return String(d.getHours()).padStart(2, '0') + ':'
|
|
||||||
+ String(d.getMinutes()).padStart(2, '0') + ':'
|
|
||||||
+ String(d.getSeconds()).padStart(2, '0') + '.'
|
|
||||||
+ String(d.getMilliseconds()).padStart(3, '0');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('cur-ta').textContent = 'A: ' + fmt(cursors.tA);
|
document.getElementById('cur-ta').textContent = 'A: ' + fmt(cursors.tA);
|
||||||
document.getElementById('cur-tb').textContent = 'B: ' + fmt(cursors.tB);
|
document.getElementById('cur-tb').textContent = 'B: ' + fmt(cursors.tB);
|
||||||
|
|
||||||
if (cursors.tA !== null && cursors.tB !== null) {
|
if (cursors.tA !== null && cursors.tB !== null) {
|
||||||
const dt = cursors.tB - cursors.tA, abs = Math.abs(dt);
|
const dt = cursors.tB - cursors.tA;
|
||||||
const s = dt >= 0 ? '+' : '-';
|
// ΔT auto-scales by its own magnitude for precision regardless of x-range.
|
||||||
const str = abs < 1 ? s + (abs * 1000).toFixed(3) + 'ms' : s + abs.toFixed(6) + 's';
|
document.getElementById('cur-dt').textContent = 'ΔT: ' + fmtDuration(dt, Math.abs(dt), true);
|
||||||
document.getElementById('cur-dt').textContent = 'ΔT: ' + str;
|
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('cur-dt').textContent = 'ΔT: —';
|
document.getElementById('cur-dt').textContent = 'ΔT: —';
|
||||||
}
|
}
|
||||||
@@ -1549,7 +1880,24 @@ function openTrigBar(open) {
|
|||||||
}
|
}
|
||||||
document.getElementById('btn-trigger').addEventListener('click', () => openTrigBar(!trig.enabled));
|
document.getElementById('btn-trigger').addEventListener('click', () => openTrigBar(!trig.enabled));
|
||||||
document.getElementById('trig-signal').addEventListener('change', e => {
|
document.getElementById('trig-signal').addEventListener('change', e => {
|
||||||
trig.signal = e.target.value; trig.prevVal = null;
|
const val = e.target.value;
|
||||||
|
if (!val) { trig.signal = ''; trigDisarm(); return; }
|
||||||
|
// Array signal: ask for element index via picker dialog.
|
||||||
|
const meta = findSignalMeta(val);
|
||||||
|
const n = meta ? numElements(meta) : 1;
|
||||||
|
if (meta && !isTemporal(meta) && n > 1) {
|
||||||
|
showArrayIdxPicker(val, n, idx => {
|
||||||
|
trig.signal = val + '[' + idx + ']'; trig.prevVal = null;
|
||||||
|
if (trig.enabled) trigArm();
|
||||||
|
}, () => {
|
||||||
|
// Cancelled: revert selection to current trig.signal base or empty.
|
||||||
|
const sel = document.getElementById('trig-signal');
|
||||||
|
const base = trig.signal ? trig.signal.replace(/\[\d+\]$/, '') : '';
|
||||||
|
sel.value = base || '';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trig.signal = val; trig.prevVal = null;
|
||||||
if (trig.enabled && trig.signal) trigArm(); else if (!trig.signal) trigDisarm();
|
if (trig.enabled && trig.signal) trigArm(); else if (!trig.signal) trigDisarm();
|
||||||
});
|
});
|
||||||
document.getElementById('trig-edge').addEventListener('change', e => { trig.edge = e.target.value; if (trig.armed) trig.prevVal = null; });
|
document.getElementById('trig-edge').addEventListener('change', e => { trig.edge = e.target.value; if (trig.armed) trig.prevVal = null; });
|
||||||
@@ -1581,28 +1929,30 @@ document.getElementById('btn-trig-stop').addEventListener('click', () => {
|
|||||||
Trigger signal selector
|
Trigger signal selector
|
||||||
════════════════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════════════════ */
|
||||||
function buildTrigSignalSelect() {
|
function buildTrigSignalSelect() {
|
||||||
const sel = document.getElementById('trig-signal'), cur = sel.value;
|
const sel = document.getElementById('trig-signal');
|
||||||
|
// Preserve the currently active trig.signal (may include an array index like "[3]").
|
||||||
|
const curBase = trig.signal ? trig.signal.replace(/\[\d+\]$/, '') : '';
|
||||||
sel.innerHTML = '<option value="">— none —</option>';
|
sel.innerHTML = '<option value="">— none —</option>';
|
||||||
Object.values(sourcesMap).forEach(src => {
|
Object.values(sourcesMap).forEach(src => {
|
||||||
const prefix = src.id + ':';
|
const prefix = src.id + ':';
|
||||||
const srcLabel = src.label || src.addr || src.id;
|
const srcLabel = src.label || src.addr || src.id;
|
||||||
(src.signals || []).forEach(sig => {
|
(src.signals || []).forEach(sig => {
|
||||||
const n = numElements(sig);
|
const n = numElements(sig);
|
||||||
if (isTemporal(sig) || n === 1) {
|
|
||||||
const key = prefix + sig.name;
|
const key = prefix + sig.name;
|
||||||
const o = document.createElement('option');
|
const o = document.createElement('option');
|
||||||
o.value = key; o.textContent = srcLabel + ': ' + sig.name; sel.appendChild(o);
|
o.value = key;
|
||||||
|
if (isTemporal(sig) || n === 1) {
|
||||||
|
o.textContent = srcLabel + ': ' + sig.name;
|
||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < n; i++) {
|
// Array: single entry; user chooses index via dialog on selection.
|
||||||
const key = prefix + sig.name + '[' + i + ']';
|
o.textContent = srcLabel + ': ' + sig.name + ' [0…' + (n - 1) + ']';
|
||||||
const o = document.createElement('option');
|
|
||||||
o.value = key; o.textContent = srcLabel + ': ' + sig.name + '[' + i + ']'; sel.appendChild(o);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
sel.appendChild(o);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (cur && [...sel.options].some(o => o.value === cur)) sel.value = cur;
|
// Restore selection: match base key so array element "sig[3]" selects "sig" option.
|
||||||
trig.signal = sel.value;
|
if (curBase && [...sel.options].some(o => o.value === curBase)) sel.value = curBase;
|
||||||
|
// Do NOT overwrite trig.signal here — an array element selection must be preserved.
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════════════════
|
||||||
@@ -1919,6 +2269,7 @@ function addPlot() {
|
|||||||
<div class="plot-title" contenteditable="true" spellcheck="false">Plot ${id}</div>
|
<div class="plot-title" contenteditable="true" spellcheck="false">Plot ${id}</div>
|
||||||
<div class="sig-badges" id="badges-${id}"></div>
|
<div class="sig-badges" id="badges-${id}"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="plot-vscale-bar" id="vstb-${id}"></div>
|
||||||
<div class="plot-body" id="pbody-${id}">
|
<div class="plot-body" id="pbody-${id}">
|
||||||
<div class="drop-hint" id="hint-${id}">Drop signals here</div>
|
<div class="drop-hint" id="hint-${id}">Drop signals here</div>
|
||||||
<div class="trig-collect-overlay"><span class="trig-collect-text">⚡ Collecting…</span></div>
|
<div class="trig-collect-overlay"><span class="trig-collect-text">⚡ Collecting…</span></div>
|
||||||
@@ -1955,6 +2306,12 @@ function removeTraceFrom(plotId, signalKey) {
|
|||||||
const p = plots.find(p => p.id === plotId); if (!p) return;
|
const p = plots.find(p => p.id === plotId); if (!p) return;
|
||||||
p.traces = p.traces.filter(t => t !== signalKey);
|
p.traces = p.traces.filter(t => t !== signalKey);
|
||||||
removeBadge(plotId, signalKey);
|
removeBadge(plotId, signalKey);
|
||||||
|
// If the removed trace was active, close toolbar and pick a new active signal.
|
||||||
|
if (plotActiveSignal[plotId] === signalKey) {
|
||||||
|
if (_vsMenuPlotId === plotId) hideVScaleMenu();
|
||||||
|
const newActive = p.traces[0] || null;
|
||||||
|
if (newActive) setActiveSig(plotId, newActive); else delete plotActiveSignal[plotId];
|
||||||
|
}
|
||||||
createUPlot(p);
|
createUPlot(p);
|
||||||
p.needsRedraw = true;
|
p.needsRedraw = true;
|
||||||
if (!p.traces.length) document.querySelector('#hint-' + plotId).style.display = '';
|
if (!p.traces.length) document.querySelector('#hint-' + plotId).style.display = '';
|
||||||
@@ -1966,17 +2323,46 @@ function addBadge(plotId, key) {
|
|||||||
const color = getSigStyle(key).color;
|
const color = getSigStyle(key).color;
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
badge.className = 'sig-badge'; badge.dataset.key = key;
|
badge.className = 'sig-badge'; badge.dataset.key = key;
|
||||||
|
|
||||||
const dot = document.createElement('span'); dot.className = 'trace-dot'; dot.style.background = color;
|
const dot = document.createElement('span'); dot.className = 'trace-dot'; dot.style.background = color;
|
||||||
|
|
||||||
|
// Show signal name without the "sourceId:" prefix.
|
||||||
|
const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
|
||||||
|
const nameSpan = document.createElement('span'); nameSpan.textContent = displayName;
|
||||||
|
|
||||||
|
// Small vscale info text (V/div + offset when not in auto mode).
|
||||||
|
const infoSpan = document.createElement('span'); infoSpan.className = 'vscale-info';
|
||||||
|
|
||||||
const x = document.createElement('span'); x.className = 'sig-badge-x'; x.title = 'Remove'; x.textContent = '×';
|
const x = document.createElement('span'); x.className = 'sig-badge-x'; x.title = 'Remove'; x.textContent = '×';
|
||||||
x.addEventListener('click', () => removeTraceFrom(plotId, key));
|
x.addEventListener('click', e => { e.stopPropagation(); removeTraceFrom(plotId, key); });
|
||||||
|
|
||||||
|
// Left-click: select + show vscale toolbar; click same signal again to deselect.
|
||||||
|
badge.addEventListener('click', e => {
|
||||||
|
if (e.target === x) return;
|
||||||
|
const isActive = plotActiveSignal[plotId] === key;
|
||||||
|
const toolbarVisible = _vsMenuKey === key && _vsMenuPlotId === plotId;
|
||||||
|
if (isActive && toolbarVisible) {
|
||||||
|
setActiveSig(plotId, null);
|
||||||
|
hideVScaleMenu();
|
||||||
|
} else {
|
||||||
|
setActiveSig(plotId, key);
|
||||||
|
showVScaleMenu(key, plotId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Right-click: open signal style (color/width/…) menu.
|
||||||
badge.addEventListener('contextmenu', e => {
|
badge.addEventListener('contextmenu', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showSignalMenu(key, plotId, e.clientX, e.clientY);
|
showSignalMenu(key, plotId, e.clientX, e.clientY);
|
||||||
});
|
});
|
||||||
// Show signal name without the "sourceId:" prefix in the badge label.
|
|
||||||
const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
|
badge.appendChild(dot);
|
||||||
badge.appendChild(dot); badge.appendChild(document.createTextNode(displayName)); badge.appendChild(x);
|
badge.appendChild(nameSpan);
|
||||||
|
badge.appendChild(infoSpan);
|
||||||
|
badge.appendChild(x);
|
||||||
c.appendChild(badge);
|
c.appendChild(badge);
|
||||||
|
|
||||||
|
// Auto-activate the first signal added to this plot.
|
||||||
|
if (!plotActiveSignal[plotId]) setActiveSig(plotId, key);
|
||||||
}
|
}
|
||||||
function removeBadge(plotId, key) {
|
function removeBadge(plotId, key) {
|
||||||
const c = document.getElementById('badges-' + plotId); if (!c) return;
|
const c = document.getElementById('badges-' + plotId); if (!c) return;
|
||||||
@@ -2167,9 +2553,12 @@ function onSources(msg) {
|
|||||||
if (statsOpen) _refreshStatsSelector();
|
if (statsOpen) _refreshStatsSelector();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSourceWS(label, addr) {
|
function addSourceWS(label, addr, multicastGroup, dataPort) {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: 'addSource', label, addr }));
|
const msg = { type: 'addSource', label, addr };
|
||||||
|
if (multicastGroup) { msg.multicastGroup = multicastGroup; }
|
||||||
|
if (dataPort) { msg.dataPort = dataPort; }
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2204,12 +2593,23 @@ function makeAddSourceSection() {
|
|||||||
labelInput.className = 'add-src-input'; labelInput.type = 'text';
|
labelInput.className = 'add-src-input'; labelInput.type = 'text';
|
||||||
labelInput.placeholder = 'label (optional)';
|
labelInput.placeholder = 'label (optional)';
|
||||||
|
|
||||||
|
const mcastInput = document.createElement('input');
|
||||||
|
mcastInput.className = 'add-src-input'; mcastInput.type = 'text';
|
||||||
|
mcastInput.placeholder = 'multicast group (e.g. 239.0.0.1, optional)';
|
||||||
|
|
||||||
|
const dataPortInput = document.createElement('input');
|
||||||
|
dataPortInput.className = 'add-src-input'; dataPortInput.type = 'number';
|
||||||
|
dataPortInput.placeholder = 'data port (multicast only)';
|
||||||
|
dataPortInput.min = '1'; dataPortInput.max = '65535';
|
||||||
|
|
||||||
const addBtn = document.createElement('button');
|
const addBtn = document.createElement('button');
|
||||||
addBtn.className = 'add-src-btn'; addBtn.textContent = 'Connect';
|
addBtn.className = 'add-src-btn'; addBtn.textContent = 'Connect';
|
||||||
addBtn.addEventListener('click', () => {
|
addBtn.addEventListener('click', () => {
|
||||||
const addr = addrInput.value.trim(); if (!addr) return;
|
const addr = addrInput.value.trim(); if (!addr) return;
|
||||||
addSourceWS(labelInput.value.trim(), addr);
|
const mcastGroup = mcastInput.value.trim();
|
||||||
addrInput.value = ''; labelInput.value = '';
|
const dataPort = dataPortInput.value ? parseInt(dataPortInput.value, 10) : 0;
|
||||||
|
addSourceWS(labelInput.value.trim(), addr, mcastGroup, dataPort);
|
||||||
|
addrInput.value = ''; labelInput.value = ''; mcastInput.value = ''; dataPortInput.value = '';
|
||||||
});
|
});
|
||||||
addrInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });
|
addrInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });
|
||||||
|
|
||||||
@@ -2218,7 +2618,7 @@ function makeAddSourceSection() {
|
|||||||
saveBtn.textContent = 'Save list'; saveBtn.title = 'Save source list to file';
|
saveBtn.textContent = 'Save list'; saveBtn.title = 'Save source list to file';
|
||||||
saveBtn.addEventListener('click', saveSourcesWS);
|
saveBtn.addEventListener('click', saveSourcesWS);
|
||||||
|
|
||||||
body.append(addrInput, labelInput, addBtn, saveBtn);
|
body.append(addrInput, labelInput, mcastInput, dataPortInput, addBtn, saveBtn);
|
||||||
section.append(title, body);
|
section.append(title, body);
|
||||||
|
|
||||||
title.addEventListener('click', () => {
|
title.addEventListener('click', () => {
|
||||||
@@ -2236,6 +2636,148 @@ function escHtml(s) {
|
|||||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
VScale menu (left-click on badge)
|
||||||
|
════════════════════════════════════════════════════════════════ */
|
||||||
|
let _vsMenuKey = null, _vsMenuPlotId = null;
|
||||||
|
|
||||||
|
function showVScaleMenu(key, plotId) {
|
||||||
|
hideSignalMenu();
|
||||||
|
// If the toolbar was open for a different plot, hide that bar first.
|
||||||
|
if (_vsMenuPlotId != null && _vsMenuPlotId !== plotId) {
|
||||||
|
const oldBar = document.getElementById('vstb-' + _vsMenuPlotId);
|
||||||
|
if (oldBar) oldBar.style.display = 'none';
|
||||||
|
}
|
||||||
|
_vsMenuKey = key; _vsMenuPlotId = plotId;
|
||||||
|
|
||||||
|
const menu = document.getElementById('vscale-menu');
|
||||||
|
const vs = getVScale(_vsMenuPlotId, key);
|
||||||
|
const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
|
||||||
|
document.getElementById('vscale-menu-key').textContent = displayName;
|
||||||
|
|
||||||
|
document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(btn =>
|
||||||
|
btn.classList.toggle('active', btn.dataset.mode === vs.mode));
|
||||||
|
|
||||||
|
// Disable Range button when signal has no defined range.
|
||||||
|
const rangeBtn = document.querySelector('#vscale-mode-btns [data-mode="range"]');
|
||||||
|
if (rangeBtn) {
|
||||||
|
const meta = findSignalMeta(key);
|
||||||
|
const hasRange = meta && meta.rangeMin != null && meta.rangeMax != null;
|
||||||
|
rangeBtn.disabled = !hasRange;
|
||||||
|
rangeBtn.title = hasRange ? '' : 'No range defined for this signal';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isManual = vs.mode === 'manual';
|
||||||
|
document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
|
||||||
|
document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none';
|
||||||
|
|
||||||
|
// Pre-fill V/div with resolved or stored value; Position always shows current screenPos.
|
||||||
|
const dv = isManual ? vs.divValue : (vs._resolvedDiv || 1);
|
||||||
|
document.getElementById('vscale-vdiv').value = dv != null ? parseFloat(dv.toPrecision(4)) : 1;
|
||||||
|
document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4));
|
||||||
|
|
||||||
|
// Move the toolbar div into this plot's vscale bar.
|
||||||
|
const bar = document.getElementById('vstb-' + plotId);
|
||||||
|
if (bar) {
|
||||||
|
bar.appendChild(menu);
|
||||||
|
bar.style.display = 'block';
|
||||||
|
}
|
||||||
|
menu.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideVScaleMenu() {
|
||||||
|
const menu = document.getElementById('vscale-menu');
|
||||||
|
// Return the menu element to body so it is detached from any plot card.
|
||||||
|
menu.style.display = 'none';
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
// Hide the vscale bar of the previously active plot.
|
||||||
|
if (_vsMenuPlotId != null) {
|
||||||
|
const bar = document.getElementById('vstb-' + _vsMenuPlotId);
|
||||||
|
if (bar) bar.style.display = 'none';
|
||||||
|
}
|
||||||
|
_vsMenuKey = null; _vsMenuPlotId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Array index picker ─────────────────────────────────────────────────── */
|
||||||
|
let _aipOnConfirm = null, _aipOnCancel = null, _aipMaxIdx = 0;
|
||||||
|
|
||||||
|
function showArrayIdxPicker(sigKey, n, onConfirm, onCancel) {
|
||||||
|
_aipOnConfirm = onConfirm; _aipOnCancel = onCancel; _aipMaxIdx = n - 1;
|
||||||
|
const menu = document.getElementById('array-idx-picker');
|
||||||
|
const displayName = sigKey.includes(':') ? sigKey.split(':').slice(1).join(':') : sigKey;
|
||||||
|
document.getElementById('aip-sig').textContent = displayName;
|
||||||
|
document.getElementById('aip-range').textContent = '(0 – ' + (n - 1) + ')';
|
||||||
|
const idxInput = document.getElementById('aip-idx');
|
||||||
|
idxInput.max = n - 1; idxInput.value = 0;
|
||||||
|
menu.style.display = 'block';
|
||||||
|
// Centre the picker on screen.
|
||||||
|
const mw = menu.offsetWidth || 220, mh = menu.offsetHeight || 120;
|
||||||
|
menu.style.left = Math.round((window.innerWidth - mw) / 2) + 'px';
|
||||||
|
menu.style.top = Math.round((window.innerHeight - mh) / 3) + 'px';
|
||||||
|
idxInput.focus(); idxInput.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _aipConfirm() {
|
||||||
|
const idxInput = document.getElementById('aip-idx');
|
||||||
|
const idx = Math.max(0, Math.min(_aipMaxIdx, parseInt(idxInput.value, 10) || 0));
|
||||||
|
document.getElementById('array-idx-picker').style.display = 'none';
|
||||||
|
if (_aipOnConfirm) _aipOnConfirm(idx);
|
||||||
|
_aipOnConfirm = _aipOnCancel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _aipCancel() {
|
||||||
|
document.getElementById('array-idx-picker').style.display = 'none';
|
||||||
|
if (_aipOnCancel) _aipOnCancel();
|
||||||
|
_aipOnConfirm = _aipOnCancel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initArrayIdxPicker() {
|
||||||
|
document.getElementById('aip-ok').addEventListener('click', _aipConfirm);
|
||||||
|
document.getElementById('aip-cancel').addEventListener('click', _aipCancel);
|
||||||
|
document.getElementById('aip-idx').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') _aipConfirm();
|
||||||
|
if (e.key === 'Escape') _aipCancel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initVScaleMenu() {
|
||||||
|
document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (!_vsMenuKey) return;
|
||||||
|
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
|
||||||
|
const newMode = btn.dataset.mode;
|
||||||
|
if (newMode === 'manual' && vs.mode !== 'manual') {
|
||||||
|
// Seed V/div from currently resolved value; screenPos stays as-is.
|
||||||
|
vs.divValue = vs._resolvedDiv || 1;
|
||||||
|
vs.offset = vs._resolvedOffset || 0; // keep for DC subtraction (internal)
|
||||||
|
document.getElementById('vscale-vdiv').value = parseFloat(vs.divValue.toPrecision(4));
|
||||||
|
document.getElementById('vscale-pos').value = parseFloat((vs.screenPos || 0).toPrecision(4));
|
||||||
|
}
|
||||||
|
vs.mode = newMode;
|
||||||
|
document.querySelectorAll('#vscale-mode-btns .ctx-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
const isManual = vs.mode === 'manual';
|
||||||
|
document.getElementById('vscale-manual-row').style.display = isManual ? 'flex' : 'none';
|
||||||
|
document.getElementById('vscale-pos-row').style.display = isManual ? 'flex' : 'none';
|
||||||
|
refreshPlotForKey(_vsMenuKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('vscale-vdiv').addEventListener('input', e => {
|
||||||
|
if (!_vsMenuKey) return;
|
||||||
|
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
|
||||||
|
vs.divValue = Math.max(parseFloat(e.target.value) || 1, 1e-30);
|
||||||
|
refreshPlotForKey(_vsMenuKey);
|
||||||
|
});
|
||||||
|
// "Position (div)" moves the marker and signal together on screen.
|
||||||
|
document.getElementById('vscale-pos').addEventListener('input', e => {
|
||||||
|
if (!_vsMenuKey) return;
|
||||||
|
const vs = getVScale(_vsMenuPlotId, _vsMenuKey);
|
||||||
|
vs.screenPos = Math.max(-4, Math.min(4, parseFloat(e.target.value) || 0));
|
||||||
|
refreshPlotForKey(_vsMenuKey);
|
||||||
|
});
|
||||||
|
document.getElementById('btn-vscale-close').addEventListener('click', hideVScaleMenu);
|
||||||
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════════════════
|
||||||
Signal style context menu
|
Signal style context menu
|
||||||
════════════════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════════════════ */
|
||||||
@@ -2307,7 +2849,7 @@ function initSignalMenu() {
|
|||||||
document.addEventListener('click', e => {
|
document.addEventListener('click', e => {
|
||||||
if (!e.target.closest('#sig-ctx-menu') && !e.target.closest('.sig-badge')) hideSignalMenu();
|
if (!e.target.closest('#sig-ctx-menu') && !e.target.closest('.sig-badge')) hideSignalMenu();
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') hideSignalMenu(); });
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') { hideSignalMenu(); hideVScaleMenu(); } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════════════════
|
||||||
@@ -2449,6 +2991,8 @@ setInterval(() => { if (statsOpen) renderStats(); }, 1000);
|
|||||||
buildLayoutMenu();
|
buildLayoutMenu();
|
||||||
applyLayout('l1x1');
|
applyLayout('l1x1');
|
||||||
buildSidebar(); // show "Add Source" section even before WS connection
|
buildSidebar(); // show "Add Source" section even before WS connection
|
||||||
|
initArrayIdxPicker();
|
||||||
|
initVScaleMenu();
|
||||||
initSignalMenu();
|
initSignalMenu();
|
||||||
document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV);
|
document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV);
|
||||||
document.getElementById('btn-stats').addEventListener('click', toggleStats);
|
document.getElementById('btn-stats').addEventListener('click', toggleStats);
|
||||||
|
|||||||
@@ -158,6 +158,39 @@
|
|||||||
<span class="ctx-range-val" id="ctx-marker-size-val">4px</span>
|
<span class="ctx-range-val" id="ctx-marker-size-val">4px</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- ── Array index picker (trigger signal) ──────────────────────── -->
|
||||||
|
<div id="array-idx-picker" style="display:none">
|
||||||
|
<div class="ctx-menu-header">Element index: <span id="aip-sig" class="ctx-menu-key"></span></div>
|
||||||
|
<div class="ctx-row">
|
||||||
|
<label>Index</label>
|
||||||
|
<input type="number" id="aip-idx" class="ctx-num" min="0" step="1" value="0">
|
||||||
|
<span id="aip-range" style="font-size:10px;color:var(--overlay0)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="ctx-row" style="justify-content:flex-end;gap:6px">
|
||||||
|
<button class="ctx-btn" id="aip-cancel">Cancel</button>
|
||||||
|
<button class="ctx-btn active" id="aip-ok">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- ── VScale toolbar (moved into plot card when active) ─────────── -->
|
||||||
|
<div id="vscale-menu" style="display:none">
|
||||||
|
<div class="vstb-header">
|
||||||
|
<span class="vstb-label">V-Scale: <span id="vscale-menu-key" class="ctx-menu-key"></span></span>
|
||||||
|
<div class="ctx-btns" id="vscale-mode-btns">
|
||||||
|
<button class="ctx-btn active" data-mode="auto">Auto</button>
|
||||||
|
<button class="ctx-btn" data-mode="range">Range</button>
|
||||||
|
<button class="ctx-btn" data-mode="manual">Manual</button>
|
||||||
|
</div>
|
||||||
|
<div id="vscale-manual-row" style="display:none;align-items:center;gap:4px">
|
||||||
|
<label class="vstb-lbl">V/div</label>
|
||||||
|
<input type="number" id="vscale-vdiv" class="ctx-num" min="1e-30" step="any" value="1">
|
||||||
|
</div>
|
||||||
|
<div id="vscale-pos-row" style="display:none;align-items:center;gap:4px">
|
||||||
|
<label class="vstb-lbl">Pos</label>
|
||||||
|
<input type="number" id="vscale-pos" class="ctx-num" step="0.1" value="0">
|
||||||
|
</div>
|
||||||
|
<button id="btn-vscale-close" class="vstb-close" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -311,6 +311,48 @@ input[type=range].trig-range::-webkit-slider-thumb {
|
|||||||
}
|
}
|
||||||
.ctx-range { width:80px; }
|
.ctx-range { width:80px; }
|
||||||
.ctx-range-val { font-size:11px; color:var(--mauve); min-width:26px; }
|
.ctx-range-val { font-size:11px; color:var(--mauve); min-width:26px; }
|
||||||
|
.ctx-num {
|
||||||
|
width:90px; background:var(--surface0); border:1px solid var(--surface1); border-radius:4px;
|
||||||
|
color:var(--text); font-size:11px; padding:2px 6px;
|
||||||
|
}
|
||||||
|
.ctx-num:focus { outline:none; border-color:var(--accent); }
|
||||||
|
.ctx-btn:disabled { opacity:0.35; cursor:not-allowed; border-color:var(--surface1); }
|
||||||
|
|
||||||
|
/* ── Array index picker ─────────────────────────────────────────── */
|
||||||
|
#array-idx-picker {
|
||||||
|
position:fixed; z-index:300;
|
||||||
|
background:var(--mantle); border:1px solid var(--surface1); border-radius:var(--radius);
|
||||||
|
box-shadow:0 8px 24px rgba(0,0,0,0.6); padding:10px; min-width:200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── VScale toolbar (embedded in plot card) ──────────────────────── */
|
||||||
|
#vscale-menu {
|
||||||
|
flex-shrink:0; background:rgba(17,17,27,0.92); border-top:1px solid var(--surface1);
|
||||||
|
padding:3px 8px;
|
||||||
|
}
|
||||||
|
.vstb-header {
|
||||||
|
display:flex; align-items:center; gap:8px; flex-wrap:nowrap; overflow-x:auto;
|
||||||
|
scrollbar-width:none;
|
||||||
|
}
|
||||||
|
.vstb-header::-webkit-scrollbar { display:none; }
|
||||||
|
.vstb-label { font-size:11px; color:var(--subtext0); white-space:nowrap; flex-shrink:0; }
|
||||||
|
.vstb-lbl { font-size:10px; color:var(--overlay0); white-space:nowrap; }
|
||||||
|
.vstb-close {
|
||||||
|
margin-left:auto; flex-shrink:0;
|
||||||
|
background:transparent; border:none; color:var(--overlay0);
|
||||||
|
cursor:pointer; font-size:11px; padding:0 3px; line-height:1;
|
||||||
|
transition:color var(--transition);
|
||||||
|
}
|
||||||
|
.vstb-close:hover { color:var(--red); }
|
||||||
|
.plot-vscale-bar { display:none; }
|
||||||
|
|
||||||
|
/* ── Badge vscale info & active state ───────────────────────────── */
|
||||||
|
.vscale-info {
|
||||||
|
font-size:9px; color:var(--overlay0); font-family:monospace; margin-left:2px; white-space:nowrap;
|
||||||
|
}
|
||||||
|
.sig-badge { cursor:pointer; }
|
||||||
|
.sig-badge-active { outline:1px solid rgba(255,255,255,0.35); background:rgba(88,91,112,0.9); }
|
||||||
|
.sig-badge-active .vscale-info { color:var(--subtext0); }
|
||||||
|
|
||||||
/* ── Source groups ────────────────────────────────────────────── */
|
/* ── Source groups ────────────────────────────────────────────── */
|
||||||
.source-group { margin-bottom:2px; }
|
.source-group { margin-bottom:2px; }
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ func (u *UDPClient) runSession() error {
|
|||||||
reassembler := NewReassembler(2 * time.Second)
|
reassembler := NewReassembler(2 * time.Second)
|
||||||
buf := make([]byte, readBufSize)
|
buf := make([]byte, readBufSize)
|
||||||
var currentSigs []SignalInfo
|
var currentSigs []SignalInfo
|
||||||
|
var currentPublishMode uint8
|
||||||
|
|
||||||
for {
|
for {
|
||||||
conn.SetReadDeadline(time.Now().Add(silenceTimeout))
|
conn.SetReadDeadline(time.Now().Add(silenceTimeout))
|
||||||
@@ -142,13 +143,14 @@ func (u *UDPClient) runSession() error {
|
|||||||
|
|
||||||
switch hdr.Type {
|
switch hdr.Type {
|
||||||
case PktConfig:
|
case PktConfig:
|
||||||
sigs, err := ParseConfig(complete)
|
sigs, pm, err := ParseConfig(complete)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[%s] udp: parse config: %v", u.sourceID, err)
|
log.Printf("[%s] udp: parse config: %v", u.sourceID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
currentSigs = sigs
|
currentSigs = sigs
|
||||||
log.Printf("[%s] udp: received CONFIG (%d signals)", u.sourceID, len(sigs))
|
currentPublishMode = pm
|
||||||
|
log.Printf("[%s] udp: received CONFIG (%d signals, publishMode=%d)", u.sourceID, len(sigs), pm)
|
||||||
u.hub.SetSourceState(u.sourceID, "connected")
|
u.hub.SetSourceState(u.sourceID, "connected")
|
||||||
u.hub.UpdateConfigForSource(u.sourceID, sigs)
|
u.hub.UpdateConfigForSource(u.sourceID, sigs)
|
||||||
|
|
||||||
@@ -156,12 +158,14 @@ func (u *UDPClient) runSession() error {
|
|||||||
if len(currentSigs) == 0 {
|
if len(currentSigs) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sample, err := ParseData(complete, currentSigs, arrivalTime)
|
samples, err := ParseData(complete, currentSigs, currentPublishMode, arrivalTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[%s] udp: parse data: %v", u.sourceID, err)
|
log.Printf("[%s] udp: parse data: %v", u.sourceID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
u.hub.PushDataForSource(u.sourceID, sample)
|
for _, s := range samples {
|
||||||
|
u.hub.PushDataForSource(u.sourceID, s)
|
||||||
|
}
|
||||||
|
|
||||||
case PktACK:
|
case PktACK:
|
||||||
log.Printf("[%s] udp: received ACK (counter=%d)", u.sourceID, hdr.Counter)
|
log.Printf("[%s] udp: received ACK (counter=%d)", u.sourceID, hdr.Counter)
|
||||||
@@ -224,11 +228,11 @@ func (u *UDPClient) runMulticastSession() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
currentSigs, err := ParseConfig(cfgPayload)
|
currentSigs, currentPublishMode, err := ParseConfig(cfgPayload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Printf("[%s] tcp: received CONFIG (%d signals)", u.sourceID, len(currentSigs))
|
log.Printf("[%s] tcp: received CONFIG (%d signals, publishMode=%d)", u.sourceID, len(currentSigs), currentPublishMode)
|
||||||
u.hub.SetSourceState(u.sourceID, "connected")
|
u.hub.SetSourceState(u.sourceID, "connected")
|
||||||
u.hub.UpdateConfigForSource(u.sourceID, currentSigs)
|
u.hub.UpdateConfigForSource(u.sourceID, currentSigs)
|
||||||
|
|
||||||
@@ -318,12 +322,14 @@ func (u *UDPClient) runMulticastSession() error {
|
|||||||
if len(currentSigs) == 0 {
|
if len(currentSigs) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sample, parseErr := ParseData(complete, currentSigs, arrivalTime)
|
samples, parseErr := ParseData(complete, currentSigs, currentPublishMode, arrivalTime)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
log.Printf("[%s] multicast: parse data: %v", u.sourceID, parseErr)
|
log.Printf("[%s] multicast: parse data: %v", u.sourceID, parseErr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
u.hub.PushDataForSource(u.sourceID, sample)
|
for _, s := range samples {
|
||||||
|
u.hub.PushDataForSource(u.sourceID, s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -127,6 +127,18 @@ UDPStreamer::UDPStreamer() :
|
|||||||
syncTimestamp = 0u;
|
syncTimestamp = 0u;
|
||||||
clientConnected = false;
|
clientConnected = false;
|
||||||
packetCounter = 0u;
|
packetCounter = 0u;
|
||||||
|
maxBatchCount = 0u;
|
||||||
|
singleCycleWireBytes = 0u;
|
||||||
|
fixedWireBytes = 0u;
|
||||||
|
lastPublishTs = 0u;
|
||||||
|
accumBuffer = NULL_PTR(uint8 *);
|
||||||
|
accumTimestamps = NULL_PTR(uint64 *);
|
||||||
|
accumFill = 0u;
|
||||||
|
readyTimestamps = NULL_PTR(uint64 *);
|
||||||
|
scratchTimestamps = NULL_PTR(uint64 *);
|
||||||
|
readyFill = 0u;
|
||||||
|
decimateRatio = 1u;
|
||||||
|
decimateCounter = 0u;
|
||||||
|
|
||||||
if (!dataSem.Create()) {
|
if (!dataSem.Create()) {
|
||||||
REPORT_ERROR(ErrorManagement::FatalError, "Could not create EventSem.");
|
REPORT_ERROR(ErrorManagement::FatalError, "Could not create EventSem.");
|
||||||
@@ -157,6 +169,23 @@ UDPStreamer::~UDPStreamer() {
|
|||||||
(void) clientSocket.Close();
|
(void) clientSocket.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HeapI *heapAccum = GlobalObjectsDatabase::Instance()->GetStandardHeap();
|
||||||
|
if (accumBuffer != NULL_PTR(uint8 *)) {
|
||||||
|
heapAccum->Free(reinterpret_cast<void *&>(accumBuffer));
|
||||||
|
}
|
||||||
|
if (accumTimestamps != NULL_PTR(uint64 *)) {
|
||||||
|
delete[] accumTimestamps;
|
||||||
|
accumTimestamps = NULL_PTR(uint64 *);
|
||||||
|
}
|
||||||
|
if (readyTimestamps != NULL_PTR(uint64 *)) {
|
||||||
|
delete[] readyTimestamps;
|
||||||
|
readyTimestamps = NULL_PTR(uint64 *);
|
||||||
|
}
|
||||||
|
if (scratchTimestamps != NULL_PTR(uint64 *)) {
|
||||||
|
delete[] scratchTimestamps;
|
||||||
|
scratchTimestamps = NULL_PTR(uint64 *);
|
||||||
|
}
|
||||||
|
|
||||||
/* Multicast-mode cleanup */
|
/* Multicast-mode cleanup */
|
||||||
if (tcpClient != NULL_PTR(BasicTCPSocket *)) {
|
if (tcpClient != NULL_PTR(BasicTCPSocket *)) {
|
||||||
(void) tcpClient->Close();
|
(void) tcpClient->Close();
|
||||||
@@ -249,34 +278,59 @@ bool UDPStreamer::Initialise(StructuredDataI &data) {
|
|||||||
if ((publishStr.Size() == 0u) || (publishStr == "Strict")) {
|
if ((publishStr.Size() == 0u) || (publishStr == "Strict")) {
|
||||||
publishMode = UDPStreamerPublishStrict;
|
publishMode = UDPStreamerPublishStrict;
|
||||||
}
|
}
|
||||||
else if (publishStr == "Auto") {
|
else if (publishStr == "Accumulate") {
|
||||||
publishMode = UDPStreamerPublishAuto;
|
publishMode = UDPStreamerPublishAccumulate;
|
||||||
|
}
|
||||||
|
else if (publishStr == "Decimate") {
|
||||||
|
publishMode = UDPStreamerPublishDecimate;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
REPORT_ERROR(ErrorManagement::ParametersError,
|
REPORT_ERROR(ErrorManagement::ParametersError,
|
||||||
"Unknown PublishingMode '%s'. Allowed: Strict|Auto.",
|
"Unknown PublishingMode '%s'. Allowed: Strict|Accumulate|Decimate.",
|
||||||
publishStr.Buffer());
|
publishStr.Buffer());
|
||||||
ok = false;
|
ok = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ok && (publishMode == UDPStreamerPublishAuto)) {
|
if (ok && (publishMode == UDPStreamerPublishAccumulate)) {
|
||||||
|
/* MinRefreshRate controls the time-based flush: flush when
|
||||||
|
* (now - lastPublishTs) >= flushPeriodTicks, or when adding one more
|
||||||
|
* sample would overflow MaxPayloadSize. Whichever fires first. */
|
||||||
if (!data.Read("MinRefreshRate", minRefreshRate) || (minRefreshRate <= 0.0)) {
|
if (!data.Read("MinRefreshRate", minRefreshRate) || (minRefreshRate <= 0.0)) {
|
||||||
REPORT_ERROR(ErrorManagement::ParametersError,
|
REPORT_ERROR(ErrorManagement::ParametersError,
|
||||||
"MinRefreshRate > 0 is required when PublishingMode = Auto.");
|
"MinRefreshRate > 0 is required when PublishingMode = Accumulate.");
|
||||||
ok = false;
|
ok = false;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
/* Pre-compute the HRT tick count for one flush interval */
|
|
||||||
float64 hrtFreq = static_cast<float64>(HighResolutionTimer::Frequency());
|
float64 hrtFreq = static_cast<float64>(HighResolutionTimer::Frequency());
|
||||||
flushPeriodTicks = static_cast<uint64>(hrtFreq / minRefreshRate);
|
flushPeriodTicks = static_cast<uint64>(hrtFreq / minRefreshRate);
|
||||||
REPORT_ERROR(ErrorManagement::Information,
|
REPORT_ERROR(ErrorManagement::Information,
|
||||||
"Auto mode: MinRefreshRate=%.1f Hz, flushPeriodTicks=%llu.",
|
"Accumulate mode: MinRefreshRate=%.1f Hz, flushPeriodTicks=%llu.",
|
||||||
minRefreshRate,
|
minRefreshRate,
|
||||||
static_cast<unsigned long long>(flushPeriodTicks));
|
static_cast<unsigned long long>(flushPeriodTicks));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ok && (publishMode == UDPStreamerPublishDecimate)) {
|
||||||
|
/* Ratio: send 1 packet every Ratio Synchronise() calls. */
|
||||||
|
uint32 ratio = 0u;
|
||||||
|
if (!data.Read("Ratio", ratio) || (ratio == 0u)) {
|
||||||
|
REPORT_ERROR(ErrorManagement::ParametersError,
|
||||||
|
"Ratio >= 1 is required when PublishingMode = Decimate.");
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
decimateRatio = ratio;
|
||||||
|
if (decimateRatio == 1u) {
|
||||||
|
REPORT_ERROR(ErrorManagement::Warning,
|
||||||
|
"Decimate mode with Ratio=1 is equivalent to Strict mode.");
|
||||||
|
}
|
||||||
|
REPORT_ERROR(ErrorManagement::Information,
|
||||||
|
"Decimate mode: Ratio=%u (1 packet per %u RT cycle(s)).",
|
||||||
|
decimateRatio, decimateRatio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
StreamString mcastStr = "";
|
StreamString mcastStr = "";
|
||||||
(void) data.Read("MulticastGroup", mcastStr);
|
(void) data.Read("MulticastGroup", mcastStr);
|
||||||
@@ -537,6 +591,9 @@ bool UDPStreamer::SetConfiguredDatabase(StructuredDataI &data) {
|
|||||||
|
|
||||||
/* --- Pass 4: validate time-signal dimensions and compute wire sizes --- */
|
/* --- Pass 4: validate time-signal dimensions and compute wire sizes --- */
|
||||||
for (uint32 i = 0u; i < numSigs && ok; i++) {
|
for (uint32 i = 0u; i < numSigs && ok; i++) {
|
||||||
|
/* Initialise accumulated flag: false until pass 5 may flip it */
|
||||||
|
signalInfos[i].accumulated = false;
|
||||||
|
|
||||||
/* Compute wire byte size per element */
|
/* Compute wire byte size per element */
|
||||||
uint32 elemWireBytes = 0u;
|
uint32 elemWireBytes = 0u;
|
||||||
switch (signalInfos[i].quantType) {
|
switch (signalInfos[i].quantType) {
|
||||||
@@ -586,6 +643,99 @@ bool UDPStreamer::SetConfiguredDatabase(StructuredDataI &data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Pass 5: Accumulate mode setup ---
|
||||||
|
*
|
||||||
|
* Scalars (numElements == 1) are tagged accumulated = true and auto-assigned
|
||||||
|
* a FullArray time reference if a primary time signal exists. numCols / numRows
|
||||||
|
* are left at 1 — the actual per-packet element count is determined at runtime
|
||||||
|
* and transmitted as a 4-byte numSamples field in the DATA payload header.
|
||||||
|
*
|
||||||
|
* Compute singleCycleWireBytes (accumulated signals) and fixedWireBytes
|
||||||
|
* (non-accumulated arrays that travel once per packet from the most-recent slot).
|
||||||
|
* Override totalWireBytes to the maximum possible DATA payload for wireBuffer
|
||||||
|
* allocation: 12 + maxBatchCount × singleCycleWireBytes + fixedWireBytes.
|
||||||
|
*/
|
||||||
|
if (ok && (publishMode == UDPStreamerPublishAccumulate)) {
|
||||||
|
|
||||||
|
/* Find primary time signal: prefer Unit="us"/"ns", fall back to first integer scalar */
|
||||||
|
uint32 primaryTsIdx = UDPS_NO_TIME_SIGNAL;
|
||||||
|
for (uint32 i = 0u; i < numSigs && (primaryTsIdx == UDPS_NO_TIME_SIGNAL); i++) {
|
||||||
|
if (signalInfos[i].numElements == 1u) {
|
||||||
|
if ((signalInfos[i].unit == "us") || (signalInfos[i].unit == "ns")) {
|
||||||
|
primaryTsIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (primaryTsIdx == UDPS_NO_TIME_SIGNAL) {
|
||||||
|
for (uint32 i = 0u; i < numSigs && (primaryTsIdx == UDPS_NO_TIME_SIGNAL); i++) {
|
||||||
|
if (signalInfos[i].numElements == 1u) {
|
||||||
|
TypeDescriptor td = signalInfos[i].type;
|
||||||
|
if ((td == UnsignedInteger32Bit) || (td == UnsignedInteger64Bit) ||
|
||||||
|
(td == SignedInteger32Bit) || (td == SignedInteger64Bit)) {
|
||||||
|
primaryTsIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (primaryTsIdx != UDPS_NO_TIME_SIGNAL) {
|
||||||
|
REPORT_ERROR(ErrorManagement::Information,
|
||||||
|
"Accumulate: primary time signal '%s' (idx=%u).",
|
||||||
|
signalInfos[primaryTsIdx].name.Buffer(), primaryTsIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Partition signals into accumulated (scalars) and fixed (arrays).
|
||||||
|
* Auto-assign FullArray time mode for scalars that had PacketTime. */
|
||||||
|
singleCycleWireBytes = 0u;
|
||||||
|
fixedWireBytes = 0u;
|
||||||
|
for (uint32 i = 0u; i < numSigs; i++) {
|
||||||
|
if (signalInfos[i].numElements == 1u) {
|
||||||
|
signalInfos[i].accumulated = true;
|
||||||
|
singleCycleWireBytes += signalInfos[i].wireByteSize; /* = srcByteSize for 1 elem */
|
||||||
|
/* Auto-assign time reference for non-primary, non-time scalars */
|
||||||
|
if ((i != primaryTsIdx) && (primaryTsIdx != UDPS_NO_TIME_SIGNAL) &&
|
||||||
|
(signalInfos[i].timeMode == UDPStreamerTimePacket)) {
|
||||||
|
signalInfos[i].timeMode = UDPStreamerTimeFullArray;
|
||||||
|
signalInfos[i].timeSignalIdx = primaryTsIdx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
/* Non-scalar: not accumulated; wire size already computed in pass 4 */
|
||||||
|
fixedWireBytes += signalInfos[i].wireByteSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (singleCycleWireBytes == 0u) {
|
||||||
|
REPORT_ERROR(ErrorManagement::ParametersError,
|
||||||
|
"Accumulate mode: no scalar signals found to accumulate.");
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
/* DATA payload: [8 HRT][4 numSamples][numSamples × singleCycle][fixed] */
|
||||||
|
static const uint32 ACCUM_HEADER = UDPS_TIMESTAMP_BYTES + 4u; /* 12 bytes */
|
||||||
|
if ((ACCUM_HEADER + singleCycleWireBytes + fixedWireBytes) > maxPayloadSize) {
|
||||||
|
REPORT_ERROR(ErrorManagement::ParametersError,
|
||||||
|
"Accumulate mode: even a single sample (%u B) exceeds "
|
||||||
|
"MaxPayloadSize (%u B).",
|
||||||
|
ACCUM_HEADER + singleCycleWireBytes + fixedWireBytes,
|
||||||
|
maxPayloadSize);
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
static const uint32 ACCUM_HEADER = UDPS_TIMESTAMP_BYTES + 4u;
|
||||||
|
maxBatchCount = (maxPayloadSize - ACCUM_HEADER - fixedWireBytes) / singleCycleWireBytes;
|
||||||
|
/* Override totalWireBytes: size of the largest possible DATA payload */
|
||||||
|
totalWireBytes = ACCUM_HEADER + maxBatchCount * singleCycleWireBytes + fixedWireBytes;
|
||||||
|
REPORT_ERROR(ErrorManagement::Information,
|
||||||
|
"Accumulate mode: singleCycleWireBytes=%u, fixedWireBytes=%u, "
|
||||||
|
"maxBatchCount=%u, maxPayloadSize=%u, totalWireBytes=%u.",
|
||||||
|
singleCycleWireBytes, fixedWireBytes,
|
||||||
|
maxBatchCount, maxPayloadSize, totalWireBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,21 +752,25 @@ bool UDPStreamer::AllocateMemory() {
|
|||||||
|
|
||||||
HeapI *heap = GlobalObjectsDatabase::Instance()->GetStandardHeap();
|
HeapI *heap = GlobalObjectsDatabase::Instance()->GetStandardHeap();
|
||||||
|
|
||||||
|
/* In Accumulate mode, readyBuffer / scratchBuffer hold maxBatchCount consecutive
|
||||||
|
* snapshots instead of a single one. */
|
||||||
|
uint32 readyBufSize = (maxBatchCount > 0u) ? (maxBatchCount * totalSrcBytes) : totalSrcBytes;
|
||||||
|
|
||||||
/* readyBuffer: copy of signal memory shared with background thread */
|
/* readyBuffer: copy of signal memory shared with background thread */
|
||||||
readyBuffer = reinterpret_cast<uint8 *>(heap->Malloc(totalSrcBytes));
|
readyBuffer = reinterpret_cast<uint8 *>(heap->Malloc(readyBufSize));
|
||||||
if (readyBuffer == NULL_PTR(uint8 *)) {
|
if (readyBuffer == NULL_PTR(uint8 *)) {
|
||||||
REPORT_ERROR(ErrorManagement::FatalError, "Could not allocate readyBuffer.");
|
REPORT_ERROR(ErrorManagement::FatalError, "Could not allocate readyBuffer.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
(void) MemoryOperationsHelper::Set(readyBuffer, 0, totalSrcBytes);
|
(void) MemoryOperationsHelper::Set(readyBuffer, 0, readyBufSize);
|
||||||
|
|
||||||
/* scratchBuffer: background-thread-private copy for serialization */
|
/* scratchBuffer: background-thread-private copy for serialization */
|
||||||
scratchBuffer = reinterpret_cast<uint8 *>(heap->Malloc(totalSrcBytes));
|
scratchBuffer = reinterpret_cast<uint8 *>(heap->Malloc(readyBufSize));
|
||||||
if (scratchBuffer == NULL_PTR(uint8 *)) {
|
if (scratchBuffer == NULL_PTR(uint8 *)) {
|
||||||
REPORT_ERROR(ErrorManagement::FatalError, "Could not allocate scratchBuffer.");
|
REPORT_ERROR(ErrorManagement::FatalError, "Could not allocate scratchBuffer.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
(void) MemoryOperationsHelper::Set(scratchBuffer, 0, totalSrcBytes);
|
(void) MemoryOperationsHelper::Set(scratchBuffer, 0, readyBufSize);
|
||||||
|
|
||||||
/* wireBuffer: serialized/quantized payload for transmission */
|
/* wireBuffer: serialized/quantized payload for transmission */
|
||||||
wireBuffer = reinterpret_cast<uint8 *>(heap->Malloc(totalWireBytes));
|
wireBuffer = reinterpret_cast<uint8 *>(heap->Malloc(totalWireBytes));
|
||||||
@@ -635,6 +789,44 @@ bool UDPStreamer::AllocateMemory() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Accumulate-mode extra buffers --- */
|
||||||
|
if (maxBatchCount > 0u) {
|
||||||
|
/* Linear fill buffer: RT thread writes one snapshot per slot (0..maxBatchCount-1) */
|
||||||
|
uint32 accumBufSize = maxBatchCount * totalSrcBytes;
|
||||||
|
accumBuffer = reinterpret_cast<uint8 *>(heap->Malloc(accumBufSize));
|
||||||
|
if (accumBuffer == NULL_PTR(uint8 *)) {
|
||||||
|
REPORT_ERROR(ErrorManagement::FatalError, "Could not allocate accumBuffer.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
(void) MemoryOperationsHelper::Set(accumBuffer, 0, accumBufSize);
|
||||||
|
|
||||||
|
/* Per-slot HRT timestamp arrays */
|
||||||
|
accumTimestamps = new uint64[maxBatchCount];
|
||||||
|
readyTimestamps = new uint64[maxBatchCount];
|
||||||
|
scratchTimestamps = new uint64[maxBatchCount];
|
||||||
|
if ((accumTimestamps == NULL_PTR(uint64 *)) ||
|
||||||
|
(readyTimestamps == NULL_PTR(uint64 *)) ||
|
||||||
|
(scratchTimestamps == NULL_PTR(uint64 *))) {
|
||||||
|
REPORT_ERROR(ErrorManagement::FatalError,
|
||||||
|
"Could not allocate timestamp arrays.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint32 tsBytes = maxBatchCount * static_cast<uint32>(sizeof(uint64));
|
||||||
|
(void) MemoryOperationsHelper::Set(
|
||||||
|
reinterpret_cast<uint8 *>(accumTimestamps), 0, tsBytes);
|
||||||
|
(void) MemoryOperationsHelper::Set(
|
||||||
|
reinterpret_cast<uint8 *>(readyTimestamps), 0, tsBytes);
|
||||||
|
(void) MemoryOperationsHelper::Set(
|
||||||
|
reinterpret_cast<uint8 *>(scratchTimestamps), 0, tsBytes);
|
||||||
|
|
||||||
|
accumFill = 0u;
|
||||||
|
readyFill = 0u;
|
||||||
|
|
||||||
|
REPORT_ERROR(ErrorManagement::Information,
|
||||||
|
"Accumulate buffers: maxBatchCount=%u, accumBufSize=%u B, readyBufSize=%u B.",
|
||||||
|
maxBatchCount, accumBufSize, readyBufSize);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,6 +899,14 @@ bool UDPStreamer::PrepareNextState(const char8 *const currentStateName,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Initialise the flush timestamp so the first Accumulate flush is deferred
|
||||||
|
* until MinRefreshRate elapses (not immediately on the first Synchronise). */
|
||||||
|
if (ok && (publishMode == UDPStreamerPublishAccumulate)) {
|
||||||
|
lastPublishTs = HighResolutionTimer::Counter();
|
||||||
|
accumFill = 0u;
|
||||||
|
readyFill = 0u;
|
||||||
|
}
|
||||||
|
|
||||||
/* Start the background thread (idempotent; shared by both modes) */
|
/* Start the background thread (idempotent; shared by both modes) */
|
||||||
if (ok && (executor.GetStatus() == EmbeddedThreadI::OffState)) {
|
if (ok && (executor.GetStatus() == EmbeddedThreadI::OffState)) {
|
||||||
executor.SetName(GetName());
|
executor.SetName(GetName());
|
||||||
@@ -724,17 +924,76 @@ bool UDPStreamer::PrepareNextState(const char8 *const currentStateName,
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool UDPStreamer::Synchronise() {
|
bool UDPStreamer::Synchronise() {
|
||||||
/* Capture timestamp as early as possible */
|
/* Capture HRT timestamp as early as possible. */
|
||||||
uint64 ts = HighResolutionTimer::Counter();
|
uint64 ts = HighResolutionTimer::Counter();
|
||||||
|
|
||||||
/* RT-safe copy of signal memory → readyBuffer */
|
if (publishMode == UDPStreamerPublishAccumulate) {
|
||||||
|
/* --- Accumulate path ---
|
||||||
|
*
|
||||||
|
* Append this snapshot to the linear accumulation buffer, then check
|
||||||
|
* the two flush conditions (from the user spec):
|
||||||
|
*
|
||||||
|
* (a) size: accumulate_size + next_sample_size >= MaxPayloadSize
|
||||||
|
* (adding one more would overflow the UDP datagram)
|
||||||
|
* (b) time: expected_next_cycle_time - lastPublishTs >= flushPeriodTicks
|
||||||
|
* approximated as: ts - lastPublishTs >= flushPeriodTicks
|
||||||
|
*
|
||||||
|
* When either fires, the completed batch is promoted to readyBuffer /
|
||||||
|
* readyTimestamps and dataSem is posted. The background thread sends
|
||||||
|
* the ready batch without any additional timer check. */
|
||||||
|
bufMutex.FastLock(TTInfiniteWait);
|
||||||
|
uint8 *slot = accumBuffer + (accumFill * totalSrcBytes);
|
||||||
|
(void) MemoryOperationsHelper::Copy(slot, memory, totalSrcBytes);
|
||||||
|
accumTimestamps[accumFill] = ts;
|
||||||
|
accumFill++;
|
||||||
|
uint32 filled = accumFill;
|
||||||
|
bufMutex.FastUnLock();
|
||||||
|
|
||||||
|
/* Check flush conditions (volatile read of lastPublishTs is safe on x86). */
|
||||||
|
static const uint32 ACCUM_HEADER = UDPS_TIMESTAMP_BYTES + 4u; /* 12 bytes */
|
||||||
|
uint32 curPayload = ACCUM_HEADER + filled * singleCycleWireBytes + fixedWireBytes;
|
||||||
|
uint32 nextPayload = curPayload + singleCycleWireBytes;
|
||||||
|
bool sizeCondition = (nextPayload >= maxPayloadSize);
|
||||||
|
bool timeCondition = ((ts - lastPublishTs) >= flushPeriodTicks);
|
||||||
|
|
||||||
|
if (sizeCondition || timeCondition) {
|
||||||
|
bufMutex.FastLock(TTInfiniteWait);
|
||||||
|
(void) MemoryOperationsHelper::Copy(
|
||||||
|
readyBuffer, accumBuffer, filled * totalSrcBytes);
|
||||||
|
(void) MemoryOperationsHelper::Copy(
|
||||||
|
reinterpret_cast<uint8 *>(readyTimestamps),
|
||||||
|
reinterpret_cast<const uint8 *>(accumTimestamps),
|
||||||
|
filled * static_cast<uint32>(sizeof(uint64)));
|
||||||
|
readyFill = filled;
|
||||||
|
accumFill = 0u;
|
||||||
|
bufMutex.FastUnLock();
|
||||||
|
|
||||||
|
/* Reset the time-based deadline (volatile write). */
|
||||||
|
lastPublishTs = ts;
|
||||||
|
(void) dataSem.Post();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (publishMode == UDPStreamerPublishDecimate) {
|
||||||
|
/* --- Decimate path ---
|
||||||
|
* Post dataSem only every decimateRatio calls. */
|
||||||
|
decimateCounter++;
|
||||||
|
if (decimateCounter >= decimateRatio) {
|
||||||
|
decimateCounter = 0u;
|
||||||
bufMutex.FastLock(TTInfiniteWait);
|
bufMutex.FastLock(TTInfiniteWait);
|
||||||
(void) MemoryOperationsHelper::Copy(readyBuffer, memory, totalSrcBytes);
|
(void) MemoryOperationsHelper::Copy(readyBuffer, memory, totalSrcBytes);
|
||||||
syncTimestamp = ts;
|
syncTimestamp = ts;
|
||||||
bufMutex.FastUnLock();
|
bufMutex.FastUnLock();
|
||||||
|
|
||||||
/* Wake the background sender thread */
|
|
||||||
(void) dataSem.Post();
|
(void) dataSem.Post();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
/* --- Strict path: post every call --- */
|
||||||
|
bufMutex.FastLock(TTInfiniteWait);
|
||||||
|
(void) MemoryOperationsHelper::Copy(readyBuffer, memory, totalSrcBytes);
|
||||||
|
syncTimestamp = ts;
|
||||||
|
bufMutex.FastUnLock();
|
||||||
|
(void) dataSem.Post();
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -742,17 +1001,13 @@ bool UDPStreamer::Synchronise() {
|
|||||||
ErrorManagement::ErrorType UDPStreamer::Execute(ExecutionInfo &info) {
|
ErrorManagement::ErrorType UDPStreamer::Execute(ExecutionInfo &info) {
|
||||||
ErrorManagement::ErrorType ret = ErrorManagement::NoError;
|
ErrorManagement::ErrorType ret = ErrorManagement::NoError;
|
||||||
|
|
||||||
/* nextFlushTick: HRT counter target for the next Auto-mode flush.
|
|
||||||
* Declared static so it persists across Execute() calls (the framework
|
|
||||||
* calls Execute() in a tight loop for the MainStage). */
|
|
||||||
static uint64 nextFlushTick = 0u;
|
|
||||||
|
|
||||||
if (info.GetStage() == ExecutionInfo::StartupStage) {
|
if (info.GetStage() == ExecutionInfo::StartupStage) {
|
||||||
nextFlushTick = HighResolutionTimer::Counter();
|
const char8 *modeStr = "Strict";
|
||||||
|
if (publishMode == UDPStreamerPublishAccumulate) { modeStr = "Accumulate"; }
|
||||||
|
else if (publishMode == UDPStreamerPublishDecimate) { modeStr = "Decimate"; }
|
||||||
REPORT_ERROR(ErrorManagement::Information,
|
REPORT_ERROR(ErrorManagement::Information,
|
||||||
"UDPStreamer background thread started (port %u, mode %s).",
|
"UDPStreamer background thread started (port %u, mode %s).",
|
||||||
static_cast<uint32>(port),
|
static_cast<uint32>(port), modeStr);
|
||||||
(publishMode == UDPStreamerPublishAuto) ? "Auto" : "Strict");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (info.GetStage() == ExecutionInfo::MainStage) {
|
if (info.GetStage() == ExecutionInfo::MainStage) {
|
||||||
@@ -856,43 +1111,54 @@ ErrorManagement::ErrorType UDPStreamer::Execute(ExecutionInfo &info) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dataReady && clientConnected) {
|
if (dataReady && clientConnected) {
|
||||||
/* In Auto mode, only flush when the HRT flush interval has elapsed.
|
/* Synchronise() already gates posting dataSem to the correct rate
|
||||||
* This reduces send syscalls and network load by (rtHz / minRefreshRate)x
|
* (size/time for Accumulate, every-Nth for Decimate, every call for
|
||||||
* without any changes to the RT thread or the wire protocol.
|
* Strict). Execute() just sends whatever is in the ready buffers. */
|
||||||
* The most-recent signal values (captured in readyBuffer) are used. */
|
if (publishMode == UDPStreamerPublishAccumulate) {
|
||||||
bool shouldSend = true;
|
/* --- Accumulate batch send --- */
|
||||||
if (publishMode == UDPStreamerPublishAuto) {
|
uint32 fill = 0u;
|
||||||
uint64 now = HighResolutionTimer::Counter();
|
bufMutex.FastLock(TTInfiniteWait);
|
||||||
if (now < nextFlushTick) {
|
fill = readyFill;
|
||||||
shouldSend = false;
|
if (fill > 0u) {
|
||||||
|
(void) MemoryOperationsHelper::Copy(
|
||||||
|
scratchBuffer, readyBuffer, fill * totalSrcBytes);
|
||||||
|
(void) MemoryOperationsHelper::Copy(
|
||||||
|
reinterpret_cast<uint8 *>(scratchTimestamps),
|
||||||
|
reinterpret_cast<const uint8 *>(readyTimestamps),
|
||||||
|
fill * static_cast<uint32>(sizeof(uint64)));
|
||||||
|
}
|
||||||
|
bufMutex.FastUnLock();
|
||||||
|
|
||||||
|
if (fill > 0u) {
|
||||||
|
SerializeAccumulated(scratchBuffer, scratchTimestamps, fill);
|
||||||
|
uint32 sendBytes = UDPS_TIMESTAMP_BYTES + 4u +
|
||||||
|
fill * singleCycleWireBytes + fixedWireBytes;
|
||||||
|
packetCounter++;
|
||||||
|
if (!SendFragmented(UDPS_TYPE_DATA, packetCounter,
|
||||||
|
wireBuffer, sendBytes)) {
|
||||||
|
REPORT_ERROR(ErrorManagement::Warning,
|
||||||
|
"Failed to send Accumulate DATA packet (counter=%u).",
|
||||||
|
packetCounter);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
/* Advance deadline by one full period (keeps phase-locked). */
|
/* --- Single-snapshot send (Strict or Decimate) --- */
|
||||||
nextFlushTick += flushPeriodTicks;
|
|
||||||
/* Guard against clock drift: if we're already more than one
|
|
||||||
* period behind, reset to avoid a burst of back-to-back sends. */
|
|
||||||
if (nextFlushTick < now) {
|
|
||||||
nextFlushTick = now + flushPeriodTicks;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldSend) {
|
|
||||||
/* Copy readyBuffer → scratchBuffer under brief spinlock */
|
|
||||||
uint64 ts = 0u;
|
uint64 ts = 0u;
|
||||||
bufMutex.FastLock(TTInfiniteWait);
|
bufMutex.FastLock(TTInfiniteWait);
|
||||||
(void) MemoryOperationsHelper::Copy(scratchBuffer, readyBuffer, totalSrcBytes);
|
(void) MemoryOperationsHelper::Copy(
|
||||||
|
scratchBuffer, readyBuffer, totalSrcBytes);
|
||||||
ts = syncTimestamp;
|
ts = syncTimestamp;
|
||||||
bufMutex.FastUnLock();
|
bufMutex.FastUnLock();
|
||||||
|
|
||||||
/* Serialize signal data into wireBuffer */
|
|
||||||
QuantizeAndSerialize(scratchBuffer, ts);
|
QuantizeAndSerialize(scratchBuffer, ts);
|
||||||
|
|
||||||
/* Send (fragmented if needed) */
|
|
||||||
packetCounter++;
|
packetCounter++;
|
||||||
if (!SendFragmented(UDPS_TYPE_DATA, packetCounter, wireBuffer, totalWireBytes)) {
|
if (!SendFragmented(UDPS_TYPE_DATA, packetCounter,
|
||||||
|
wireBuffer, totalWireBytes)) {
|
||||||
REPORT_ERROR(ErrorManagement::Warning,
|
REPORT_ERROR(ErrorManagement::Warning,
|
||||||
"Failed to send DATA packet (counter=%u).", packetCounter);
|
"Failed to send DATA packet (counter=%u).",
|
||||||
|
packetCounter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -919,6 +1185,152 @@ ErrorManagement::ErrorType UDPStreamer::Execute(ExecutionInfo &info) {
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UDPStreamer::SerializeAccumulated(const uint8 *src,
|
||||||
|
const uint64 *timestamps,
|
||||||
|
uint32 numSamples) {
|
||||||
|
/* Wire layout (Accumulate mode DATA payload):
|
||||||
|
* [8 bytes] : HRT of slot 0 (oldest sample)
|
||||||
|
* [4 bytes] : numSamples (uint32, little-endian)
|
||||||
|
* for each signal:
|
||||||
|
* if accumulated : numSamples elements (one per slot)
|
||||||
|
* if non-accumulated (array): one copy from the most-recent slot
|
||||||
|
*/
|
||||||
|
uint8 *dst = wireBuffer;
|
||||||
|
|
||||||
|
/* 8-byte packet-level HRT timestamp = timestamp of the first (oldest) sample */
|
||||||
|
(void) MemoryOperationsHelper::Copy(dst, ×tamps[0u], UDPS_TIMESTAMP_BYTES);
|
||||||
|
dst += UDPS_TIMESTAMP_BYTES;
|
||||||
|
|
||||||
|
/* 4-byte sample count */
|
||||||
|
(void) MemoryOperationsHelper::Copy(dst, &numSamples, 4u);
|
||||||
|
dst += 4u;
|
||||||
|
|
||||||
|
for (uint32 i = 0u; i < numSigs; i++) {
|
||||||
|
if (signalInfos[i].accumulated) {
|
||||||
|
/* Scalar: pack one value from each slot in order */
|
||||||
|
uint32 elemSrcBytes = signalInfos[i].srcByteSize; /* bytes for one element */
|
||||||
|
|
||||||
|
for (uint32 k = 0u; k < numSamples; k++) {
|
||||||
|
const uint8 *slotSrc = src + (k * totalSrcBytes) + signalInfos[i].bufferOffset;
|
||||||
|
|
||||||
|
if (signalInfos[i].quantType == UDPStreamerQuantNone) {
|
||||||
|
(void) MemoryOperationsHelper::Copy(dst, slotSrc, elemSrcBytes);
|
||||||
|
dst += elemSrcBytes;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
float64 rawVal = 0.0;
|
||||||
|
if (signalInfos[i].type == Float32Bit) {
|
||||||
|
float32 f32 = 0.0f;
|
||||||
|
(void) MemoryOperationsHelper::Copy(&f32, slotSrc, 4u);
|
||||||
|
rawVal = static_cast<float64>(f32);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
(void) MemoryOperationsHelper::Copy(&rawVal, slotSrc, 8u);
|
||||||
|
}
|
||||||
|
float64 rMin = signalInfos[i].rangeMin;
|
||||||
|
float64 rRange = signalInfos[i].rangeMax - rMin;
|
||||||
|
if (rRange == 0.0) { rRange = 1.0; }
|
||||||
|
float64 norm = (rawVal - rMin) / rRange;
|
||||||
|
if (norm < 0.0) { norm = 0.0; }
|
||||||
|
if (norm > 1.0) { norm = 1.0; }
|
||||||
|
switch (signalInfos[i].quantType) {
|
||||||
|
case UDPStreamerQuantUint8: {
|
||||||
|
uint8 q = static_cast<uint8>(norm * 255.0);
|
||||||
|
*dst = q; dst += 1u;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case UDPStreamerQuantInt8: {
|
||||||
|
int8 q = static_cast<int8>((norm * 254.0) - 127.0);
|
||||||
|
(void) MemoryOperationsHelper::Copy(dst, &q, 1u);
|
||||||
|
dst += 1u;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case UDPStreamerQuantUint16: {
|
||||||
|
uint16 q = static_cast<uint16>(norm * 65535.0);
|
||||||
|
(void) MemoryOperationsHelper::Copy(dst, &q, 2u);
|
||||||
|
dst += 2u;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case UDPStreamerQuantInt16: {
|
||||||
|
int16 q = static_cast<int16>((norm * 65534.0) - 32767.0);
|
||||||
|
(void) MemoryOperationsHelper::Copy(dst, &q, 2u);
|
||||||
|
dst += 2u;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
(void) MemoryOperationsHelper::Copy(dst, slotSrc, elemSrcBytes);
|
||||||
|
dst += elemSrcBytes;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
/* Non-accumulated array: send from the most-recent slot */
|
||||||
|
const uint8 *slotSrc = src + ((numSamples - 1u) * totalSrcBytes) +
|
||||||
|
signalInfos[i].bufferOffset;
|
||||||
|
|
||||||
|
if (signalInfos[i].quantType == UDPStreamerQuantNone) {
|
||||||
|
(void) MemoryOperationsHelper::Copy(dst, slotSrc, signalInfos[i].srcByteSize);
|
||||||
|
dst += signalInfos[i].srcByteSize;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
float64 rMin = signalInfos[i].rangeMin;
|
||||||
|
float64 rRange = signalInfos[i].rangeMax - rMin;
|
||||||
|
if (rRange == 0.0) { rRange = 1.0; }
|
||||||
|
bool isSrcFloat32 = (signalInfos[i].type == Float32Bit);
|
||||||
|
uint32 nelems = signalInfos[i].numElements;
|
||||||
|
const uint8 *s = slotSrc;
|
||||||
|
|
||||||
|
for (uint32 e = 0u; e < nelems; e++) {
|
||||||
|
float64 rawVal = 0.0;
|
||||||
|
if (isSrcFloat32) {
|
||||||
|
float32 f32 = 0.0f;
|
||||||
|
(void) MemoryOperationsHelper::Copy(&f32, s, 4u);
|
||||||
|
rawVal = static_cast<float64>(f32);
|
||||||
|
s += 4u;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
(void) MemoryOperationsHelper::Copy(&rawVal, s, 8u);
|
||||||
|
s += 8u;
|
||||||
|
}
|
||||||
|
float64 norm = (rawVal - rMin) / rRange;
|
||||||
|
if (norm < 0.0) { norm = 0.0; }
|
||||||
|
if (norm > 1.0) { norm = 1.0; }
|
||||||
|
switch (signalInfos[i].quantType) {
|
||||||
|
case UDPStreamerQuantUint8: {
|
||||||
|
uint8 q = static_cast<uint8>(norm * 255.0);
|
||||||
|
*dst = q; dst += 1u;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case UDPStreamerQuantInt8: {
|
||||||
|
int8 q = static_cast<int8>((norm * 254.0) - 127.0);
|
||||||
|
(void) MemoryOperationsHelper::Copy(dst, &q, 1u);
|
||||||
|
dst += 1u;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case UDPStreamerQuantUint16: {
|
||||||
|
uint16 q = static_cast<uint16>(norm * 65535.0);
|
||||||
|
(void) MemoryOperationsHelper::Copy(dst, &q, 2u);
|
||||||
|
dst += 2u;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case UDPStreamerQuantInt16: {
|
||||||
|
int16 q = static_cast<int16>((norm * 65534.0) - 32767.0);
|
||||||
|
(void) MemoryOperationsHelper::Copy(dst, &q, 2u);
|
||||||
|
dst += 2u;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void UDPStreamer::HandleClientCommand(const uint8 *buf, uint32 size) {
|
void UDPStreamer::HandleClientCommand(const uint8 *buf, uint32 size) {
|
||||||
if (size < static_cast<uint32>(sizeof(UDPSPacketHeader))) {
|
if (size < static_cast<uint32>(sizeof(UDPSPacketHeader))) {
|
||||||
return;
|
return;
|
||||||
@@ -956,7 +1368,7 @@ void UDPStreamer::HandleClientCommand(const uint8 *buf, uint32 size) {
|
|||||||
static_cast<uint32>(src.GetPort()));
|
static_cast<uint32>(src.GetPort()));
|
||||||
|
|
||||||
/* Send CONFIG packet */
|
/* Send CONFIG packet */
|
||||||
uint32 configBufSize = 4u + (numSigs * UDPS_SIGNAL_DESC_SIZE) + 32u;
|
uint32 configBufSize = 4u + (numSigs * UDPS_SIGNAL_DESC_SIZE) + 32u + 1u;
|
||||||
HeapI *heap = GlobalObjectsDatabase::Instance()->GetStandardHeap();
|
HeapI *heap = GlobalObjectsDatabase::Instance()->GetStandardHeap();
|
||||||
uint8 *cfgBuf = reinterpret_cast<uint8 *>(heap->Malloc(configBufSize));
|
uint8 *cfgBuf = reinterpret_cast<uint8 *>(heap->Malloc(configBufSize));
|
||||||
if (cfgBuf != NULL_PTR(uint8 *)) {
|
if (cfgBuf != NULL_PTR(uint8 *)) {
|
||||||
@@ -1082,6 +1494,13 @@ bool UDPStreamer::BuildConfigPayload(uint8 *buf,
|
|||||||
payloadSize += UDPS_SIGNAL_DESC_SIZE;
|
payloadSize += UDPS_SIGNAL_DESC_SIZE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 1 byte: publishing mode (so clients can parse DATA payloads correctly) */
|
||||||
|
if ((payloadSize + 1u) > bufSize) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
buf[payloadSize] = static_cast<uint8>(publishMode);
|
||||||
|
payloadSize += 1u;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,13 +50,17 @@ namespace MARTe {
|
|||||||
/**
|
/**
|
||||||
* @brief Publishing mode for the background sender thread.
|
* @brief Publishing mode for the background sender thread.
|
||||||
*
|
*
|
||||||
* - Strict: send one UDP packet on every Synchronise() call (legacy behaviour).
|
* - Strict: send one packet every Synchronise() call (default).
|
||||||
* - Auto: buffer successive ticks and only flush when the HRT-based flush
|
* - Accumulate: buffer N successive snapshots and flush either when the next
|
||||||
* interval has elapsed. Controlled by MinRefreshRate (Hz).
|
* 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 {
|
typedef enum {
|
||||||
UDPStreamerPublishStrict = 0u, /**< Send on every RT cycle (default) */
|
UDPStreamerPublishStrict = 0u, /**< Send on every RT cycle (default) */
|
||||||
UDPStreamerPublishAuto = 1u /**< Rate-limited: flush at MinRefreshRate Hz */
|
UDPStreamerPublishAccumulate = 1u, /**< Accumulate until size/time limit; then flush */
|
||||||
|
UDPStreamerPublishDecimate = 2u /**< Send 1 packet every Ratio cycles */
|
||||||
} UDPStreamerPublishMode;
|
} UDPStreamerPublishMode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,6 +104,7 @@ struct UDPStreamerSignalInfo {
|
|||||||
uint32 srcByteSize; /**< Bytes in MARTe2 memory */
|
uint32 srcByteSize; /**< Bytes in MARTe2 memory */
|
||||||
uint32 wireByteSize; /**< Bytes on the wire (may differ when quantized) */
|
uint32 wireByteSize; /**< Bytes on the wire (may differ when quantized) */
|
||||||
uint32 bufferOffset; /**< Byte offset in the flat MemoryDataSourceI memory buffer */
|
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,29 +150,80 @@ static const uint8 UDPS_TYPECODE_FLOAT64 = 9u;
|
|||||||
static const uint8 UDPS_TYPECODE_UNKNOWN = 255u;
|
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
|
* @details This output DataSource accepts signals from GAMs and forwards them
|
||||||
* asynchronously to a connected UDP client. A dedicated background thread handles
|
* asynchronously over the network. A dedicated background thread handles all
|
||||||
* all network I/O so that the real-time thread is only blocked for a fast spinlock
|
* network I/O so that the real-time thread is only blocked by a fast spinlock
|
||||||
* and a memcpy during Synchronise().
|
* and a memcpy during Synchronise().
|
||||||
*
|
*
|
||||||
* Protocol:
|
* Two operating modes are selected by the presence or absence of MulticastGroup:
|
||||||
* - 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.
|
|
||||||
*
|
*
|
||||||
* 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>
|
* <pre>
|
||||||
* +UDPStreamer1 = {
|
* +Streamer = {
|
||||||
* Class = UDPStreamer
|
* Class = UDPStreamer
|
||||||
* Port = 44500 // Optional (default 44500)
|
* Port = 44500
|
||||||
* MaxPayloadSize = 1400 // Optional (default 1400); max payload bytes per UDP datagram
|
* MaxPayloadSize = 1400
|
||||||
* CPUMask = 0x2 // Optional, affinity for background thread
|
* PublishingMode = "Strict"
|
||||||
* StackSize = 1048576 // Optional, stack size for background thread
|
|
||||||
* PublishingMode = "Auto" // Optional: "Strict" (default) | "Auto"
|
|
||||||
* MinRefreshRate = 120 // Optional (Hz); only used when PublishingMode = "Auto"
|
|
||||||
* Signals = {
|
* Signals = {
|
||||||
* Time = {
|
* Time = {
|
||||||
* Type = uint64
|
* Type = uint64
|
||||||
@@ -176,28 +232,52 @@ static const uint8 UDPS_TYPECODE_UNKNOWN = 255u;
|
|||||||
* Type = float32
|
* Type = float32
|
||||||
* NumberOfDimensions = 1
|
* NumberOfDimensions = 1
|
||||||
* NumberOfElements = 100
|
* NumberOfElements = 100
|
||||||
* Unit = "Pa" // Optional
|
* Unit = "Pa"
|
||||||
* RangeMin = 0.0 // Optional (required for quantization)
|
* RangeMin = 0.0
|
||||||
* RangeMax = 1000000.0 // Optional (required for quantization)
|
* RangeMax = 1000000.0
|
||||||
* QuantizedType = uint16 // Optional: none|uint8|int8|uint16|int16
|
* QuantizedType = uint16
|
||||||
* TimeMode = LastSample // Optional: PacketTime|FullArray|FirstSample|LastSample
|
* TimeMode = LastSample
|
||||||
* TimeSignal = Time // Required when TimeMode != PacketTime
|
* TimeSignal = Time
|
||||||
* SamplingRate = 10000.0 // Required when TimeMode = FirstSample or LastSample
|
* SamplingRate = 10000.0
|
||||||
* }
|
* }
|
||||||
* Temperature = {
|
* Temperature = {
|
||||||
* Type = float64
|
* Type = float64
|
||||||
* Unit = "K"
|
* Unit = "degC"
|
||||||
* TimeMode = PacketTime
|
* TimeMode = PacketTime
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
* Notes:
|
* @par Example — multicast mode
|
||||||
* - QuantizedType is only valid for float32/float64 signals.
|
* <pre>
|
||||||
* - TimeMode = PacketTime uses the HRT counter captured in Synchronise().
|
* +Streamer = {
|
||||||
* - TimeMode = FullArray requires TimeSignal to have the same NumberOfElements.
|
* Class = UDPStreamer
|
||||||
* - TimeMode = FirstSample/LastSample requires a scalar TimeSignal and SamplingRate.
|
* 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 {
|
class UDPStreamer : public MemoryDataSourceI, public EmbeddedServiceMethodBinderI {
|
||||||
public:
|
public:
|
||||||
@@ -214,8 +294,11 @@ public:
|
|||||||
virtual ~UDPStreamer();
|
virtual ~UDPStreamer();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Parses top-level configuration parameters (Port, MaxPayloadSize, CPUMask, StackSize).
|
* @brief Parses top-level configuration parameters.
|
||||||
* @return true if all mandatory parameters are valid.
|
* @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);
|
virtual bool Initialise(StructuredDataI &data);
|
||||||
|
|
||||||
@@ -312,6 +395,14 @@ private:
|
|||||||
*/
|
*/
|
||||||
void HandleTCPConnect(const uint8 *buf, uint32 size);
|
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.
|
* @brief Maps a MARTe2 TypeDescriptor to the UDPS_TYPECODE_* constants.
|
||||||
*/
|
*/
|
||||||
@@ -325,6 +416,20 @@ private:
|
|||||||
UDPStreamerPublishMode publishMode; /**< Strict or Auto publishing mode */
|
UDPStreamerPublishMode publishMode; /**< Strict or Auto publishing mode */
|
||||||
float64 minRefreshRate; /**< Minimum flush rate (Hz) for Auto mode */
|
float64 minRefreshRate; /**< Minimum flush rate (Hz) for Auto mode */
|
||||||
uint64 flushPeriodTicks; /**< HRT ticks per flush interval (computed from minRefreshRate) */
|
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 */
|
/* Signal metadata */
|
||||||
uint32 numSigs; /**< Number of signals */
|
uint32 numSigs; /**< Number of signals */
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ bool UDPStreamerTest::TestInitialise_AutoMode_Valid() {
|
|||||||
UDPStreamer ds;
|
UDPStreamer ds;
|
||||||
ConfigurationDatabase cdb;
|
ConfigurationDatabase cdb;
|
||||||
cdb.Write("Port", 44502u);
|
cdb.Write("Port", 44502u);
|
||||||
cdb.Write("PublishingMode", "Auto");
|
cdb.Write("PublishingMode", "Accumulate");
|
||||||
cdb.Write("MinRefreshRate", 120.0);
|
cdb.Write("MinRefreshRate", 120.0);
|
||||||
cdb.CreateRelative("Signals");
|
cdb.CreateRelative("Signals");
|
||||||
cdb.MoveToRoot();
|
cdb.MoveToRoot();
|
||||||
@@ -248,7 +248,7 @@ bool UDPStreamerTest::TestInitialise_AutoMode_MissingRefreshRate() {
|
|||||||
UDPStreamer ds;
|
UDPStreamer ds;
|
||||||
ConfigurationDatabase cdb;
|
ConfigurationDatabase cdb;
|
||||||
cdb.Write("Port", 44503u);
|
cdb.Write("Port", 44503u);
|
||||||
cdb.Write("PublishingMode", "Auto");
|
cdb.Write("PublishingMode", "Accumulate");
|
||||||
/* MinRefreshRate intentionally omitted */
|
/* MinRefreshRate intentionally omitted */
|
||||||
cdb.CreateRelative("Signals");
|
cdb.CreateRelative("Signals");
|
||||||
cdb.MoveToRoot();
|
cdb.MoveToRoot();
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
*
|
*
|
||||||
* Three independent RT threads demonstrate all four UDPStreamer time modes:
|
* Three independent RT threads demonstrate all four UDPStreamer time modes:
|
||||||
*
|
*
|
||||||
* Thread1 – "Streamer" port 44500 (scalar signals, PacketTime)
|
* Thread1 – "Streamer" port 44500 TCP control / 44503 UDP multicast 239.0.0.1
|
||||||
|
* (scalar signals, auto-accumulated to arrays[10], multicast mode,
|
||||||
|
* 100 Hz flush = 10 samples per packet, 1 kHz source)
|
||||||
* Counter – uint32 cycle counter
|
* Counter – uint32 cycle counter
|
||||||
* Time – uint32 time in microseconds (LinuxTimer, 1 kHz)
|
* Time – uint32 time in microseconds (LinuxTimer, 1 kHz)
|
||||||
* Sine1 – float32, 1 Hz, quantised to uint16 on wire (PacketTime)
|
* Sine1 – float32, 1 Hz, quantised to uint16 on wire (PacketTime)
|
||||||
@@ -413,10 +415,18 @@ $TestApp = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Streamer: scalar signals, PacketTime (port 44500) ─────────────────
|
// ── Streamer: scalar signals, PacketTime (port 44500) ─────────────────
|
||||||
|
// Multicast mode: clients connect via TCP on port 44500 to receive the
|
||||||
|
// CONFIG packet, then join 239.0.0.1:44503 to receive DATA datagrams.
|
||||||
|
// Auto publishing ensures data is sent every RT cycle (1 kHz = 1 ms
|
||||||
|
// temporal resolution) but never faster, so the rate is bounded.
|
||||||
+Streamer = {
|
+Streamer = {
|
||||||
Class = UDPStreamer
|
Class = UDPStreamer
|
||||||
Port = 44500
|
Port = 44500
|
||||||
|
MulticastGroup = "239.0.0.1"
|
||||||
|
DataPort = 44503
|
||||||
MaxPayloadSize = 1400
|
MaxPayloadSize = 1400
|
||||||
|
PublishingMode = "Accumulate"
|
||||||
|
MinRefreshRate = 100
|
||||||
Signals = {
|
Signals = {
|
||||||
Counter = {
|
Counter = {
|
||||||
Type = uint32
|
Type = uint32
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ WEBUI_PID=""
|
|||||||
if [ "${START_WEBUI}" -eq 1 ]; then
|
if [ "${START_WEBUI}" -eq 1 ]; then
|
||||||
echo "==> Starting WebUI on http://localhost:8080..."
|
echo "==> Starting WebUI on http://localhost:8080..."
|
||||||
"${WEBUI_BIN}" \
|
"${WEBUI_BIN}" \
|
||||||
--source "Streamer@127.0.0.1:44500" \
|
--source "Streamer@127.0.0.1:44500/239.0.0.1:44503" \
|
||||||
--source "FastStreamer@127.0.0.1:44501" \
|
--source "FastStreamer@127.0.0.1:44501" \
|
||||||
--source "FullArrStreamer@127.0.0.1:44502" \
|
--source "FullArrStreamer@127.0.0.1:44502" \
|
||||||
--listen :8080 &
|
--listen :8080 &
|
||||||
|
|||||||
Reference in New Issue
Block a user