This commit is contained in:
mikecarper
2026-08-06 01:20:00 +00:00
parent 84b0a52940
commit 5311b7f4eb
36 changed files with 30288 additions and 354 deletions
+660
View File
@@ -0,0 +1,660 @@
(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);