feat: retain multi-channel voltage telemetry

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

No files matched your search

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