feat: retain multi-channel voltage telemetry

This commit is contained in:
mikecarper committed 2026-08-25 22:51:22 -07:00
1 parent ad1ff3f03e
commit 131bbafc67
17 files changed
+1603 -138

No files matched your search

+229 -34
View File
@@ -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,
});
+4 -2
View File
@@ -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
View File
@@ -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 |
+1 -1
View File
@@ -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
View File
@@ -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).