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

Spaces, line breaks, colons, dashes, a leading 0x, and a quoted - JSON field are accepted. Legacy CLI Base64 replies are also auto-detected. - Press Ctrl/Command+Enter to decode. + JSON field are accepted. CLI Base64 replies are also auto-detected. Paste + multiple compatible packet or reply lines together to merge them by + timestamp before downloading one CSV. Press Ctrl/Command+Enter to decode.

@@ -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). diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 296d888f4..390860515 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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 diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 3e3534381..e4be41a98 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -99,6 +99,7 @@ #include #include #if MESH_ENABLE_TELEMETRY_HISTORY +#include #include #endif #include @@ -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); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index c0b4131e7..23d77e74f 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -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 diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 01128807e..d31ad2641 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -35,6 +35,28 @@ #include #include +#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 +#include +#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); diff --git a/scripts/test_telemetry_decoder.js b/scripts/test_telemetry_decoder.js index a80695bf4..211de1a41 100644 --- a/scripts/test_telemetry_decoder.js +++ b/scripts/test_telemetry_decoder.js @@ -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)), diff --git a/src/helpers/ExternalVoltageHistory.h b/src/helpers/ExternalVoltageHistory.h new file mode 100644 index 000000000..7b8ab2f6d --- /dev/null +++ b/src/helpers/ExternalVoltageHistory.h @@ -0,0 +1,529 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +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(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 [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 diff --git a/src/helpers/SensorManager.h b/src/helpers/SensorManager.h index 877460f63..7f4ce3d41 100644 --- a/src/helpers/SensorManager.h +++ b/src/helpers/SensorManager.h @@ -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; } diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 651989b20..b32025bd1 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -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 diff --git a/src/helpers/sensors/EnvironmentSensorManager.h b/src/helpers/sensors/EnvironmentSensorManager.h index b224a5d3a..406910745 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.h +++ b/src/helpers/sensors/EnvironmentSensorManager.h @@ -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 diff --git a/test/README.md b/test/README.md index 8afcbd06b..3377b615a 100644 --- a/test/README.md +++ b/test/README.md @@ -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 | diff --git a/test/test_telemetry_history/test_telemetry_history.cpp b/test/test_telemetry_history/test_telemetry_history.cpp index 83472813b..f762691fd 100644 --- a/test/test_telemetry_history/test_telemetry_history.cpp +++ b/test/test_telemetry_history/test_telemetry_history.cpp @@ -3,9 +3,11 @@ #include #include #include +#include #include #include +#include #include 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::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 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 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();