// 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). 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);