feat: retain multi-channel voltage telemetry
This commit is contained in:
1 parent
ad1ff3f03e
commit
131bbafc67
17 files changed
+1603
-138
No files matched your search
@@ -4,10 +4,15 @@
|
||||
const TYPE_TEMPERATURE = 0x11;
|
||||
const TYPE_VOLTAGE = 0x12;
|
||||
const TYPE_GPS = 0x13;
|
||||
const TYPE_EXTERNAL_VOLTAGE = 0x14;
|
||||
const BINARY_TEMPERATURE_MAGIC = "TTB1";
|
||||
const BINARY_VOLTAGE_MAGIC = "TVB1";
|
||||
const BINARY_EXTERNAL_VOLTAGE_MAGIC = "IVB1";
|
||||
const BINARY_HEADER_SIZE = 19;
|
||||
const BINARY_MAX_SAMPLES = 165;
|
||||
const BINARY_EXTERNAL_HEADER_SIZE = 20;
|
||||
const BINARY_EXTERNAL_MAX_SAMPLES = 64;
|
||||
const EXTERNAL_VOLTAGE_BITS = 15;
|
||||
const PAYLOAD_TYPE_RAW_CUSTOM = 0x0f;
|
||||
const METERS_PER_DEGREE = 111320;
|
||||
const DEGREES_TO_RADIANS = Math.PI / 180;
|
||||
@@ -23,6 +28,12 @@
|
||||
command: "send telemetry.tx now",
|
||||
reply: "3E00545642311122334455667788800092651E000800010264C8FEFFDC",
|
||||
}),
|
||||
packetExternalVoltage: Object.freeze({
|
||||
label: "Analyzer I²C voltage packet",
|
||||
command: "send telemetry.tx now",
|
||||
reply:
|
||||
"3E00495642311122334455667788800092651E00020800000004019026927109C427107FFF",
|
||||
}),
|
||||
temperature: Object.freeze({
|
||||
label: "Temperature example",
|
||||
command: "get telemetry.temp",
|
||||
@@ -35,6 +46,12 @@
|
||||
reply:
|
||||
"> EkDUcWoeMAAB5+bl5eTj4uLh4ODf3t7d3Nvb2tnZ2NfX1tXU1NPS0tHQ0M/Ozc3My8vKycnI/w==",
|
||||
}),
|
||||
externalVoltage: Object.freeze({
|
||||
label: "I²C voltage example",
|
||||
command: "get telemetry.volt.i2c 2",
|
||||
reply:
|
||||
"> FIAAkmUeMAIAAAAEE0gfcD8AfkD9AfsD+Af0D/Af8EAAgEEBAgMECAgUEDAgcEEAgkEFAgsEGAg0EHAg8EIAhEEJAhMEKAhUELAhcEMAhkENAhsEOAh0EPAh8EQAiEERf/8=",
|
||||
}),
|
||||
gps: Object.freeze({
|
||||
label: "GPS example",
|
||||
command: "get telemetry.gps",
|
||||
@@ -89,6 +106,16 @@
|
||||
return text;
|
||||
}
|
||||
|
||||
function extractBase64Payloads(input) {
|
||||
if (typeof input !== "string") return [extractBase64(input)];
|
||||
const replies = input
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /^>\s*[A-Za-z0-9+/]+={0,2}$/.test(line))
|
||||
.map((line) => line.replace(/^>\s*/, ""));
|
||||
return replies.length === 0 ? [extractBase64(input)] : replies;
|
||||
}
|
||||
|
||||
function base64ToBytes(encoded) {
|
||||
let binary;
|
||||
try {
|
||||
@@ -166,7 +193,9 @@
|
||||
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) {
|
||||
if (magic === BINARY_TEMPERATURE_MAGIC
|
||||
|| magic === BINARY_VOLTAGE_MAGIC
|
||||
|| magic === BINARY_EXTERNAL_VOLTAGE_MAGIC) {
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -174,7 +203,7 @@
|
||||
|
||||
if (firstValid) {
|
||||
throw new TelemetryDecodeError(
|
||||
"Hex was found, but it does not contain TTB1 temperature or TVB1 voltage telemetry."
|
||||
"Hex was found, but it does not contain TTB1, TVB1, or IVB1 telemetry."
|
||||
);
|
||||
}
|
||||
throw new TelemetryDecodeError(
|
||||
@@ -276,21 +305,28 @@
|
||||
}
|
||||
|
||||
function decodeBinarySnapshot(bytes, offset) {
|
||||
if (bytes.length - offset < BINARY_HEADER_SIZE) {
|
||||
const magic = asciiAt(bytes, offset, 4);
|
||||
const externalVoltage = magic === BINARY_EXTERNAL_VOLTAGE_MAGIC;
|
||||
const headerSize = externalVoltage
|
||||
? BINARY_EXTERNAL_HEADER_SIZE : BINARY_HEADER_SIZE;
|
||||
const maximumSamples = externalVoltage
|
||||
? BINARY_EXTERNAL_MAX_SAMPLES : BINARY_MAX_SAMPLES;
|
||||
if (bytes.length - offset < headerSize) {
|
||||
throw new TelemetryDecodeError("The binary telemetry header is incomplete.");
|
||||
}
|
||||
const magic = asciiAt(bytes, offset, 4);
|
||||
const count = bytes[offset + 18];
|
||||
const count = bytes[offset + (externalVoltage ? 19 : 18)];
|
||||
const intervalMinutes = bytes[offset + 16] | (bytes[offset + 17] << 8);
|
||||
if (count < 1 || count > BINARY_MAX_SAMPLES) {
|
||||
if (count < 1 || count > maximumSamples) {
|
||||
throw new TelemetryDecodeError(
|
||||
`Binary telemetry sample count ${count} is outside the supported 1-${BINARY_MAX_SAMPLES} range.`
|
||||
`Binary telemetry sample count ${count} is outside the supported 1-${maximumSamples} range.`
|
||||
);
|
||||
}
|
||||
if (intervalMinutes === 0) {
|
||||
throw new TelemetryDecodeError("Binary telemetry sample interval cannot be zero.");
|
||||
}
|
||||
const payloadLength = BINARY_HEADER_SIZE + count;
|
||||
const dataLength = externalVoltage
|
||||
? Math.ceil((count * EXTERNAL_VOLTAGE_BITS) / 8) : count;
|
||||
const payloadLength = headerSize + dataLength;
|
||||
if (bytes.length - offset < payloadLength) {
|
||||
throw new TelemetryDecodeError(
|
||||
`Binary telemetry declares ${count} samples but ${payloadLength - (bytes.length - offset)} payload bytes are missing.`
|
||||
@@ -309,10 +345,15 @@
|
||||
inputByteLength: bytes.length,
|
||||
trailingBytes: bytes.length - offset - payloadLength,
|
||||
packet: decodePacketEnvelope(bytes, offset),
|
||||
channel: externalVoltage ? bytes[offset + 18] : null,
|
||||
};
|
||||
const rows = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const rawCode = bytes[offset + BINARY_HEADER_SIZE + index];
|
||||
const rawCode = externalVoltage
|
||||
? readPackedBits(bytes, offset + headerSize,
|
||||
index * EXTERNAL_VOLTAGE_BITS,
|
||||
EXTERNAL_VOLTAGE_BITS)
|
||||
: bytes[offset + headerSize + index];
|
||||
if (magic === BINARY_TEMPERATURE_MAGIC) {
|
||||
let status = "Value";
|
||||
let valueC = null;
|
||||
@@ -329,7 +370,7 @@
|
||||
rawCode,
|
||||
valueC,
|
||||
});
|
||||
} else {
|
||||
} else if (magic === BINARY_VOLTAGE_MAGIC) {
|
||||
let status = "Value";
|
||||
let millivolts = null;
|
||||
if (rawCode === 0) status = "Missing";
|
||||
@@ -343,6 +384,14 @@
|
||||
rawCode,
|
||||
millivolts,
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
index,
|
||||
epoch: sampleEpoch(header, index),
|
||||
status: rawCode === 0 ? "Missing" : "Value",
|
||||
rawCode,
|
||||
millivolts: rawCode === 0 ? null : rawCode * 20,
|
||||
});
|
||||
}
|
||||
}
|
||||
const warnings = [];
|
||||
@@ -358,10 +407,19 @@
|
||||
&& rows.some((row) => row.status === "Reserved code")) {
|
||||
warnings.push("One or more temperature samples use a reserved code and may be corrupt.");
|
||||
}
|
||||
let kind = "voltage";
|
||||
let label = "Battery voltage";
|
||||
if (magic === BINARY_TEMPERATURE_MAGIC) {
|
||||
kind = "temperature";
|
||||
label = "Temperature";
|
||||
} else if (externalVoltage) {
|
||||
kind = "external-voltage";
|
||||
label = `I²C voltage channel ${header.channel}`;
|
||||
}
|
||||
return {
|
||||
...header,
|
||||
kind: magic === BINARY_TEMPERATURE_MAGIC ? "temperature" : "voltage",
|
||||
label: magic === BINARY_TEMPERATURE_MAGIC ? "Temperature" : "Battery voltage",
|
||||
kind,
|
||||
label,
|
||||
rows,
|
||||
warnings,
|
||||
};
|
||||
@@ -373,7 +431,9 @@
|
||||
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;
|
||||
if (magic !== BINARY_TEMPERATURE_MAGIC
|
||||
&& magic !== BINARY_VOLTAGE_MAGIC
|
||||
&& magic !== BINARY_EXTERNAL_VOLTAGE_MAGIC) continue;
|
||||
try {
|
||||
decodedCandidates.push(decodeBinarySnapshot(bytes, offset));
|
||||
} catch (error) {
|
||||
@@ -470,6 +530,42 @@
|
||||
};
|
||||
}
|
||||
|
||||
function decodeExternalVoltage(bytes, header) {
|
||||
const headerSize = 8;
|
||||
const dataBytes = Math.ceil(
|
||||
(header.count * EXTERNAL_VOLTAGE_BITS) / 8
|
||||
);
|
||||
assertLength(bytes, headerSize + dataBytes, "I²C voltage");
|
||||
const channel = bytes[7];
|
||||
if (channel === 0) {
|
||||
throw new TelemetryDecodeError("I²C voltage channel cannot be zero.");
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (let index = 0; index < header.count; index += 1) {
|
||||
const rawCode = readPackedBits(
|
||||
bytes, headerSize, index * EXTERNAL_VOLTAGE_BITS,
|
||||
EXTERNAL_VOLTAGE_BITS
|
||||
);
|
||||
rows.push({
|
||||
index,
|
||||
epoch: sampleEpoch(header, index),
|
||||
status: rawCode === 0 ? "Missing" : "Value",
|
||||
rawCode,
|
||||
millivolts: rawCode === 0 ? null : rawCode * 20,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...header,
|
||||
channel,
|
||||
kind: "external-voltage",
|
||||
label: `I²C voltage channel ${channel}`,
|
||||
rows,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
function decodeGps(bytes, header) {
|
||||
if (bytes.length < 17) {
|
||||
throw new TelemetryDecodeError("GPS payload is shorter than its 17-byte header.");
|
||||
@@ -589,19 +685,7 @@
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
function decodeBase64Telemetry(encoded) {
|
||||
const bytes = base64ToBytes(encoded);
|
||||
if (bytes.length === 0) {
|
||||
throw new TelemetryDecodeError("The Base64 payload decoded to zero bytes.");
|
||||
@@ -615,15 +699,91 @@
|
||||
decoded = decodeVoltage(bytes, header);
|
||||
} else if (header.typeCode === TYPE_GPS) {
|
||||
decoded = decodeGps(bytes, header);
|
||||
} else if (header.typeCode === TYPE_EXTERNAL_VOLTAGE) {
|
||||
decoded = decodeExternalVoltage(bytes, header);
|
||||
} else {
|
||||
throw new TelemetryDecodeError(
|
||||
`Unsupported telemetry type 0x${header.typeCode.toString(16).padStart(2, "0")}; expected 0x11, 0x12, or 0x13.`
|
||||
`Unsupported telemetry type 0x${header.typeCode.toString(16).padStart(2, "0")}; expected 0x11, 0x12, 0x13, or 0x14.`
|
||||
);
|
||||
}
|
||||
decoded.encoded = encoded;
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function mergeDecodedTelemetry(decodedItems) {
|
||||
if (decodedItems.length === 1) return decodedItems[0];
|
||||
const first = decodedItems[0];
|
||||
decodedItems.slice(1).forEach((item) => {
|
||||
const sameSource = (item.sourceId || null) === (first.sourceId || null);
|
||||
const sameChannel = (item.channel || null) === (first.channel || null);
|
||||
if (item.kind !== first.kind || item.typeCode !== first.typeCode
|
||||
|| item.intervalMinutes !== first.intervalMinutes
|
||||
|| !sameSource || !sameChannel) {
|
||||
throw new TelemetryDecodeError(
|
||||
"Multiple telemetry replies must use the same type, source, channel, and interval."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const rowsByEpoch = new Map();
|
||||
decodedItems.forEach((item) => {
|
||||
item.rows.forEach((row) => rowsByEpoch.set(row.epoch, row));
|
||||
});
|
||||
const rows = Array.from(rowsByEpoch.values())
|
||||
.sort((left, right) => left.epoch - right.epoch)
|
||||
.map((row, index) => ({ ...row, index }));
|
||||
if (rows.length === 0) {
|
||||
throw new TelemetryDecodeError("The telemetry replies contain no samples.");
|
||||
}
|
||||
|
||||
return {
|
||||
...first,
|
||||
firstEpoch: rows[0].epoch,
|
||||
count: rows.length,
|
||||
byteLength: decodedItems.reduce(
|
||||
(total, item) => total + item.byteLength, 0
|
||||
),
|
||||
format: "merged",
|
||||
pageCount: decodedItems.length,
|
||||
packet: null,
|
||||
payloadOffset: 0,
|
||||
trailingBytes: 0,
|
||||
rows,
|
||||
warnings: Array.from(new Set(
|
||||
decodedItems.flatMap((item) => item.warnings || [])
|
||||
)),
|
||||
encoded: decodedItems.map((item) => item.encoded).join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeTelemetry(input) {
|
||||
const rawLines = typeof input === "string"
|
||||
? input.split(/\r?\n/).filter(
|
||||
(line) => /54544231|54564231|49564231/i.test(line)
|
||||
)
|
||||
: [];
|
||||
if (rawLines.length > 1) {
|
||||
return mergeDecodedTelemetry(
|
||||
rawLines.map((line) => decodeRawTelemetryHex(line))
|
||||
);
|
||||
}
|
||||
|
||||
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|49564231/i.test(String(input))) {
|
||||
return decodeRawTelemetryHex(input);
|
||||
}
|
||||
return mergeDecodedTelemetry(
|
||||
extractBase64Payloads(input).map(decodeBase64Telemetry)
|
||||
);
|
||||
}
|
||||
|
||||
function timestampText(epoch, localTime) {
|
||||
const date = new Date(epoch * 1000);
|
||||
if (!Number.isFinite(date.getTime())) return "Invalid timestamp";
|
||||
@@ -651,7 +811,7 @@
|
||||
timestampHeader,
|
||||
"Status",
|
||||
"Temperature",
|
||||
decoded.format === "binary" ? "Raw code" : "Raw status/code",
|
||||
decoded.sourceId ? "Raw code" : "Raw status/code",
|
||||
],
|
||||
rows: decoded.rows.map((row) => ({
|
||||
muted: row.status === "Missing",
|
||||
@@ -694,6 +854,29 @@
|
||||
};
|
||||
}
|
||||
|
||||
if (decoded.kind === "external-voltage") {
|
||||
return {
|
||||
headers: [
|
||||
"#",
|
||||
timestampHeader,
|
||||
"Status",
|
||||
`I²C voltage (channel ${decoded.channel})`,
|
||||
"Raw code",
|
||||
],
|
||||
rows: decoded.rows.map((row) => ({
|
||||
muted: row.status === "Missing",
|
||||
values: [
|
||||
row.index + 1,
|
||||
timestampText(row.epoch, localTime),
|
||||
row.status,
|
||||
row.millivolts === null
|
||||
? "—" : `${(row.millivolts / 1000).toFixed(2)} V`,
|
||||
row.rawCode,
|
||||
],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
headers: [
|
||||
"#",
|
||||
@@ -720,11 +903,12 @@
|
||||
}
|
||||
|
||||
function summaryItems(decoded, localTime) {
|
||||
const lastEpoch = sampleEpoch(decoded, decoded.count - 1);
|
||||
const lastEpoch = decoded.rows.length === 0
|
||||
? decoded.firstEpoch : decoded.rows[decoded.rows.length - 1].epoch;
|
||||
const items = [
|
||||
[
|
||||
"Payload",
|
||||
decoded.format === "binary"
|
||||
typeof decoded.typeCode === "string"
|
||||
? `${decoded.label} (${decoded.typeCode})`
|
||||
: `${decoded.label} (0x${decoded.typeCode.toString(16)})`,
|
||||
],
|
||||
@@ -734,11 +918,19 @@
|
||||
["Last sample", timestampText(lastEpoch, localTime)],
|
||||
["Decoded size", `${decoded.byteLength} bytes`],
|
||||
];
|
||||
if (decoded.format === "binary") {
|
||||
items.splice(1, 0, ["Repeater ID", decoded.sourceId]);
|
||||
if (decoded.pageCount) {
|
||||
items.splice(2, 0, ["Merged inputs", String(decoded.pageCount)]);
|
||||
}
|
||||
if (decoded.channel) {
|
||||
items.splice(1, 0, ["LPP channel", String(decoded.channel)]);
|
||||
}
|
||||
if (decoded.sourceId) {
|
||||
items.splice(1, 0, ["Source ID", decoded.sourceId]);
|
||||
items.push([
|
||||
"Input",
|
||||
decoded.packet
|
||||
decoded.pageCount
|
||||
? `${decoded.pageCount} merged telemetry packets`
|
||||
: decoded.packet
|
||||
? `MeshCore ${decoded.packet.routeName}, ${decoded.packet.hopCount} path hops`
|
||||
: decoded.payloadOffset === 0
|
||||
? "Telemetry payload hex"
|
||||
@@ -901,7 +1093,8 @@
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
const sourcePart = current.sourceId ? `${current.sourceId.toLowerCase()}-` : "";
|
||||
link.download = `meshcore-telemetry-${sourcePart}${current.kind}.csv`;
|
||||
const channelPart = current.channel ? `-ch${current.channel}` : "";
|
||||
link.download = `meshcore-telemetry-${sourcePart}${current.kind}${channelPart}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
@@ -922,6 +1115,8 @@
|
||||
decodeRawTelemetryHex,
|
||||
extractHexBytes,
|
||||
extractBase64,
|
||||
extractBase64Payloads,
|
||||
mergeDecodedTelemetry,
|
||||
tableModel,
|
||||
modelToCsv,
|
||||
});
|
||||
|
||||
@@ -68,7 +68,8 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
|
||||
| Statistics | [`stats-radio`](cli_commands.md#stats-radio) | Local serial | Serial | Serial | Serial |
|
||||
| Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local serial | Serial | Serial | Serial |
|
||||
| Statistics | [`stats-packets`](cli_commands.md#stats-packets) | Local serial | Serial | Serial | Serial |
|
||||
| Statistics | [`get telemetry.temp/volt`; optional GPS history; `get/set telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS commands require a GPS provider; remote access requires administrator | Yes | Yes | Yes |
|
||||
| Statistics | [`get telemetry.temp/volt/volt.i2c`; optional GPS history](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater or room server; I2C command requires a detected voltage monitor; GPS requires a provider; remote access requires administrator | Yes | Yes | Yes |
|
||||
| Statistics | [`set telemetry.gps`; `get/set/send telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS setting requires a provider; remote access requires administrator | Yes | Yes | Yes |
|
||||
| Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data; other roles can return empty data | Yes | Yes | Yes |
|
||||
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial |
|
||||
| Logging | [`get/set usb.logging`; unified FULL `get/set logging.output`](cli_commands.md#control-live-usb-logging) | Logging artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; dual-CDC Full Companion adds/removes its second USB port after reboot | No | Yes | No |
|
||||
@@ -230,7 +231,8 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
|
||||
| Statistics | [`stats-radio`](cli_commands.md#stats-radio) | Local serial | Serial | Serial | Serial | Serial | Serial |
|
||||
| Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local serial | Serial | Serial | Serial | Serial | Serial |
|
||||
| Statistics | [`stats-packets`](cli_commands.md#stats-packets) | Local serial | Serial | Serial | Serial | Serial | Serial |
|
||||
| Statistics | [`get telemetry.temp/volt`; optional GPS history; `get/set telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS commands require a GPS provider; remote access requires administrator | Yes | Yes | Yes | Yes | Yes |
|
||||
| Statistics | [`get telemetry.temp/volt/volt.i2c`; optional GPS history](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater or room server; I2C command requires a detected voltage monitor; GPS requires a provider; remote access requires administrator | Yes | Yes | Yes | Yes | Yes |
|
||||
| Statistics | [`set telemetry.gps`; `get/set/send telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS setting requires a provider; remote access requires administrator | Yes | Yes | Yes | Yes | Yes |
|
||||
| Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data | Yes | Yes | Yes | Yes | Yes |
|
||||
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial | Serial | Serial |
|
||||
| Logging | [`get/set usb.logging`; unified FULL `get/set logging.output`](cli_commands.md#control-live-usb-logging) | Logging artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; dual-CDC Full Companion adds/removes its second USB port after reboot | No | Yes | No | No | Yes |
|
||||
|
||||
+75
-24
@@ -360,21 +360,24 @@ Elsewhere it replies `Err - neighbors not enabled in this build`. If a
|
||||
|
||||
---
|
||||
|
||||
### Read repeater telemetry history
|
||||
<a id="read-repeater-telemetry-history"></a>
|
||||
### Read repeater and room-server telemetry history
|
||||
|
||||
Repeater firmware records one UTC-aligned sample every 30 minutes. Temperature
|
||||
and battery voltage retain 336 samples (seven rolling days). GPS-capable builds
|
||||
retain three GPS days by default and request a seven-day default at startup.
|
||||
Builds without a GPS provider omit the GPS history commands to conserve flash.
|
||||
The history and any runtime retention change are held in RAM and reset after a
|
||||
reboot.
|
||||
Repeater and room-server firmware record one UTC-aligned sample every 30
|
||||
minutes. Temperature and battery voltage retain 336 samples (seven rolling
|
||||
days). Each detected INA219, INA226, INA260, or INA3221 voltage channel retains
|
||||
192 samples (four rolling days). GPS-capable builds retain three GPS days by
|
||||
default; repeaters request a seven-day default at startup. Builds without a GPS
|
||||
provider omit the GPS history commands to conserve flash. All history and any
|
||||
runtime retention change are held in RAM and reset after a reboot.
|
||||
|
||||
The feature is omitted from flash-constrained STM32 repeater images.
|
||||
The feature is omitted from flash-constrained STM32 repeater and room images.
|
||||
|
||||
**Usage:**
|
||||
|
||||
- `get telemetry.temp [page]`
|
||||
- `get telemetry.volt [page]`
|
||||
- `get telemetry.volt.i2c [channel [page]]`
|
||||
- `get telemetry.gps [page]`
|
||||
- `set telemetry.gps <days>`
|
||||
- `get telemetry.tx`
|
||||
@@ -388,6 +391,16 @@ The feature is omitted from flash-constrained STM32 repeater images.
|
||||
24 hours and accept `1`-`7`. GPS pages each hold 12 hours and accept `1`
|
||||
through twice the current GPS retention in days. Omitting the page selects
|
||||
page `1`.
|
||||
- `channel`: Cayenne LPP channel assigned to an external voltage monitor.
|
||||
`get telemetry.volt.i2c` lists channels that have at least one non-zero
|
||||
sample. Supplying a channel returns 48 points (24 hours) per page; pages
|
||||
`1`-`4` cover all four days. A detected input whose complete retained history
|
||||
is zero is treated as disconnected and is omitted until it reports a
|
||||
non-zero voltage. An INA3221 contributes three consecutive entries, in
|
||||
hardware-input order. If it is the only detected external sensor these are
|
||||
normally LPP channels `2`, `3`, and `4`; always use the no-argument command
|
||||
as the authoritative list because earlier detected sensors shift the LPP
|
||||
numbers.
|
||||
- `days`: Requested GPS retention from `1` through `30` days. Retention above
|
||||
three days uses heap memory. The allocator reduces the requested value as
|
||||
needed to leave at least 2048 bytes free and replies with the days and pages
|
||||
@@ -400,10 +413,16 @@ The feature is omitted from flash-constrained STM32 repeater images.
|
||||
- `schedule`: Automatic interval in whole days. The default is `2d`; `off`
|
||||
retains the configured direct path for manual test sends.
|
||||
|
||||
Local serial and remote administrator CLI sessions can read the history.
|
||||
Collection uses the MCU temperature, battery voltage, and an already-valid
|
||||
onboard GPS fix. It does not wake GPS, so an off, sleeping, or unfixed GPS
|
||||
produces a missing location sample without changing its power-saving schedule.
|
||||
Local serial and remote administrator CLI sessions can read the history on
|
||||
both roles. Collection uses the MCU temperature, battery voltage, external I2C
|
||||
voltage monitors, and an already-valid onboard GPS fix. It does not wake GPS,
|
||||
so an off, sleeping, or unfixed GPS produces a missing location sample without
|
||||
changing its power-saving schedule.
|
||||
|
||||
External voltage history allocates 360 bytes for each detected monitor channel
|
||||
(1,080 bytes for all three INA3221 inputs). The allocation reserves all
|
||||
detected inputs so a sensor connected later can start recording, but all-zero
|
||||
inputs are omitted from command replies and LoRa transmission.
|
||||
|
||||
Replies contain `> ` followed by standard padded Base64. After decoding, all
|
||||
multi-byte integers are little-endian. Packed fields are written most
|
||||
@@ -417,19 +436,22 @@ the data.
|
||||
across reboots. Configuring `direct` or a routed path enables the default `2d`
|
||||
schedule; `set telemetry.tx schedule` changes it from one through 30 days or
|
||||
turns it off. An automatic run waits until 165 half-hour positions are
|
||||
available, then sends one maximum-size temperature packet and one maximum-size
|
||||
voltage packet. GPS is never included in this raw transmission. The history
|
||||
remains boot-local, so the first scheduled pair after a reboot needs about
|
||||
82.5 hours to fill. On the default two-day interval, each 82.5-hour packet
|
||||
overlaps the previous packet by 34.5 hours. A queue failure is retried after
|
||||
30 minutes only for the packet that did not queue; successfully queued packets
|
||||
are not duplicated.
|
||||
available, then sends one maximum-size temperature packet, one maximum-size
|
||||
battery-voltage packet, and up to three `IVB1` packets for every populated I2C
|
||||
voltage channel. GPS is never included in this raw transmission. The history
|
||||
remains boot-local, so the first scheduled run after a reboot needs about 82.5
|
||||
hours to fill. On the default two-day interval, each 82.5-hour temperature or
|
||||
battery packet overlaps its predecessor by 34.5 hours. Packets are paced two
|
||||
seconds apart. A queue failure is retried after 30 minutes only for the packet
|
||||
that did not queue; successfully queued packets are not duplicated.
|
||||
|
||||
`send telemetry.tx now` is an administrator-only test action. It immediately
|
||||
queues one temperature and one voltage direct packet over the configured path,
|
||||
using all currently available positions up to 165. It works while the schedule
|
||||
is off and does not move the next scheduled send time. RAW_CUSTOM direct
|
||||
packets do not enter the normal encrypted direct-message retry mechanism.
|
||||
`send telemetry.tx now` is an administrator-only test action. It queues
|
||||
temperature and battery-voltage snapshots plus the available I2C voltage
|
||||
chunks over the configured path. It uses all currently available base
|
||||
positions up to 165 and all available I2C positions up to 192 per populated
|
||||
channel. It works while the schedule is off and does not move the next
|
||||
scheduled send time. RAW_CUSTOM direct packets do not enter the normal
|
||||
encrypted direct-message retry mechanism.
|
||||
|
||||
Full snapshots are 184-byte RAW_CUSTOM payloads. Manual tests can be shorter
|
||||
while history fills. Both formats are binary, not Base64 and not encrypted:
|
||||
@@ -448,6 +470,22 @@ for above `+77 C`. Codes `3` through `130` represent exact whole degrees from
|
||||
`-50 C` through `+77 C`; decode them as `code - 53`. Voltage codes use the
|
||||
same encoding as the paged voltage payload documented below.
|
||||
|
||||
External voltage snapshots use a separate packed layout. A full four-day
|
||||
channel takes three 140-byte `IVB1` payloads of 64 points each:
|
||||
|
||||
| Bytes | Meaning |
|
||||
|---|---|
|
||||
| `0`-`3` | ASCII magic `IVB1` |
|
||||
| `4`-`11` | First eight bytes of the source public key |
|
||||
| `12`-`15` | First sample UTC epoch, unsigned 32-bit little-endian |
|
||||
| `16`-`17` | Sample interval in minutes, unsigned 16-bit little-endian (`30`) |
|
||||
| `18` | Cayenne LPP voltage channel |
|
||||
| `19` | Sample count (`1`-`64`) |
|
||||
| `20` onward | Oldest-to-newest packed 15-bit voltage codes |
|
||||
|
||||
Code `0` means missing or disconnected. Codes `1`-`32767` represent `0.02 V`
|
||||
through `655.34 V` in `0.02 V` steps; decode millivolts as `code * 20`.
|
||||
|
||||
Match bytes `4`-`11` to the first 16 hex characters of the repeater public key
|
||||
shown by its advert or `get public.key`. This compact identifier is useful for
|
||||
association but is not authenticated and can be spoofed. An MQTT observer can
|
||||
@@ -487,6 +525,19 @@ Voltage codes reserve `0` for no reading, `1` for below `1.88 V`, and `255`
|
||||
for above `4.40 V`. Codes `2`-`254` represent `1.88 V` through `4.40 V` in
|
||||
`0.01 V` steps; decode millivolts as `1880 + (code - 2) * 10`.
|
||||
|
||||
External I2C voltage payload (`0x14`, 98 bytes):
|
||||
|
||||
| Bytes | Meaning |
|
||||
|---|---|
|
||||
| `0` | Format/type `0x14` |
|
||||
| `1`-`4` | First sample UTC epoch, unsigned 32-bit |
|
||||
| `5` | Sample interval in minutes (`30`) |
|
||||
| `6` | Sample count (`48`) |
|
||||
| `7` | Cayenne LPP voltage channel |
|
||||
| `8`-`97` | 48 packed 15-bit voltage codes |
|
||||
|
||||
The codes have the same `0.02 V` through `655.34 V` meaning as `IVB1`.
|
||||
|
||||
GPS payload (`0x13`, 101 bytes):
|
||||
|
||||
| Bytes | Meaning |
|
||||
|
||||
@@ -75,7 +75,7 @@ set flood.retry.ignore none
|
||||
|
||||
| Setting | What it does | How to use | Example |
|
||||
| --- | --- | --- | --- |
|
||||
| `telemetry.temp`, `telemetry.volt`, `telemetry.gps` | Records 30-minute MCU temperature and battery samples for seven days. GPS-capable builds retain GPS separately for authenticated paging. `telemetry.tx` sends one temperature RAW_CUSTOM packet and one voltage packet over a configured direct path, on a persistent 1-30 day schedule (default two days) or immediately for testing; GPS is never included. Scheduled packets contain 165 points. History resets on reboot. | `get telemetry.temp [page]`, `get telemetry.volt [page]`, `get telemetry.gps [page]`, `set telemetry.gps <1-30>`, `get telemetry.tx`, `set telemetry.tx <off|direct|path>`, `set telemetry.tx schedule <off|1-30d>`, `send telemetry.tx now` | `set telemetry.tx A1B2,C3D4` |
|
||||
| `telemetry.temp`, `telemetry.volt`, `telemetry.volt.i2c`, `telemetry.gps` | Repeater and room builds record 30-minute MCU temperature and battery samples for seven days. Detected INA voltage channels retain four days at 0.02 V resolution through 655.34 V; all-zero channels are omitted as disconnected. GPS-capable builds retain GPS separately. Repeater `telemetry.tx` sends temperature, battery, and per-channel I2C RAW_CUSTOM packets over a configured direct path on a persistent 1-30 day schedule (default two days) or immediately for testing; GPS is never included. History resets on reboot. | `get telemetry.temp [page]`, `get telemetry.volt [page]`, `get telemetry.volt.i2c [channel [page]]`, `get telemetry.gps [page]`, `set telemetry.gps <1-30>`, `get telemetry.tx`, `set telemetry.tx <off|direct|path>`, `set telemetry.tx schedule <off|1-30d>`, `send telemetry.tx now` | `get telemetry.volt.i2c 2 1` |
|
||||
| `battery.alert` | Sends opt-in, region-scoped low-battery warnings to `#repeaters` after 30 minutes of uptime. | `get battery.alert`, `get battery.alert.region`, `set battery.alert on [region]`, `set battery.alert off` | `set battery.alert on sea` |
|
||||
| `battery.alert.low` | Warning threshold percentage. Must be greater than `battery.alert.critical`. | `get battery.alert.low`, `set battery.alert.low <1-100>` | `set battery.alert.low 20` |
|
||||
| `battery.alert.critical` | Critical threshold percentage. Critical and warning alerts use the same 12-hour resend cooldown. | `get battery.alert.critical`, `set battery.alert.critical <0-99>` | `set battery.alert.critical 10` |
|
||||
|
||||
+49
-18
@@ -2,11 +2,13 @@
|
||||
|
||||
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.
|
||||
repeater's scheduled temperature, battery-voltage, or external I2C-voltage
|
||||
snapshot. The decoder also accepts the payload hex without its MeshCore packet
|
||||
header and the Base64 pages returned by repeater or room-server CLI commands.
|
||||
Decoding happens entirely in this browser; pasted data is not uploaded or sent
|
||||
anywhere.
|
||||
|
||||
The **Repeater ID** in the result is the first eight bytes of the sending
|
||||
The **Source ID** in a raw-packet 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.
|
||||
@@ -37,10 +39,13 @@ set telemetry.tx schedule off
|
||||
send telemetry.tx now
|
||||
```
|
||||
|
||||
The `send telemetry.tx now` command queues one `TTB1` temperature packet and
|
||||
one `TVB1` voltage packet containing all currently available samples, up to 165
|
||||
per packet. There is no temperature-only CLI command. The command requires at
|
||||
least one collected sample and works even when the automatic schedule is off.
|
||||
The `send telemetry.tx now` command queues one `TTB1` temperature packet, one
|
||||
`TVB1` battery-voltage packet, and `IVB1` chunks for every connected I2C voltage
|
||||
channel. `TTB1` and `TVB1` carry up to 165 samples. Each `IVB1` carries 64, so a
|
||||
full 192-point/four-day channel takes three packets. Channels whose retained
|
||||
history is entirely zero are treated as disconnected and are not sent. The
|
||||
command requires at least one collected base sample and works even when the
|
||||
automatic schedule is off.
|
||||
|
||||
## Decode a packet
|
||||
|
||||
@@ -49,6 +54,8 @@ least one collected sample and works even when the automatic schedule is off.
|
||||
<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>
|
||||
<button type="button" data-telemetry-example="packetExternalVoltage">I2C voltage packet</button>
|
||||
<button type="button" data-telemetry-example="externalVoltage">I2C voltage CLI page</button>
|
||||
</div>
|
||||
|
||||
<label for="telemetry-reply-input">Raw packet or payload hex</label>
|
||||
@@ -62,8 +69,9 @@ least one collected sample and works even when the automatic schedule is off.
|
||||
></textarea>
|
||||
<p class="telemetry-tool-help" id="telemetry-input-help">
|
||||
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.
|
||||
JSON field are accepted. CLI Base64 replies are also auto-detected. Paste
|
||||
multiple compatible packet or reply lines together to merge them by
|
||||
timestamp before downloading one CSV. Press Ctrl/Command+Enter to decode.
|
||||
</p>
|
||||
|
||||
<div class="telemetry-actions">
|
||||
@@ -112,9 +120,15 @@ finds and validates the payload automatically.
|
||||
3E00545642311122334455667788800092651E000800010264C8FEFFDC
|
||||
```
|
||||
|
||||
Both examples identify the source as repeater ID `1122334455667788`.
|
||||
### External I2C voltage
|
||||
|
||||
## Legacy CLI replies
|
||||
```text
|
||||
3E00495642311122334455667788800092651E00020800000004019026927109C427107FFF
|
||||
```
|
||||
|
||||
All three examples identify the source as `1122334455667788`.
|
||||
|
||||
## CLI history pages
|
||||
|
||||
The same page continues to decode the padded Base64 returned by these
|
||||
administrator commands:
|
||||
@@ -123,6 +137,7 @@ administrator commands:
|
||||
|---|---|---|---|
|
||||
| MCU temperature | `get telemetry.temp` | `get telemetry.temp 2` | 48 (24 hours) |
|
||||
| Battery voltage | `get telemetry.volt` | `get telemetry.volt 3` | 48 (24 hours) |
|
||||
| I2C voltage | `get telemetry.volt.i2c 2` | `get telemetry.volt.i2c 2 4` | 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
|
||||
@@ -133,17 +148,34 @@ get telemetry.volt 1
|
||||
> EkDUcWoeMAAB5+bl5eTj4uLh4ODf3t7d3Nvb2tnZ2NfX1tXU1NPS0tHQ0M/Ozc3My8vKycnI/w==
|
||||
```
|
||||
|
||||
Run `get telemetry.volt.i2c` without a channel first to list connected
|
||||
channels. For each channel, collect pages `1` through `4`. Paste all four reply
|
||||
lines into the decoder at once; it merges their timestamps into one 192-point,
|
||||
four-day table and downloads them as one CSV. The same merging works for the
|
||||
three `IVB1` analyzer packets from a full channel. Inputs must have the same
|
||||
telemetry type, source, LPP channel, and sample interval.
|
||||
|
||||
An INA3221 exposes its three enabled hardware inputs as three consecutive LPP
|
||||
channels. When it is the only external sensor these are normally `2`, `3`, and
|
||||
`4`, in hardware-input order. Other sensors can shift the numbers, so copy the
|
||||
IDs reported by `get telemetry.volt.i2c` rather than assuming them.
|
||||
|
||||
## 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.
|
||||
- `TTB1` means a raw temperature snapshot, `TVB1` a raw battery-voltage
|
||||
snapshot, and `IVB1` a packed external I2C-voltage chunk. 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
|
||||
missing and out-of-range states.
|
||||
- External I2C voltage uses a 15-bit code at `0.02 V` resolution. Code `0` is
|
||||
missing/disconnected; codes `1` through `32767` cover `0.02 V` through
|
||||
`655.34 V`. The LPP channel remains part of every page and raw chunk so
|
||||
multiple monitor inputs cannot be mixed accidentally.
|
||||
- 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
|
||||
@@ -152,9 +184,8 @@ get telemetry.volt 1
|
||||
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.
|
||||
never puts GPS in RAW_CUSTOM packets. Location data therefore must come from a
|
||||
CLI Base64 page rather than analyzer hex.
|
||||
|
||||
For the byte-level layouts, see
|
||||
[Read repeater telemetry history](cli_commands.md#read-repeater-telemetry-history).
|
||||
Reference in new issue
Block a user