TextEncoder always emits well-formed UTF-8, so truncation can strand at most a lead byte plus three continuations — one repair pass covers it. The C++ hub needs a loop because its input is raw bytes off the wire. Also drops a dead variable from the byte-boundary test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
162 lines
6.2 KiB
JavaScript
162 lines
6.2 KiB
JavaScript
// Per-signal affine calibration: value = raw * scale + offset, with an optional
|
|
// unit override. Pure and dependency-free so it can be unit-tested under Node;
|
|
// in the browser it defines the global `Calib`.
|
|
(function (root) {
|
|
'use strict';
|
|
|
|
var MAX_UNIT_LEN = 16;
|
|
var IDENTITY = Object.freeze({scale: 1, offset: 0, unit: ''});
|
|
|
|
// The table is keyed by (source label, base signal name). U+0000 cannot occur
|
|
// in either, so it is an unambiguous separator.
|
|
function calKey(source, signal) {
|
|
return source + '\u0000' + signal;
|
|
}
|
|
|
|
// 'Adc[3]' -> 'Adc'. One calibration covers every element of an array signal.
|
|
function baseSignalName(name) {
|
|
var s = String(name == null ? '' : name);
|
|
var open = s.lastIndexOf('[');
|
|
if (open > 0 && s.charAt(s.length - 1) === ']') {
|
|
var idx = s.slice(open + 1, s.length - 1);
|
|
if (idx.length > 0 && /^[0-9]+$/.test(idx)) return s.slice(0, open);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
function isFiniteNum(v) {
|
|
return typeof v === 'number' && isFinite(v);
|
|
}
|
|
|
|
// Mirrors the hub-side validation exactly (Go: CalConfig.Normalise,
|
|
// C++: StreamHub::HandleSetCalibration). Returns null when the entry must be
|
|
// rejected, so a caller can revert an input field to its last accepted value.
|
|
function normaliseCal(obj) {
|
|
if (obj === null || typeof obj !== 'object') return null;
|
|
var source = String(obj.source == null ? '' : obj.source).trim();
|
|
var signal = baseSignalName(String(obj.signal == null ? '' : obj.signal).trim());
|
|
if (source === '' || signal === '') return null;
|
|
var scale = obj.scale === undefined ? 1 : obj.scale;
|
|
var offset = obj.offset === undefined ? 0 : obj.offset;
|
|
if (!isFiniteNum(scale) || scale === 0) return null;
|
|
if (!isFiniteNum(offset)) return null;
|
|
var unit = String(obj.unit == null ? '' : obj.unit).trim();
|
|
// Cap at MAX_UNIT_LEN UTF-8 bytes, matching both hubs' Normalise() exactly.
|
|
// TextEncoder/TextDecoder are available natively in all modern browsers and
|
|
// Node v11+; no build step or bundler is needed.
|
|
var enc = new TextEncoder();
|
|
var bytes = enc.encode(unit);
|
|
if (bytes.length > MAX_UNIT_LEN) {
|
|
// Truncate to MAX_UNIT_LEN bytes, then repair any split UTF-8 rune at the
|
|
// tail. Mirrors Go's utf8.DecodeLastRuneInString walk-back loop and the
|
|
// C++ repair loop in StreamHub::SetCalibrationEntry:
|
|
// Drop continuation bytes (10xxxxxx) from the tail until the last byte
|
|
// is either an ASCII byte (< 0x80) or a lead byte whose sequence is
|
|
// complete (i.e. all expected continuation bytes are present).
|
|
//
|
|
// A single pass suffices here, where the C++ needs a loop. The C++ input
|
|
// is a raw const char* straight off the wire and may hold arbitrary bytes;
|
|
// `bytes` here comes from TextEncoder, which always emits well-formed
|
|
// UTF-8 (unpaired surrogates become U+FFFD = EF BF BD, and no byte is ever
|
|
// >= 0xF8). Truncating well-formed UTF-8 can therefore strand at most a
|
|
// lead byte plus three continuation bytes, which one pass fully repairs.
|
|
var b = bytes.slice(0, MAX_UNIT_LEN);
|
|
var len = b.length;
|
|
// Walk back over continuation bytes (up to 3) to find the lead byte of
|
|
// the last sequence.
|
|
var cont = 0;
|
|
while (cont < 3 && cont < len && (b[len - 1 - cont] & 0xC0) === 0x80) {
|
|
cont++;
|
|
}
|
|
if (cont < len) {
|
|
var lead = b[len - 1 - cont];
|
|
// Determine expected sequence length from the lead byte.
|
|
var seqLen = lead < 0x80 ? 1 : // 0xxxxxxx ASCII
|
|
(lead & 0xE0) === 0xC0 ? 2 : // 110xxxxx
|
|
(lead & 0xF0) === 0xE0 ? 3 : // 1110xxxx
|
|
(lead & 0xF8) === 0xF0 ? 4 : // 11110xxx
|
|
1; // orphaned continuation byte — treat as 1
|
|
var haveBytes = cont + 1; // lead + continuation bytes present
|
|
if (haveBytes < seqLen) {
|
|
// Incomplete sequence: drop the lead byte and all its continuations.
|
|
len = len - haveBytes;
|
|
}
|
|
}
|
|
unit = new TextDecoder().decode(b.slice(0, len));
|
|
}
|
|
return {source: source, signal: signal, scale: scale, offset: offset, unit: unit};
|
|
}
|
|
|
|
function isIdentity(cal) {
|
|
return cal.scale === 1 && cal.offset === 0 && cal.unit === '';
|
|
}
|
|
|
|
function applyCal(raw, cal) {
|
|
return raw * cal.scale + cal.offset;
|
|
}
|
|
|
|
function invertCal(value, cal) {
|
|
return (value - cal.offset) / cal.scale;
|
|
}
|
|
|
|
// A negative scale swaps the ends of a range, so re-order after calibrating.
|
|
function calRange(min, max, cal) {
|
|
var a = applyCal(min, cal), b = applyCal(max, cal);
|
|
return a <= b ? [a, b] : [b, a];
|
|
}
|
|
|
|
function CalTable() {
|
|
this._m = Object.create(null);
|
|
}
|
|
|
|
CalTable.prototype.get = function (source, signal) {
|
|
var e = this._m[calKey(source, baseSignalName(signal))];
|
|
return e === undefined ? IDENTITY : e;
|
|
};
|
|
|
|
// Returns false when the entry was rejected as invalid. An entry that reduces
|
|
// to the identity is deleted rather than stored, so a Reset cleans the table
|
|
// (and, once saved, the config file) instead of filling it with no-ops.
|
|
CalTable.prototype.set = function (entry) {
|
|
var c = normaliseCal(entry);
|
|
if (c === null) return false;
|
|
var k = calKey(c.source, c.signal);
|
|
if (isIdentity(c)) delete this._m[k];
|
|
else this._m[k] = c;
|
|
return true;
|
|
};
|
|
|
|
CalTable.prototype.replaceAll = function (list) {
|
|
this._m = Object.create(null);
|
|
if (!list) return;
|
|
for (var i = 0; i < list.length; i++) this.set(list[i]);
|
|
};
|
|
|
|
CalTable.prototype.list = function () {
|
|
var out = [], k;
|
|
for (k in this._m) out.push(this._m[k]);
|
|
out.sort(function (a, b) {
|
|
if (a.source !== b.source) return a.source < b.source ? -1 : 1;
|
|
if (a.signal !== b.signal) return a.signal < b.signal ? -1 : 1;
|
|
return 0;
|
|
});
|
|
return out;
|
|
};
|
|
|
|
var api = {
|
|
MAX_UNIT_LEN: MAX_UNIT_LEN,
|
|
IDENTITY: IDENTITY,
|
|
calKey: calKey,
|
|
baseSignalName: baseSignalName,
|
|
normaliseCal: normaliseCal,
|
|
isIdentity: isIdentity,
|
|
applyCal: applyCal,
|
|
invertCal: invertCal,
|
|
calRange: calRange,
|
|
CalTable: CalTable,
|
|
};
|
|
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
else root.Calib = api;
|
|
})(typeof globalThis !== 'undefined' ? globalThis : this);
|