StreamHub: fix calibration trim, UTF-8 unit truncation, and sort order

Finding 1: Add TrimInPlace helper; apply trim→strip-[i]→empty-check order
in SetCalibrationEntry (matching Go Normalise()), so whitespace-padded
source/signal from WS clients normalise identically to config-file loads.

Finding 2: Trim unit before truncating to kMaxUnitLen, then walk back
continuation bytes (0x80-0xBF) to avoid leaving a partial UTF-8 rune,
matching Go's utf8.DecodeLastRuneInString loop.

Finding 3: BroadcastCalibration and HandleSaveSources now emit entries
sorted by source then signal (insertion sort over an index array, no STL),
producing byte-identical calibration frames and config files to the Go hub.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-08-16 23:55:46 +02:00
co-authored by Claude Sonnet 4.6
parent cdafb877a3
commit 957be793ae
+145 -24
View File
@@ -703,21 +703,87 @@ void StreamHub::BroadcastConfig(uint32 idx) {
/* Calibration store */ /* Calibration store */
/*---------------------------------------------------------------------------*/ /*---------------------------------------------------------------------------*/
/* In-place trim of leading and trailing ASCII whitespace in a char buffer.
* Returns a pointer to the first non-space character (which remains in buf). */
static void TrimInPlace(char *buf) {
if (buf == static_cast<char *>(0)) { return; }
/* Trim leading */
char *p = buf;
while ((*p == ' ') || (*p == '\t') || (*p == '\n') || (*p == '\r')) { p++; }
if (p != buf) {
uint32 i = 0u;
while (p[i] != '\0') { buf[i] = p[i]; i++; }
buf[i] = '\0';
}
/* Trim trailing */
uint32 len = static_cast<uint32>(strlen(buf));
while (len > 0u) {
char c = buf[len - 1u];
if ((c == ' ') || (c == '\t') || (c == '\n') || (c == '\r')) {
buf[--len] = '\0';
}
else { break; }
}
}
bool StreamHub::SetCalibrationEntry(const char *source, const char *signal, bool StreamHub::SetCalibrationEntry(const char *source, const char *signal,
float64 scale, float64 offset, float64 scale, float64 offset,
const char *unit) { const char *unit) {
if ((source == static_cast<const char *>(0)) || (source[0] == '\0')) { return false; } if (source == static_cast<const char *>(0)) { return false; }
if ((signal == static_cast<const char *>(0)) || (signal[0] == '\0')) { return false; } if (signal == static_cast<const char *>(0)) { return false; }
/* Go Normalise() order: trim → strip trailing "[digits]" → reject if empty. */
char src[128];
char sig[128];
strncpy(src, source, sizeof(src) - 1u);
src[sizeof(src) - 1u] = '\0';
strncpy(sig, signal, sizeof(sig) - 1u);
sig[sizeof(sig) - 1u] = '\0';
TrimInPlace(src);
TrimInPlace(sig);
/* Strip trailing "[i]" array-element suffix from signal, matching Go and JS. */
char *br = strchr(sig, '[');
if (br != static_cast<char *>(0)) { *br = '\0'; }
if (src[0] == '\0') { return false; }
if (sig[0] == '\0') { return false; }
/* A zero or non-finite scale makes the calibration non-invertible, which /* A zero or non-finite scale makes the calibration non-invertible, which
* the SPA's trigger-threshold conversion depends on. */ * the SPA's trigger-threshold conversion depends on. */
if (!JsonIsFinite(scale) || (scale == 0.0)) { return false; } if (!JsonIsFinite(scale) || (scale == 0.0)) { return false; }
if (!JsonIsFinite(offset)) { return false; } if (!JsonIsFinite(offset)) { return false; }
/* Trim unit, then truncate to kMaxUnitLen bytes, walking back any
* partial UTF-8 rune to keep the stored bytes valid UTF-8 (Go parity). */
char u[kMaxUnitLen + 1u]; char u[kMaxUnitLen + 1u];
u[0] = '\0'; u[0] = '\0';
if (unit != static_cast<const char *>(0)) { if (unit != static_cast<const char *>(0)) {
strncpy(u, unit, kMaxUnitLen); strncpy(u, unit, sizeof(u) - 1u);
u[kMaxUnitLen] = '\0'; u[sizeof(u) - 1u] = '\0';
TrimInPlace(u);
/* Truncate to kMaxUnitLen bytes */
if (strlen(u) > kMaxUnitLen) {
u[kMaxUnitLen] = '\0';
}
/* Walk back any trailing partial UTF-8 rune. A byte b is a
* continuation byte (10xxxxxx) iff (b & 0xC0) == 0x80. A truncation
* may leave a sequence starter with fewer continuation bytes than it
* expects; drop bytes from the end while the last byte is a lone
* continuation byte that decodes as an invalid (RuneError, 1) pair.
* Concrete: if the last byte is 0x80-0xBF (continuation), remove it,
* then repeat — this matches Go's utf8.DecodeLastRuneInString loop. */
uint32 ulen = static_cast<uint32>(strlen(u));
while (ulen > 0u) {
const unsigned char last = static_cast<unsigned char>(u[ulen - 1u]);
/* Is it a UTF-8 continuation byte (10xxxxxx)? */
if ((last & 0xC0u) == 0x80u) {
u[--ulen] = '\0';
} else {
break;
}
}
} }
/* An identity entry carries no information: drop it rather than store and /* An identity entry carries no information: drop it rather than store and
@@ -727,8 +793,8 @@ bool StreamHub::SetCalibrationEntry(const char *source, const char *signal,
(void) calibrationMutex_.FastLock(); (void) calibrationMutex_.FastLock();
uint32 found = kMaxCalibration; uint32 found = kMaxCalibration;
for (uint32 i = 0u; i < numCalibration_; i++) { for (uint32 i = 0u; i < numCalibration_; i++) {
if ((strcmp(calibration_[i].source, source) == 0) && if ((strcmp(calibration_[i].source, src) == 0) &&
(strcmp(calibration_[i].signal, signal) == 0)) { (strcmp(calibration_[i].signal, sig) == 0)) {
found = i; found = i;
break; break;
} }
@@ -750,9 +816,9 @@ bool StreamHub::SetCalibrationEntry(const char *source, const char *signal,
found = numCalibration_; found = numCalibration_;
numCalibration_++; numCalibration_++;
} }
strncpy(calibration_[found].source, source, sizeof(calibration_[found].source) - 1u); strncpy(calibration_[found].source, src, sizeof(calibration_[found].source) - 1u);
calibration_[found].source[sizeof(calibration_[found].source) - 1u] = '\0'; calibration_[found].source[sizeof(calibration_[found].source) - 1u] = '\0';
strncpy(calibration_[found].signal, signal, sizeof(calibration_[found].signal) - 1u); strncpy(calibration_[found].signal, sig, sizeof(calibration_[found].signal) - 1u);
calibration_[found].signal[sizeof(calibration_[found].signal) - 1u] = '\0'; calibration_[found].signal[sizeof(calibration_[found].signal) - 1u] = '\0';
strncpy(calibration_[found].unit, u, sizeof(calibration_[found].unit) - 1u); strncpy(calibration_[found].unit, u, sizeof(calibration_[found].unit) - 1u);
calibration_[found].unit[sizeof(calibration_[found].unit) - 1u] = '\0'; calibration_[found].unit[sizeof(calibration_[found].unit) - 1u] = '\0';
@@ -776,19 +842,48 @@ void StreamHub::BroadcastCalibration() {
uint32 off = 0u; uint32 off = 0u;
JsonAppendf(buf, off, cap, "{\"type\":\"calibration\",\"cal\":["); JsonAppendf(buf, off, cap, "{\"type\":\"calibration\",\"cal\":[");
/* Snapshot the calibration table, then release the mutex before building
* JSON (Go parity: emit sorted by source then signal). */
(void) calibrationMutex_.FastLock(); (void) calibrationMutex_.FastLock();
for (uint32 i = 0u; i < numCalibration_; i++) { const uint32 n = numCalibration_;
/* Build a sorted index array (insertion sort — no STL). */
uint32 *idx = new uint32[n];
for (uint32 i = 0u; i < n; i++) { idx[i] = i; }
for (uint32 i = 1u; i < n; i++) {
const uint32 key = idx[i];
MARTe::int32 j = static_cast<MARTe::int32>(i) - 1;
while (j >= 0) {
const uint32 cur = idx[static_cast<uint32>(j)];
const int cmpSrc = strcmp(calibration_[cur].source,
calibration_[key].source);
const bool before = (cmpSrc > 0) ||
((cmpSrc == 0) &&
(strcmp(calibration_[cur].signal,
calibration_[key].signal) > 0));
if (!before) { break; }
idx[static_cast<uint32>(j) + 1u] = cur;
j--;
}
idx[static_cast<uint32>(j) + 1u] = key;
}
/* Snapshot entries in sorted order so we can release lock before BroadcastText. */
CalibrationEntry *snap = new CalibrationEntry[n];
for (uint32 i = 0u; i < n; i++) { snap[i] = calibration_[idx[i]]; }
calibrationMutex_.FastUnLock();
delete[] idx;
for (uint32 i = 0u; i < n; i++) {
JsonAppendf(buf, off, cap, JsonAppendf(buf, off, cap,
"%s{\"source\":\"%s\",\"signal\":\"%s\"," "%s{\"source\":\"%s\",\"signal\":\"%s\","
"\"scale\":%.17g,\"offset\":%.17g,\"unit\":\"%s\"}", "\"scale\":%.17g,\"offset\":%.17g,\"unit\":\"%s\"}",
(i > 0u) ? "," : "", (i > 0u) ? "," : "",
calibration_[i].source, snap[i].source,
calibration_[i].signal, snap[i].signal,
calibration_[i].scale, snap[i].scale,
calibration_[i].offset, snap[i].offset,
calibration_[i].unit); snap[i].unit);
} }
calibrationMutex_.FastUnLock(); delete[] snap;
JsonAppendf(buf, off, cap, "]}"); JsonAppendf(buf, off, cap, "]}");
wsServer_.BroadcastText(buf, off); wsServer_.BroadcastText(buf, off);
@@ -1121,26 +1216,52 @@ void StreamHub::HandleSaveSources() {
nSaved++; nSaved++;
} }
/* Calibration entries are further elements of the SAME flat array. */ /* Calibration entries are further elements of the SAME flat array.
* Emit sorted by source then signal to match Go's encodeConfigFile output. */
uint32 nCal = 0u; uint32 nCal = 0u;
(void) calibrationMutex_.FastLock(); (void) calibrationMutex_.FastLock();
for (uint32 i = 0u; i < numCalibration_; i++) { const uint32 nCalTotal = numCalibration_;
uint32 *cidx = new uint32[nCalTotal];
for (uint32 i = 0u; i < nCalTotal; i++) { cidx[i] = i; }
for (uint32 i = 1u; i < nCalTotal; i++) {
const uint32 key = cidx[i];
MARTe::int32 j = static_cast<MARTe::int32>(i) - 1;
while (j >= 0) {
const uint32 cur = cidx[static_cast<uint32>(j)];
const int cmpSrc = strcmp(calibration_[cur].source,
calibration_[key].source);
const bool before = (cmpSrc > 0) ||
((cmpSrc == 0) &&
(strcmp(calibration_[cur].signal,
calibration_[key].signal) > 0));
if (!before) { break; }
cidx[static_cast<uint32>(j) + 1u] = cur;
j--;
}
cidx[static_cast<uint32>(j) + 1u] = key;
}
CalibrationEntry *csnap = new CalibrationEntry[nCalTotal];
for (uint32 i = 0u; i < nCalTotal; i++) { csnap[i] = calibration_[cidx[i]]; }
calibrationMutex_.FastUnLock();
delete[] cidx;
for (uint32 i = 0u; i < nCalTotal; i++) {
(void) fprintf(f, (void) fprintf(f,
"%s {\n \"source\": \"%s\",\n \"signal\": \"%s\",\n" "%s {\n \"source\": \"%s\",\n \"signal\": \"%s\",\n"
" \"scale\": %.17g,\n \"offset\": %.17g", " \"scale\": %.17g,\n \"offset\": %.17g",
((nSaved + nCal) > 0u) ? ",\n" : "", ((nSaved + nCal) > 0u) ? ",\n" : "",
calibration_[i].source, csnap[i].source,
calibration_[i].signal, csnap[i].signal,
calibration_[i].scale, csnap[i].scale,
calibration_[i].offset); csnap[i].offset);
if (calibration_[i].unit[0] != '\0') { if (csnap[i].unit[0] != '\0') {
(void) fprintf(f, ",\n \"unit\": \"%s\"", (void) fprintf(f, ",\n \"unit\": \"%s\"",
calibration_[i].unit); csnap[i].unit);
} }
(void) fprintf(f, "\n }"); (void) fprintf(f, "\n }");
nCal++; nCal++;
} }
calibrationMutex_.FastUnLock(); delete[] csnap;
(void) fprintf(f, "\n]\n"); (void) fprintf(f, "\n]\n");
(void) fclose(f); (void) fclose(f);