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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -170,10 +170,12 @@ static const char FLOOD_CHANNEL_SCOPE_USAGE[] =
|
||||
#define TELEMETRY_HISTORY_TX_PREFS_FILE "/telemetry_tx"
|
||||
static const uint64_t TELEMETRY_HISTORY_TX_RETRY_MILLIS =
|
||||
30ULL * 60ULL * 1000ULL;
|
||||
static const uint64_t TELEMETRY_HISTORY_TX_PACKET_SPACING_MILLIS = 2000ULL;
|
||||
static const uint8_t TELEMETRY_HISTORY_TX_DEFAULT_DAYS = 2U;
|
||||
static const uint8_t TELEMETRY_HISTORY_TX_MAX_DAYS = 30U;
|
||||
static const uint8_t TELEMETRY_HISTORY_TX_TEMPERATURE = 1U;
|
||||
static const uint8_t TELEMETRY_HISTORY_TX_VOLTAGE = 2U;
|
||||
static const uint8_t TELEMETRY_HISTORY_TX_EXTERNAL_VOLTAGE = 4U;
|
||||
#endif
|
||||
|
||||
#define CLOCK_SYNC_VALID_YEARS 10
|
||||
@@ -3229,7 +3231,11 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
|
||||
telemetry_history_tx_path_len = OUT_PATH_UNKNOWN;
|
||||
telemetry_history_tx_interval_days = TELEMETRY_HISTORY_TX_DEFAULT_DAYS;
|
||||
telemetry_history_tx_pending = 0;
|
||||
telemetry_history_tx_manual = false;
|
||||
telemetry_history_tx_external_channel = 0;
|
||||
telemetry_history_tx_external_chunk = 0;
|
||||
telemetry_history_next_tx_uptime = 0;
|
||||
telemetry_history_tx_resume_uptime = 0;
|
||||
#endif
|
||||
|
||||
#if MAX_NEIGHBOURS
|
||||
@@ -3527,6 +3533,20 @@ void MyMesh::begin(FILESYSTEM *fs) {
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
uint8_t voltage_channels[mesh::ExternalVoltageHistory::MAX_CHANNELS];
|
||||
const uint8_t voltage_channel_count = sensors.getVoltageSensorChannels(
|
||||
voltage_channels, mesh::ExternalVoltageHistory::MAX_CHANNELS);
|
||||
if (!external_voltage_history.configure(voltage_channels,
|
||||
voltage_channel_count)) {
|
||||
MESH_DEBUG_PRINTLN("I2C voltage history allocation failed");
|
||||
} else if (voltage_channel_count != 0) {
|
||||
MESH_DEBUG_PRINTLN("I2C voltage history: %u channels, %u bytes",
|
||||
(unsigned)external_voltage_history.channelCount(),
|
||||
(unsigned)external_voltage_history.storageBytes());
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) {
|
||||
@@ -10539,6 +10559,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
mesh::TelemetryHistory::SERIES_TEMPERATURE;
|
||||
static const char telemetry_temp_command[] = "get telemetry.temp";
|
||||
static const char telemetry_volt_command[] = "get telemetry.volt";
|
||||
static const char telemetry_i2c_volt_command[] =
|
||||
"get telemetry.volt.i2c";
|
||||
#if MESH_ENABLE_TELEMETRY_GPS_HISTORY
|
||||
static const char telemetry_gps_command[] = "get telemetry.gps";
|
||||
static const char telemetry_gps_set_command[] = "set telemetry.gps";
|
||||
@@ -10560,26 +10582,38 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
strcpy(reply, "Err - configure telemetry.tx direct or path first");
|
||||
return;
|
||||
}
|
||||
if (telemetry_history_tx_pending != 0) {
|
||||
strcpy(reply, "OK - telemetry.tx already queued");
|
||||
return;
|
||||
}
|
||||
if (telemetry_history.voltageSampleCount() == 0) {
|
||||
strcpy(reply, "Err - telemetry history is empty");
|
||||
return;
|
||||
}
|
||||
const bool temperature_queued = sendTelemetryHistorySnapshot(
|
||||
mesh::TelemetryHistory::SERIES_TEMPERATURE);
|
||||
const bool voltage_queued = sendTelemetryHistorySnapshot(
|
||||
mesh::TelemetryHistory::SERIES_VOLTAGE);
|
||||
if (temperature_queued && voltage_queued) {
|
||||
const uint16_t available = telemetry_history.voltageSampleCount();
|
||||
const unsigned sent = available < mesh::TelemetryHistory::BINARY_MAX_SAMPLES
|
||||
? (unsigned)available
|
||||
: (unsigned)mesh::TelemetryHistory::BINARY_MAX_SAMPLES;
|
||||
snprintf(reply, 160, "OK - telemetry.tx queued temp=%u volt=%u",
|
||||
sent, sent);
|
||||
} else {
|
||||
snprintf(reply, 160, "Err - telemetry.tx queue temp=%s volt=%s",
|
||||
temperature_queued ? "yes" : "no",
|
||||
voltage_queued ? "yes" : "no");
|
||||
telemetry_history_tx_pending = TELEMETRY_HISTORY_TX_TEMPERATURE
|
||||
| TELEMETRY_HISTORY_TX_VOLTAGE;
|
||||
const uint8_t external_channels =
|
||||
external_voltage_history.populatedChannelCount();
|
||||
if (external_channels != 0) {
|
||||
telemetry_history_tx_pending |= TELEMETRY_HISTORY_TX_EXTERNAL_VOLTAGE;
|
||||
}
|
||||
if (!telemetry_history_tx_manual) {
|
||||
telemetry_history_tx_resume_uptime = telemetry_history_next_tx_uptime;
|
||||
}
|
||||
telemetry_history_tx_manual = true;
|
||||
telemetry_history_tx_external_channel = 0;
|
||||
telemetry_history_tx_external_chunk = 0;
|
||||
telemetry_history_next_tx_uptime = 0;
|
||||
|
||||
const uint16_t available = telemetry_history.voltageSampleCount();
|
||||
const unsigned sent = available < mesh::TelemetryHistory::BINARY_MAX_SAMPLES
|
||||
? (unsigned)available
|
||||
: (unsigned)mesh::TelemetryHistory::BINARY_MAX_SAMPLES;
|
||||
const unsigned external_packets = (unsigned)external_channels
|
||||
* external_voltage_history.binaryChunkCount();
|
||||
snprintf(reply, 160,
|
||||
"OK - telemetry.tx queued temp=%u volt=%u i2c=%u/%u",
|
||||
sent, sent, (unsigned)external_channels, external_packets);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10629,16 +10663,30 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
const bool previous_enabled = telemetry_history_tx_enabled;
|
||||
const uint8_t previous_days = telemetry_history_tx_interval_days;
|
||||
const uint8_t previous_pending = telemetry_history_tx_pending;
|
||||
const bool previous_manual = telemetry_history_tx_manual;
|
||||
const uint8_t previous_external_channel =
|
||||
telemetry_history_tx_external_channel;
|
||||
const uint8_t previous_external_chunk =
|
||||
telemetry_history_tx_external_chunk;
|
||||
const uint64_t previous_next_tx = telemetry_history_next_tx_uptime;
|
||||
const uint64_t previous_resume_tx = telemetry_history_tx_resume_uptime;
|
||||
telemetry_history_tx_enabled = enable;
|
||||
if (enable) telemetry_history_tx_interval_days = (uint8_t)days;
|
||||
telemetry_history_tx_pending = 0;
|
||||
telemetry_history_tx_manual = false;
|
||||
telemetry_history_tx_external_channel = 0;
|
||||
telemetry_history_tx_external_chunk = 0;
|
||||
telemetry_history_next_tx_uptime = 0;
|
||||
telemetry_history_tx_resume_uptime = 0;
|
||||
if (!saveTelemetryHistoryTxPrefs()) {
|
||||
telemetry_history_tx_enabled = previous_enabled;
|
||||
telemetry_history_tx_interval_days = previous_days;
|
||||
telemetry_history_tx_pending = previous_pending;
|
||||
telemetry_history_tx_manual = previous_manual;
|
||||
telemetry_history_tx_external_channel = previous_external_channel;
|
||||
telemetry_history_tx_external_chunk = previous_external_chunk;
|
||||
telemetry_history_next_tx_uptime = previous_next_tx;
|
||||
telemetry_history_tx_resume_uptime = previous_resume_tx;
|
||||
strcpy(reply, "Err - unable to save telemetry.tx schedule");
|
||||
} else if (enable) {
|
||||
snprintf(reply, 160, "OK - telemetry.tx schedule=%ud", (unsigned)days);
|
||||
@@ -10668,14 +10716,24 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
const bool previous_enabled = telemetry_history_tx_enabled;
|
||||
const uint8_t previous_path_len = telemetry_history_tx_path_len;
|
||||
const uint8_t previous_pending = telemetry_history_tx_pending;
|
||||
const bool previous_manual = telemetry_history_tx_manual;
|
||||
const uint8_t previous_external_channel =
|
||||
telemetry_history_tx_external_channel;
|
||||
const uint8_t previous_external_chunk =
|
||||
telemetry_history_tx_external_chunk;
|
||||
const uint64_t previous_next_tx = telemetry_history_next_tx_uptime;
|
||||
const uint64_t previous_resume_tx = telemetry_history_tx_resume_uptime;
|
||||
uint8_t previous_path[MAX_PATH_SIZE];
|
||||
memcpy(previous_path, telemetry_history_tx_path, sizeof(previous_path));
|
||||
|
||||
if (strcmp(spec, "off") == 0) {
|
||||
telemetry_history_tx_enabled = false;
|
||||
telemetry_history_tx_pending = 0;
|
||||
telemetry_history_tx_manual = false;
|
||||
telemetry_history_tx_external_channel = 0;
|
||||
telemetry_history_tx_external_chunk = 0;
|
||||
telemetry_history_next_tx_uptime = 0;
|
||||
telemetry_history_tx_resume_uptime = 0;
|
||||
} else {
|
||||
uint8_t path[MAX_PATH_SIZE];
|
||||
uint8_t path_len = OUT_PATH_UNKNOWN;
|
||||
@@ -10690,7 +10748,11 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
telemetry_history_tx_enabled = true;
|
||||
telemetry_history_tx_path_len = path_len;
|
||||
telemetry_history_tx_pending = 0;
|
||||
telemetry_history_tx_manual = false;
|
||||
telemetry_history_tx_external_channel = 0;
|
||||
telemetry_history_tx_external_chunk = 0;
|
||||
telemetry_history_next_tx_uptime = 0;
|
||||
telemetry_history_tx_resume_uptime = 0;
|
||||
memset(telemetry_history_tx_path, 0,
|
||||
sizeof(telemetry_history_tx_path));
|
||||
if ((path_len & 63U) != 0) {
|
||||
@@ -10702,7 +10764,11 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
telemetry_history_tx_enabled = previous_enabled;
|
||||
telemetry_history_tx_path_len = previous_path_len;
|
||||
telemetry_history_tx_pending = previous_pending;
|
||||
telemetry_history_tx_manual = previous_manual;
|
||||
telemetry_history_tx_external_channel = previous_external_channel;
|
||||
telemetry_history_tx_external_chunk = previous_external_chunk;
|
||||
telemetry_history_next_tx_uptime = previous_next_tx;
|
||||
telemetry_history_tx_resume_uptime = previous_resume_tx;
|
||||
memcpy(telemetry_history_tx_path, previous_path,
|
||||
sizeof(telemetry_history_tx_path));
|
||||
strcpy(reply, "Err - unable to save telemetry.tx");
|
||||
@@ -10741,6 +10807,20 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
}
|
||||
#endif
|
||||
|
||||
if (strncmp(command, telemetry_i2c_volt_command,
|
||||
sizeof(telemetry_i2c_volt_command) - 1U) == 0
|
||||
&& (command[sizeof(telemetry_i2c_volt_command) - 1U] == 0
|
||||
|| command[sizeof(telemetry_i2c_volt_command) - 1U] == ' ')) {
|
||||
if (sender != NULL && !sender->isAdmin()) {
|
||||
strcpy(reply, "Err - not permitted");
|
||||
} else {
|
||||
external_voltage_history.formatPageReply(
|
||||
command + sizeof(telemetry_i2c_volt_command) - 1U,
|
||||
reply, 160);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (strncmp(command, telemetry_temp_command,
|
||||
sizeof(telemetry_temp_command) - 1U) == 0
|
||||
&& (command[sizeof(telemetry_temp_command) - 1U] == 0
|
||||
@@ -11255,7 +11335,11 @@ void MyMesh::loadTelemetryHistoryTxPrefs() {
|
||||
telemetry_history_tx_path_len = OUT_PATH_UNKNOWN;
|
||||
telemetry_history_tx_interval_days = TELEMETRY_HISTORY_TX_DEFAULT_DAYS;
|
||||
telemetry_history_tx_pending = 0;
|
||||
telemetry_history_tx_manual = false;
|
||||
telemetry_history_tx_external_channel = 0;
|
||||
telemetry_history_tx_external_chunk = 0;
|
||||
telemetry_history_next_tx_uptime = 0;
|
||||
telemetry_history_tx_resume_uptime = 0;
|
||||
|
||||
if (_fs == NULL || !_fs->exists(TELEMETRY_HISTORY_TX_PREFS_FILE)) return;
|
||||
File file = openFloodSettingsRead(_fs, TELEMETRY_HISTORY_TX_PREFS_FILE);
|
||||
@@ -11321,10 +11405,12 @@ void MyMesh::formatTelemetryHistoryTxStatus(char* reply,
|
||||
formatPathReply(telemetry_history_tx_path,
|
||||
telemetry_history_tx_path_len,
|
||||
path_reply, sizeof(path_reply));
|
||||
snprintf(reply, reply_size, "> %s%ud id=%s p=%s",
|
||||
snprintf(reply, reply_size, "> %s%ud id=%s i2c=%u p=%s",
|
||||
telemetry_history_tx_enabled ? "on " : "off ",
|
||||
(unsigned)telemetry_history_tx_interval_days,
|
||||
source_id, path_reply[0] == '>' && path_reply[1] == ' '
|
||||
source_id,
|
||||
(unsigned)external_voltage_history.populatedChannelCount(),
|
||||
path_reply[0] == '>' && path_reply[1] == ' '
|
||||
? path_reply + 2 : path_reply);
|
||||
}
|
||||
|
||||
@@ -11344,13 +11430,26 @@ bool MyMesh::sendTelemetryHistorySnapshot(
|
||||
telemetry_history_tx_path_len);
|
||||
}
|
||||
|
||||
void MyMesh::serviceTelemetryHistoryTx() {
|
||||
if (!telemetry_history_tx_enabled
|
||||
|| telemetry_history.voltageSampleCount()
|
||||
< mesh::TelemetryHistory::BINARY_MAX_SAMPLES) {
|
||||
return;
|
||||
bool MyMesh::sendExternalVoltageHistorySnapshot(uint8_t channel_index,
|
||||
uint8_t chunk_index) {
|
||||
const uint8_t channel =
|
||||
external_voltage_history.populatedChannelAt(channel_index);
|
||||
if (channel == 0) return false;
|
||||
|
||||
uint8_t payload[mesh::ExternalVoltageHistory::BINARY_PAYLOAD_SIZE];
|
||||
const size_t payload_len = external_voltage_history.formatBinarySnapshot(
|
||||
self_id.pub_key, channel, chunk_index, payload, sizeof(payload));
|
||||
if (payload_len <= mesh::ExternalVoltageHistory::BINARY_HEADER_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
mesh::Packet* packet = createRawData(payload, payload_len);
|
||||
if (packet == NULL) return false;
|
||||
return sendDirect(packet, telemetry_history_tx_path,
|
||||
telemetry_history_tx_path_len);
|
||||
}
|
||||
|
||||
void MyMesh::serviceTelemetryHistoryTx() {
|
||||
const uint64_t current_uptime_millis =
|
||||
uptime_millis + (uint32_t)(millis() - last_millis);
|
||||
if (telemetry_history_next_tx_uptime != 0
|
||||
@@ -11359,28 +11458,86 @@ void MyMesh::serviceTelemetryHistoryTx() {
|
||||
}
|
||||
|
||||
if (telemetry_history_tx_pending == 0) {
|
||||
if (!telemetry_history_tx_enabled
|
||||
|| telemetry_history.voltageSampleCount()
|
||||
< mesh::TelemetryHistory::BINARY_MAX_SAMPLES) {
|
||||
return;
|
||||
}
|
||||
telemetry_history_tx_manual = false;
|
||||
telemetry_history_tx_resume_uptime = 0;
|
||||
telemetry_history_tx_pending = TELEMETRY_HISTORY_TX_TEMPERATURE
|
||||
| TELEMETRY_HISTORY_TX_VOLTAGE;
|
||||
if (external_voltage_history.populatedChannelCount() != 0) {
|
||||
telemetry_history_tx_pending |= TELEMETRY_HISTORY_TX_EXTERNAL_VOLTAGE;
|
||||
}
|
||||
telemetry_history_tx_external_channel = 0;
|
||||
telemetry_history_tx_external_chunk = 0;
|
||||
}
|
||||
if ((telemetry_history_tx_pending & TELEMETRY_HISTORY_TX_TEMPERATURE) != 0
|
||||
&& sendTelemetryHistorySnapshot(
|
||||
mesh::TelemetryHistory::SERIES_TEMPERATURE)) {
|
||||
telemetry_history_tx_pending &=
|
||||
(uint8_t)~TELEMETRY_HISTORY_TX_TEMPERATURE;
|
||||
|
||||
bool sent = false;
|
||||
if ((telemetry_history_tx_pending & TELEMETRY_HISTORY_TX_TEMPERATURE) != 0) {
|
||||
sent = sendTelemetryHistorySnapshot(
|
||||
mesh::TelemetryHistory::SERIES_TEMPERATURE);
|
||||
if (sent) {
|
||||
telemetry_history_tx_pending &=
|
||||
(uint8_t)~TELEMETRY_HISTORY_TX_TEMPERATURE;
|
||||
}
|
||||
} else if ((telemetry_history_tx_pending
|
||||
& TELEMETRY_HISTORY_TX_VOLTAGE) != 0) {
|
||||
sent = sendTelemetryHistorySnapshot(
|
||||
mesh::TelemetryHistory::SERIES_VOLTAGE);
|
||||
if (sent) {
|
||||
telemetry_history_tx_pending &=
|
||||
(uint8_t)~TELEMETRY_HISTORY_TX_VOLTAGE;
|
||||
}
|
||||
} else if ((telemetry_history_tx_pending
|
||||
& TELEMETRY_HISTORY_TX_EXTERNAL_VOLTAGE) != 0) {
|
||||
const uint8_t channel_count =
|
||||
external_voltage_history.populatedChannelCount();
|
||||
const uint8_t chunk_count = external_voltage_history.binaryChunkCount();
|
||||
if (telemetry_history_tx_external_channel >= channel_count
|
||||
|| chunk_count == 0) {
|
||||
telemetry_history_tx_pending &=
|
||||
(uint8_t)~TELEMETRY_HISTORY_TX_EXTERNAL_VOLTAGE;
|
||||
} else {
|
||||
sent = sendExternalVoltageHistorySnapshot(
|
||||
telemetry_history_tx_external_channel,
|
||||
telemetry_history_tx_external_chunk);
|
||||
if (sent) {
|
||||
telemetry_history_tx_external_chunk++;
|
||||
if (telemetry_history_tx_external_chunk >= chunk_count) {
|
||||
telemetry_history_tx_external_chunk = 0;
|
||||
telemetry_history_tx_external_channel++;
|
||||
}
|
||||
if (telemetry_history_tx_external_channel >= channel_count) {
|
||||
telemetry_history_tx_pending &=
|
||||
(uint8_t)~TELEMETRY_HISTORY_TX_EXTERNAL_VOLTAGE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((telemetry_history_tx_pending & TELEMETRY_HISTORY_TX_VOLTAGE) != 0
|
||||
&& sendTelemetryHistorySnapshot(mesh::TelemetryHistory::SERIES_VOLTAGE)) {
|
||||
telemetry_history_tx_pending &= (uint8_t)~TELEMETRY_HISTORY_TX_VOLTAGE;
|
||||
|
||||
if (!sent && telemetry_history_tx_pending != 0) {
|
||||
telemetry_history_next_tx_uptime =
|
||||
current_uptime_millis + TELEMETRY_HISTORY_TX_RETRY_MILLIS;
|
||||
return;
|
||||
}
|
||||
|
||||
if (telemetry_history_tx_pending == 0) {
|
||||
telemetry_history_next_tx_uptime =
|
||||
current_uptime_millis
|
||||
+ (uint64_t)telemetry_history_tx_interval_days
|
||||
* 24ULL * 60ULL * 60ULL * 1000ULL;
|
||||
if (telemetry_history_tx_manual) {
|
||||
telemetry_history_next_tx_uptime = telemetry_history_tx_resume_uptime;
|
||||
telemetry_history_tx_manual = false;
|
||||
telemetry_history_tx_resume_uptime = 0;
|
||||
} else {
|
||||
telemetry_history_next_tx_uptime = telemetry_history_tx_enabled
|
||||
? current_uptime_millis
|
||||
+ (uint64_t)telemetry_history_tx_interval_days
|
||||
* 24ULL * 60ULL * 60ULL * 1000ULL
|
||||
: 0;
|
||||
}
|
||||
} else {
|
||||
telemetry_history_next_tx_uptime =
|
||||
current_uptime_millis + TELEMETRY_HISTORY_TX_RETRY_MILLIS;
|
||||
current_uptime_millis + TELEMETRY_HISTORY_TX_PACKET_SPACING_MILLIS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11417,6 +11574,19 @@ void MyMesh::sampleTelemetryHistory() {
|
||||
telemetry_history.record(now, temperature_c, temperature_valid,
|
||||
_cli.getBoard()->getBattMilliVolts(),
|
||||
latitude_e7, longitude_e7, gps_valid);
|
||||
|
||||
SensorManager::VoltageSensorReading sensor_readings[
|
||||
mesh::ExternalVoltageHistory::MAX_CHANNELS];
|
||||
const uint8_t reading_count = sensors.queryVoltageSensors(
|
||||
sensor_readings, mesh::ExternalVoltageHistory::MAX_CHANNELS);
|
||||
mesh::ExternalVoltageHistory::Reading history_readings[
|
||||
mesh::ExternalVoltageHistory::MAX_CHANNELS];
|
||||
for (uint8_t i = 0; i < reading_count; i++) {
|
||||
history_readings[i].channel = sensor_readings[i].channel;
|
||||
history_readings[i].voltage = sensor_readings[i].voltage;
|
||||
history_readings[i].valid = sensor_readings[i].valid;
|
||||
}
|
||||
external_voltage_history.record(now, history_readings, reading_count);
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
#include <helpers/StaticPoolPacketManager.h>
|
||||
#include <helpers/StatsFormatHelper.h>
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
#include <helpers/ExternalVoltageHistory.h>
|
||||
#include <helpers/TelemetryHistory.h>
|
||||
#endif
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
@@ -457,12 +458,17 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
CayenneLPP telemetry;
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
mesh::TelemetryHistory telemetry_history;
|
||||
mesh::ExternalVoltageHistory external_voltage_history;
|
||||
bool telemetry_history_tx_enabled;
|
||||
uint8_t telemetry_history_tx_path[MAX_PATH_SIZE];
|
||||
uint8_t telemetry_history_tx_path_len;
|
||||
uint8_t telemetry_history_tx_interval_days;
|
||||
uint8_t telemetry_history_tx_pending;
|
||||
bool telemetry_history_tx_manual;
|
||||
uint8_t telemetry_history_tx_external_channel;
|
||||
uint8_t telemetry_history_tx_external_chunk;
|
||||
uint64_t telemetry_history_next_tx_uptime;
|
||||
uint64_t telemetry_history_tx_resume_uptime;
|
||||
#endif
|
||||
unsigned long _ota_update_at = 0; // deferred `ota update` fire time (0 = none scheduled)
|
||||
float active_bw; // live BW, including temporary radio overrides
|
||||
@@ -628,6 +634,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
bool saveTelemetryHistoryTxPrefs();
|
||||
void serviceTelemetryHistoryTx();
|
||||
bool sendTelemetryHistorySnapshot(mesh::TelemetryHistory::Series series);
|
||||
bool sendExternalVoltageHistorySnapshot(uint8_t channel_index,
|
||||
uint8_t chunk_index);
|
||||
void formatTelemetryHistoryTxStatus(char* reply, size_t reply_size) const;
|
||||
#endif
|
||||
void sendSelfAdvertisementNow(uint32_t delay_millis, bool flood);
|
||||
|
||||
@@ -1378,6 +1378,20 @@ void MyMesh::begin(FILESYSTEM *fs) {
|
||||
Serial.println(wc_reply);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
uint8_t voltage_channels[mesh::ExternalVoltageHistory::MAX_CHANNELS];
|
||||
const uint8_t voltage_channel_count = sensors.getVoltageSensorChannels(
|
||||
voltage_channels, mesh::ExternalVoltageHistory::MAX_CHANNELS);
|
||||
if (!external_voltage_history.configure(voltage_channels,
|
||||
voltage_channel_count)) {
|
||||
MESH_DEBUG_PRINTLN("I2C voltage history allocation failed");
|
||||
} else if (voltage_channel_count != 0) {
|
||||
MESH_DEBUG_PRINTLN("I2C voltage history: %u channels, %u bytes",
|
||||
(unsigned)external_voltage_history.channelCount(),
|
||||
(unsigned)external_voltage_history.storageBytes());
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool MyMesh::applySavedRadioParams() {
|
||||
@@ -1977,6 +1991,103 @@ void MyMesh::onUserGpioTimerCompleted(uint8_t pin, uint8_t state,
|
||||
}
|
||||
#endif
|
||||
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
bool MyMesh::handleTelemetryHistoryCommand(const char* command, char* reply) {
|
||||
static const char temperature_command[] = "get telemetry.temp";
|
||||
static const char battery_command[] = "get telemetry.volt";
|
||||
static const char external_voltage_command[] =
|
||||
"get telemetry.volt.i2c";
|
||||
#if MESH_ENABLE_TELEMETRY_GPS_HISTORY
|
||||
static const char gps_command[] = "get telemetry.gps";
|
||||
#endif
|
||||
|
||||
if (strncmp(command, external_voltage_command,
|
||||
sizeof(external_voltage_command) - 1U) == 0
|
||||
&& (command[sizeof(external_voltage_command) - 1U] == 0
|
||||
|| command[sizeof(external_voltage_command) - 1U] == ' ')) {
|
||||
external_voltage_history.formatPageReply(
|
||||
command + sizeof(external_voltage_command) - 1U, reply, 160);
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* args = NULL;
|
||||
mesh::TelemetryHistory::Series series =
|
||||
mesh::TelemetryHistory::SERIES_TEMPERATURE;
|
||||
if (strncmp(command, temperature_command,
|
||||
sizeof(temperature_command) - 1U) == 0
|
||||
&& (command[sizeof(temperature_command) - 1U] == 0
|
||||
|| command[sizeof(temperature_command) - 1U] == ' ')) {
|
||||
args = command + sizeof(temperature_command) - 1U;
|
||||
} else if (strncmp(command, battery_command,
|
||||
sizeof(battery_command) - 1U) == 0
|
||||
&& (command[sizeof(battery_command) - 1U] == 0
|
||||
|| command[sizeof(battery_command) - 1U] == ' ')) {
|
||||
series = mesh::TelemetryHistory::SERIES_VOLTAGE;
|
||||
args = command + sizeof(battery_command) - 1U;
|
||||
#if MESH_ENABLE_TELEMETRY_GPS_HISTORY
|
||||
} else if (strncmp(command, gps_command, sizeof(gps_command) - 1U) == 0
|
||||
&& (command[sizeof(gps_command) - 1U] == 0
|
||||
|| command[sizeof(gps_command) - 1U] == ' ')) {
|
||||
series = mesh::TelemetryHistory::SERIES_GPS;
|
||||
args = command + sizeof(gps_command) - 1U;
|
||||
#endif
|
||||
}
|
||||
|
||||
if (args == NULL) return false;
|
||||
telemetry_history.formatPageReply(series, args, reply, 160);
|
||||
return true;
|
||||
}
|
||||
|
||||
void MyMesh::sampleTelemetryHistory() {
|
||||
const uint32_t now = getRTCClock()->getCurrentTime();
|
||||
if (!telemetry_history.sampleDue(now)) return;
|
||||
|
||||
int32_t latitude_e7 = 0;
|
||||
int32_t longitude_e7 = 0;
|
||||
bool gps_valid = false;
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
LocationProvider* location = sensors.getLocationProvider();
|
||||
if (location != NULL && location->isEnabled() && location->isValid()) {
|
||||
const long latitude_e6 = location->getLatitude();
|
||||
const long longitude_e6 = location->getLongitude();
|
||||
if (latitude_e6 >= -90000000L && latitude_e6 <= 90000000L
|
||||
&& longitude_e6 >= -180000000L
|
||||
&& longitude_e6 <= 180000000L) {
|
||||
latitude_e7 = (int32_t)((int64_t)latitude_e6 * 10);
|
||||
longitude_e7 = (int32_t)((int64_t)longitude_e6 * 10);
|
||||
gps_valid = latitude_e7 != 0 || longitude_e7 != 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
const float measured_temperature = _cli.getBoard()->getMCUTemperature();
|
||||
const bool temperature_valid = isfinite(measured_temperature);
|
||||
int16_t temperature_c = 0;
|
||||
if (temperature_valid) {
|
||||
if (measured_temperature < -32768.0f) temperature_c = INT16_MIN;
|
||||
else if (measured_temperature > 32767.0f) temperature_c = INT16_MAX;
|
||||
else temperature_c = (int16_t)lroundf(measured_temperature);
|
||||
}
|
||||
|
||||
telemetry_history.record(now, temperature_c, temperature_valid,
|
||||
_cli.getBoard()->getBattMilliVolts(),
|
||||
latitude_e7, longitude_e7, gps_valid);
|
||||
|
||||
SensorManager::VoltageSensorReading sensor_readings[
|
||||
mesh::ExternalVoltageHistory::MAX_CHANNELS];
|
||||
const uint8_t reading_count = sensors.queryVoltageSensors(
|
||||
sensor_readings, mesh::ExternalVoltageHistory::MAX_CHANNELS);
|
||||
mesh::ExternalVoltageHistory::Reading history_readings[
|
||||
mesh::ExternalVoltageHistory::MAX_CHANNELS];
|
||||
for (uint8_t i = 0; i < reading_count; i++) {
|
||||
history_readings[i].channel = sensor_readings[i].channel;
|
||||
history_readings[i].voltage = sensor_readings[i].voltage;
|
||||
history_readings[i].valid = sensor_readings[i].valid;
|
||||
}
|
||||
external_voltage_history.record(now, history_readings, reading_count);
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply,
|
||||
int gpio_client_index,
|
||||
@@ -2042,6 +2153,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
|
||||
|
||||
mesh::cli::normalizeCommandVerb(command);
|
||||
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
if (handleTelemetryHistoryCommand(command, reply)) return;
|
||||
#endif
|
||||
|
||||
// handle ACL related commands
|
||||
if (memcmp(command, "setperm ", 8) == 0) { // format: setperm {pubkey-hex} {permissions-int8}
|
||||
char* hex = &command[8];
|
||||
@@ -2148,6 +2263,9 @@ void MyMesh::loop() {
|
||||
mesh::Mesh::loop();
|
||||
_cli.loop();
|
||||
_clock_sync.loop();
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
sampleTelemetryHistory();
|
||||
#endif
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
// bridge.loop() is now handled by FreeRTOS task on Core 0 - no need to call it here
|
||||
#endif
|
||||
|
||||
@@ -35,6 +35,28 @@
|
||||
#include <RTClib.h>
|
||||
#include <target.h>
|
||||
|
||||
#ifndef MESH_ENABLE_TELEMETRY_HISTORY
|
||||
// LoRa-E5-class STM32 room images do not have enough flash for history.
|
||||
#if defined(STM32_PLATFORM)
|
||||
#define MESH_ENABLE_TELEMETRY_HISTORY 0
|
||||
#else
|
||||
#define MESH_ENABLE_TELEMETRY_HISTORY 1
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifndef MESH_ENABLE_TELEMETRY_GPS_HISTORY
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
#define MESH_ENABLE_TELEMETRY_GPS_HISTORY 1
|
||||
#else
|
||||
#define MESH_ENABLE_TELEMETRY_GPS_HISTORY 0
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
#include <helpers/ExternalVoltageHistory.h>
|
||||
#include <helpers/TelemetryHistory.h>
|
||||
#endif
|
||||
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
#include "helpers/bridges/MQTTBridge.h"
|
||||
#define WITH_BRIDGE
|
||||
@@ -161,6 +183,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
|
||||
int next_post_idx;
|
||||
PostInfo posts[MAX_UNSYNCED_POSTS]; // cyclic queue
|
||||
CayenneLPP telemetry;
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
mesh::TelemetryHistory telemetry_history;
|
||||
mesh::ExternalVoltageHistory external_voltage_history;
|
||||
#endif
|
||||
RegionEntry* load_stack[8];
|
||||
RegionEntry* recv_pkt_region;
|
||||
TransportKey default_scope;
|
||||
@@ -339,6 +365,10 @@ protected:
|
||||
#endif
|
||||
|
||||
void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size);
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
bool handleTelemetryHistoryCommand(const char* command, char* reply);
|
||||
void sampleTelemetryHistory();
|
||||
#endif
|
||||
|
||||
public:
|
||||
MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables);
|
||||
|
||||
@@ -22,6 +22,44 @@ const TEMPERATURE_PACKET =
|
||||
"3E00545442311122334455667788800092651E0008000102354A4E5082";
|
||||
const VOLTAGE_PACKET =
|
||||
"3E00545642311122334455667788800092651E000800010264C8FEFFDC";
|
||||
const EXTERNAL_VOLTAGE_PACKET =
|
||||
"3E00495642311122334455667788800092651E00020800000004019026927109C427107FFF";
|
||||
|
||||
function pack15(codes) {
|
||||
const output = Buffer.alloc(Math.ceil((codes.length * 15) / 8));
|
||||
let bitOffset = 0;
|
||||
codes.forEach((code) => {
|
||||
for (let bit = 14; bit >= 0; bit -= 1, bitOffset += 1) {
|
||||
if (code & (1 << bit)) {
|
||||
output[Math.floor(bitOffset / 8)] |= 1 << (7 - (bitOffset % 8));
|
||||
}
|
||||
}
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function externalPage(epoch, channel, codes) {
|
||||
const header = Buffer.alloc(8);
|
||||
header[0] = 0x14;
|
||||
header.writeUInt32LE(epoch, 1);
|
||||
header[5] = 30;
|
||||
header[6] = codes.length;
|
||||
header[7] = channel;
|
||||
return `> ${Buffer.concat([header, pack15(codes)]).toString("base64")}`;
|
||||
}
|
||||
|
||||
function externalPacket(epoch, channel, codes) {
|
||||
const header = Buffer.alloc(20);
|
||||
header.write("IVB1", 0, "ascii");
|
||||
Buffer.from("1122334455667788", "hex").copy(header, 4);
|
||||
header.writeUInt32LE(epoch, 12);
|
||||
header.writeUInt16LE(30, 16);
|
||||
header[18] = channel;
|
||||
header[19] = codes.length;
|
||||
return Buffer.concat([
|
||||
Buffer.from("3e00", "hex"), header, pack15(codes),
|
||||
]).toString("hex").toUpperCase();
|
||||
}
|
||||
|
||||
test("decodes complete analyzer temperature packet hex", () => {
|
||||
const decoded = decoder.decodeRawTelemetryHex(TEMPERATURE_PACKET);
|
||||
@@ -68,6 +106,66 @@ test("decodes analyzer voltage packet and exact voltage codes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("decodes full-range I2C voltage packets", () => {
|
||||
const decoded = decoder.decodeTelemetry(EXTERNAL_VOLTAGE_PACKET);
|
||||
assert.strictEqual(decoded.kind, "external-voltage");
|
||||
assert.strictEqual(decoded.typeCode, "IVB1");
|
||||
assert.strictEqual(decoded.channel, 2);
|
||||
assert.strictEqual(decoded.count, 8);
|
||||
assert.deepStrictEqual(
|
||||
decoded.rows.map((row) => [row.status, row.millivolts]),
|
||||
[
|
||||
["Missing", null],
|
||||
["Value", 20],
|
||||
["Value", 1000],
|
||||
["Value", 12340],
|
||||
["Value", 100000],
|
||||
["Value", 200000],
|
||||
["Value", 400000],
|
||||
["Value", 655340],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test("decodes and merges four I2C voltage CLI pages", () => {
|
||||
const firstEpoch = 1704067200;
|
||||
const pages = [];
|
||||
for (let page = 3; page >= 0; page -= 1) {
|
||||
const codes = Array.from(
|
||||
{ length: 48 }, (_, index) => page * 48 + index + 1
|
||||
);
|
||||
pages.push(externalPage(firstEpoch + page * 48 * 1800, 7, codes));
|
||||
}
|
||||
const decoded = decoder.decodeTelemetry(pages.join("\n"));
|
||||
assert.strictEqual(decoded.kind, "external-voltage");
|
||||
assert.strictEqual(decoded.channel, 7);
|
||||
assert.strictEqual(decoded.count, 192);
|
||||
assert.strictEqual(decoded.pageCount, 4);
|
||||
assert.strictEqual(decoded.rows[0].millivolts, 20);
|
||||
assert.strictEqual(decoded.rows[191].millivolts, 3840);
|
||||
const csv = decoder.modelToCsv(decoder.tableModel(decoded, false));
|
||||
assert.strictEqual(csv.split("\r\n").length, 193);
|
||||
});
|
||||
|
||||
test("merges sequential IVB1 chunks but rejects mixed channels", () => {
|
||||
const firstEpoch = 1704067200;
|
||||
const first = externalPacket(firstEpoch, 2, [1, 2, 3]);
|
||||
const second = externalPacket(firstEpoch + 3 * 1800, 2, [4, 5, 6]);
|
||||
const decoded = decoder.decodeTelemetry(`${second}\n${first}`);
|
||||
assert.strictEqual(decoded.count, 6);
|
||||
assert.strictEqual(decoded.pageCount, 2);
|
||||
assert.deepStrictEqual(
|
||||
decoded.rows.map((row) => row.millivolts),
|
||||
[20, 40, 60, 80, 100, 120]
|
||||
);
|
||||
assertDecoderError(
|
||||
() => decoder.decodeTelemetry(
|
||||
`${first}\n${externalPacket(firstEpoch + 3 * 1800, 3, [4, 5, 6])}`
|
||||
),
|
||||
/same type, source, channel, and interval/
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts payload-only spaced hex", () => {
|
||||
const payload = TEMPERATURE_PACKET.slice(4).match(/.{2}/g).join(" ");
|
||||
const decoded = decoder.decodeRawTelemetryHex(payload);
|
||||
@@ -101,7 +199,7 @@ test("retains legacy CLI Base64 decoding", () => {
|
||||
test("rejects unrelated, truncated, and ambiguous hex", () => {
|
||||
assertDecoderError(
|
||||
() => decoder.decodeRawTelemetryHex("00000000000000000000000000000000000000"),
|
||||
/does not contain TTB1 temperature or TVB1 voltage/
|
||||
/does not contain TTB1, TVB1, or IVB1/
|
||||
);
|
||||
assertDecoderError(
|
||||
() => decoder.decodeRawTelemetryHex(TEMPERATURE_PACKET.slice(0, -2)),
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
#pragma once
|
||||
|
||||
#include <math.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
// Boot-local history for external voltage monitors. Each configured LPP
|
||||
// channel owns four days of half-hour samples. Codes are packed at 15 bits:
|
||||
// zero means missing and 1..32767 represent 0.02..655.34 V.
|
||||
class ExternalVoltageHistory {
|
||||
public:
|
||||
struct Reading {
|
||||
uint8_t channel;
|
||||
float voltage;
|
||||
bool valid;
|
||||
};
|
||||
|
||||
static constexpr uint32_t SAMPLE_INTERVAL_SECONDS = 30UL * 60UL;
|
||||
static constexpr uint8_t SAMPLES_PER_DAY = 48;
|
||||
static constexpr uint8_t RETENTION_DAYS = 4;
|
||||
static constexpr uint16_t RETENTION_SAMPLES =
|
||||
RETENTION_DAYS * SAMPLES_PER_DAY;
|
||||
static constexpr uint8_t SAMPLE_BITS = 15;
|
||||
static constexpr uint16_t MAX_CODE = 0x7FFF;
|
||||
static constexpr uint32_t STEP_MILLIVOLTS = 20;
|
||||
static constexpr uint32_t MAX_MILLIVOLTS =
|
||||
(uint32_t)MAX_CODE * STEP_MILLIVOLTS;
|
||||
static constexpr uint8_t MAX_CHANNELS = 16;
|
||||
static constexpr size_t BYTES_PER_CHANNEL =
|
||||
(RETENTION_SAMPLES * SAMPLE_BITS + 7U) / 8U;
|
||||
|
||||
static constexpr uint8_t PAGE_PAYLOAD_TYPE_V1 = 0x14;
|
||||
static constexpr uint8_t PAGE_COUNT = RETENTION_DAYS;
|
||||
static constexpr size_t PAGE_HEADER_SIZE = 8;
|
||||
static constexpr size_t PAGE_DATA_SIZE =
|
||||
(SAMPLES_PER_DAY * SAMPLE_BITS + 7U) / 8U;
|
||||
|
||||
static constexpr size_t BINARY_SOURCE_ID_SIZE = 8;
|
||||
static constexpr size_t BINARY_HEADER_SIZE = 20;
|
||||
static constexpr uint8_t BINARY_SAMPLES_PER_CHUNK = 64;
|
||||
static constexpr size_t BINARY_DATA_SIZE =
|
||||
(BINARY_SAMPLES_PER_CHUNK * SAMPLE_BITS + 7U) / 8U;
|
||||
static constexpr size_t BINARY_PAYLOAD_SIZE =
|
||||
BINARY_HEADER_SIZE + BINARY_DATA_SIZE;
|
||||
|
||||
ExternalVoltageHistory()
|
||||
: _samples(NULL), _channel_count(0), _next(0), _count(0),
|
||||
_last_bucket(0), _has_bucket(false) {
|
||||
memset(_channels, 0, sizeof(_channels));
|
||||
}
|
||||
|
||||
~ExternalVoltageHistory() {
|
||||
free(_samples);
|
||||
}
|
||||
|
||||
ExternalVoltageHistory(const ExternalVoltageHistory&) = delete;
|
||||
ExternalVoltageHistory& operator=(const ExternalVoltageHistory&) = delete;
|
||||
|
||||
bool configure(const uint8_t channels[], uint8_t count) {
|
||||
uint8_t unique[MAX_CHANNELS];
|
||||
uint8_t unique_count = 0;
|
||||
if (channels != NULL) {
|
||||
for (uint8_t i = 0; i < count && unique_count < MAX_CHANNELS; i++) {
|
||||
if (channels[i] == 0) continue;
|
||||
bool duplicate = false;
|
||||
for (uint8_t j = 0; j < unique_count; j++) {
|
||||
if (unique[j] == channels[i]) {
|
||||
duplicate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!duplicate) unique[unique_count++] = channels[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (unique_count == 0) {
|
||||
free(_samples);
|
||||
_samples = NULL;
|
||||
_channel_count = 0;
|
||||
memset(_channels, 0, sizeof(_channels));
|
||||
clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
const size_t bytes = (size_t)unique_count * BYTES_PER_CHANNEL;
|
||||
uint8_t* replacement = static_cast<uint8_t*>(malloc(bytes));
|
||||
if (replacement == NULL) return false;
|
||||
memset(replacement, 0, bytes);
|
||||
|
||||
free(_samples);
|
||||
_samples = replacement;
|
||||
_channel_count = unique_count;
|
||||
memset(_channels, 0, sizeof(_channels));
|
||||
memcpy(_channels, unique, (size_t)unique_count * sizeof(unique[0]));
|
||||
_next = 0;
|
||||
_count = 0;
|
||||
_last_bucket = 0;
|
||||
_has_bucket = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
if (_samples != NULL) {
|
||||
memset(_samples, 0, (size_t)_channel_count * BYTES_PER_CHANNEL);
|
||||
}
|
||||
_next = 0;
|
||||
_count = 0;
|
||||
_last_bucket = 0;
|
||||
_has_bucket = false;
|
||||
}
|
||||
|
||||
uint8_t channelCount() const { return _channel_count; }
|
||||
uint8_t channelAt(uint8_t index) const {
|
||||
return index < _channel_count ? _channels[index] : 0;
|
||||
}
|
||||
uint8_t populatedChannelCount() const {
|
||||
uint8_t count = 0;
|
||||
for (uint8_t i = 0; i < _channel_count; i++) {
|
||||
if (channelIndexHasValue(i)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
uint8_t populatedChannelAt(uint8_t index) const {
|
||||
for (uint8_t i = 0; i < _channel_count; i++) {
|
||||
if (!channelIndexHasValue(i)) continue;
|
||||
if (index == 0) return _channels[i];
|
||||
index--;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
bool channelHasValue(uint8_t channel) const {
|
||||
const int channel_index = findChannel(channel);
|
||||
return channel_index >= 0
|
||||
&& channelIndexHasValue((uint8_t)channel_index);
|
||||
}
|
||||
uint16_t sampleCount() const { return _count; }
|
||||
size_t storageBytes() const {
|
||||
return (size_t)_channel_count * BYTES_PER_CHANNEL;
|
||||
}
|
||||
|
||||
bool sampleDue(uint32_t epoch_seconds) const {
|
||||
return !_has_bucket
|
||||
|| epoch_seconds / SAMPLE_INTERVAL_SECONDS != _last_bucket;
|
||||
}
|
||||
|
||||
static uint16_t encodeVoltage(float voltage, bool valid = true) {
|
||||
if (!valid || !isfinite(voltage) || voltage <= 0.0f) return 0;
|
||||
const double scaled = (double)voltage * 1000.0
|
||||
/ (double)STEP_MILLIVOLTS;
|
||||
if (scaled >= MAX_CODE) return MAX_CODE;
|
||||
long code = lround(scaled);
|
||||
if (code < 1) code = 1;
|
||||
return (uint16_t)code;
|
||||
}
|
||||
|
||||
static uint32_t decodeMillivolts(uint16_t code) {
|
||||
return code == 0 ? 0 : (uint32_t)code * STEP_MILLIVOLTS;
|
||||
}
|
||||
|
||||
void record(uint32_t epoch_seconds, const Reading readings[],
|
||||
uint8_t reading_count) {
|
||||
if (_samples == NULL || _channel_count == 0) return;
|
||||
const uint32_t bucket = epoch_seconds / SAMPLE_INTERVAL_SECONDS;
|
||||
if (!_has_bucket) {
|
||||
append(readings, reading_count);
|
||||
_last_bucket = bucket;
|
||||
_has_bucket = true;
|
||||
return;
|
||||
}
|
||||
if (bucket == _last_bucket) return;
|
||||
if (bucket < _last_bucket
|
||||
|| bucket - _last_bucket > RETENTION_SAMPLES) {
|
||||
clear();
|
||||
append(readings, reading_count);
|
||||
_last_bucket = bucket;
|
||||
_has_bucket = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const uint32_t skipped = bucket - _last_bucket - 1U;
|
||||
for (uint32_t i = 0; i < skipped; i++) append(NULL, 0);
|
||||
append(readings, reading_count);
|
||||
_last_bucket = bucket;
|
||||
}
|
||||
|
||||
uint8_t binaryChunkCount() const {
|
||||
return (uint8_t)((_count + BINARY_SAMPLES_PER_CHUNK - 1U)
|
||||
/ BINARY_SAMPLES_PER_CHUNK);
|
||||
}
|
||||
|
||||
size_t formatBinarySnapshot(
|
||||
const uint8_t source_id[BINARY_SOURCE_ID_SIZE], uint8_t channel,
|
||||
uint8_t chunk_index, uint8_t* dest, size_t dest_size) const {
|
||||
const int channel_index = findChannel(channel);
|
||||
const uint8_t chunk_count = binaryChunkCount();
|
||||
if (source_id == NULL || dest == NULL || channel_index < 0
|
||||
|| !channelIndexHasValue((uint8_t)channel_index)
|
||||
|| chunk_index >= chunk_count) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint16_t chronological_start =
|
||||
(uint16_t)(chunk_index * BINARY_SAMPLES_PER_CHUNK);
|
||||
uint16_t sample_count = (uint16_t)(_count - chronological_start);
|
||||
if (sample_count > BINARY_SAMPLES_PER_CHUNK) {
|
||||
sample_count = BINARY_SAMPLES_PER_CHUNK;
|
||||
}
|
||||
const size_t data_size =
|
||||
(sample_count * SAMPLE_BITS + 7U) / 8U;
|
||||
const size_t payload_size = BINARY_HEADER_SIZE + data_size;
|
||||
if (dest_size < payload_size) return 0;
|
||||
memset(dest, 0, payload_size);
|
||||
|
||||
memcpy(dest, "IVB1", 4);
|
||||
memcpy(&dest[4], source_id, BINARY_SOURCE_ID_SIZE);
|
||||
const uint16_t oldest_offset =
|
||||
(uint16_t)(_count - 1U - chronological_start);
|
||||
putUint32LE(&dest[12], startEpochForOffset(oldest_offset));
|
||||
putUint16LE(&dest[16],
|
||||
(uint16_t)(SAMPLE_INTERVAL_SECONDS / 60U));
|
||||
dest[18] = channel;
|
||||
dest[19] = (uint8_t)sample_count;
|
||||
|
||||
BitWriter writer(&dest[BINARY_HEADER_SIZE], data_size);
|
||||
for (uint16_t slot = 0; slot < sample_count; slot++) {
|
||||
writer.write(codeAtOffset(
|
||||
(uint8_t)channel_index, (uint16_t)(oldest_offset - slot)));
|
||||
}
|
||||
return payload_size;
|
||||
}
|
||||
|
||||
bool formatPageReply(const char* args, char* reply,
|
||||
size_t reply_size) const {
|
||||
if (reply == NULL || reply_size == 0) return false;
|
||||
reply[0] = 0;
|
||||
if (_channel_count == 0) {
|
||||
copyReply(reply, reply_size, "Err - no I2C voltage sensors");
|
||||
return false;
|
||||
}
|
||||
if (!_has_bucket) {
|
||||
copyReply(reply, reply_size, "Err - I2C voltage history is empty");
|
||||
return false;
|
||||
}
|
||||
if (populatedChannelCount() == 0) {
|
||||
copyReply(reply, reply_size,
|
||||
"Err - no connected I2C voltage channels");
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* cursor = skipSpaces(args == NULL ? "" : args);
|
||||
if (*cursor == 0) return formatStatusReply(reply, reply_size);
|
||||
|
||||
char token[12];
|
||||
unsigned channel = 0;
|
||||
unsigned page = 1;
|
||||
if (!readToken(cursor, token, sizeof(token))
|
||||
|| !parseUnsigned(token, channel) || channel > 255U) {
|
||||
return formatUsage(reply, reply_size);
|
||||
}
|
||||
if (readToken(cursor, token, sizeof(token))) {
|
||||
if (!parseUnsigned(token, page)) {
|
||||
return formatUsage(reply, reply_size);
|
||||
}
|
||||
}
|
||||
cursor = skipSpaces(cursor);
|
||||
if (*cursor != 0) return formatUsage(reply, reply_size);
|
||||
const int channel_index = findChannel((uint8_t)channel);
|
||||
if (channel_index < 0
|
||||
|| !channelIndexHasValue((uint8_t)channel_index)) {
|
||||
snprintf(reply, reply_size,
|
||||
"Err - I2C voltage channel %u has no readings", channel);
|
||||
return false;
|
||||
}
|
||||
if (page < 1U || page > PAGE_COUNT) {
|
||||
copyReply(reply, reply_size,
|
||||
"Err - telemetry.volt.i2c page must be 1-4");
|
||||
return false;
|
||||
}
|
||||
return formatPage((uint8_t)channel_index, (uint8_t)page,
|
||||
reply, reply_size);
|
||||
}
|
||||
|
||||
private:
|
||||
class BitWriter {
|
||||
uint8_t* _dest;
|
||||
size_t _capacity;
|
||||
size_t _bits;
|
||||
|
||||
public:
|
||||
BitWriter(uint8_t* dest, size_t capacity)
|
||||
: _dest(dest), _capacity(capacity), _bits(0) {
|
||||
memset(dest, 0, capacity);
|
||||
}
|
||||
|
||||
bool write(uint16_t value) {
|
||||
if (_bits + SAMPLE_BITS > _capacity * 8U) return false;
|
||||
for (int bit = SAMPLE_BITS - 1; bit >= 0; bit--) {
|
||||
if ((value & ((uint16_t)1U << bit)) != 0) {
|
||||
_dest[_bits / 8U] |=
|
||||
(uint8_t)(1U << (7U - (_bits % 8U)));
|
||||
}
|
||||
_bits++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
uint8_t* _samples;
|
||||
uint8_t _channels[MAX_CHANNELS];
|
||||
uint8_t _channel_count;
|
||||
uint16_t _next;
|
||||
uint16_t _count;
|
||||
uint32_t _last_bucket;
|
||||
bool _has_bucket;
|
||||
|
||||
int findChannel(uint8_t channel) const {
|
||||
for (uint8_t i = 0; i < _channel_count; i++) {
|
||||
if (_channels[i] == channel) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool channelIndexHasValue(uint8_t channel_index) const {
|
||||
for (uint16_t offset = 0; offset < _count; offset++) {
|
||||
if (codeAtOffset(channel_index, offset) != 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t bitOffset(uint8_t channel_index, uint16_t sample_index) const {
|
||||
return ((size_t)channel_index * RETENTION_SAMPLES + sample_index)
|
||||
* SAMPLE_BITS;
|
||||
}
|
||||
|
||||
void setCode(uint8_t channel_index, uint16_t sample_index,
|
||||
uint16_t value) {
|
||||
const size_t first_bit = bitOffset(channel_index, sample_index);
|
||||
for (uint8_t bit = 0; bit < SAMPLE_BITS; bit++) {
|
||||
const size_t absolute = first_bit + bit;
|
||||
const uint8_t mask = (uint8_t)(1U << (7U - absolute % 8U));
|
||||
if ((value & ((uint16_t)1U << (SAMPLE_BITS - 1U - bit))) != 0) {
|
||||
_samples[absolute / 8U] |= mask;
|
||||
} else {
|
||||
_samples[absolute / 8U] &= (uint8_t)~mask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t codeAt(uint8_t channel_index, uint16_t sample_index) const {
|
||||
const size_t first_bit = bitOffset(channel_index, sample_index);
|
||||
uint16_t value = 0;
|
||||
for (uint8_t bit = 0; bit < SAMPLE_BITS; bit++) {
|
||||
const size_t absolute = first_bit + bit;
|
||||
value = (uint16_t)((value << 1U)
|
||||
| ((_samples[absolute / 8U] >> (7U - absolute % 8U)) & 1U));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
uint16_t codeAtOffset(uint8_t channel_index, uint16_t offset) const {
|
||||
if (offset >= _count) return 0;
|
||||
const uint16_t index = (uint16_t)(
|
||||
(_next + RETENTION_SAMPLES - 1U - offset) % RETENTION_SAMPLES);
|
||||
return codeAt(channel_index, index);
|
||||
}
|
||||
|
||||
void append(const Reading readings[], uint8_t reading_count) {
|
||||
if (readings == NULL) reading_count = 0;
|
||||
for (uint8_t channel_index = 0;
|
||||
channel_index < _channel_count; channel_index++) {
|
||||
uint16_t code = 0;
|
||||
for (uint8_t reading = 0; reading < reading_count; reading++) {
|
||||
if (readings[reading].channel == _channels[channel_index]) {
|
||||
code = encodeVoltage(readings[reading].voltage,
|
||||
readings[reading].valid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
setCode(channel_index, _next, code);
|
||||
}
|
||||
_next = (uint16_t)((_next + 1U) % RETENTION_SAMPLES);
|
||||
if (_count < RETENTION_SAMPLES) _count++;
|
||||
}
|
||||
|
||||
uint32_t startEpochForOffset(uint16_t oldest_offset) const {
|
||||
const uint32_t start_bucket = _last_bucket >= oldest_offset
|
||||
? _last_bucket - oldest_offset : 0;
|
||||
return start_bucket * SAMPLE_INTERVAL_SECONDS;
|
||||
}
|
||||
|
||||
bool formatPage(uint8_t channel_index, uint8_t page,
|
||||
char* reply, size_t reply_size) const {
|
||||
uint8_t payload[PAGE_HEADER_SIZE + PAGE_DATA_SIZE];
|
||||
memset(payload, 0, sizeof(payload));
|
||||
const uint16_t oldest_offset =
|
||||
(uint16_t)(page * SAMPLES_PER_DAY - 1U);
|
||||
payload[0] = PAGE_PAYLOAD_TYPE_V1;
|
||||
putUint32LE(&payload[1], startEpochForOffset(oldest_offset));
|
||||
payload[5] = (uint8_t)(SAMPLE_INTERVAL_SECONDS / 60U);
|
||||
payload[6] = SAMPLES_PER_DAY;
|
||||
payload[7] = _channels[channel_index];
|
||||
BitWriter writer(&payload[PAGE_HEADER_SIZE], PAGE_DATA_SIZE);
|
||||
for (uint8_t slot = 0; slot < SAMPLES_PER_DAY; slot++) {
|
||||
writer.write(codeAtOffset(
|
||||
channel_index, (uint16_t)(oldest_offset - slot)));
|
||||
}
|
||||
return formatBase64Reply(payload, sizeof(payload), reply, reply_size);
|
||||
}
|
||||
|
||||
bool formatStatusReply(char* reply, size_t reply_size) const {
|
||||
size_t used = (size_t)snprintf(reply, reply_size, "> channels=");
|
||||
bool first = true;
|
||||
for (uint8_t i = 0; i < _channel_count && used < reply_size; i++) {
|
||||
if (!channelIndexHasValue(i)) continue;
|
||||
const int written = snprintf(reply + used, reply_size - used,
|
||||
"%s%u", first ? "" : ",",
|
||||
(unsigned)_channels[i]);
|
||||
if (written < 0) return false;
|
||||
used += (size_t)written;
|
||||
first = false;
|
||||
}
|
||||
if (used < reply_size) {
|
||||
snprintf(reply + used, reply_size - used,
|
||||
" samples=%u retention=4d step=0.02V max=655.34V",
|
||||
(unsigned)_count);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static const char* skipSpaces(const char* value) {
|
||||
while (*value == ' ') value++;
|
||||
return value;
|
||||
}
|
||||
|
||||
static bool readToken(const char*& cursor, char* token,
|
||||
size_t token_size) {
|
||||
cursor = skipSpaces(cursor);
|
||||
if (*cursor == 0) return false;
|
||||
size_t len = 0;
|
||||
while (*cursor != 0 && *cursor != ' ') {
|
||||
if (len + 1U >= token_size) {
|
||||
while (*cursor != 0 && *cursor != ' ') cursor++;
|
||||
token[0] = 0;
|
||||
return true;
|
||||
}
|
||||
token[len++] = *cursor++;
|
||||
}
|
||||
token[len] = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool parseUnsigned(const char* token, unsigned& value) {
|
||||
if (token == NULL || *token == 0) return false;
|
||||
unsigned parsed = 0;
|
||||
while (*token != 0) {
|
||||
if (*token < '0' || *token > '9' || parsed > 1000U) return false;
|
||||
parsed = parsed * 10U + (unsigned)(*token++ - '0');
|
||||
}
|
||||
value = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void copyReply(char* reply, size_t reply_size, const char* text) {
|
||||
if (reply_size == 0) return;
|
||||
snprintf(reply, reply_size, "%s", text);
|
||||
}
|
||||
|
||||
static bool formatUsage(char* reply, size_t reply_size) {
|
||||
copyReply(reply, reply_size,
|
||||
"Err - use: get telemetry.volt.i2c <channel> [page]");
|
||||
return false;
|
||||
}
|
||||
|
||||
static void putUint32LE(uint8_t* dest, uint32_t value) {
|
||||
dest[0] = (uint8_t)value;
|
||||
dest[1] = (uint8_t)(value >> 8);
|
||||
dest[2] = (uint8_t)(value >> 16);
|
||||
dest[3] = (uint8_t)(value >> 24);
|
||||
}
|
||||
|
||||
static void putUint16LE(uint8_t* dest, uint16_t value) {
|
||||
dest[0] = (uint8_t)value;
|
||||
dest[1] = (uint8_t)(value >> 8);
|
||||
}
|
||||
|
||||
static size_t base64Encode(const uint8_t* source, size_t source_len,
|
||||
char* dest, size_t dest_size) {
|
||||
static const char alphabet[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const size_t output_len = ((source_len + 2U) / 3U) * 4U;
|
||||
if (dest_size <= output_len) return 0;
|
||||
size_t in = 0;
|
||||
size_t out = 0;
|
||||
while (in < source_len) {
|
||||
const size_t remaining = source_len - in;
|
||||
const uint32_t a = source[in++];
|
||||
const uint32_t b = remaining > 1U ? source[in++] : 0U;
|
||||
const uint32_t c = remaining > 2U ? source[in++] : 0U;
|
||||
const uint32_t value = (a << 16) | (b << 8) | c;
|
||||
dest[out++] = alphabet[(value >> 18) & 0x3FU];
|
||||
dest[out++] = alphabet[(value >> 12) & 0x3FU];
|
||||
dest[out++] = remaining > 1U ? alphabet[(value >> 6) & 0x3FU] : '=';
|
||||
dest[out++] = remaining > 2U ? alphabet[value & 0x3FU] : '=';
|
||||
}
|
||||
dest[out] = 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
static bool formatBase64Reply(const uint8_t* payload, size_t payload_len,
|
||||
char* reply, size_t reply_size) {
|
||||
if (reply_size < 3U) return false;
|
||||
reply[0] = '>';
|
||||
reply[1] = ' ';
|
||||
if (base64Encode(payload, payload_len, &reply[2],
|
||||
reply_size - 2U) == 0) {
|
||||
copyReply(reply, reply_size,
|
||||
"Err - telemetry reply buffer too small");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace mesh
|
||||
@@ -65,6 +65,12 @@ protected:
|
||||
#endif
|
||||
|
||||
public:
|
||||
struct VoltageSensorReading {
|
||||
uint8_t channel;
|
||||
float voltage;
|
||||
bool valid;
|
||||
};
|
||||
|
||||
double node_lat, node_lon; // modify these, if you want to affect Advert location
|
||||
double node_altitude; // altitude in meters
|
||||
bool powersaving_enabled; // powersaving mode
|
||||
@@ -73,6 +79,18 @@ public:
|
||||
virtual bool i2c_probe(TwoWire& wire, uint8_t addr) { return false; }
|
||||
virtual bool begin() { return false; }
|
||||
virtual bool querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) { return false; }
|
||||
virtual uint8_t getVoltageSensorChannels(uint8_t channels[],
|
||||
uint8_t capacity) const {
|
||||
(void)channels;
|
||||
(void)capacity;
|
||||
return 0;
|
||||
}
|
||||
virtual uint8_t queryVoltageSensors(VoltageSensorReading readings[],
|
||||
uint8_t capacity) {
|
||||
(void)readings;
|
||||
(void)capacity;
|
||||
return 0;
|
||||
}
|
||||
virtual void loop() { }
|
||||
virtual void setTelemetryLocationAccessAvailable(bool available);
|
||||
virtual int getNumSettings() const { return 0; }
|
||||
|
||||
@@ -421,6 +421,16 @@ static void query_ina3221(uint8_t ch, uint8_t sub_ch, CayenneLPP& lpp) {
|
||||
}
|
||||
}
|
||||
}
|
||||
static bool query_ina3221_voltage(uint8_t sub_ch, float& voltage) {
|
||||
uint8_t seen = 0;
|
||||
for (int i = 0; i < TELEM_INA3221_NUM_CHANNELS; i++) {
|
||||
if (!INA3221.isChannelEnabled(i)) continue;
|
||||
if (seen++ != sub_ch) continue;
|
||||
voltage = INA3221.getBusVoltage(i);
|
||||
return isfinite(voltage);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
#if ENV_INCLUDE_INA219
|
||||
@@ -433,6 +443,10 @@ static void query_ina219(uint8_t ch, uint8_t, CayenneLPP& lpp) {
|
||||
lpp.addCurrent(ch, INA219.getCurrent_mA() / 1000.0f);
|
||||
lpp.addPower(ch, INA219.getPower_mW() / 1000.0f);
|
||||
}
|
||||
static bool query_ina219_voltage(uint8_t, float& voltage) {
|
||||
voltage = INA219.getBusVoltage_V();
|
||||
return INA219.success() && isfinite(voltage);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if ENV_INCLUDE_INA260
|
||||
@@ -444,6 +458,10 @@ static void query_ina260(uint8_t ch, uint8_t, CayenneLPP& lpp) {
|
||||
lpp.addCurrent(ch, INA260.readCurrent() / 1000.0f);
|
||||
lpp.addPower(ch, INA260.readPower() / 1000.0f);
|
||||
}
|
||||
static bool query_ina260_voltage(uint8_t, float& voltage) {
|
||||
voltage = INA260.readBusVoltage() / 1000.0f;
|
||||
return isfinite(voltage);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if ENV_INCLUDE_INA226
|
||||
@@ -458,6 +476,10 @@ static void query_ina226(uint8_t ch, uint8_t, CayenneLPP& lpp) {
|
||||
lpp.addCurrent(ch, INA226.getCurrent_mA() / 1000.0f);
|
||||
lpp.addPower(ch, INA226.getPower_mW() / 1000.0f);
|
||||
}
|
||||
static bool query_ina226_voltage(uint8_t, float& voltage) {
|
||||
voltage = INA226.getBusVoltage();
|
||||
return isfinite(voltage);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if ENV_INCLUDE_MLX90614
|
||||
@@ -600,64 +622,69 @@ struct SensorDef {
|
||||
const char* name;
|
||||
uint8_t (*init)(TwoWire* wire, uint8_t address);
|
||||
void (*query)(uint8_t channel, uint8_t sub_channel, CayenneLPP& telemetry);
|
||||
bool (*query_voltage)(uint8_t sub_channel, float& voltage);
|
||||
};
|
||||
|
||||
#define TELEM_BOSCH_ALT_ADDR(addr) ((uint8_t)((addr) == 0x76 ? 0x77 : 0x76))
|
||||
|
||||
static const SensorDef SENSOR_TABLE[] = {
|
||||
#if ENV_INCLUDE_AHTX0
|
||||
{ TELEM_AHTX_ADDRESS, "AHT10/AHT20", init_ahtx0, query_ahtx0 },
|
||||
{ TELEM_AHTX_ADDRESS, "AHT10/AHT20", init_ahtx0, query_ahtx0, NULL },
|
||||
#endif
|
||||
#ifdef ENV_INCLUDE_BME680
|
||||
{ TELEM_BME680_ADDRESS, "BME680", init_bme680, query_bme680 },
|
||||
{ TELEM_BOSCH_ALT_ADDR(TELEM_BME680_ADDRESS), "BME680", init_bme680, query_bme680 },
|
||||
{ TELEM_BME680_ADDRESS, "BME680", init_bme680, query_bme680, NULL },
|
||||
{ TELEM_BOSCH_ALT_ADDR(TELEM_BME680_ADDRESS), "BME680", init_bme680, query_bme680, NULL },
|
||||
#endif
|
||||
#if ENV_INCLUDE_BME680_BSEC
|
||||
{ TELEM_BME680_ADDRESS, "BME680+BSEC", init_bme680_bsec, query_bme680_bsec },
|
||||
{ TELEM_BOSCH_ALT_ADDR(TELEM_BME680_ADDRESS), "BME680+BSEC", init_bme680_bsec, query_bme680_bsec },
|
||||
{ TELEM_BME680_ADDRESS, "BME680+BSEC", init_bme680_bsec, query_bme680_bsec, NULL },
|
||||
{ TELEM_BOSCH_ALT_ADDR(TELEM_BME680_ADDRESS), "BME680+BSEC", init_bme680_bsec, query_bme680_bsec, NULL },
|
||||
#endif
|
||||
#if ENV_INCLUDE_BME280
|
||||
{ TELEM_BME280_ADDRESS, "BME280", init_bme280, query_bme280 },
|
||||
{ TELEM_BOSCH_ALT_ADDR(TELEM_BME280_ADDRESS), "BME280", init_bme280, query_bme280 },
|
||||
{ TELEM_BME280_ADDRESS, "BME280", init_bme280, query_bme280, NULL },
|
||||
{ TELEM_BOSCH_ALT_ADDR(TELEM_BME280_ADDRESS), "BME280", init_bme280, query_bme280, NULL },
|
||||
#endif
|
||||
#if ENV_INCLUDE_BMP280
|
||||
{ TELEM_BMP280_ADDRESS, "BMP280", init_bmp280, query_bmp280 },
|
||||
{ TELEM_BOSCH_ALT_ADDR(TELEM_BMP280_ADDRESS), "BMP280", init_bmp280, query_bmp280 },
|
||||
{ TELEM_BMP280_ADDRESS, "BMP280", init_bmp280, query_bmp280, NULL },
|
||||
{ TELEM_BOSCH_ALT_ADDR(TELEM_BMP280_ADDRESS), "BMP280", init_bmp280, query_bmp280, NULL },
|
||||
#endif
|
||||
#if ENV_INCLUDE_SHTC3
|
||||
{ 0x70, "SHTC3", init_shtc3, query_shtc3 },
|
||||
{ 0x70, "SHTC3", init_shtc3, query_shtc3, NULL },
|
||||
#endif
|
||||
#if ENV_INCLUDE_SHT4X
|
||||
{ TELEM_SHT4X_ADDRESS, "SHT4X", init_sht4x, query_sht4x },
|
||||
{ TELEM_SHT4X_ADDRESS, "SHT4X", init_sht4x, query_sht4x, NULL },
|
||||
#endif
|
||||
#if ENV_INCLUDE_LPS22HB
|
||||
{ 0x5C, "LPS22HB", init_lps22hb, query_lps22hb },
|
||||
{ 0x5C, "LPS22HB", init_lps22hb, query_lps22hb, NULL },
|
||||
#endif
|
||||
#if ENV_INCLUDE_INA3221
|
||||
{ TELEM_INA3221_ADDRESS, "INA3221", init_ina3221, query_ina3221 },
|
||||
{ TELEM_INA3221_ADDRESS, "INA3221", init_ina3221, query_ina3221,
|
||||
query_ina3221_voltage },
|
||||
#endif
|
||||
#if ENV_INCLUDE_INA219
|
||||
{ TELEM_INA219_ADDRESS, "INA219", init_ina219, query_ina219 },
|
||||
{ TELEM_INA219_ADDRESS, "INA219", init_ina219, query_ina219,
|
||||
query_ina219_voltage },
|
||||
#endif
|
||||
#if ENV_INCLUDE_INA260
|
||||
{ TELEM_INA260_ADDRESS, "INA260", init_ina260, query_ina260 },
|
||||
{ TELEM_INA260_ADDRESS, "INA260", init_ina260, query_ina260,
|
||||
query_ina260_voltage },
|
||||
#endif
|
||||
#if ENV_INCLUDE_INA226
|
||||
{ TELEM_INA226_ADDRESS, "INA226", init_ina226, query_ina226 },
|
||||
{ TELEM_INA226_ADDRESS, "INA226", init_ina226, query_ina226,
|
||||
query_ina226_voltage },
|
||||
#endif
|
||||
#if ENV_INCLUDE_MLX90614
|
||||
{ TELEM_MLX90614_ADDRESS,"MLX90614", init_mlx90614, query_mlx90614 },
|
||||
{ TELEM_MLX90614_ADDRESS,"MLX90614", init_mlx90614, query_mlx90614, NULL },
|
||||
#endif
|
||||
#if ENV_INCLUDE_VL53L0X
|
||||
{ TELEM_VL53L0X_ADDRESS, "VL53L0X", init_vl53l0x, query_vl53l0x },
|
||||
{ TELEM_VL53L0X_ADDRESS, "VL53L0X", init_vl53l0x, query_vl53l0x, NULL },
|
||||
#endif
|
||||
#ifdef ENV_INCLUDE_BMP085
|
||||
{ 0x77, "BMP085", init_bmp085, query_bmp085 },
|
||||
{ 0x77, "BMP085", init_bmp085, query_bmp085, NULL },
|
||||
#endif
|
||||
#if ENV_INCLUDE_RAK12035
|
||||
{ TELEM_RAK12035_ADDRESS,"RAK12035", init_rak12035, query_rak12035 },
|
||||
{ TELEM_RAK12035_ADDRESS,"RAK12035", init_rak12035, query_rak12035, NULL },
|
||||
#endif
|
||||
{ 0, nullptr, nullptr, nullptr } // sentinel - keeps the array non-empty
|
||||
{ 0, nullptr, nullptr, nullptr, nullptr } // sentinel keeps array non-empty
|
||||
};
|
||||
|
||||
#undef TELEM_BOSCH_ALT_ADDR
|
||||
@@ -722,7 +749,9 @@ bool EnvironmentSensorManager::begin() {
|
||||
MESH_DEBUG_PRINTLN("Found %s at address: %02X", def.name, def.address);
|
||||
detected[def.address] = false; // consumed; later entries must not re-claim this device
|
||||
for (uint8_t sub = 0; sub < n && _active_sensor_count < MAX_ACTIVE_SENSORS; sub++) {
|
||||
_active_sensors[_active_sensor_count++] = { def.query, sub };
|
||||
_active_sensors[_active_sensor_count++] = {
|
||||
def.query, def.query_voltage, sub
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,7 +781,34 @@ bool EnvironmentSensorManager::querySensors(uint8_t requester_permissions, Cayen
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t EnvironmentSensorManager::getVoltageSensorChannels(
|
||||
uint8_t channels[], uint8_t capacity) const {
|
||||
uint8_t count = 0;
|
||||
for (int i = 0; i < _active_sensor_count; i++) {
|
||||
if (_active_sensors[i].query_voltage == NULL) continue;
|
||||
if (count < capacity && channels != NULL) {
|
||||
channels[count] = (uint8_t)(TELEM_CHANNEL_SELF + 1 + i);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
return count < capacity ? count : capacity;
|
||||
}
|
||||
|
||||
uint8_t EnvironmentSensorManager::queryVoltageSensors(
|
||||
VoltageSensorReading readings[], uint8_t capacity) {
|
||||
uint8_t count = 0;
|
||||
for (int i = 0; i < _active_sensor_count && count < capacity; i++) {
|
||||
const ActiveSensor& sensor = _active_sensors[i];
|
||||
if (sensor.query_voltage == NULL) continue;
|
||||
VoltageSensorReading& reading = readings[count++];
|
||||
reading.channel = (uint8_t)(TELEM_CHANNEL_SELF + 1 + i);
|
||||
reading.voltage = 0.0f;
|
||||
reading.valid = sensor.query_voltage(sensor.sub_channel,
|
||||
reading.voltage)
|
||||
&& isfinite(reading.voltage);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
int EnvironmentSensorManager::getNumSettings() const {
|
||||
int settings = 0;
|
||||
#if ENV_INCLUDE_GPS
|
||||
|
||||
@@ -12,6 +12,7 @@ protected:
|
||||
// Sub-channel is 0 for all single-output sensors.
|
||||
struct ActiveSensor {
|
||||
void (*query)(uint8_t channel, uint8_t sub_channel, CayenneLPP& telemetry);
|
||||
bool (*query_voltage)(uint8_t sub_channel, float& voltage);
|
||||
uint8_t sub_channel;
|
||||
};
|
||||
|
||||
@@ -48,6 +49,10 @@ public:
|
||||
bool i2c_probe(TwoWire& wire, uint8_t addr) override;
|
||||
bool begin() override;
|
||||
bool querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) override;
|
||||
uint8_t getVoltageSensorChannels(uint8_t channels[],
|
||||
uint8_t capacity) const override;
|
||||
uint8_t queryVoltageSensors(VoltageSensorReading readings[],
|
||||
uint8_t capacity) override;
|
||||
#if ENV_INCLUDE_GPS || defined(ENV_INCLUDE_BME680_BSEC)
|
||||
void loop() override;
|
||||
#endif
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ does not reflect the GoogleTest count -- run the built binary directly
|
||||
| `test_mqtt_prefs_atomic_store` | `src/helpers/MQTTPrefsAtomicStore.h` | transactional MQTT writes and legacy `/node_prefs` handoff; exact short-write detection; begin/finish/rename failure cleanup; original-file preservation |
|
||||
| `test_prefs_save_routing` | `src/helpers/PrefsSaveRouting.h` | runtime common/observer setters write only their owning preference image; mixed-owner setters and migrations can deliberately write both |
|
||||
| `test_mqtt_payload_builder` | `src/helpers/MQTTPayloadBuilder.cpp` | status/packet/raw JSON contracts; optional fields; escaping; RX metrics and path; score handling; exact buffer bounds; maximum representative payloads |
|
||||
| `test_telemetry_history` | `src/helpers/TelemetryHistory.h` | 30-minute rings; seven-day temperature/voltage and dynamically sized GPS retention; exact 1 C temperature/status encoding; separate Base64 series payloads; 14-bit GPS differentials; resize preservation, heap budgets, and 1-based paging bounds |
|
||||
| `test_telemetry_history` | `src/helpers/TelemetryHistory.h`, `ExternalVoltageHistory.h` | 30-minute rings; seven-day temperature/battery and four-day multi-channel I2C voltage retention; exact 1 C temperature/status and 0.02 V packed voltage encodings; zero-only channel omission; Base64 pages and raw chunks; GPS differentials, resize preservation, heap budgets, and paging bounds |
|
||||
| `test_flood_filter_policy` | `src/helpers/FloodFilterPolicy.h` | unordered blacklist matching; ordered 1/2/3-byte pbyte rule prefixes; original incoming scope classes and canonical region-name identity; channel-authentication cache key comparison; priority ordering and terminal stop masks; bridge-bucket and regionless channel-target selector encoding; `require=region` and per-channel scope-gate truth tables; fast/slow timing; adding, replacing, and preserving packet scope |
|
||||
| `test_logical_message_cache` | `src/helpers/LogicalMessageCache.h` | bounded logical-message mapping; stable retry timestamps; exact older retries after newer messages; stale and same-timestamp mismatch rejection |
|
||||
| `test_cli_command_utils` | `src/helpers/CLICommandUtils.h`, `src/helpers/ContactListOrder.h`, `src/helpers/TerminalCommandTracker.h`, `src/helpers/TerminalDisplayFilter.h` | terminal verb/argument/path parsing; routed receive labels; quiet display defaults and independent emergency filtering; favorite-first contact ordering; single-command reply matching, round-trip timing, and rollover-safe expiration |
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <helpers/ExternalVoltageHistory.h>
|
||||
#include <helpers/TelemetryHistory.h>
|
||||
|
||||
namespace {
|
||||
@@ -431,6 +433,155 @@ TEST(TelemetryHistory, ValidatesRetentionAndPagingArguments) {
|
||||
EXPECT_NE(nullptr, strstr(reply, "get telemetry.gps"));
|
||||
}
|
||||
|
||||
TEST(ExternalVoltageHistory, QuantizesFullLppRangeAtTwentyMillivolts) {
|
||||
using mesh::ExternalVoltageHistory;
|
||||
|
||||
EXPECT_EQ(0, ExternalVoltageHistory::encodeVoltage(0.0f));
|
||||
EXPECT_EQ(0, ExternalVoltageHistory::encodeVoltage(-1.0f));
|
||||
EXPECT_EQ(0, ExternalVoltageHistory::encodeVoltage(
|
||||
std::numeric_limits<float>::quiet_NaN()));
|
||||
EXPECT_EQ(0, ExternalVoltageHistory::encodeVoltage(12.0f, false));
|
||||
EXPECT_EQ(1, ExternalVoltageHistory::encodeVoltage(0.02f));
|
||||
EXPECT_EQ(617, ExternalVoltageHistory::encodeVoltage(12.34f));
|
||||
EXPECT_EQ(32767, ExternalVoltageHistory::encodeVoltage(655.34f));
|
||||
EXPECT_EQ(32767, ExternalVoltageHistory::encodeVoltage(700.0f));
|
||||
EXPECT_EQ(12340U, ExternalVoltageHistory::decodeMillivolts(617));
|
||||
EXPECT_EQ(655340U,
|
||||
ExternalVoltageHistory::decodeMillivolts(32767));
|
||||
}
|
||||
|
||||
TEST(ExternalVoltageHistory, OmitsChannelsWhoseHistoryIsEntirelyZero) {
|
||||
using mesh::ExternalVoltageHistory;
|
||||
ExternalVoltageHistory history;
|
||||
const uint8_t channels[] = {2, 3, 3};
|
||||
ASSERT_TRUE(history.configure(channels, sizeof(channels)));
|
||||
EXPECT_EQ(2, history.channelCount());
|
||||
EXPECT_EQ(2U * ExternalVoltageHistory::BYTES_PER_CHANNEL,
|
||||
history.storageBytes());
|
||||
|
||||
ExternalVoltageHistory::Reading readings[] = {
|
||||
{2, 0.0f, true}, {3, 0.0f, true}};
|
||||
history.record(1800000000U, readings, 2);
|
||||
EXPECT_EQ(0, history.populatedChannelCount());
|
||||
|
||||
char reply[160];
|
||||
EXPECT_FALSE(history.formatPageReply("", reply, sizeof(reply)));
|
||||
EXPECT_STREQ("Err - no connected I2C voltage channels", reply);
|
||||
|
||||
readings[1].voltage = 24.0f;
|
||||
history.record(1800000000U + ExternalVoltageHistory::SAMPLE_INTERVAL_SECONDS,
|
||||
readings, 2);
|
||||
EXPECT_EQ(1, history.populatedChannelCount());
|
||||
EXPECT_EQ(3, history.populatedChannelAt(0));
|
||||
EXPECT_FALSE(history.channelHasValue(2));
|
||||
EXPECT_TRUE(history.channelHasValue(3));
|
||||
EXPECT_TRUE(history.formatPageReply("", reply, sizeof(reply)));
|
||||
EXPECT_NE(nullptr, strstr(reply, "channels=3"));
|
||||
|
||||
readings[1].voltage = 0.0f;
|
||||
for (uint32_t sample = 0;
|
||||
sample < ExternalVoltageHistory::RETENTION_SAMPLES; sample++) {
|
||||
history.record(
|
||||
1800000000U
|
||||
+ (sample + 2U)
|
||||
* ExternalVoltageHistory::SAMPLE_INTERVAL_SECONDS,
|
||||
readings, 2);
|
||||
}
|
||||
EXPECT_EQ(ExternalVoltageHistory::RETENTION_SAMPLES,
|
||||
history.sampleCount());
|
||||
EXPECT_EQ(0, history.populatedChannelCount());
|
||||
EXPECT_FALSE(history.channelHasValue(3));
|
||||
EXPECT_FALSE(history.formatPageReply("", reply, sizeof(reply)));
|
||||
EXPECT_STREQ("Err - no connected I2C voltage channels", reply);
|
||||
}
|
||||
|
||||
TEST(ExternalVoltageHistory, RetainsFourDaysAndFormatsMultiChannelPages) {
|
||||
using mesh::ExternalVoltageHistory;
|
||||
ExternalVoltageHistory history;
|
||||
const uint8_t channels[] = {2, 7};
|
||||
ASSERT_TRUE(history.configure(channels, sizeof(channels)));
|
||||
const uint32_t first_bucket = 100000U;
|
||||
|
||||
for (uint32_t sample = 0;
|
||||
sample <= ExternalVoltageHistory::RETENTION_SAMPLES; sample++) {
|
||||
ExternalVoltageHistory::Reading readings[] = {
|
||||
{2, (float)(sample + 1U) * 0.02f, true},
|
||||
{7, (float)(sample + 101U) * 0.02f, true}};
|
||||
history.record((first_bucket + sample)
|
||||
* ExternalVoltageHistory::SAMPLE_INTERVAL_SECONDS,
|
||||
readings, 2);
|
||||
}
|
||||
|
||||
EXPECT_EQ(ExternalVoltageHistory::RETENTION_SAMPLES,
|
||||
history.sampleCount());
|
||||
EXPECT_EQ(2, history.populatedChannelCount());
|
||||
char reply[160];
|
||||
ASSERT_TRUE(history.formatPageReply("2 4", reply, sizeof(reply)));
|
||||
const std::vector<uint8_t> payload = decodeReply(reply);
|
||||
ASSERT_EQ(ExternalVoltageHistory::PAGE_HEADER_SIZE
|
||||
+ ExternalVoltageHistory::PAGE_DATA_SIZE,
|
||||
payload.size());
|
||||
EXPECT_EQ(ExternalVoltageHistory::PAGE_PAYLOAD_TYPE_V1, payload[0]);
|
||||
EXPECT_EQ((first_bucket + 1U)
|
||||
* ExternalVoltageHistory::SAMPLE_INTERVAL_SECONDS,
|
||||
uint32LE(&payload[1]));
|
||||
EXPECT_EQ(30, payload[5]);
|
||||
EXPECT_EQ(48, payload[6]);
|
||||
EXPECT_EQ(2, payload[7]);
|
||||
size_t bit_offset = 0;
|
||||
EXPECT_EQ(2U, readBits(&payload[ExternalVoltageHistory::PAGE_HEADER_SIZE],
|
||||
bit_offset, ExternalVoltageHistory::SAMPLE_BITS));
|
||||
|
||||
ASSERT_TRUE(history.formatPageReply("7 1", reply, sizeof(reply)));
|
||||
const std::vector<uint8_t> newest = decodeReply(reply);
|
||||
bit_offset = 0;
|
||||
EXPECT_EQ(246U,
|
||||
readBits(&newest[ExternalVoltageHistory::PAGE_HEADER_SIZE],
|
||||
bit_offset, ExternalVoltageHistory::SAMPLE_BITS));
|
||||
}
|
||||
|
||||
TEST(ExternalVoltageHistory, FormatsThreeRawChunksPerFullChannel) {
|
||||
using mesh::ExternalVoltageHistory;
|
||||
ExternalVoltageHistory history;
|
||||
const uint8_t channels[] = {2, 7};
|
||||
ASSERT_TRUE(history.configure(channels, sizeof(channels)));
|
||||
const uint32_t first_bucket = 200000U;
|
||||
for (uint32_t sample = 0;
|
||||
sample < ExternalVoltageHistory::RETENTION_SAMPLES; sample++) {
|
||||
ExternalVoltageHistory::Reading readings[] = {
|
||||
{2, (float)(sample + 1U) * 0.02f, true},
|
||||
{7, 0.0f, true}};
|
||||
history.record((first_bucket + sample)
|
||||
* ExternalVoltageHistory::SAMPLE_INTERVAL_SECONDS,
|
||||
readings, 2);
|
||||
}
|
||||
|
||||
EXPECT_EQ(3, history.binaryChunkCount());
|
||||
const uint8_t source_id[ExternalVoltageHistory::BINARY_SOURCE_ID_SIZE] = {
|
||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF};
|
||||
uint8_t payload[ExternalVoltageHistory::BINARY_PAYLOAD_SIZE];
|
||||
for (uint8_t chunk = 0; chunk < 3; chunk++) {
|
||||
ASSERT_EQ(sizeof(payload), history.formatBinarySnapshot(
|
||||
source_id, 2, chunk,
|
||||
payload, sizeof(payload)));
|
||||
EXPECT_EQ(0, memcmp(payload, "IVB1", 4));
|
||||
EXPECT_EQ(0, memcmp(&payload[4], source_id, sizeof(source_id)));
|
||||
EXPECT_EQ((first_bucket + (uint32_t)chunk * 64U)
|
||||
* ExternalVoltageHistory::SAMPLE_INTERVAL_SECONDS,
|
||||
uint32LE(&payload[12]));
|
||||
EXPECT_EQ(30, uint16LE(&payload[16]));
|
||||
EXPECT_EQ(2, payload[18]);
|
||||
EXPECT_EQ(64, payload[19]);
|
||||
size_t bit_offset = 0;
|
||||
EXPECT_EQ(1U + (uint32_t)chunk * 64U,
|
||||
readBits(&payload[ExternalVoltageHistory::BINARY_HEADER_SIZE],
|
||||
bit_offset, ExternalVoltageHistory::SAMPLE_BITS));
|
||||
}
|
||||
|
||||
EXPECT_EQ(0U, history.formatBinarySnapshot(
|
||||
source_id, 7, 0, payload, sizeof(payload)));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
|
||||
Reference in new issue
Block a user