diff --git a/.gitignore b/.gitignore index 6d076440..1ac1012c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ out/ site/ # Local firmware archives and build logs out.*/ +out-*/ build-logs/ .direnv/ .DS_Store diff --git a/docs/_javascript/telemetry_decoder.js b/docs/_javascript/telemetry_decoder.js index 6696822a..91a667d1 100644 --- a/docs/_javascript/telemetry_decoder.js +++ b/docs/_javascript/telemetry_decoder.js @@ -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, diff --git a/docs/index.md b/docs/index.md index 9d4cc13d..61edebe3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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) diff --git a/docs/telemetry_decoder.md b/docs/telemetry_decoder.md index 1145a89e..b3a8cb22 100644 --- a/docs/telemetry_decoder.md +++ b/docs/telemetry_decoder.md @@ -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
- Paste either the complete > ... response or Base64 alone.
+ Spaces, line breaks, colons, dashes, a leading 0x, and a quoted
+ JSON field are accepted. Legacy CLI Base64 replies are also auto-detected.
Press Ctrl/Command+Enter to decode.