From 957be793aeef73421a628f9b128a784037a56369 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 16 Aug 2026 23:55:46 +0200 Subject: [PATCH] StreamHub: fix calibration trim, UTF-8 unit truncation, and sort order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Source/Applications/StreamHub/StreamHub.cpp | 169 +++++++++++++++++--- 1 file changed, 145 insertions(+), 24 deletions(-) diff --git a/Source/Applications/StreamHub/StreamHub.cpp b/Source/Applications/StreamHub/StreamHub.cpp index ce45342..a8fea0d 100644 --- a/Source/Applications/StreamHub/StreamHub.cpp +++ b/Source/Applications/StreamHub/StreamHub.cpp @@ -703,21 +703,87 @@ void StreamHub::BroadcastConfig(uint32 idx) { /* 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(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(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, float64 scale, float64 offset, const char *unit) { - if ((source == static_cast(0)) || (source[0] == '\0')) { return false; } - if ((signal == static_cast(0)) || (signal[0] == '\0')) { return false; } + if (source == static_cast(0)) { return false; } + if (signal == static_cast(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(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 * the SPA's trigger-threshold conversion depends on. */ if (!JsonIsFinite(scale) || (scale == 0.0)) { 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]; u[0] = '\0'; if (unit != static_cast(0)) { - strncpy(u, unit, kMaxUnitLen); - u[kMaxUnitLen] = '\0'; + strncpy(u, unit, sizeof(u) - 1u); + 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(strlen(u)); + while (ulen > 0u) { + const unsigned char last = static_cast(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 @@ -727,8 +793,8 @@ bool StreamHub::SetCalibrationEntry(const char *source, const char *signal, (void) calibrationMutex_.FastLock(); uint32 found = kMaxCalibration; for (uint32 i = 0u; i < numCalibration_; i++) { - if ((strcmp(calibration_[i].source, source) == 0) && - (strcmp(calibration_[i].signal, signal) == 0)) { + if ((strcmp(calibration_[i].source, src) == 0) && + (strcmp(calibration_[i].signal, sig) == 0)) { found = i; break; } @@ -750,9 +816,9 @@ bool StreamHub::SetCalibrationEntry(const char *source, const char *signal, found = 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'; - 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'; strncpy(calibration_[found].unit, u, sizeof(calibration_[found].unit) - 1u); calibration_[found].unit[sizeof(calibration_[found].unit) - 1u] = '\0'; @@ -776,19 +842,48 @@ void StreamHub::BroadcastCalibration() { uint32 off = 0u; 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(); - 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(i) - 1; + while (j >= 0) { + const uint32 cur = idx[static_cast(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(j) + 1u] = cur; + j--; + } + idx[static_cast(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, "%s{\"source\":\"%s\",\"signal\":\"%s\"," "\"scale\":%.17g,\"offset\":%.17g,\"unit\":\"%s\"}", (i > 0u) ? "," : "", - calibration_[i].source, - calibration_[i].signal, - calibration_[i].scale, - calibration_[i].offset, - calibration_[i].unit); + snap[i].source, + snap[i].signal, + snap[i].scale, + snap[i].offset, + snap[i].unit); } - calibrationMutex_.FastUnLock(); + delete[] snap; JsonAppendf(buf, off, cap, "]}"); wsServer_.BroadcastText(buf, off); @@ -1121,26 +1216,52 @@ void StreamHub::HandleSaveSources() { 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; (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(i) - 1; + while (j >= 0) { + const uint32 cur = cidx[static_cast(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(j) + 1u] = cur; + j--; + } + cidx[static_cast(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, "%s {\n \"source\": \"%s\",\n \"signal\": \"%s\",\n" " \"scale\": %.17g,\n \"offset\": %.17g", ((nSaved + nCal) > 0u) ? ",\n" : "", - calibration_[i].source, - calibration_[i].signal, - calibration_[i].scale, - calibration_[i].offset); - if (calibration_[i].unit[0] != '\0') { + csnap[i].source, + csnap[i].signal, + csnap[i].scale, + csnap[i].offset); + if (csnap[i].unit[0] != '\0') { (void) fprintf(f, ",\n \"unit\": \"%s\"", - calibration_[i].unit); + csnap[i].unit); } (void) fprintf(f, "\n }"); nCal++; } - calibrationMutex_.FastUnLock(); + delete[] csnap; (void) fprintf(f, "\n]\n"); (void) fclose(f);