mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-10 02:15:40 +00:00
Decode raw telemetry packets
This commit is contained in:
@@ -9,6 +9,7 @@ out/
|
||||
site/
|
||||
# Local firmware archives and build logs
|
||||
out.*/
|
||||
out-*/
|
||||
build-logs/
|
||||
.direnv/
|
||||
.DS_Store
|
||||
|
||||
@@ -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
@@ -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
@@ -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>> ...</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).
|
||||
|
||||
@@ -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`);
|
||||
Reference in New Issue
Block a user