(function (global) { "use strict"; const TYPE_TEMPERATURE = 0x11; const TYPE_VOLTAGE = 0x12; const TYPE_GPS = 0x13; const METERS_PER_DEGREE = 111320; const DEGREES_TO_RADIANS = Math.PI / 180; const EXAMPLES = Object.freeze({ 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==", }), 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 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 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 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 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 decodeTelemetry(input) { const encoded = extractBase64(input); 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 { throw new TelemetryDecodeError( `Unsupported telemetry type 0x${header.typeCode.toString(16).padStart(2, "0")}; expected 0x11, 0x12, or 0x13.` ); } decoded.encoded = encoded; return decoded; } 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", "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} / ${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, ], })), }; } 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 = sampleEpoch(decoded, decoded.count - 1); const items = [ ["Payload", `${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.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; 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; link.download = `meshcore-telemetry-${current.kind}.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, extractBase64, 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);