diff --git a/build.sh b/build.sh
index 84fc84f87..322d35357 100755
--- a/build.sh
+++ b/build.sh
@@ -2415,6 +2415,9 @@ declare_build_capability_contract() {
if is_repeater_role_target "$env_name" \
|| is_room_server_role_target "$env_name"; then
record_build_expectation "cli.retry_preset" "retry.preset"
+ if [ "$env_platform" != "STM32_PLATFORM" ]; then
+ record_build_expectation "telemetry.history" "telemetry.volt.i2c"
+ fi
fi
if is_lora_ota_build "$env_name"; then
diff --git a/docs/_javascript/telemetry_decoder.js b/docs/_javascript/telemetry_decoder.js
index 91a667d1e..a0e1cb18a 100644
--- a/docs/_javascript/telemetry_decoder.js
+++ b/docs/_javascript/telemetry_decoder.js
@@ -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,
});
diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md
index 9561e7fd3..199298ab0 100644
--- a/docs/cli_command_availability.md
+++ b/docs/cli_command_availability.md
@@ -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 |
diff --git a/docs/cli_commands.md b/docs/cli_commands.md
index 54381395a..7a9027d44 100644
--- a/docs/cli_commands.md
+++ b/docs/cli_commands.md
@@ -360,21 +360,24 @@ Elsewhere it replies `Err - neighbors not enabled in this build`. If a
---
-### Read repeater telemetry history
+
+### 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 `
- `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 |
diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md
index 121f228b1..76850e7d6 100644
--- a/docs/halo_keymind_settings.md
+++ b/docs/halo_keymind_settings.md
@@ -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 `, `set telemetry.tx schedule `, `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 `, `set telemetry.tx schedule `, `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` |
diff --git a/docs/telemetry_decoder.md b/docs/telemetry_decoder.md
index 4e3311c0b..99aef73be 100644
--- a/docs/telemetry_decoder.md
+++ b/docs/telemetry_decoder.md
@@ -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.
Try an analyzer example:
+
+
@@ -62,8 +69,9 @@ least one collected sample and works even when the automatic schedule is off.
>
Spaces, line breaks, colons, dashes, a leading 0x, and a quoted
- JSON field are accepted. Legacy CLI Base64 replies are also auto-detected.
- Press Ctrl/Command+Enter to decode.
+ 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.