mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-20 13:52:10 +00:00
Point both stable providers and runtime controls to the validated v1.17.1.9-halo-keymind-cascade-dev-88c85108 family. Record completed package validation while retaining hardware coverage limitations. Give all documentation source pages their exact hosted links and mount the telemetry form only in the browser. Verify current combined Full and nRF52 Full/Reduced catalog fixtures.
1191 lines
39 KiB
JavaScript
1191 lines
39 KiB
JavaScript
(function (global) {
|
|
"use strict";
|
|
|
|
const TYPE_TEMPERATURE = 0x11;
|
|
const TYPE_VOLTAGE = 0x12;
|
|
const TYPE_GPS = 0x13;
|
|
const TYPE_EXTERNAL_VOLTAGE = 0x14;
|
|
const BINARY_TEMPERATURE_MAGIC = "TTB1";
|
|
const BINARY_VOLTAGE_MAGIC = "TVB1";
|
|
const BINARY_EXTERNAL_VOLTAGE_MAGIC = "IVB1";
|
|
const BINARY_HEADER_SIZE = 19;
|
|
const BINARY_MAX_SAMPLES = 165;
|
|
const BINARY_EXTERNAL_HEADER_SIZE = 20;
|
|
const BINARY_EXTERNAL_MAX_SAMPLES = 64;
|
|
const EXTERNAL_VOLTAGE_BITS = 15;
|
|
const PAYLOAD_TYPE_RAW_CUSTOM = 0x0f;
|
|
const METERS_PER_DEGREE = 111320;
|
|
const DEGREES_TO_RADIANS = Math.PI / 180;
|
|
|
|
// Keep the browser form out of GitHub's Markdown preview.
|
|
const DECODER_MARKUP = `
|
|
<div class="telemetry-examples" aria-label="Load an example reply">
|
|
<strong>Try an analyzer example:</strong>
|
|
<button type="button" data-telemetry-example="packetTemperature">Temperature packet</button>
|
|
<button type="button" data-telemetry-example="packetVoltage">Voltage packet</button>
|
|
<button type="button" data-telemetry-example="packetExternalVoltage">I2C voltage packet</button>
|
|
<button type="button" data-telemetry-example="externalVoltage">I2C voltage CLI page</button>
|
|
</div>
|
|
|
|
<label for="telemetry-reply-input">Raw packet or payload hex</label>
|
|
<textarea
|
|
id="telemetry-reply-input"
|
|
data-role="input"
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
placeholder="Paste hexadecimal Raw Data from the analyzer packet page"
|
|
aria-describedby="telemetry-input-help"
|
|
></textarea>
|
|
<p class="telemetry-tool-help" id="telemetry-input-help">
|
|
Spaces, line breaks, colons, dashes, a leading <code>0x</code>, and a quoted
|
|
JSON field are accepted. CLI Base64 replies are also auto-detected. Paste
|
|
multiple compatible packet or reply lines together to merge them by
|
|
timestamp before downloading one CSV. Press Ctrl/Command+Enter to decode.
|
|
</p>
|
|
|
|
<div class="telemetry-actions">
|
|
<button class="telemetry-primary-action" type="button" data-role="decode">Decode telemetry</button>
|
|
<button type="button" data-role="clear">Clear</button>
|
|
<label class="telemetry-local-time">
|
|
<input type="checkbox" data-role="local-time">
|
|
Show browser-local time
|
|
</label>
|
|
</div>
|
|
|
|
<div class="telemetry-error" data-role="error" role="alert" aria-live="polite" hidden></div>
|
|
|
|
<section class="telemetry-results" data-role="results" aria-live="polite" hidden>
|
|
<div class="telemetry-results-header">
|
|
<h2 data-role="result-title">Decoded telemetry</h2>
|
|
<button type="button" data-role="download">Download CSV</button>
|
|
</div>
|
|
<dl class="telemetry-summary" data-role="summary"></dl>
|
|
<div class="telemetry-warnings" data-role="warnings" hidden>
|
|
<strong>Decode notes</strong>
|
|
<ul data-role="warning-list"></ul>
|
|
</div>
|
|
<div class="telemetry-table-wrap">
|
|
<table class="telemetry-table" data-role="table"></table>
|
|
</div>
|
|
</section>
|
|
`;
|
|
|
|
const EXAMPLES = Object.freeze({
|
|
packetTemperature: Object.freeze({
|
|
label: "Analyzer temperature packet",
|
|
command: "send telemetry.tx now",
|
|
reply: "3E00545442311122334455667788800092651E0008000102354A4E5082",
|
|
}),
|
|
packetVoltage: Object.freeze({
|
|
label: "Analyzer voltage packet",
|
|
command: "send telemetry.tx now",
|
|
reply: "3E00545642311122334455667788800092651E000800010264C8FEFFDC",
|
|
}),
|
|
packetExternalVoltage: Object.freeze({
|
|
label: "Analyzer I²C voltage packet",
|
|
command: "send telemetry.tx now",
|
|
reply:
|
|
"3E00495642311122334455667788800092651E00020800000004019026927109C427107FFF",
|
|
}),
|
|
temperature: Object.freeze({
|
|
label: "Temperature example",
|
|
command: "get telemetry.temp",
|
|
reply:
|
|
"> EUDUcWoeMAVZVVVVUVVVVVXVVQACTJlSwEyZMmTJkuXKkyZEeNFARIcOFChQoUOHDiRY0aPI/ypUuXMmTA==",
|
|
}),
|
|
voltage: Object.freeze({
|
|
label: "Voltage example",
|
|
command: "get telemetry.volt",
|
|
reply:
|
|
"> EkDUcWoeMAAB5+bl5eTj4uLh4ODf3t7d3Nvb2tnZ2NfX1tXU1NPS0tHQ0M/Ozc3My8vKycnI/w==",
|
|
}),
|
|
externalVoltage: Object.freeze({
|
|
label: "I²C voltage example",
|
|
command: "get telemetry.volt.i2c 2",
|
|
reply:
|
|
"> FIAAkmUeMAIAAAAEE0gfcD8AfkD9AfsD+Af0D/Af8EAAgEEBAgMECAgUEDAgcEEAgkEFAgsEGAg0EHAg8EIAhEEJAhMEKAhUELAhcEMAhkENAhsEOAh0EPAh8EQAiEERf/8=",
|
|
}),
|
|
gps: Object.freeze({
|
|
label: "GPS example",
|
|
command: "get telemetry.gps",
|
|
reply:
|
|
"> EwB9cmoeGIChAxwAR0i3AgAAAAAAAAAAAAAAAAKAAAAAAIAAAAD/9ABAAX/+AAgAYAAAB//wAD/+gAAAA/+wAP/8ABwAEAEABgAAAA//gAX/7AAv/3/8AAf/oAF//QAYACAAgAc=",
|
|
}),
|
|
});
|
|
|
|
class TelemetryDecodeError extends Error {
|
|
constructor(message) {
|
|
super(message);
|
|
this.name = "TelemetryDecodeError";
|
|
}
|
|
}
|
|
|
|
function extractBase64(input) {
|
|
if (typeof input !== "string" || input.trim() === "") {
|
|
throw new TelemetryDecodeError("Paste a telemetry reply or Base64 payload first.");
|
|
}
|
|
|
|
let text = input.trim();
|
|
if (text.length > 4096) {
|
|
throw new TelemetryDecodeError("The pasted value is too large to be a telemetry page.");
|
|
}
|
|
|
|
const replyLine = text
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.find((line) => /^>\s*[A-Za-z0-9+/]+={0,2}$/.test(line));
|
|
|
|
if (replyLine) {
|
|
text = replyLine.replace(/^>\s*/, "");
|
|
} else {
|
|
text = text
|
|
.replace(/^```(?:text)?\s*/i, "")
|
|
.replace(/```\s*$/, "")
|
|
.trim()
|
|
.replace(/^>\s*/, "")
|
|
.replace(/\s+/g, "");
|
|
}
|
|
|
|
if (text === "") {
|
|
throw new TelemetryDecodeError("The telemetry reply does not contain Base64 data.");
|
|
}
|
|
if (text.length % 4 !== 0) {
|
|
throw new TelemetryDecodeError("Base64 length is invalid; copy the complete padded reply.");
|
|
}
|
|
const validBase64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
if (!validBase64.test(text)) {
|
|
throw new TelemetryDecodeError("The reply is not valid standard padded Base64.");
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function extractBase64Payloads(input) {
|
|
if (typeof input !== "string") return [extractBase64(input)];
|
|
const replies = input
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => /^>\s*[A-Za-z0-9+/]+={0,2}$/.test(line))
|
|
.map((line) => line.replace(/^>\s*/, ""));
|
|
return replies.length === 0 ? [extractBase64(input)] : replies;
|
|
}
|
|
|
|
function base64ToBytes(encoded) {
|
|
let binary;
|
|
try {
|
|
if (typeof global.atob === "function") {
|
|
binary = global.atob(encoded);
|
|
} else if (typeof Buffer !== "undefined") {
|
|
return Uint8Array.from(Buffer.from(encoded, "base64"));
|
|
} else {
|
|
throw new Error("No Base64 decoder is available");
|
|
}
|
|
} catch (_error) {
|
|
throw new TelemetryDecodeError("The Base64 payload could not be decoded.");
|
|
}
|
|
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let index = 0; index < binary.length; index += 1) {
|
|
bytes[index] = binary.charCodeAt(index);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function bytesToHex(bytes) {
|
|
return Array.from(bytes, (value) => value.toString(16).padStart(2, "0"))
|
|
.join("")
|
|
.toUpperCase();
|
|
}
|
|
|
|
function asciiAt(bytes, offset, length) {
|
|
let value = "";
|
|
for (let index = 0; index < length; index += 1) {
|
|
value += String.fromCharCode(bytes[offset + index]);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function extractHexBytes(input) {
|
|
if (typeof input !== "string" || input.trim() === "") {
|
|
throw new TelemetryDecodeError("Paste raw packet hex from the analyzer first.");
|
|
}
|
|
if (input.length > 32768) {
|
|
throw new TelemetryDecodeError("The pasted value is too large to be a MeshCore packet.");
|
|
}
|
|
|
|
const text = input
|
|
.trim()
|
|
.replace(/^```(?:text|json)?\s*/i, "")
|
|
.replace(/```\s*$/, "");
|
|
const candidates = [text];
|
|
text.split(/\r?\n/).forEach((line) => {
|
|
candidates.push(line);
|
|
const separator = line.search(/[:=]/);
|
|
if (separator >= 0) candidates.push(line.slice(separator + 1));
|
|
});
|
|
const streams = text.match(
|
|
/(?:0x)?[0-9a-f]{2}(?:(?:[\s:,_-]*)(?:0x)?[0-9a-f]{2}){18,}/gi
|
|
);
|
|
if (streams) candidates.push(...streams);
|
|
|
|
let firstValid = null;
|
|
for (const candidate of candidates) {
|
|
const normalized = candidate
|
|
.trim()
|
|
.replace(/^['"]|['",;]$/g, "")
|
|
.replace(/0x/gi, "")
|
|
.replace(/[\s:,_-]/g, "");
|
|
if (normalized.length < BINARY_HEADER_SIZE * 2
|
|
|| normalized.length % 2 !== 0
|
|
|| !/^[0-9a-f]+$/i.test(normalized)) {
|
|
continue;
|
|
}
|
|
const bytes = new Uint8Array(normalized.length / 2);
|
|
for (let index = 0; index < bytes.length; index += 1) {
|
|
bytes[index] = Number.parseInt(normalized.slice(index * 2, index * 2 + 2), 16);
|
|
}
|
|
if (firstValid === null) firstValid = bytes;
|
|
for (let offset = 0; offset <= bytes.length - 4; offset += 1) {
|
|
const magic = asciiAt(bytes, offset, 4);
|
|
if (magic === BINARY_TEMPERATURE_MAGIC
|
|
|| magic === BINARY_VOLTAGE_MAGIC
|
|
|| magic === BINARY_EXTERNAL_VOLTAGE_MAGIC) {
|
|
return bytes;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (firstValid) {
|
|
throw new TelemetryDecodeError(
|
|
"Hex was found, but it does not contain TTB1, TVB1, or IVB1 telemetry."
|
|
);
|
|
}
|
|
throw new TelemetryDecodeError(
|
|
"Raw packet data must contain complete two-character hexadecimal bytes."
|
|
);
|
|
}
|
|
|
|
function uint32LE(bytes, offset) {
|
|
return (
|
|
bytes[offset] |
|
|
(bytes[offset + 1] << 8) |
|
|
(bytes[offset + 2] << 16) |
|
|
(bytes[offset + 3] << 24)
|
|
) >>> 0;
|
|
}
|
|
|
|
function int32LE(bytes, offset) {
|
|
const value = uint32LE(bytes, offset);
|
|
return value > 0x7fffffff ? value - 0x100000000 : value;
|
|
}
|
|
|
|
function readPackedBits(bytes, byteOffset, bitOffset, width) {
|
|
let value = 0;
|
|
const firstBit = byteOffset * 8 + bitOffset;
|
|
for (let bit = 0; bit < width; bit += 1) {
|
|
const absoluteBit = firstBit + bit;
|
|
value =
|
|
(value << 1) |
|
|
((bytes[Math.floor(absoluteBit / 8)] >> (7 - (absoluteBit % 8))) & 1);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function signed14(value) {
|
|
return value & 0x2000 ? value - 0x4000 : value;
|
|
}
|
|
|
|
function decodeHeader(bytes) {
|
|
if (bytes.length < 7) {
|
|
throw new TelemetryDecodeError("Telemetry payload is shorter than its seven-byte header.");
|
|
}
|
|
const header = {
|
|
typeCode: bytes[0],
|
|
firstEpoch: uint32LE(bytes, 1),
|
|
intervalMinutes: bytes[5],
|
|
count: bytes[6],
|
|
byteLength: bytes.length,
|
|
};
|
|
if (header.intervalMinutes === 0) {
|
|
throw new TelemetryDecodeError("Telemetry sample interval cannot be zero.");
|
|
}
|
|
if (header.count === 0) {
|
|
throw new TelemetryDecodeError("Telemetry sample count cannot be zero.");
|
|
}
|
|
return header;
|
|
}
|
|
|
|
function assertLength(bytes, expected, label) {
|
|
if (bytes.length !== expected) {
|
|
throw new TelemetryDecodeError(
|
|
`${label} payload should be ${expected} bytes for this sample count; received ${bytes.length}.`
|
|
);
|
|
}
|
|
}
|
|
|
|
function sampleEpoch(header, index) {
|
|
return header.firstEpoch + index * header.intervalMinutes * 60;
|
|
}
|
|
|
|
function decodePacketEnvelope(bytes, payloadOffset) {
|
|
if (bytes.length < 2) return null;
|
|
const headerByte = bytes[0];
|
|
const routeCode = headerByte & 0x03;
|
|
const payloadType = (headerByte >> 2) & 0x0f;
|
|
const payloadVersion = (headerByte >> 6) + 1;
|
|
let pathLengthOffset = 1;
|
|
if (routeCode === 0 || routeCode === 3) pathLengthOffset += 4;
|
|
if (pathLengthOffset >= bytes.length) return null;
|
|
const pathMetadata = bytes[pathLengthOffset];
|
|
const hashSizeCode = pathMetadata >> 6;
|
|
if (hashSizeCode === 3) return null;
|
|
const hopCount = pathMetadata & 0x3f;
|
|
const pathHashBytes = hashSizeCode + 1;
|
|
const expectedPayloadOffset = pathLengthOffset + 1 + hopCount * pathHashBytes;
|
|
if (expectedPayloadOffset !== payloadOffset || expectedPayloadOffset > bytes.length) return null;
|
|
|
|
const routeNames = ["transport flood", "flood", "direct", "transport direct"];
|
|
return {
|
|
headerByte,
|
|
routeCode,
|
|
routeName: routeNames[routeCode],
|
|
payloadType,
|
|
payloadVersion,
|
|
hopCount,
|
|
pathHashBytes,
|
|
payloadOffset,
|
|
isRawCustom: payloadType === PAYLOAD_TYPE_RAW_CUSTOM,
|
|
};
|
|
}
|
|
|
|
function decodeBinarySnapshot(bytes, offset) {
|
|
const magic = asciiAt(bytes, offset, 4);
|
|
const externalVoltage = magic === BINARY_EXTERNAL_VOLTAGE_MAGIC;
|
|
const headerSize = externalVoltage
|
|
? BINARY_EXTERNAL_HEADER_SIZE : BINARY_HEADER_SIZE;
|
|
const maximumSamples = externalVoltage
|
|
? BINARY_EXTERNAL_MAX_SAMPLES : BINARY_MAX_SAMPLES;
|
|
if (bytes.length - offset < headerSize) {
|
|
throw new TelemetryDecodeError("The binary telemetry header is incomplete.");
|
|
}
|
|
const count = bytes[offset + (externalVoltage ? 19 : 18)];
|
|
const intervalMinutes = bytes[offset + 16] | (bytes[offset + 17] << 8);
|
|
if (count < 1 || count > maximumSamples) {
|
|
throw new TelemetryDecodeError(
|
|
`Binary telemetry sample count ${count} is outside the supported 1-${maximumSamples} range.`
|
|
);
|
|
}
|
|
if (intervalMinutes === 0) {
|
|
throw new TelemetryDecodeError("Binary telemetry sample interval cannot be zero.");
|
|
}
|
|
const dataLength = externalVoltage
|
|
? Math.ceil((count * EXTERNAL_VOLTAGE_BITS) / 8) : count;
|
|
const payloadLength = headerSize + dataLength;
|
|
if (bytes.length - offset < payloadLength) {
|
|
throw new TelemetryDecodeError(
|
|
`Binary telemetry declares ${count} samples but ${payloadLength - (bytes.length - offset)} payload bytes are missing.`
|
|
);
|
|
}
|
|
|
|
const header = {
|
|
typeCode: magic,
|
|
firstEpoch: uint32LE(bytes, offset + 12),
|
|
intervalMinutes,
|
|
count,
|
|
byteLength: payloadLength,
|
|
sourceId: bytesToHex(bytes.slice(offset + 4, offset + 12)),
|
|
format: "binary",
|
|
payloadOffset: offset,
|
|
inputByteLength: bytes.length,
|
|
trailingBytes: bytes.length - offset - payloadLength,
|
|
packet: decodePacketEnvelope(bytes, offset),
|
|
channel: externalVoltage ? bytes[offset + 18] : null,
|
|
};
|
|
const rows = [];
|
|
for (let index = 0; index < count; index += 1) {
|
|
const rawCode = externalVoltage
|
|
? readPackedBits(bytes, offset + headerSize,
|
|
index * EXTERNAL_VOLTAGE_BITS,
|
|
EXTERNAL_VOLTAGE_BITS)
|
|
: bytes[offset + headerSize + index];
|
|
if (magic === BINARY_TEMPERATURE_MAGIC) {
|
|
let status = "Value";
|
|
let valueC = null;
|
|
if (rawCode === 0) status = "Missing";
|
|
else if (rawCode === 1) status = "Below range";
|
|
else if (rawCode === 2) status = "Above range";
|
|
else if (rawCode <= 130) valueC = rawCode - 53;
|
|
else status = "Reserved code";
|
|
rows.push({
|
|
index,
|
|
epoch: sampleEpoch(header, index),
|
|
status,
|
|
statusCode: null,
|
|
rawCode,
|
|
valueC,
|
|
});
|
|
} else if (magic === BINARY_VOLTAGE_MAGIC) {
|
|
let status = "Value";
|
|
let millivolts = null;
|
|
if (rawCode === 0) status = "Missing";
|
|
else if (rawCode === 1) status = "Below range";
|
|
else if (rawCode === 255) status = "Above range";
|
|
else millivolts = 1880 + (rawCode - 2) * 10;
|
|
rows.push({
|
|
index,
|
|
epoch: sampleEpoch(header, index),
|
|
status,
|
|
rawCode,
|
|
millivolts,
|
|
});
|
|
} else {
|
|
rows.push({
|
|
index,
|
|
epoch: sampleEpoch(header, index),
|
|
status: rawCode === 0 ? "Missing" : "Value",
|
|
rawCode,
|
|
millivolts: rawCode === 0 ? null : rawCode * 20,
|
|
});
|
|
}
|
|
}
|
|
const warnings = [];
|
|
if (header.packet && !header.packet.isRawCustom) {
|
|
warnings.push(
|
|
`The enclosing packet type is 0x${header.packet.payloadType.toString(16)}, not RAW_CUSTOM (0x0f).`
|
|
);
|
|
}
|
|
if (header.trailingBytes > 0) {
|
|
warnings.push(`${header.trailingBytes} byte(s) after the declared telemetry payload were ignored.`);
|
|
}
|
|
if (magic === BINARY_TEMPERATURE_MAGIC
|
|
&& rows.some((row) => row.status === "Reserved code")) {
|
|
warnings.push("One or more temperature samples use a reserved code and may be corrupt.");
|
|
}
|
|
let kind = "voltage";
|
|
let label = "Battery voltage";
|
|
if (magic === BINARY_TEMPERATURE_MAGIC) {
|
|
kind = "temperature";
|
|
label = "Temperature";
|
|
} else if (externalVoltage) {
|
|
kind = "external-voltage";
|
|
label = `I²C voltage channel ${header.channel}`;
|
|
}
|
|
return {
|
|
...header,
|
|
kind,
|
|
label,
|
|
rows,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
function decodeRawTelemetryHex(input) {
|
|
const bytes = extractHexBytes(input);
|
|
const decodedCandidates = [];
|
|
let lastError = null;
|
|
for (let offset = 0; offset <= bytes.length - 4; offset += 1) {
|
|
const magic = asciiAt(bytes, offset, 4);
|
|
if (magic !== BINARY_TEMPERATURE_MAGIC
|
|
&& magic !== BINARY_VOLTAGE_MAGIC
|
|
&& magic !== BINARY_EXTERNAL_VOLTAGE_MAGIC) continue;
|
|
try {
|
|
decodedCandidates.push(decodeBinarySnapshot(bytes, offset));
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
if (decodedCandidates.length === 0) {
|
|
if (lastError) throw lastError;
|
|
throw new TelemetryDecodeError("No supported telemetry snapshot was found in the hex data.");
|
|
}
|
|
if (decodedCandidates.length > 1) {
|
|
throw new TelemetryDecodeError(
|
|
"More than one telemetry snapshot was found; paste one analyzer packet at a time."
|
|
);
|
|
}
|
|
const decoded = decodedCandidates[0];
|
|
decoded.encoded = bytesToHex(bytes);
|
|
return decoded;
|
|
}
|
|
|
|
function decodeTemperature(bytes, header) {
|
|
const statusBytes = Math.ceil((header.count * 2) / 8);
|
|
const valueBytes = Math.ceil((header.count * 7) / 8);
|
|
assertLength(bytes, 7 + statusBytes + valueBytes, "Temperature");
|
|
|
|
const rows = [];
|
|
for (let index = 0; index < header.count; index += 1) {
|
|
const statusCode = readPackedBits(bytes, 7, index * 2, 2);
|
|
const rawCode = readPackedBits(bytes, 7 + statusBytes, index * 7, 7);
|
|
let status;
|
|
let valueC = null;
|
|
if (statusCode === 0) {
|
|
status = "Missing";
|
|
} else if (statusCode === 1) {
|
|
status = "Value";
|
|
valueC = rawCode - 50;
|
|
} else if (statusCode === 2) {
|
|
status = "Below range";
|
|
} else {
|
|
status = "Above range";
|
|
}
|
|
rows.push({
|
|
index,
|
|
epoch: sampleEpoch(header, index),
|
|
status,
|
|
statusCode,
|
|
rawCode,
|
|
valueC,
|
|
});
|
|
}
|
|
|
|
return {
|
|
...header,
|
|
kind: "temperature",
|
|
label: "Temperature",
|
|
rows,
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
function decodeVoltage(bytes, header) {
|
|
assertLength(bytes, 7 + header.count, "Voltage");
|
|
|
|
const rows = [];
|
|
for (let index = 0; index < header.count; index += 1) {
|
|
const rawCode = bytes[7 + index];
|
|
let status;
|
|
let millivolts = null;
|
|
if (rawCode === 0) {
|
|
status = "Missing";
|
|
} else if (rawCode === 1) {
|
|
status = "Below range";
|
|
} else if (rawCode === 255) {
|
|
status = "Above range";
|
|
} else {
|
|
status = "Value";
|
|
millivolts = 1880 + (rawCode - 2) * 10;
|
|
}
|
|
rows.push({
|
|
index,
|
|
epoch: sampleEpoch(header, index),
|
|
status,
|
|
rawCode,
|
|
millivolts,
|
|
});
|
|
}
|
|
|
|
return {
|
|
...header,
|
|
kind: "voltage",
|
|
label: "Battery voltage",
|
|
rows,
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
function decodeExternalVoltage(bytes, header) {
|
|
const headerSize = 8;
|
|
const dataBytes = Math.ceil(
|
|
(header.count * EXTERNAL_VOLTAGE_BITS) / 8
|
|
);
|
|
assertLength(bytes, headerSize + dataBytes, "I²C voltage");
|
|
const channel = bytes[7];
|
|
if (channel === 0) {
|
|
throw new TelemetryDecodeError("I²C voltage channel cannot be zero.");
|
|
}
|
|
|
|
const rows = [];
|
|
for (let index = 0; index < header.count; index += 1) {
|
|
const rawCode = readPackedBits(
|
|
bytes, headerSize, index * EXTERNAL_VOLTAGE_BITS,
|
|
EXTERNAL_VOLTAGE_BITS
|
|
);
|
|
rows.push({
|
|
index,
|
|
epoch: sampleEpoch(header, index),
|
|
status: rawCode === 0 ? "Missing" : "Value",
|
|
rawCode,
|
|
millivolts: rawCode === 0 ? null : rawCode * 20,
|
|
});
|
|
}
|
|
|
|
return {
|
|
...header,
|
|
channel,
|
|
kind: "external-voltage",
|
|
label: `I²C voltage channel ${channel}`,
|
|
rows,
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
function decodeGps(bytes, header) {
|
|
if (bytes.length < 17) {
|
|
throw new TelemetryDecodeError("GPS payload is shorter than its 17-byte header.");
|
|
}
|
|
const dataBytes = Math.ceil((header.count * 28) / 8);
|
|
assertLength(bytes, 17 + dataBytes, "GPS");
|
|
|
|
const originLatE7 = int32LE(bytes, 7);
|
|
const originLonE7 = int32LE(bytes, 11);
|
|
const originIndex = bytes[15];
|
|
const flags = bytes[16];
|
|
if (originIndex !== 0xff && originIndex >= header.count) {
|
|
throw new TelemetryDecodeError(
|
|
`GPS origin index ${originIndex} is outside the ${header.count}-sample page.`
|
|
);
|
|
}
|
|
|
|
const warnings = [];
|
|
if (flags & 0x01) {
|
|
warnings.push(
|
|
"At least one GPS differential was clipped to the signed 14-bit range; positions after it may be less accurate."
|
|
);
|
|
}
|
|
if (flags & 0xfe) {
|
|
warnings.push(`GPS flags contain unknown bits: 0x${flags.toString(16).padStart(2, "0")}.`);
|
|
}
|
|
if (originIndex !== 0xff) {
|
|
warnings.push(
|
|
"A 0 m / 0 m GPS differential after the origin is ambiguous: it can mean an unchanged fix, movement under the 10 m resolution, or no fix."
|
|
);
|
|
}
|
|
if (
|
|
originIndex !== 0xff &&
|
|
(originLatE7 < -900000000 ||
|
|
originLatE7 > 900000000 ||
|
|
originLonE7 < -1800000000 ||
|
|
originLonE7 > 1800000000)
|
|
) {
|
|
warnings.push("The GPS origin is outside the valid latitude/longitude range.");
|
|
}
|
|
|
|
let referenceLat = originLatE7 / 10000000;
|
|
let referenceLon = originLonE7 / 10000000;
|
|
let unexpectedPreOriginDelta = false;
|
|
const rows = [];
|
|
|
|
for (let index = 0; index < header.count; index += 1) {
|
|
const bitOffset = index * 28;
|
|
const northUnits = signed14(readPackedBits(bytes, 17, bitOffset, 14));
|
|
const eastUnits = signed14(readPackedBits(bytes, 17, bitOffset + 14, 14));
|
|
const northMeters = northUnits * 10;
|
|
const eastMeters = eastUnits * 10;
|
|
let latitude = null;
|
|
let longitude = null;
|
|
let status;
|
|
let ambiguous = false;
|
|
let possiblyClipped = false;
|
|
|
|
if (originIndex === 0xff) {
|
|
status = "No fix";
|
|
if (northUnits !== 0 || eastUnits !== 0) unexpectedPreOriginDelta = true;
|
|
} else if (index < originIndex) {
|
|
status = "No fix";
|
|
if (northUnits !== 0 || eastUnits !== 0) unexpectedPreOriginDelta = true;
|
|
} else if (index === originIndex) {
|
|
status = "Origin fix";
|
|
latitude = referenceLat;
|
|
longitude = referenceLon;
|
|
if (northUnits !== 0 || eastUnits !== 0) {
|
|
warnings.push("The GPS origin row contains a non-zero differential.");
|
|
}
|
|
} else if (northUnits === 0 && eastUnits === 0) {
|
|
status = "Unchanged / no fix";
|
|
ambiguous = true;
|
|
} else {
|
|
referenceLat += northMeters / METERS_PER_DEGREE;
|
|
const longitudeScale =
|
|
METERS_PER_DEGREE * Math.cos(referenceLat * DEGREES_TO_RADIANS);
|
|
if (Math.abs(longitudeScale) > 0.001) {
|
|
referenceLon += eastMeters / longitudeScale;
|
|
}
|
|
latitude = referenceLat;
|
|
longitude = referenceLon;
|
|
possiblyClipped =
|
|
Boolean(flags & 0x01) &&
|
|
[northUnits, eastUnits].some((value) => value === -8192 || value === 8191);
|
|
status = possiblyClipped ? "Fix (clipped delta)" : "Fix";
|
|
}
|
|
|
|
rows.push({
|
|
index,
|
|
epoch: sampleEpoch(header, index),
|
|
status,
|
|
latitude,
|
|
longitude,
|
|
northMeters,
|
|
eastMeters,
|
|
ambiguous,
|
|
possiblyClipped,
|
|
});
|
|
}
|
|
|
|
if (unexpectedPreOriginDelta) {
|
|
warnings.push("A no-fix row contains a non-zero GPS differential.");
|
|
}
|
|
|
|
return {
|
|
...header,
|
|
kind: "gps",
|
|
label: "GPS",
|
|
originLatE7,
|
|
originLonE7,
|
|
originIndex,
|
|
flags,
|
|
rows,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
function decodeBase64Telemetry(encoded) {
|
|
const bytes = base64ToBytes(encoded);
|
|
if (bytes.length === 0) {
|
|
throw new TelemetryDecodeError("The Base64 payload decoded to zero bytes.");
|
|
}
|
|
|
|
const header = decodeHeader(bytes);
|
|
let decoded;
|
|
if (header.typeCode === TYPE_TEMPERATURE) {
|
|
decoded = decodeTemperature(bytes, header);
|
|
} else if (header.typeCode === TYPE_VOLTAGE) {
|
|
decoded = decodeVoltage(bytes, header);
|
|
} else if (header.typeCode === TYPE_GPS) {
|
|
decoded = decodeGps(bytes, header);
|
|
} else if (header.typeCode === TYPE_EXTERNAL_VOLTAGE) {
|
|
decoded = decodeExternalVoltage(bytes, header);
|
|
} else {
|
|
throw new TelemetryDecodeError(
|
|
`Unsupported telemetry type 0x${header.typeCode.toString(16).padStart(2, "0")}; expected 0x11, 0x12, 0x13, or 0x14.`
|
|
);
|
|
}
|
|
decoded.encoded = encoded;
|
|
return decoded;
|
|
}
|
|
|
|
function mergeDecodedTelemetry(decodedItems) {
|
|
if (decodedItems.length === 1) return decodedItems[0];
|
|
const first = decodedItems[0];
|
|
decodedItems.slice(1).forEach((item) => {
|
|
const sameSource = (item.sourceId || null) === (first.sourceId || null);
|
|
const sameChannel = (item.channel || null) === (first.channel || null);
|
|
if (item.kind !== first.kind || item.typeCode !== first.typeCode
|
|
|| item.intervalMinutes !== first.intervalMinutes
|
|
|| !sameSource || !sameChannel) {
|
|
throw new TelemetryDecodeError(
|
|
"Multiple telemetry replies must use the same type, source, channel, and interval."
|
|
);
|
|
}
|
|
});
|
|
|
|
const rowsByEpoch = new Map();
|
|
decodedItems.forEach((item) => {
|
|
item.rows.forEach((row) => rowsByEpoch.set(row.epoch, row));
|
|
});
|
|
const rows = Array.from(rowsByEpoch.values())
|
|
.sort((left, right) => left.epoch - right.epoch)
|
|
.map((row, index) => ({ ...row, index }));
|
|
if (rows.length === 0) {
|
|
throw new TelemetryDecodeError("The telemetry replies contain no samples.");
|
|
}
|
|
|
|
return {
|
|
...first,
|
|
firstEpoch: rows[0].epoch,
|
|
count: rows.length,
|
|
byteLength: decodedItems.reduce(
|
|
(total, item) => total + item.byteLength, 0
|
|
),
|
|
format: "merged",
|
|
pageCount: decodedItems.length,
|
|
packet: null,
|
|
payloadOffset: 0,
|
|
trailingBytes: 0,
|
|
rows,
|
|
warnings: Array.from(new Set(
|
|
decodedItems.flatMap((item) => item.warnings || [])
|
|
)),
|
|
encoded: decodedItems.map((item) => item.encoded).join("\n"),
|
|
};
|
|
}
|
|
|
|
function decodeTelemetry(input) {
|
|
const rawLines = typeof input === "string"
|
|
? input.split(/\r?\n/).filter(
|
|
(line) => /54544231|54564231|49564231/i.test(line)
|
|
)
|
|
: [];
|
|
if (rawLines.length > 1) {
|
|
return mergeDecodedTelemetry(
|
|
rawLines.map((line) => decodeRawTelemetryHex(line))
|
|
);
|
|
}
|
|
|
|
const compactHex = typeof input === "string"
|
|
? input.replace(/^```(?:text|json)?\s*/i, "")
|
|
.replace(/```\s*$/, "")
|
|
.replace(/0x/gi, "")
|
|
.replace(/[\s:,_-]/g, "")
|
|
.replace(/^['"]|['",;]$/g, "")
|
|
: "";
|
|
if ((/^[0-9a-f]+$/i.test(compactHex) && compactHex.length % 2 === 0)
|
|
|| /54544231|54564231|49564231/i.test(String(input))) {
|
|
return decodeRawTelemetryHex(input);
|
|
}
|
|
return mergeDecodedTelemetry(
|
|
extractBase64Payloads(input).map(decodeBase64Telemetry)
|
|
);
|
|
}
|
|
|
|
function timestampText(epoch, localTime) {
|
|
const date = new Date(epoch * 1000);
|
|
if (!Number.isFinite(date.getTime())) return "Invalid timestamp";
|
|
if (!localTime) return date.toISOString().replace(".000Z", "Z");
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
timeZoneName: "short",
|
|
}).format(date);
|
|
}
|
|
|
|
function tableModel(decoded, localTime) {
|
|
const localZone =
|
|
(Intl.DateTimeFormat().resolvedOptions() || {}).timeZone || "browser local";
|
|
const timestampHeader = localTime ? `Timestamp (${localZone})` : "Timestamp (UTC)";
|
|
|
|
if (decoded.kind === "temperature") {
|
|
return {
|
|
headers: [
|
|
"#",
|
|
timestampHeader,
|
|
"Status",
|
|
"Temperature",
|
|
decoded.sourceId ? "Raw code" : "Raw status/code",
|
|
],
|
|
rows: decoded.rows.map((row) => ({
|
|
muted: row.status === "Missing",
|
|
values: [
|
|
row.index + 1,
|
|
timestampText(row.epoch, localTime),
|
|
row.status,
|
|
row.status === "Below range"
|
|
? "< -50 °C"
|
|
: row.status === "Above range"
|
|
? "> 77 °C"
|
|
: row.valueC === null
|
|
? "—"
|
|
: `${row.valueC} °C`,
|
|
row.statusCode === null ? row.rawCode : `${row.statusCode} / ${row.rawCode}`,
|
|
],
|
|
})),
|
|
};
|
|
}
|
|
|
|
if (decoded.kind === "voltage") {
|
|
return {
|
|
headers: ["#", timestampHeader, "Status", "Battery voltage", "Raw code"],
|
|
rows: decoded.rows.map((row) => ({
|
|
muted: row.status === "Missing",
|
|
values: [
|
|
row.index + 1,
|
|
timestampText(row.epoch, localTime),
|
|
row.status,
|
|
row.status === "Below range"
|
|
? "< 1.88 V"
|
|
: row.status === "Above range"
|
|
? "> 4.40 V"
|
|
: row.millivolts === null
|
|
? "—"
|
|
: `${(row.millivolts / 1000).toFixed(2)} V`,
|
|
row.rawCode,
|
|
],
|
|
})),
|
|
};
|
|
}
|
|
|
|
if (decoded.kind === "external-voltage") {
|
|
return {
|
|
headers: [
|
|
"#",
|
|
timestampHeader,
|
|
"Status",
|
|
`I²C voltage (channel ${decoded.channel})`,
|
|
"Raw code",
|
|
],
|
|
rows: decoded.rows.map((row) => ({
|
|
muted: row.status === "Missing",
|
|
values: [
|
|
row.index + 1,
|
|
timestampText(row.epoch, localTime),
|
|
row.status,
|
|
row.millivolts === null
|
|
? "—" : `${(row.millivolts / 1000).toFixed(2)} V`,
|
|
row.rawCode,
|
|
],
|
|
})),
|
|
};
|
|
}
|
|
|
|
return {
|
|
headers: [
|
|
"#",
|
|
timestampHeader,
|
|
"Status",
|
|
"Latitude",
|
|
"Longitude",
|
|
"North Δ",
|
|
"East Δ",
|
|
],
|
|
rows: decoded.rows.map((row) => ({
|
|
muted: row.status === "No fix" || row.ambiguous,
|
|
values: [
|
|
row.index + 1,
|
|
timestampText(row.epoch, localTime),
|
|
row.status,
|
|
row.latitude === null ? "—" : row.latitude.toFixed(7),
|
|
row.longitude === null ? "—" : row.longitude.toFixed(7),
|
|
`${row.northMeters} m`,
|
|
`${row.eastMeters} m`,
|
|
],
|
|
})),
|
|
};
|
|
}
|
|
|
|
function summaryItems(decoded, localTime) {
|
|
const lastEpoch = decoded.rows.length === 0
|
|
? decoded.firstEpoch : decoded.rows[decoded.rows.length - 1].epoch;
|
|
const items = [
|
|
[
|
|
"Payload",
|
|
typeof decoded.typeCode === "string"
|
|
? `${decoded.label} (${decoded.typeCode})`
|
|
: `${decoded.label} (0x${decoded.typeCode.toString(16)})`,
|
|
],
|
|
["Samples", String(decoded.count)],
|
|
["Interval", `${decoded.intervalMinutes} minutes`],
|
|
["First sample", timestampText(decoded.firstEpoch, localTime)],
|
|
["Last sample", timestampText(lastEpoch, localTime)],
|
|
["Decoded size", `${decoded.byteLength} bytes`],
|
|
];
|
|
if (decoded.pageCount) {
|
|
items.splice(2, 0, ["Merged inputs", String(decoded.pageCount)]);
|
|
}
|
|
if (decoded.channel) {
|
|
items.splice(1, 0, ["LPP channel", String(decoded.channel)]);
|
|
}
|
|
if (decoded.sourceId) {
|
|
items.splice(1, 0, ["Source ID", decoded.sourceId]);
|
|
items.push([
|
|
"Input",
|
|
decoded.pageCount
|
|
? `${decoded.pageCount} merged telemetry packets`
|
|
: decoded.packet
|
|
? `MeshCore ${decoded.packet.routeName}, ${decoded.packet.hopCount} path hops`
|
|
: decoded.payloadOffset === 0
|
|
? "Telemetry payload hex"
|
|
: `Embedded payload at byte ${decoded.payloadOffset}`,
|
|
]);
|
|
if (decoded.packet) {
|
|
items.push([
|
|
"Packet header",
|
|
`0x${decoded.packet.headerByte.toString(16).padStart(2, "0")} / payload v${decoded.packet.payloadVersion}`,
|
|
]);
|
|
}
|
|
}
|
|
if (decoded.kind === "gps") {
|
|
items.push([
|
|
"Origin",
|
|
decoded.originIndex === 0xff
|
|
? "No fix on page"
|
|
: `row ${decoded.originIndex + 1}: ${(decoded.originLatE7 / 1e7).toFixed(7)}, ${(decoded.originLonE7 / 1e7).toFixed(7)}`,
|
|
]);
|
|
items.push(["GPS flags", `0x${decoded.flags.toString(16).padStart(2, "0")}`]);
|
|
}
|
|
return items;
|
|
}
|
|
|
|
function csvCell(value) {
|
|
const text = String(value);
|
|
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
}
|
|
|
|
function modelToCsv(model) {
|
|
return [model.headers, ...model.rows.map((row) => row.values)]
|
|
.map((row) => row.map(csvCell).join(","))
|
|
.join("\r\n");
|
|
}
|
|
|
|
function appendCell(row, tagName, value) {
|
|
const cell = document.createElement(tagName);
|
|
cell.textContent = String(value);
|
|
row.appendChild(cell);
|
|
}
|
|
|
|
function initializeDecoder() {
|
|
const root = document.querySelector("[data-telemetry-decoder]");
|
|
if (!root) return;
|
|
|
|
// This is trusted, static markup; pasted telemetry is rendered as text below.
|
|
root.innerHTML = DECODER_MARKUP;
|
|
|
|
const input = root.querySelector("[data-role='input']");
|
|
const decodeButton = root.querySelector("[data-role='decode']");
|
|
const clearButton = root.querySelector("[data-role='clear']");
|
|
const downloadButton = root.querySelector("[data-role='download']");
|
|
const localTime = root.querySelector("[data-role='local-time']");
|
|
const errorBox = root.querySelector("[data-role='error']");
|
|
const results = root.querySelector("[data-role='results']");
|
|
const resultTitle = root.querySelector("[data-role='result-title']");
|
|
const summary = root.querySelector("[data-role='summary']");
|
|
const warningBox = root.querySelector("[data-role='warnings']");
|
|
const warningList = root.querySelector("[data-role='warning-list']");
|
|
const table = root.querySelector("[data-role='table']");
|
|
let current = null;
|
|
|
|
root.querySelectorAll("[data-example-reply]").forEach((element) => {
|
|
const example = EXAMPLES[element.getAttribute("data-example-reply")];
|
|
if (example) element.textContent = example.reply;
|
|
});
|
|
|
|
function hideError() {
|
|
errorBox.hidden = true;
|
|
errorBox.textContent = "";
|
|
}
|
|
|
|
function showError(error) {
|
|
current = null;
|
|
results.hidden = true;
|
|
errorBox.textContent = error instanceof Error ? error.message : String(error);
|
|
errorBox.hidden = false;
|
|
}
|
|
|
|
function render() {
|
|
if (!current) return;
|
|
const useLocalTime = localTime.checked;
|
|
const model = tableModel(current, useLocalTime);
|
|
resultTitle.textContent = `${current.label} telemetry`;
|
|
|
|
summary.replaceChildren();
|
|
summaryItems(current, useLocalTime).forEach(([term, description]) => {
|
|
const item = document.createElement("div");
|
|
const dt = document.createElement("dt");
|
|
const dd = document.createElement("dd");
|
|
dt.textContent = term;
|
|
dd.textContent = description;
|
|
item.append(dt, dd);
|
|
summary.appendChild(item);
|
|
});
|
|
|
|
warningList.replaceChildren();
|
|
current.warnings.forEach((warning) => {
|
|
const item = document.createElement("li");
|
|
item.textContent = warning;
|
|
warningList.appendChild(item);
|
|
});
|
|
warningBox.hidden = current.warnings.length === 0;
|
|
|
|
table.replaceChildren();
|
|
const head = document.createElement("thead");
|
|
const headRow = document.createElement("tr");
|
|
model.headers.forEach((header) => appendCell(headRow, "th", header));
|
|
head.appendChild(headRow);
|
|
table.appendChild(head);
|
|
|
|
const body = document.createElement("tbody");
|
|
model.rows.forEach((modelRow) => {
|
|
const row = document.createElement("tr");
|
|
if (modelRow.muted) row.classList.add("telemetry-row-muted");
|
|
modelRow.values.forEach((value) => appendCell(row, "td", value));
|
|
body.appendChild(row);
|
|
});
|
|
table.appendChild(body);
|
|
results.hidden = false;
|
|
}
|
|
|
|
function decodeInput() {
|
|
hideError();
|
|
try {
|
|
current = decodeTelemetry(input.value);
|
|
render();
|
|
} catch (error) {
|
|
showError(error);
|
|
}
|
|
}
|
|
|
|
decodeButton.addEventListener("click", decodeInput);
|
|
clearButton.addEventListener("click", () => {
|
|
input.value = "";
|
|
current = null;
|
|
results.hidden = true;
|
|
hideError();
|
|
input.focus();
|
|
});
|
|
localTime.addEventListener("change", render);
|
|
input.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
|
event.preventDefault();
|
|
decodeInput();
|
|
}
|
|
});
|
|
|
|
root.querySelectorAll("[data-telemetry-example]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const example = EXAMPLES[button.getAttribute("data-telemetry-example")];
|
|
if (!example) return;
|
|
input.value = example.reply;
|
|
decodeInput();
|
|
});
|
|
});
|
|
|
|
downloadButton.addEventListener("click", () => {
|
|
if (!current) return;
|
|
const csv = modelToCsv(tableModel(current, localTime.checked));
|
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
const sourcePart = current.sourceId ? `${current.sourceId.toLowerCase()}-` : "";
|
|
const channelPart = current.channel ? `-ch${current.channel}` : "";
|
|
link.download = `meshcore-telemetry-${sourcePart}${current.kind}${channelPart}.csv`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
const requestedExample = new URLSearchParams(global.location.search).get("example");
|
|
if (requestedExample && EXAMPLES[requestedExample]) {
|
|
input.value = EXAMPLES[requestedExample].reply;
|
|
decodeInput();
|
|
}
|
|
}
|
|
|
|
const api = Object.freeze({
|
|
EXAMPLES,
|
|
TelemetryDecodeError,
|
|
decodeTelemetry,
|
|
decodeRawTelemetryHex,
|
|
extractHexBytes,
|
|
extractBase64,
|
|
extractBase64Payloads,
|
|
mergeDecodedTelemetry,
|
|
tableModel,
|
|
modelToCsv,
|
|
});
|
|
|
|
global.MeshCoreTelemetryDecoder = api;
|
|
if (typeof module === "object" && module.exports) module.exports = api;
|
|
|
|
if (typeof document !== "undefined") {
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", initializeDecoder, { once: true });
|
|
} else {
|
|
initializeDecoder();
|
|
}
|
|
}
|
|
})(typeof globalThis !== "undefined" ? globalThis : this);
|