Add telemetry history decoder page
This commit is contained in:
1 parent
81ff965c7c
commit
64f9942001
8 files changed
+1052
-5
No files matched your search
@@ -5,6 +5,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- keymindCascade
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -23,6 +24,11 @@ jobs:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
MKDOCS_SITE_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/
|
||||
MKDOCS_REPO_NAME: ${{ github.repository }}
|
||||
MKDOCS_REPO_URL: https://github.com/${{ github.repository }}/
|
||||
MKDOCS_EDIT_URI: edit/${{ github.ref_name }}/docs/
|
||||
run: |
|
||||
pip install mkdocs-material
|
||||
mkdocs build
|
||||
@@ -31,6 +37,8 @@ jobs:
|
||||
uses: peaceiris/actions-gh-pages@v4.1.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
cname: docs.meshcore.io
|
||||
# Preserve the upstream custom domain, but let forks publish at their
|
||||
# normal <owner>.github.io/<repo>/ URL without claiming that domain.
|
||||
cname: ${{ github.repository_owner == 'meshcore-dev' && 'docs.meshcore.io' || '' }}
|
||||
publish_dir: ./site
|
||||
publish_branch: 'gh-pages'
|
||||
@@ -6,6 +6,7 @@
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
out/
|
||||
site/
|
||||
# Local firmware archives and build logs
|
||||
out.*/
|
||||
build-logs/
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,243 @@
|
||||
.telemetry-tool {
|
||||
--telemetry-border: color-mix(in srgb, var(--md-default-fg-color) 18%, transparent);
|
||||
--telemetry-soft-bg: color-mix(in srgb, var(--md-default-fg-color) 5%, transparent);
|
||||
--telemetry-accent-bg: color-mix(in srgb, var(--md-primary-fg-color) 12%, transparent);
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--telemetry-border);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--md-default-bg-color);
|
||||
box-shadow: 0 0.15rem 0.6rem color-mix(in srgb, #000 10%, transparent);
|
||||
}
|
||||
|
||||
.telemetry-tool [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.telemetry-tool label {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.telemetry-tool textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 8rem;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
padding: 0.8rem;
|
||||
border: 1px solid var(--telemetry-border);
|
||||
border-radius: 0.4rem;
|
||||
color: var(--md-code-fg-color);
|
||||
background: var(--md-code-bg-color);
|
||||
font: 0.82rem/1.55 var(--md-code-font-family);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.telemetry-tool textarea:focus-visible,
|
||||
.telemetry-tool button:focus-visible,
|
||||
.telemetry-tool input:focus-visible {
|
||||
outline: 0.15rem solid var(--md-accent-fg-color);
|
||||
outline-offset: 0.12rem;
|
||||
}
|
||||
|
||||
.telemetry-tool-help {
|
||||
margin: 0.4rem 0 0;
|
||||
color: color-mix(in srgb, var(--md-default-fg-color) 70%, transparent);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.telemetry-actions,
|
||||
.telemetry-examples {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.telemetry-tool button {
|
||||
appearance: none;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.42rem 0.75rem;
|
||||
border: 1px solid var(--telemetry-border);
|
||||
border-radius: 0.35rem;
|
||||
color: var(--md-default-fg-color);
|
||||
background: var(--telemetry-soft-bg);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.telemetry-tool button:hover {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
background: var(--telemetry-accent-bg);
|
||||
}
|
||||
|
||||
.telemetry-tool .telemetry-primary-action {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
color: var(--md-primary-bg-color);
|
||||
background: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.telemetry-tool .telemetry-primary-action:hover {
|
||||
filter: brightness(1.12);
|
||||
background: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.telemetry-local-time {
|
||||
display: inline-flex !important;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin: 0 0 0 auto !important;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.telemetry-local-time input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.telemetry-error,
|
||||
.telemetry-warnings {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-left: 0.25rem solid;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.telemetry-error {
|
||||
border-color: #d32f2f;
|
||||
color: color-mix(in srgb, #d32f2f 82%, var(--md-default-fg-color));
|
||||
background: color-mix(in srgb, #d32f2f 10%, transparent);
|
||||
}
|
||||
|
||||
.telemetry-warnings {
|
||||
border-color: #ed9b00;
|
||||
background: color-mix(in srgb, #ed9b00 11%, transparent);
|
||||
}
|
||||
|
||||
.telemetry-warnings strong {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.telemetry-warnings ul {
|
||||
margin: 0.25rem 0 0 1rem;
|
||||
}
|
||||
|
||||
.telemetry-results {
|
||||
margin-top: 1.35rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--telemetry-border);
|
||||
}
|
||||
|
||||
.telemetry-results-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.telemetry-results-header h2 {
|
||||
margin: 0.8rem 0 0 !important;
|
||||
}
|
||||
|
||||
.telemetry-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.telemetry-summary > div {
|
||||
min-width: 0;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid var(--telemetry-border);
|
||||
border-radius: 0.4rem;
|
||||
background: var(--telemetry-soft-bg);
|
||||
}
|
||||
|
||||
.telemetry-summary dt {
|
||||
color: color-mix(in srgb, var(--md-default-fg-color) 70%, transparent);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.025em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.telemetry-summary dd {
|
||||
margin: 0.2rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.telemetry-table-wrap {
|
||||
max-height: 35rem;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--telemetry-border);
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
.telemetry-tool .telemetry-table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border-collapse: collapse;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.telemetry-tool .telemetry-table th,
|
||||
.telemetry-tool .telemetry-table td {
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-bottom: 1px solid var(--telemetry-border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.telemetry-tool .telemetry-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
color: var(--md-default-fg-color);
|
||||
background: var(--md-default-bg-color);
|
||||
}
|
||||
|
||||
.telemetry-tool .telemetry-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.telemetry-tool .telemetry-table tbody tr:nth-child(even) {
|
||||
background: var(--telemetry-soft-bg);
|
||||
}
|
||||
|
||||
.telemetry-tool .telemetry-row-muted td {
|
||||
color: color-mix(in srgb, var(--md-default-fg-color) 55%, transparent);
|
||||
}
|
||||
|
||||
.telemetry-example-replies code {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@media (max-width: 44rem) {
|
||||
.telemetry-tool {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.telemetry-local-time {
|
||||
width: 100%;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.telemetry-tool button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
@@ -341,6 +341,10 @@ Replies contain `> ` followed by standard padded Base64. After decoding, all
|
||||
multi-byte integers are little-endian. Packed fields are written most
|
||||
significant bit first, oldest sample first.
|
||||
|
||||
Use the browser-based [Telemetry history decoder](telemetry_decoder.md) to
|
||||
turn a reply into a timestamped table or downloadable CSV without uploading
|
||||
the data.
|
||||
|
||||
Temperature payload (`0x11`, 61 bytes):
|
||||
|
||||
| Bytes | Meaning |
|
||||
|
||||
@@ -6,6 +6,7 @@ Below are a few quick start guides.
|
||||
|
||||
- [Frequently Asked Questions](./faq.md)
|
||||
- [CLI Commands](./cli_commands.md)
|
||||
- [Telemetry History Decoder](./telemetry_decoder.md)
|
||||
- [CLI Availability by Firmware Build](./cli_build_matrix.md)
|
||||
- [Easy LoRa OTA: ESP32 full images and nRF52 deltas](./ota_easy.md)
|
||||
- [MeshTower V2 microSD self-updates](./ota_meshtower_v2_sdcard.md)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Telemetry history decoder
|
||||
|
||||
Decode the Base64 reply from MeshCore repeater telemetry commands into a
|
||||
timestamped table. Decoding happens entirely in this browser; the pasted reply
|
||||
is not uploaded or sent anywhere.
|
||||
|
||||
## Commands to run
|
||||
|
||||
Run one of these commands in a local serial CLI or a remote administrator CLI
|
||||
session, then copy the complete reply beginning with `> ` into the decoder.
|
||||
|
||||
| Data | Newest page | Older-page example | Samples per page |
|
||||
|---|---|---|---|
|
||||
| MCU temperature | `get telemetry.temp` | `get telemetry.temp 2` | 48 (24 hours) |
|
||||
| Battery voltage | `get telemetry.volt` | `get telemetry.volt 3` | 48 (24 hours) |
|
||||
| GPS position | `get telemetry.gps` | `get telemetry.gps 2` | 24 (12 hours) |
|
||||
|
||||
Page `1` is always the newest. Temperature and voltage support pages `1`-`7`.
|
||||
GPS normally supports pages `1`-`6`; its configured retention can be changed
|
||||
from one through 30 days:
|
||||
|
||||
```text
|
||||
set telemetry.gps 7
|
||||
get telemetry.gps 1
|
||||
get telemetry.gps 14
|
||||
```
|
||||
|
||||
The GPS setter reports the number of days and pages the device could actually
|
||||
allocate. Telemetry history is boot-local, so a freshly rebooted repeater may
|
||||
reply that its history is empty.
|
||||
|
||||
## Decode a reply
|
||||
|
||||
<div class="telemetry-tool" data-telemetry-decoder>
|
||||
<label for="telemetry-reply-input">CLI reply or Base64 payload</label>
|
||||
<textarea
|
||||
id="telemetry-reply-input"
|
||||
data-role="input"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
placeholder="> paste the Base64 telemetry reply here"
|
||||
aria-describedby="telemetry-input-help"
|
||||
></textarea>
|
||||
<p class="telemetry-tool-help" id="telemetry-input-help">
|
||||
Paste either the complete <code>> ...</code> response or Base64 alone.
|
||||
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-examples" aria-label="Load an example reply">
|
||||
<strong>Try an example:</strong>
|
||||
<button type="button" data-telemetry-example="temperature">Temperature</button>
|
||||
<button type="button" data-telemetry-example="voltage">Voltage</button>
|
||||
<button type="button" data-telemetry-example="gps">GPS</button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
## Example calls and replies
|
||||
|
||||
The buttons above load these synthetic but protocol-valid examples. Real
|
||||
responses have the same `> ` prefix and are auto-detected from payload type
|
||||
`0x11`, `0x12`, or `0x13`.
|
||||
|
||||
### Temperature
|
||||
|
||||
```text
|
||||
get telemetry.temp
|
||||
> EUDUcWoeMAVZVVVVUVVVVVXVVQACTJlSwEyZMmTJkuXKkyZEeNFARIcOFChQoUOHDiRY0aPI/ypUuXMmTA==
|
||||
```
|
||||
|
||||
### Battery voltage
|
||||
|
||||
```text
|
||||
get telemetry.volt 1
|
||||
> EkDUcWoeMAAB5+bl5eTj4uLh4ODf3t7d3Nvb2tnZ2NfX1tXU1NPS0tHQ0M/Ozc3My8vKycnI/w==
|
||||
```
|
||||
|
||||
### GPS
|
||||
|
||||
```text
|
||||
get telemetry.gps 1
|
||||
> EwB9cmoeGIChAxwAR0i3AgAAAAAAAAAAAAAAAAKAAAAAAIAAAAD/9ABAAX/+AAgAYAAAB//wAD/+gAAAA/+wAP/8ABwAEAEABgAAAA//gAX/7AAv/3/8AAf/oAF//QAYACAAgAc=
|
||||
```
|
||||
|
||||
## Reading the table
|
||||
|
||||
- Timestamps default to UTC. Select **Show browser-local time** to convert
|
||||
them for display and CSV export.
|
||||
- Temperature preserves exact whole degrees from `-50 C` through `+77 C`, plus
|
||||
missing, below-range, and above-range states.
|
||||
- Voltage preserves hundredths of a volt from `1.88 V` through `4.40 V`, plus
|
||||
missing and out-of-range states.
|
||||
- GPS positions are reconstructed from signed 10-meter differentials. A zero
|
||||
differential after the page origin is inherently ambiguous: it can represent
|
||||
an unchanged fix, movement below the encoded resolution, or no fix. The table
|
||||
labels those rows rather than inventing a coordinate.
|
||||
- A GPS clipping warning means at least one movement exceeded the differential
|
||||
range, so positions after that point can be less accurate.
|
||||
|
||||
For the byte-level layouts, see
|
||||
[Read repeater telemetry history](cli_commands.md#read-repeater-telemetry-history).
|
||||
+8
-4
@@ -1,10 +1,10 @@
|
||||
site_name: MeshCore Docs
|
||||
site_url: https://meshcore-dev.github.io/meshcore/
|
||||
site_url: !ENV [MKDOCS_SITE_URL, 'https://meshcore-dev.github.io/meshcore/']
|
||||
site_description: Documentation for the open source MeshCore firmware
|
||||
|
||||
repo_name: meshcore-dev/meshcore
|
||||
repo_url: https://github.com/meshcore-dev/meshcore/
|
||||
edit_uri: edit/main/docs/
|
||||
repo_name: !ENV [MKDOCS_REPO_NAME, 'meshcore-dev/meshcore']
|
||||
repo_url: !ENV [MKDOCS_REPO_URL, 'https://github.com/meshcore-dev/meshcore/']
|
||||
edit_uri: !ENV [MKDOCS_EDIT_URI, 'edit/main/docs/']
|
||||
|
||||
theme:
|
||||
name: material
|
||||
@@ -17,3 +17,7 @@ theme:
|
||||
|
||||
extra_css:
|
||||
- _stylesheets/extra.css
|
||||
- _stylesheets/telemetry_decoder.css
|
||||
|
||||
extra_javascript:
|
||||
- _javascript/telemetry_decoder.js
|
||||
Reference in new issue
Block a user