Decode raw telemetry packets

This commit is contained in:
mikecarper
2026-08-14 00:01:38 -07:00
parent 7372ad6c26
commit e92794eabc
5 changed files with 461 additions and 54 deletions
+1
View File
@@ -9,6 +9,7 @@ out/
site/
# Local firmware archives and build logs
out.*/
out-*/
build-logs/
.direnv/
.DS_Store
+283 -4
View File
@@ -4,10 +4,25 @@
const TYPE_TEMPERATURE = 0x11;
const TYPE_VOLTAGE = 0x12;
const TYPE_GPS = 0x13;
const BINARY_TEMPERATURE_MAGIC = "TTB1";
const BINARY_VOLTAGE_MAGIC = "TVB1";
const BINARY_HEADER_SIZE = 19;
const BINARY_MAX_SAMPLES = 165;
const PAYLOAD_TYPE_RAW_CUSTOM = 0x0f;
const METERS_PER_DEGREE = 111320;
const DEGREES_TO_RADIANS = Math.PI / 180;
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",
}),
temperature: Object.freeze({
label: "Temperature example",
command: "get telemetry.temp",
@@ -95,6 +110,78 @@
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) {
return bytes;
}
}
}
if (firstValid) {
throw new TelemetryDecodeError(
"Hex was found, but it does not contain TTB1 temperature or TVB1 voltage telemetry."
);
}
throw new TelemetryDecodeError(
"Raw packet data must contain complete two-character hexadecimal bytes."
);
}
function uint32LE(bytes, offset) {
return (
bytes[offset] |
@@ -157,6 +244,156 @@
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) {
if (bytes.length - offset < BINARY_HEADER_SIZE) {
throw new TelemetryDecodeError("The binary telemetry header is incomplete.");
}
const magic = asciiAt(bytes, offset, 4);
const count = bytes[offset + 18];
const intervalMinutes = bytes[offset + 16] | (bytes[offset + 17] << 8);
if (count < 1 || count > BINARY_MAX_SAMPLES) {
throw new TelemetryDecodeError(
`Binary telemetry sample count ${count} is outside the supported 1-${BINARY_MAX_SAMPLES} range.`
);
}
if (intervalMinutes === 0) {
throw new TelemetryDecodeError("Binary telemetry sample interval cannot be zero.");
}
const payloadLength = BINARY_HEADER_SIZE + count;
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),
};
const rows = [];
for (let index = 0; index < count; index += 1) {
const rawCode = bytes[offset + BINARY_HEADER_SIZE + 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 {
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,
});
}
}
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.");
}
return {
...header,
kind: magic === BINARY_TEMPERATURE_MAGIC ? "temperature" : "voltage",
label: magic === BINARY_TEMPERATURE_MAGIC ? "Temperature" : "Battery voltage",
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) 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);
@@ -353,6 +590,17 @@
}
function decodeTelemetry(input) {
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/i.test(String(input))) {
return decodeRawTelemetryHex(input);
}
const encoded = extractBase64(input);
const bytes = base64ToBytes(encoded);
if (bytes.length === 0) {
@@ -398,7 +646,13 @@
if (decoded.kind === "temperature") {
return {
headers: ["#", timestampHeader, "Status", "Temperature", "Raw status/code"],
headers: [
"#",
timestampHeader,
"Status",
"Temperature",
decoded.format === "binary" ? "Raw code" : "Raw status/code",
],
rows: decoded.rows.map((row) => ({
muted: row.status === "Missing",
values: [
@@ -412,7 +666,7 @@
: row.valueC === null
? "—"
: `${row.valueC} °C`,
`${row.statusCode} / ${row.rawCode}`,
row.statusCode === null ? row.rawCode : `${row.statusCode} / ${row.rawCode}`,
],
})),
};
@@ -468,13 +722,35 @@
function summaryItems(decoded, localTime) {
const lastEpoch = sampleEpoch(decoded, decoded.count - 1);
const items = [
["Payload", `${decoded.label} (0x${decoded.typeCode.toString(16)})`],
[
"Payload",
decoded.format === "binary"
? `${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.format === "binary") {
items.splice(1, 0, ["Repeater ID", decoded.sourceId]);
items.push([
"Input",
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",
@@ -624,7 +900,8 @@
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `meshcore-telemetry-${current.kind}.csv`;
const sourcePart = current.sourceId ? `${current.sourceId.toLowerCase()}-` : "";
link.download = `meshcore-telemetry-${sourcePart}${current.kind}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
@@ -642,6 +919,8 @@
EXAMPLES,
TelemetryDecodeError,
decodeTelemetry,
decodeRawTelemetryHex,
extractHexBytes,
extractBase64,
tableModel,
modelToCsv,
+1 -1
View File
@@ -7,7 +7,7 @@ Below are a few quick start guides.
- [Frequently Asked Questions](./faq.md)
- [CLI Commands](./cli_commands.md)
- [Filter Policy Playground](./filter_tool.md)
- [Telemetry History Decoder](./telemetry_decoder.md)
- [Telemetry 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)
- [Scripted LoRa OTA: Bash and PowerShell](./lora_ota_automation.md)
+52 -49
View File
@@ -1,55 +1,37 @@
# Telemetry history decoder
# Telemetry 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.
Paste raw hexadecimal packet data copied from the
[Let's Mesh packet analyzer](https://analyzer.letsmesh.net/packets) to decode a
repeater's scheduled temperature or battery-voltage snapshot. The decoder also
accepts the payload hex without its MeshCore packet header. Decoding happens
entirely in this browser; pasted data is not uploaded or sent anywhere.
## Commands to run
The **Repeater ID** in the result is the first eight bytes of the sending
repeater's public key. Match that 16-character hex value against the public-key
prefix recorded for your repeaters. It comes from the telemetry payload itself,
so it is available even when only the payload was copied.
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
## Decode a packet
<div class="telemetry-tool" data-telemetry-decoder>
<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>
<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>
</div>
<label for="telemetry-reply-input">CLI reply or Base64 payload</label>
<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 the Base64 telemetry reply here"
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">
Paste either the complete <code>&gt; ...</code> response or Base64 alone.
Spaces, line breaks, colons, dashes, a leading <code>0x</code>, and a quoted
JSON field are accepted. Legacy CLI Base64 replies are also auto-detected.
Press Ctrl/Command+Enter to decode.
</p>
@@ -80,37 +62,53 @@ reply that its history is empty.
</section>
</div>
## Example calls and replies
## Analyzer hex examples
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`.
The buttons load synthetic, protocol-valid zero-hop RAW_CUSTOM packets. A real
scheduled snapshot normally has 165 samples and is much longer. Routed packets
also contain path bytes before the `TTB1` or `TVB1` payload magic; the decoder
finds and validates the payload automatically.
### Temperature
```text
get telemetry.temp
> EUDUcWoeMAVZVVVVUVVVVVXVVQACTJlSwEyZMmTJkuXKkyZEeNFARIcOFChQoUOHDiRY0aPI/ypUuXMmTA==
3E00545442311122334455667788800092651E0008000102354A4E5082
```
### Battery voltage
```text
3E00545642311122334455667788800092651E000800010264C8FEFFDC
```
Both examples identify the source as repeater ID `1122334455667788`.
## Legacy CLI replies
The same page continues to decode the padded Base64 returned by these
administrator commands:
| 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) |
Paste either the complete reply beginning with `> ` or Base64 alone. For
example:
```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.
- `TTB1` means a raw temperature snapshot and `TVB1` means a raw voltage
snapshot. The input summary also reports the MeshCore route and path-hop
count when a complete packet was pasted.
- 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
@@ -122,5 +120,10 @@ get telemetry.gps 1
- A GPS clipping warning means at least one movement exceeded the differential
range, so positions after that point can be less accurate.
GPS history remains available through the administrator CLI, but `telemetry.tx`
never puts GPS in RAW_CUSTOM packets. Therefore analyzer hex decoding supports
only temperature and voltage; location data cannot be recovered through this
page.
For the byte-level layouts, see
[Read repeater telemetry history](cli_commands.md#read-repeater-telemetry-history).
+124
View File
@@ -0,0 +1,124 @@
"use strict";
const assert = require("assert");
const decoder = require("../docs/_javascript/telemetry_decoder.js");
let passed = 0;
function test(name, callback) {
callback();
passed += 1;
process.stdout.write(`ok ${passed} - ${name}\n`);
}
function assertDecoderError(callback, pattern) {
assert.throws(
callback,
(error) => error instanceof decoder.TelemetryDecodeError && pattern.test(error.message)
);
}
const TEMPERATURE_PACKET =
"3E00545442311122334455667788800092651E0008000102354A4E5082";
const VOLTAGE_PACKET =
"3E00545642311122334455667788800092651E000800010264C8FEFFDC";
test("decodes complete analyzer temperature packet hex", () => {
const decoded = decoder.decodeRawTelemetryHex(TEMPERATURE_PACKET);
assert.strictEqual(decoded.kind, "temperature");
assert.strictEqual(decoded.typeCode, "TTB1");
assert.strictEqual(decoded.sourceId, "1122334455667788");
assert.strictEqual(decoded.firstEpoch, 1704067200);
assert.strictEqual(decoded.intervalMinutes, 30);
assert.strictEqual(decoded.count, 8);
assert.strictEqual(decoded.packet.routeName, "direct");
assert.strictEqual(decoded.packet.payloadType, 0x0f);
assert.strictEqual(decoded.packet.hopCount, 0);
assert.deepStrictEqual(
decoded.rows.map((row) => [row.status, row.valueC]),
[
["Missing", null],
["Below range", null],
["Above range", null],
["Value", 0],
["Value", 21],
["Value", 25],
["Value", 27],
["Value", 77],
]
);
});
test("decodes analyzer voltage packet and exact voltage codes", () => {
const decoded = decoder.decodeTelemetry(VOLTAGE_PACKET.toLowerCase());
assert.strictEqual(decoded.kind, "voltage");
assert.strictEqual(decoded.typeCode, "TVB1");
assert.deepStrictEqual(
decoded.rows.map((row) => [row.status, row.millivolts]),
[
["Missing", null],
["Below range", null],
["Value", 1880],
["Value", 2860],
["Value", 3860],
["Value", 4400],
["Above range", null],
["Value", 4060],
]
);
});
test("accepts payload-only spaced hex", () => {
const payload = TEMPERATURE_PACKET.slice(4).match(/.{2}/g).join(" ");
const decoded = decoder.decodeRawTelemetryHex(payload);
assert.strictEqual(decoded.payloadOffset, 0);
assert.strictEqual(decoded.packet, null);
assert.strictEqual(decoded.sourceId, "1122334455667788");
});
test("extracts raw hex from a quoted analyzer-style field", () => {
const decoded = decoder.decodeTelemetry(`{\n "raw": "${VOLTAGE_PACKET}"\n}`);
assert.strictEqual(decoded.kind, "voltage");
assert.strictEqual(decoded.packet.isRawCustom, true);
});
test("recognizes a routed packet and its path", () => {
const routed = `3E4212345678${TEMPERATURE_PACKET.slice(4)}`;
const decoded = decoder.decodeRawTelemetryHex(routed);
assert.strictEqual(decoded.packet.routeName, "direct");
assert.strictEqual(decoded.packet.hopCount, 2);
assert.strictEqual(decoded.packet.pathHashBytes, 2);
assert.strictEqual(decoded.payloadOffset, 6);
});
test("retains legacy CLI Base64 decoding", () => {
const decoded = decoder.decodeTelemetry(decoder.EXAMPLES.voltage.reply);
assert.strictEqual(decoded.kind, "voltage");
assert.strictEqual(decoded.count, 48);
assert.strictEqual(decoded.format, undefined);
});
test("rejects unrelated, truncated, and ambiguous hex", () => {
assertDecoderError(
() => decoder.decodeRawTelemetryHex("00000000000000000000000000000000000000"),
/does not contain TTB1 temperature or TVB1 voltage/
);
assertDecoderError(
() => decoder.decodeRawTelemetryHex(TEMPERATURE_PACKET.slice(0, -2)),
/payload bytes are missing/
);
assertDecoderError(
() => decoder.decodeRawTelemetryHex(`${TEMPERATURE_PACKET}${VOLTAGE_PACKET}`),
/More than one telemetry snapshot/
);
});
test("exports raw telemetry rows as CSV", () => {
const decoded = decoder.decodeRawTelemetryHex(VOLTAGE_PACKET);
const csv = decoder.modelToCsv(decoder.tableModel(decoded, false));
assert.match(csv, /Timestamp \(UTC\)/);
assert.match(csv, /2\.86 V/);
assert.strictEqual(csv.split("\r\n").length, 9);
});
process.stdout.write(`1..${passed}\n`);