diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index b01ddc939..d53b9c809 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -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 .github.io// URL without claiming that domain. + cname: ${{ github.repository_owner == 'meshcore-dev' && 'docs.meshcore.io' || '' }} publish_dir: ./site publish_branch: 'gh-pages' diff --git a/.gitignore b/.gitignore index 59afd205d..6d0764404 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ .vscode/launch.json .vscode/ipch out/ +site/ # Local firmware archives and build logs out.*/ build-logs/ diff --git a/docs/_javascript/telemetry_decoder.js b/docs/_javascript/telemetry_decoder.js new file mode 100644 index 000000000..6696822a4 --- /dev/null +++ b/docs/_javascript/telemetry_decoder.js @@ -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); diff --git a/docs/_stylesheets/telemetry_decoder.css b/docs/_stylesheets/telemetry_decoder.css new file mode 100644 index 000000000..c6d6e8355 --- /dev/null +++ b/docs/_stylesheets/telemetry_decoder.css @@ -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; + } +} diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 19ca01540..3014631d2 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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 | diff --git a/docs/index.md b/docs/index.md index 80fdd780d..ab3d906d1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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) diff --git a/docs/telemetry_decoder.md b/docs/telemetry_decoder.md new file mode 100644 index 000000000..df202a2b9 --- /dev/null +++ b/docs/telemetry_decoder.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 + +
+ + +

+ Paste either the complete > ... response or Base64 alone. + Press Ctrl/Command+Enter to decode. +

+ +
+ + + +
+ +
+ Try an example: + + + +
+ + + + +
+ +## 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). diff --git a/mkdocs.yml b/mkdocs.yml index 110680cd4..710b9b288 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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