mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 15:42:29 +00:00
Add packed noise-floor telemetry history for both radio profiles
This commit is contained in:
1 parent
52a36c89dc
commit
f4ade738df
32 files changed
+2520
-53
No files matched your search
@@ -426,6 +426,13 @@ jobs:
|
||||
- name: Verify telemetry schedule input and persistence rollback
|
||||
run: python3 -B test/test_telemetry_tx_command.py -v
|
||||
|
||||
- name: Verify packed noise history, fresh measurements and sampling
|
||||
working-directory: test
|
||||
run: >-
|
||||
python3 -B -m unittest
|
||||
test_noise_floor_history test_noise_floor_readings test_noise_floor_sampler
|
||||
test_noise_floor_rssi_errors test_noise_floor_cli -v
|
||||
|
||||
- name: Verify transactional telemetry preferences and legacy route recovery
|
||||
run: python3 -B test/test_telemetry_tx_persistence.py -v
|
||||
|
||||
|
||||
@@ -5,9 +5,16 @@
|
||||
const TYPE_VOLTAGE = 0x12;
|
||||
const TYPE_GPS = 0x13;
|
||||
const TYPE_EXTERNAL_VOLTAGE = 0x14;
|
||||
const TYPE_NOISE = 0x15;
|
||||
const TYPE_NOISE_SECONDARY = 0x16;
|
||||
const BINARY_TEMPERATURE_MAGIC = "TTB1";
|
||||
const BINARY_VOLTAGE_MAGIC = "TVB1";
|
||||
const BINARY_EXTERNAL_VOLTAGE_MAGIC = "IVB1";
|
||||
const BINARY_NOISE_MAGIC = "N1B1";
|
||||
const BINARY_NOISE_SECONDARY_MAGIC = "N2B1";
|
||||
const NOISE_BITS = 5;
|
||||
const NOISE_CLI_MAX_SAMPLES = 176;
|
||||
const NOISE_BINARY_MAX_SAMPLES = 255;
|
||||
const BINARY_HEADER_SIZE = 19;
|
||||
const BINARY_MAX_SAMPLES = 165;
|
||||
const BINARY_EXTERNAL_HEADER_SIZE = 20;
|
||||
@@ -25,6 +32,8 @@
|
||||
<button type="button" data-telemetry-example="packetVoltage">Voltage packet</button>
|
||||
<button type="button" data-telemetry-example="packetExternalVoltage">I2C voltage packet</button>
|
||||
<button type="button" data-telemetry-example="externalVoltage">I2C voltage CLI page</button>
|
||||
<button type="button" data-telemetry-example="packetNoise">Radio1 noise packet</button>
|
||||
<button type="button" data-telemetry-example="noiseSecondary">Radio2 noise CLI page</button>
|
||||
</div>
|
||||
|
||||
<label for="telemetry-reply-input">Raw packet or payload hex</label>
|
||||
@@ -60,6 +69,7 @@
|
||||
<button type="button" data-role="download">Download CSV</button>
|
||||
</div>
|
||||
<dl class="telemetry-summary" data-role="summary"></dl>
|
||||
<div class="telemetry-noise-chart" data-role="chart" hidden></div>
|
||||
<div class="telemetry-warnings" data-role="warnings" hidden>
|
||||
<strong>Decode notes</strong>
|
||||
<ul data-role="warning-list"></ul>
|
||||
@@ -87,6 +97,16 @@
|
||||
reply:
|
||||
"3E00495642311122334455667788800092651E00020800000004019026927109C427107FFF",
|
||||
}),
|
||||
packetNoise: Object.freeze({
|
||||
label: "Analyzer radio1 noise-floor packet",
|
||||
command: "send telemetry.tx now",
|
||||
reply: "3E004E3142311122334455667788800092650F00080044CB77DF",
|
||||
}),
|
||||
noiseSecondary: Object.freeze({
|
||||
label: "Radio2 noise-floor CLI page",
|
||||
command: "get telemetry.noise2",
|
||||
reply: "> FoAAkmUPCABEy3ff",
|
||||
}),
|
||||
temperature: Object.freeze({
|
||||
label: "Temperature example",
|
||||
command: "get telemetry.temp",
|
||||
@@ -120,6 +140,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
function stripBase64ReplyPrefix(line) {
|
||||
return line.trim().replace(/^[^|\r\n]{2}\|\s*/, "").replace(/^>\s*/, "");
|
||||
}
|
||||
|
||||
function isBase64ReplyLine(line) {
|
||||
return /^(?:[^|\r\n]{2}\||>)/.test(line)
|
||||
&& /^[A-Za-z0-9+/]+={0,2}$/.test(stripBase64ReplyPrefix(line));
|
||||
}
|
||||
|
||||
function extractBase64(input) {
|
||||
if (typeof input !== "string" || input.trim() === "") {
|
||||
throw new TelemetryDecodeError("Paste a telemetry reply or Base64 payload first.");
|
||||
@@ -133,15 +162,16 @@
|
||||
const replyLine = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => /^>\s*[A-Za-z0-9+/]+={0,2}$/.test(line));
|
||||
.find(isBase64ReplyLine);
|
||||
|
||||
if (replyLine) {
|
||||
text = replyLine.replace(/^>\s*/, "");
|
||||
text = stripBase64ReplyPrefix(replyLine);
|
||||
} else {
|
||||
text = text
|
||||
.replace(/^```(?:text)?\s*/i, "")
|
||||
.replace(/```\s*$/, "")
|
||||
.trim()
|
||||
.replace(/^[^|\r\n]{2}\|\s*/, "")
|
||||
.replace(/^>\s*/, "")
|
||||
.replace(/\s+/g, "");
|
||||
}
|
||||
@@ -164,8 +194,8 @@
|
||||
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*/, ""));
|
||||
.filter(isBase64ReplyLine)
|
||||
.map(stripBase64ReplyPrefix);
|
||||
return replies.length === 0 ? [extractBase64(input)] : replies;
|
||||
}
|
||||
|
||||
@@ -204,6 +234,19 @@
|
||||
return value;
|
||||
}
|
||||
|
||||
function noiseProfile(type) {
|
||||
if (type === TYPE_NOISE || type === BINARY_NOISE_MAGIC) return 1;
|
||||
if (type === TYPE_NOISE_SECONDARY || type === BINARY_NOISE_SECONDARY_MAGIC) return 2;
|
||||
return null;
|
||||
}
|
||||
|
||||
function binaryMagicSupported(magic) {
|
||||
return magic === BINARY_TEMPERATURE_MAGIC
|
||||
|| magic === BINARY_VOLTAGE_MAGIC
|
||||
|| magic === BINARY_EXTERNAL_VOLTAGE_MAGIC
|
||||
|| noiseProfile(magic) !== null;
|
||||
}
|
||||
|
||||
function extractHexBytes(input) {
|
||||
if (typeof input !== "string" || input.trim() === "") {
|
||||
throw new TelemetryDecodeError("Paste raw packet hex from the analyzer first.");
|
||||
@@ -246,9 +289,7 @@
|
||||
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
|
||||
|| magic === BINARY_EXTERNAL_VOLTAGE_MAGIC) {
|
||||
if (binaryMagicSupported(magic)) {
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -256,7 +297,7 @@
|
||||
|
||||
if (firstValid) {
|
||||
throw new TelemetryDecodeError(
|
||||
"Hex was found, but it does not contain TTB1, TVB1, or IVB1 telemetry."
|
||||
"Hex was found, but it does not contain TTB1, TVB1, IVB1, N1B1, or N2B1 telemetry."
|
||||
);
|
||||
}
|
||||
throw new TelemetryDecodeError(
|
||||
@@ -360,9 +401,10 @@
|
||||
function decodeBinarySnapshot(bytes, offset) {
|
||||
const magic = asciiAt(bytes, offset, 4);
|
||||
const externalVoltage = magic === BINARY_EXTERNAL_VOLTAGE_MAGIC;
|
||||
const profile = noiseProfile(magic);
|
||||
const headerSize = externalVoltage
|
||||
? BINARY_EXTERNAL_HEADER_SIZE : BINARY_HEADER_SIZE;
|
||||
const maximumSamples = externalVoltage
|
||||
const maximumSamples = profile ? NOISE_BINARY_MAX_SAMPLES : externalVoltage
|
||||
? BINARY_EXTERNAL_MAX_SAMPLES : BINARY_MAX_SAMPLES;
|
||||
if (bytes.length - offset < headerSize) {
|
||||
throw new TelemetryDecodeError("The binary telemetry header is incomplete.");
|
||||
@@ -377,7 +419,7 @@
|
||||
if (intervalMinutes === 0) {
|
||||
throw new TelemetryDecodeError("Binary telemetry sample interval cannot be zero.");
|
||||
}
|
||||
const dataLength = externalVoltage
|
||||
const dataLength = profile ? Math.ceil(count * NOISE_BITS / 8) : externalVoltage
|
||||
? Math.ceil((count * EXTERNAL_VOLTAGE_BITS) / 8) : count;
|
||||
const payloadLength = headerSize + dataLength;
|
||||
if (bytes.length - offset < payloadLength) {
|
||||
@@ -400,6 +442,13 @@
|
||||
packet: decodePacketEnvelope(bytes, offset),
|
||||
channel: externalVoltage ? bytes[offset + 18] : null,
|
||||
};
|
||||
if (profile) {
|
||||
assertLength(bytes, offset + payloadLength, "Noise-floor");
|
||||
if (offset > 0 && (!header.packet || !header.packet.isRawCustom)) {
|
||||
throw new TelemetryDecodeError("Noise-floor telemetry must be a payload or a complete RAW_CUSTOM packet.");
|
||||
}
|
||||
return decodeNoise(bytes, header, offset + headerSize, NOISE_BINARY_MAX_SAMPLES);
|
||||
}
|
||||
const rows = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const rawCode = externalVoltage
|
||||
@@ -484,9 +533,7 @@
|
||||
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
|
||||
&& magic !== BINARY_EXTERNAL_VOLTAGE_MAGIC) continue;
|
||||
if (!binaryMagicSupported(magic)) continue;
|
||||
try {
|
||||
decodedCandidates.push(decodeBinarySnapshot(bytes, offset));
|
||||
} catch (error) {
|
||||
@@ -619,6 +666,45 @@
|
||||
};
|
||||
}
|
||||
|
||||
function decodeNoise(bytes, header, dataOffset = 7,
|
||||
maximumSamples = NOISE_CLI_MAX_SAMPLES) {
|
||||
if (header.intervalMinutes !== 15) {
|
||||
throw new TelemetryDecodeError("Noise-floor telemetry requires a 15-minute sample interval.");
|
||||
}
|
||||
if (header.count < 1 || header.count > maximumSamples) {
|
||||
throw new TelemetryDecodeError(
|
||||
`Noise-floor sample count must be between 1 and ${maximumSamples}.`
|
||||
);
|
||||
}
|
||||
const dataBytes = Math.ceil(header.count * NOISE_BITS / 8);
|
||||
assertLength(bytes, dataOffset + dataBytes, "Noise-floor");
|
||||
const paddingBits = dataBytes * 8 - header.count * NOISE_BITS;
|
||||
if (paddingBits && (bytes[bytes.length - 1] & ((1 << paddingBits) - 1))) {
|
||||
throw new TelemetryDecodeError("Noise-floor padding bits must be zero.");
|
||||
}
|
||||
const profile = noiseProfile(header.typeCode);
|
||||
const rows = [];
|
||||
for (let index = 0; index < header.count; index += 1) {
|
||||
const rawCode = readPackedBits(bytes, dataOffset, index * NOISE_BITS, NOISE_BITS);
|
||||
rows.push({
|
||||
index,
|
||||
epoch: sampleEpoch(header, index),
|
||||
status: rawCode === 30 ? "MAX" : rawCode === 31 ? "Missing" : "Value",
|
||||
rawCode,
|
||||
noiseDbm: rawCode >= 30 ? null
|
||||
: rawCode <= 2 ? -120 + rawCode : -118 + (rawCode - 2) * 2,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...header,
|
||||
profile,
|
||||
kind: "noise",
|
||||
label: `Radio${profile} noise floor`,
|
||||
rows,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
function decodeGps(bytes, header) {
|
||||
if (bytes.length < 17) {
|
||||
throw new TelemetryDecodeError("GPS payload is shorter than its 17-byte header.");
|
||||
@@ -754,9 +840,11 @@
|
||||
decoded = decodeGps(bytes, header);
|
||||
} else if (header.typeCode === TYPE_EXTERNAL_VOLTAGE) {
|
||||
decoded = decodeExternalVoltage(bytes, header);
|
||||
} else if (noiseProfile(header.typeCode)) {
|
||||
decoded = decodeNoise(bytes, header);
|
||||
} else {
|
||||
throw new TelemetryDecodeError(
|
||||
`Unsupported telemetry type 0x${header.typeCode.toString(16).padStart(2, "0")}; expected 0x11, 0x12, 0x13, or 0x14.`
|
||||
`Unsupported telemetry type 0x${header.typeCode.toString(16).padStart(2, "0")}; expected 0x11 through 0x16.`
|
||||
);
|
||||
}
|
||||
decoded.encoded = encoded;
|
||||
@@ -769,11 +857,12 @@
|
||||
decodedItems.slice(1).forEach((item) => {
|
||||
const sameSource = (item.sourceId || null) === (first.sourceId || null);
|
||||
const sameChannel = (item.channel || null) === (first.channel || null);
|
||||
const sameProfile = (item.profile || null) === (first.profile || null);
|
||||
if (item.kind !== first.kind || item.typeCode !== first.typeCode
|
||||
|| item.intervalMinutes !== first.intervalMinutes
|
||||
|| !sameSource || !sameChannel) {
|
||||
|| !sameSource || !sameChannel || !sameProfile) {
|
||||
throw new TelemetryDecodeError(
|
||||
"Multiple telemetry replies must use the same type, source, channel, and interval."
|
||||
"Multiple telemetry replies must use the same type, source, channel, and interval, including the radio profile."
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -812,7 +901,7 @@
|
||||
function decodeTelemetry(input) {
|
||||
const rawLines = typeof input === "string"
|
||||
? input.split(/\r?\n/).filter(
|
||||
(line) => /54544231|54564231|49564231/i.test(line)
|
||||
(line) => /54544231|54564231|49564231|4e314231|4e324231/i.test(line)
|
||||
)
|
||||
: [];
|
||||
if (rawLines.length > 1) {
|
||||
@@ -829,7 +918,7 @@
|
||||
.replace(/^['"]|['",;]$/g, "")
|
||||
: "";
|
||||
if ((/^[0-9a-f]+$/i.test(compactHex) && compactHex.length % 2 === 0)
|
||||
|| /54544231|54564231|49564231/i.test(String(input))) {
|
||||
|| /54544231|54564231|49564231|4e314231|4e324231/i.test(String(input))) {
|
||||
return decodeRawTelemetryHex(input);
|
||||
}
|
||||
return mergeDecodedTelemetry(
|
||||
@@ -857,6 +946,23 @@
|
||||
(Intl.DateTimeFormat().resolvedOptions() || {}).timeZone || "browser local";
|
||||
const timestampHeader = localTime ? `Timestamp (${localZone})` : "Timestamp (UTC)";
|
||||
|
||||
if (decoded.kind === "noise") {
|
||||
return {
|
||||
headers: ["#", timestampHeader, "Status", decoded.label, "Raw code"],
|
||||
csvHeaders: ["sample", localTime ? `timestamp (${localZone})` : "timestamp_utc",
|
||||
"radio_profile", "status", "noise_floor_dbm", "raw_code"],
|
||||
rows: decoded.rows.map((row) => ({
|
||||
muted: row.status === "Missing",
|
||||
values: [row.index + 1, timestampText(row.epoch, localTime), row.status,
|
||||
row.status === "MAX" ? ">-64 dBm"
|
||||
: row.noiseDbm === null ? "—" : `${row.noiseDbm} dBm`, row.rawCode],
|
||||
csvValues: [row.index + 1, timestampText(row.epoch, localTime), decoded.profile,
|
||||
row.status.toLowerCase(), row.noiseDbm === null ? "" : row.noiseDbm,
|
||||
row.rawCode],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (decoded.kind === "temperature") {
|
||||
return {
|
||||
headers: [
|
||||
@@ -977,6 +1083,9 @@
|
||||
if (decoded.channel) {
|
||||
items.splice(1, 0, ["LPP channel", String(decoded.channel)]);
|
||||
}
|
||||
if (decoded.profile) {
|
||||
items.splice(1, 0, ["Radio profile", `radio${decoded.profile}`]);
|
||||
}
|
||||
if (decoded.sourceId) {
|
||||
items.splice(1, 0, ["Source ID", decoded.sourceId]);
|
||||
items.push([
|
||||
@@ -1014,11 +1123,80 @@
|
||||
}
|
||||
|
||||
function modelToCsv(model) {
|
||||
return [model.headers, ...model.rows.map((row) => row.values)]
|
||||
return [model.csvHeaders || model.headers,
|
||||
...model.rows.map((row) => row.csvValues || row.values)]
|
||||
.map((row) => row.map(csvCell).join(","))
|
||||
.join("\r\n");
|
||||
}
|
||||
|
||||
function noiseChartModel(decoded) {
|
||||
if (decoded.kind !== "noise") return null;
|
||||
const first = decoded.rows[0].epoch;
|
||||
const last = decoded.rows[decoded.rows.length - 1].epoch;
|
||||
const span = Math.max(900, last - first);
|
||||
const segments = [];
|
||||
let segment = [];
|
||||
let previousEpoch = null;
|
||||
decoded.rows.forEach((row) => {
|
||||
if (row.noiseDbm === null || (previousEpoch !== null && row.epoch - previousEpoch !== 900)) {
|
||||
if (segment.length) segments.push(segment);
|
||||
segment = [];
|
||||
}
|
||||
if (row.noiseDbm !== null) {
|
||||
segment.push({
|
||||
x: 50 + (row.epoch - first) * 570 / span,
|
||||
y: 150 - (row.noiseDbm + 120) * 130 / 56,
|
||||
label: `${timestampText(row.epoch, false)}: ${row.noiseDbm} dBm`,
|
||||
});
|
||||
}
|
||||
previousEpoch = row.epoch;
|
||||
});
|
||||
if (segment.length) segments.push(segment);
|
||||
return {
|
||||
segments,
|
||||
description: `${decoded.label}. Numeric readings only; MAX and missing samples leave gaps.`,
|
||||
};
|
||||
}
|
||||
|
||||
function renderNoiseChart(container, decoded) {
|
||||
container.replaceChildren();
|
||||
const model = noiseChartModel(decoded);
|
||||
container.hidden = model === null;
|
||||
if (!model) return;
|
||||
const svgElement = (tag, attributes = {}) => {
|
||||
const node = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
||||
Object.entries(attributes).forEach(([name, value]) => node.setAttribute(name, String(value)));
|
||||
return node;
|
||||
};
|
||||
const svg = svgElement("svg", { viewBox: "0 0 640 175", role: "img",
|
||||
"aria-label": model.description });
|
||||
for (const [value, y] of [[-64, 20], [-92, 85], [-120, 150]]) {
|
||||
svg.appendChild(svgElement("line", { x1: 50, x2: 620, y1: y, y2: y,
|
||||
class: "telemetry-chart-grid" }));
|
||||
const label = svgElement("text", { x: 42, y: y + 4, "text-anchor": "end" });
|
||||
label.textContent = `${value}`;
|
||||
svg.appendChild(label);
|
||||
}
|
||||
model.segments.forEach((segment) => {
|
||||
svg.appendChild(svgElement("polyline", {
|
||||
points: segment.map((point) => `${point.x},${point.y}`).join(" "),
|
||||
class: "telemetry-chart-line",
|
||||
}));
|
||||
segment.forEach((point) => {
|
||||
const dot = svgElement("circle", { cx: point.x, cy: point.y, r: 2,
|
||||
class: "telemetry-chart-point" });
|
||||
const title = svgElement("title");
|
||||
title.textContent = point.label;
|
||||
dot.appendChild(title);
|
||||
svg.appendChild(dot);
|
||||
});
|
||||
});
|
||||
container.appendChild(svg);
|
||||
const caption = document.createElement("p");
|
||||
caption.textContent = "Noise floor (dBm), oldest to newest. MAX (>−64 dBm) and missing samples leave gaps.";
|
||||
container.appendChild(caption);
|
||||
}
|
||||
|
||||
function appendCell(row, tagName, value) {
|
||||
const cell = document.createElement(tagName);
|
||||
cell.textContent = String(value);
|
||||
@@ -1041,6 +1219,7 @@
|
||||
const results = root.querySelector("[data-role='results']");
|
||||
const resultTitle = root.querySelector("[data-role='result-title']");
|
||||
const summary = root.querySelector("[data-role='summary']");
|
||||
const chart = root.querySelector("[data-role='chart']");
|
||||
const warningBox = root.querySelector("[data-role='warnings']");
|
||||
const warningList = root.querySelector("[data-role='warning-list']");
|
||||
const table = root.querySelector("[data-role='table']");
|
||||
@@ -1068,6 +1247,7 @@
|
||||
const useLocalTime = localTime.checked;
|
||||
const model = tableModel(current, useLocalTime);
|
||||
resultTitle.textContent = `${current.label} telemetry`;
|
||||
renderNoiseChart(chart, current);
|
||||
|
||||
summary.replaceChildren();
|
||||
summaryItems(current, useLocalTime).forEach(([term, description]) => {
|
||||
@@ -1150,7 +1330,8 @@
|
||||
link.href = url;
|
||||
const sourcePart = current.sourceId ? `${current.sourceId.toLowerCase()}-` : "";
|
||||
const channelPart = current.channel ? `-ch${current.channel}` : "";
|
||||
link.download = `meshcore-telemetry-${sourcePart}${current.kind}${channelPart}.csv`;
|
||||
const profilePart = current.profile ? `-radio${current.profile}` : "";
|
||||
link.download = `meshcore-telemetry-${sourcePart}${current.kind}${channelPart}${profilePart}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
@@ -1175,6 +1356,7 @@
|
||||
mergeDecodedTelemetry,
|
||||
tableModel,
|
||||
modelToCsv,
|
||||
noiseChartModel,
|
||||
});
|
||||
|
||||
global.MeshCoreTelemetryDecoder = api;
|
||||
|
||||
@@ -184,6 +184,38 @@
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
.telemetry-noise-chart {
|
||||
margin: 1rem 0;
|
||||
color: var(--md-default-fg-color);
|
||||
}
|
||||
|
||||
.telemetry-noise-chart svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 18rem;
|
||||
font: 12px sans-serif;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.telemetry-noise-chart p {
|
||||
margin: 0.2rem 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.telemetry-chart-grid {
|
||||
stroke: var(--telemetry-border);
|
||||
}
|
||||
|
||||
.telemetry-chart-line {
|
||||
fill: none;
|
||||
stroke: var(--md-primary-fg-color);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.telemetry-chart-point {
|
||||
fill: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.telemetry-tool .telemetry-table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
|
||||
+72
-6
@@ -749,6 +749,11 @@ 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.
|
||||
|
||||
Radio noise-floor history is recorded separately every 15 minutes, retaining
|
||||
768 positions (eight rolling days) per profile. It uses idle RSSI calibration
|
||||
estimates rather than received-packet RSSI. Main-radio and radio2 history are
|
||||
kept separate; failed measurements retain a Missing position in the timeline.
|
||||
|
||||
The feature is omitted from flash-constrained STM32 repeater and room images.
|
||||
|
||||
**Usage:**
|
||||
@@ -757,6 +762,8 @@ The feature is omitted from flash-constrained STM32 repeater and room images.
|
||||
- `get telemetry.volt [page]`
|
||||
- `get telemetry.volt.i2c [channel [page]]`
|
||||
- `get telemetry.gps [page]`
|
||||
- `get telemetry.noise [page]`
|
||||
- `get telemetry.noise2 [page]`
|
||||
- `set telemetry.gps <days>`
|
||||
- `get telemetry.tx`
|
||||
- `set telemetry.tx <off|direct|path>`
|
||||
@@ -773,7 +780,9 @@ The feature is omitted from flash-constrained STM32 repeater and room images.
|
||||
- `page`: Page `1` is always newest. Temperature and voltage pages each hold
|
||||
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`.
|
||||
page `1`. Noise pages contain up to 176 quarter-hour positions (44 hours)
|
||||
and accept only pages already collected, up to `1`-`5`. Use `telemetry.noise`
|
||||
for the main profile and `telemetry.noise2` for radio2.
|
||||
- `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
|
||||
@@ -803,11 +812,24 @@ 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.
|
||||
|
||||
Noise history combines three fresh estimates collected roughly five minutes
|
||||
apart in each UTC-aligned quarter-hour. If any is at or below `-100 dBm`, use
|
||||
the minimum. Otherwise, if any is at or above `-80 dBm`, use the maximum.
|
||||
Otherwise, average all three. Thus a bucket containing both low and high
|
||||
values uses the minimum. Minimums round down, maximums round up, and averages
|
||||
round to the nearest encoded step. Incomplete buckets are Missing. A busy
|
||||
channel can prevent a fresh estimate; old calibration results are not reused
|
||||
as fresh measurements.
|
||||
|
||||
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.
|
||||
|
||||
Packed noise history uses 480 bytes per retained profile, plus its collection
|
||||
state. It does not add a 15-minute transmission schedule. USB and administrator
|
||||
LoRa CLI return the same Base64 format in the existing reply buffer.
|
||||
|
||||
Replies contain `> ` followed by standard padded Base64. After decoding, all
|
||||
multi-byte integers are little-endian. Packed fields are written most
|
||||
significant bit first, oldest sample first.
|
||||
@@ -825,7 +847,8 @@ shared path and enables the default `2d` schedule; new configuration should use
|
||||
An automatic run waits until 165 half-hour positions are
|
||||
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
|
||||
voltage channel, plus the collected noise-floor snapshots. 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
|
||||
@@ -834,14 +857,19 @@ that did not queue; successfully queued packets are not duplicated.
|
||||
|
||||
`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
|
||||
chunks over the configured path. It also queues one main-radio `N1B1` packet
|
||||
once a noise bucket has completed and one `N2B1` packet when radio2 is enabled
|
||||
and has a completed bucket. Missing positions are included; an all-Missing
|
||||
history is still sent. 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
|
||||
channel, plus the latest 255 positions per noise profile (63 hours 45 minutes).
|
||||
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:
|
||||
Full temperature and battery snapshots are 184-byte RAW_CUSTOM payloads.
|
||||
Manual tests can be shorter while history fills. Both formats are binary,
|
||||
not Base64 and not encrypted:
|
||||
|
||||
| Bytes | Meaning |
|
||||
|---|---|
|
||||
@@ -857,6 +885,14 @@ 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.
|
||||
|
||||
Noise snapshots reuse this 19-byte telemetry header. Their magic is `N1B1`
|
||||
for the main radio or `N2B1` for radio2; the interval is `15`, the count is
|
||||
`1`-`255`, and data begins at byte `19`. Samples are packed 5-bit codes,
|
||||
oldest first and most significant bit first, with zero padding in the last
|
||||
byte. The payload is `19 + ceil(count * 5 / 8)` bytes: 179 bytes for 255
|
||||
positions. No GPS coordinates are included in a noise packet. Noise packets
|
||||
are additional RAW_CUSTOM telemetry snapshots, not a new MeshCore packet type.
|
||||
|
||||
External voltage snapshots use a separate packed layout. A full four-day
|
||||
channel takes three 140-byte `IVB1` payloads of 64 points each:
|
||||
|
||||
@@ -925,6 +961,36 @@ External I2C voltage payload (`0x14`, 98 bytes):
|
||||
|
||||
The codes have the same `0.02 V` through `655.34 V` meaning as `IVB1`.
|
||||
|
||||
Noise-floor payload (`0x15` main radio, `0x16` radio2; up to 117 bytes):
|
||||
|
||||
| Bytes | Meaning |
|
||||
|---|---|
|
||||
| `0` | Format/type `0x15` or `0x16` |
|
||||
| `1`-`4` | First sample UTC epoch, unsigned 32-bit |
|
||||
| `5` | Sample interval in minutes (`15`) |
|
||||
| `6` | Sample count (`1`-`176`) |
|
||||
| `7` onward | Oldest-to-newest packed 5-bit noise-floor codes |
|
||||
|
||||
The same codes are used by `N1B1` and `N2B1`:
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0` | `-120 dBm` (also clamps lower values) |
|
||||
| `1` | `-119 dBm` |
|
||||
| `2`-`29` | `-118` through `-64 dBm`, in 2 dB steps |
|
||||
| `30` | MAX: above `-64 dBm` |
|
||||
| `31` | Missing: fewer than three fresh, valid estimates |
|
||||
|
||||
The last byte's unused low bits are zero. A full page encodes as 156 Base64
|
||||
characters, plus the existing `> ` reply prefix. The decoder keeps radio1 and
|
||||
radio2 separate and rejects wrong lengths, intervals, or padding. MAX is a
|
||||
status, not an invented numeric `-62 dBm` reading.
|
||||
|
||||
When a Companion CLI command includes its three-character correlation prefix
|
||||
(for example `01|`), noise replies retain that prefix and omit the `> ` prompt.
|
||||
A full `01|Base64` response then fits in 160 bytes including its terminating
|
||||
NUL. The decoder accepts either reply style.
|
||||
|
||||
GPS payload (`0x13`, 101 bytes):
|
||||
|
||||
| Bytes | Meaning |
|
||||
|
||||
@@ -9,7 +9,7 @@ the instructions but cannot run the decoder's buttons or JavaScript.
|
||||
|
||||
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, battery-voltage, or external I2C-voltage
|
||||
repeater's temperature, battery-voltage, external I2C-voltage, or radio noise-floor
|
||||
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
|
||||
@@ -54,6 +54,14 @@ 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.
|
||||
|
||||
It also queues a `N1B1` noise-floor packet once the main radio has completed a
|
||||
15-minute history bucket. With radio2 enabled, it queues a separate `N2B1`
|
||||
packet for that profile once it has a completed bucket. Each holds the latest
|
||||
255 positions: up to 63 hours 45 minutes at 15-minute resolution. A position
|
||||
can be Missing; failed measurements do not silently shorten the timeline.
|
||||
The existing automatic transmission cadence is unchanged. Recording a new
|
||||
noise-floor position every 15 minutes does not transmit a packet every 15 minutes.
|
||||
|
||||
Schedule changes are saved transactionally; a failed write does not truncate
|
||||
the previous preferences. A legacy route must be successfully migrated into
|
||||
`data.tx` before its schedule can run. Corrupt, unreadable, or ambiguously
|
||||
@@ -75,8 +83,8 @@ to paste a packet, load an example, or download CSV.
|
||||
## Analyzer hex examples
|
||||
|
||||
The buttons load synthetic, protocol-valid zero-hop RAW_CUSTOM packets. A real
|
||||
scheduled snapshot normally has 165 samples and is much longer. Routed packets
|
||||
also contain path bytes before the `TTB1` or `TVB1` payload magic; the decoder
|
||||
snapshot can contain many more samples. Routed packets
|
||||
also contain path bytes before the telemetry payload magic; the decoder
|
||||
finds and validates the payload automatically.
|
||||
|
||||
### Temperature
|
||||
@@ -97,7 +105,13 @@ finds and validates the payload automatically.
|
||||
3E00495642311122334455667788800092651E00020800000004019026927109C427107FFF
|
||||
```
|
||||
|
||||
All three examples identify the source as `1122334455667788`.
|
||||
### Radio1 noise floor
|
||||
|
||||
```text
|
||||
3E004E3142311122334455667788800092650F00080044CB77DF
|
||||
```
|
||||
|
||||
All four examples identify the source as `1122334455667788`.
|
||||
|
||||
## CLI history pages
|
||||
|
||||
@@ -110,9 +124,12 @@ administrator commands:
|
||||
| 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) |
|
||||
| Main-radio noise floor | `get telemetry.noise` | `get telemetry.noise 2` | Up to 176 (44 hours) |
|
||||
| Radio2 noise floor | `get telemetry.noise2` | `get telemetry.noise2 2` | Up to 176 (44 hours) |
|
||||
|
||||
Paste either the complete reply beginning with `> ` or Base64 alone. For
|
||||
example:
|
||||
noise replies, the optional two-character CLI correlation prefix is accepted
|
||||
too (`01|Base64` or `01|> Base64`). For example:
|
||||
|
||||
```text
|
||||
get telemetry.volt 1
|
||||
@@ -124,7 +141,15 @@ 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.
|
||||
telemetry type, source, LPP channel, radio profile, and sample interval.
|
||||
|
||||
Noise history retains eight days (768 quarter-hour positions) per profile in
|
||||
RAM and resets on reboot. Page `1` is newest; up to five pages are available
|
||||
as history fills. Paste the replies for one radio together to merge them.
|
||||
Main-radio and radio2 pages remain separate. A full noise CLI page is 117
|
||||
decoded bytes or 156 Base64 characters, fitting the current CLI reply buffer
|
||||
over USB and administrator LoRa CLI. Radio2 history is read with
|
||||
`get telemetry.noise2`, including when it is not being transmitted.
|
||||
|
||||
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
|
||||
@@ -147,6 +172,12 @@ IDs reported by `get telemetry.volt.i2c` rather than assuming them.
|
||||
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.
|
||||
- `N1B1` is main-radio noise history; `N2B1` is radio2 history. The chart and
|
||||
table use dBm. Codes `0`, `1`, and `2` mean `-120`, `-119`, and `-118 dBm`;
|
||||
codes `3` through `29` continue in 2 dB steps through `-64 dBm`. Code `30`
|
||||
is **MAX**, displayed as `>-64 dBm`, and code `31` is **Missing**. CSV uses
|
||||
separate `radio_profile`, `status`, and `noise_floor_dbm` columns. MAX and
|
||||
Missing have an empty numeric field and leave gaps in the chart.
|
||||
- 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
|
||||
@@ -154,6 +185,16 @@ IDs reported by `get telemetry.volt.i2c` rather than assuming them.
|
||||
- A GPS clipping warning means at least one movement exceeded the differential
|
||||
range, so positions after that point can be less accurate.
|
||||
|
||||
Each noise position combines three fresh noise-floor estimates collected
|
||||
roughly five minutes apart within its UTC-aligned 15-minute bucket. If any
|
||||
estimate is `-100 dBm` or lower, the minimum wins, including a bucket containing
|
||||
both low and high values. Otherwise, if any estimate is `-80 dBm` or higher,
|
||||
the maximum wins. Otherwise the three estimates are averaged. Minimums round
|
||||
downward, maximums upward, and averages to the nearest encodable value. Values
|
||||
below `-120 dBm` clamp to `-120 dBm`; values above `-64 dBm` become MAX.
|
||||
Fewer than three fresh, valid estimates produce Missing. These are idle RSSI
|
||||
noise estimates, not received-packet RSSI measurements.
|
||||
|
||||
GPS history remains available through the administrator CLI, but `telemetry.tx`
|
||||
never puts GPS in RAW_CUSTOM packets. Location data therefore must come from a
|
||||
CLI Base64 page rather than analyzer hex.
|
||||
|
||||
@@ -245,6 +245,8 @@ 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;
|
||||
static const uint8_t TELEMETRY_HISTORY_TX_NOISE = 8U;
|
||||
static const uint8_t TELEMETRY_HISTORY_TX_NOISE2 = 16U;
|
||||
#endif
|
||||
|
||||
#define CLOCK_SYNC_VALID_YEARS 10
|
||||
@@ -12270,6 +12272,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
|
||||
while (*command == ' ') command++; // skip leading spaces
|
||||
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
const bool prefixed_reply = strlen(command) > 4 && command[2] == '|';
|
||||
#endif
|
||||
if (strlen(command) > 4 && command[2] == '|') { // optional prefix (for companion radio CLI)
|
||||
memcpy(reply, command, 3); // reflect the prefix back
|
||||
reply += 3;
|
||||
@@ -12309,6 +12314,17 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
#endif
|
||||
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
const char* noise_commands[] = {"get telemetry.noise", "get telemetry.noise2"};
|
||||
for (uint8_t profile = 0; profile < 2; ++profile) {
|
||||
const size_t length = strlen(noise_commands[profile]);
|
||||
if (strncmp(command, noise_commands[profile], length) == 0
|
||||
&& (command[length] == 0 || command[length] == ' ')) {
|
||||
if (sender != NULL && !sender->isAdmin()) strcpy(reply, "Err - not permitted");
|
||||
else noise_floor_history.formatPageReply(profile, command + length, reply,
|
||||
prefixed_reply ? 157 : 160, !prefixed_reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const char* telemetry_args = NULL;
|
||||
mesh::TelemetryHistory::Series telemetry_series =
|
||||
mesh::TelemetryHistory::SERIES_TEMPERATURE;
|
||||
@@ -12351,6 +12367,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
}
|
||||
telemetry_history_tx_pending = TELEMETRY_HISTORY_TX_TEMPERATURE
|
||||
| TELEMETRY_HISTORY_TX_VOLTAGE;
|
||||
if (noise_floor_history.sampleCount(0) != 0)
|
||||
telemetry_history_tx_pending |= TELEMETRY_HISTORY_TX_NOISE;
|
||||
if (isDualRadioActive() && noise_floor_history.sampleCount(1) != 0)
|
||||
telemetry_history_tx_pending |= TELEMETRY_HISTORY_TX_NOISE2;
|
||||
const uint8_t external_channels =
|
||||
external_voltage_history.populatedChannelCount();
|
||||
if (external_channels != 0) {
|
||||
@@ -12371,8 +12391,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
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);
|
||||
"OK - telemetry.tx queued temp=%u volt=%u i2c=%u/%u noise=%u/%u",
|
||||
sent, sent, (unsigned)external_channels, external_packets,
|
||||
(telemetry_history_tx_pending & TELEMETRY_HISTORY_TX_NOISE) != 0,
|
||||
(telemetry_history_tx_pending & TELEMETRY_HISTORY_TX_NOISE2) != 0);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -13326,6 +13348,18 @@ bool MyMesh::sendTelemetryHistorySnapshot(
|
||||
return sendDirect(packet, data_path, data_path_len);
|
||||
}
|
||||
|
||||
bool MyMesh::sendNoiseFloorHistorySnapshot(uint8_t profile) {
|
||||
const uint8_t* data_path = NULL;
|
||||
uint8_t data_path_len = OUT_PATH_UNKNOWN;
|
||||
if (!_cli.getDataTxPath(data_path, data_path_len)) return false;
|
||||
uint8_t payload[mesh::NoiseFloorHistory::BINARY_PAYLOAD_SIZE];
|
||||
const size_t length = noise_floor_history.formatBinarySnapshot(
|
||||
profile, self_id.pub_key, payload, sizeof(payload));
|
||||
if (length <= mesh::NoiseFloorHistory::BINARY_HEADER_SIZE) return false;
|
||||
mesh::Packet* packet = createRawData(payload, length);
|
||||
return packet != NULL && sendDirect(packet, data_path, data_path_len);
|
||||
}
|
||||
|
||||
bool MyMesh::sendExternalVoltageHistorySnapshot(uint8_t channel_index,
|
||||
uint8_t chunk_index) {
|
||||
const uint8_t* data_path = NULL;
|
||||
@@ -13369,6 +13403,10 @@ void MyMesh::serviceTelemetryHistoryTx() {
|
||||
telemetry_history_tx_resume_uptime = 0;
|
||||
telemetry_history_tx_pending = TELEMETRY_HISTORY_TX_TEMPERATURE
|
||||
| TELEMETRY_HISTORY_TX_VOLTAGE;
|
||||
if (noise_floor_history.sampleCount(0) != 0)
|
||||
telemetry_history_tx_pending |= TELEMETRY_HISTORY_TX_NOISE;
|
||||
if (isDualRadioActive() && noise_floor_history.sampleCount(1) != 0)
|
||||
telemetry_history_tx_pending |= TELEMETRY_HISTORY_TX_NOISE2;
|
||||
if (external_voltage_history.populatedChannelCount() != 0) {
|
||||
telemetry_history_tx_pending |= TELEMETRY_HISTORY_TX_EXTERNAL_VOLTAGE;
|
||||
}
|
||||
@@ -13392,6 +13430,14 @@ void MyMesh::serviceTelemetryHistoryTx() {
|
||||
telemetry_history_tx_pending &=
|
||||
(uint8_t)~TELEMETRY_HISTORY_TX_VOLTAGE;
|
||||
}
|
||||
} else if ((telemetry_history_tx_pending & TELEMETRY_HISTORY_TX_NOISE) != 0) {
|
||||
sent = noise_floor_history.sampleCount(0) == 0
|
||||
|| sendNoiseFloorHistorySnapshot(0);
|
||||
if (sent) telemetry_history_tx_pending &= (uint8_t)~TELEMETRY_HISTORY_TX_NOISE;
|
||||
} else if ((telemetry_history_tx_pending & TELEMETRY_HISTORY_TX_NOISE2) != 0) {
|
||||
sent = !isDualRadioActive() || noise_floor_history.sampleCount(1) == 0
|
||||
|| sendNoiseFloorHistorySnapshot(1);
|
||||
if (sent) telemetry_history_tx_pending &= (uint8_t)~TELEMETRY_HISTORY_TX_NOISE2;
|
||||
} else if ((telemetry_history_tx_pending
|
||||
& TELEMETRY_HISTORY_TX_EXTERNAL_VOLTAGE) != 0) {
|
||||
const uint8_t channel_count =
|
||||
@@ -13443,6 +13489,11 @@ void MyMesh::serviceTelemetryHistoryTx() {
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::sampleNoiseFloorHistory() {
|
||||
noise_floor_sampler.poll(*_radio, noise_floor_history,
|
||||
rtc_clock.getCurrentTime(), millis());
|
||||
}
|
||||
|
||||
void MyMesh::sampleTelemetryHistory() {
|
||||
const uint32_t now = rtc_clock.getCurrentTime();
|
||||
if (!telemetry_history.sampleDue(now)) return;
|
||||
@@ -13516,6 +13567,7 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() {
|
||||
#endif
|
||||
checkRxInactivityWatchdog();
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
sampleNoiseFloorHistory();
|
||||
sampleTelemetryHistory();
|
||||
serviceTelemetryHistoryTx();
|
||||
#endif
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
#include <helpers/ExternalVoltageHistory.h>
|
||||
#include <helpers/TelemetryHistory.h>
|
||||
#include <helpers/NoiseFloorHistorySampler.h>
|
||||
#endif
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include <helpers/RegionMap.h>
|
||||
@@ -575,6 +576,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
mesh::TelemetryHistory telemetry_history;
|
||||
mesh::ExternalVoltageHistory external_voltage_history;
|
||||
mesh::NoiseFloorHistory noise_floor_history;
|
||||
mesh::NoiseFloorHistorySampler noise_floor_sampler;
|
||||
bool telemetry_history_tx_prefs_healthy;
|
||||
bool telemetry_history_tx_enabled;
|
||||
uint8_t telemetry_history_tx_interval_days;
|
||||
@@ -859,6 +862,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
void servicePostMeshLoop();
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
void sampleTelemetryHistory();
|
||||
void sampleNoiseFloorHistory();
|
||||
#if MESH_ENABLE_TELEMETRY_GPS_HISTORY
|
||||
uint8_t resizeTelemetryGpsDays(uint8_t requested_days);
|
||||
#endif
|
||||
@@ -866,6 +870,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
bool saveTelemetryHistoryTxPrefs();
|
||||
void serviceTelemetryHistoryTx();
|
||||
bool sendTelemetryHistorySnapshot(mesh::TelemetryHistory::Series series);
|
||||
bool sendNoiseFloorHistorySnapshot(uint8_t profile);
|
||||
bool sendExternalVoltageHistorySnapshot(uint8_t channel_index,
|
||||
uint8_t chunk_index);
|
||||
void formatTelemetryHistoryTxStatus(char* reply, size_t reply_size) const;
|
||||
|
||||
@@ -3649,7 +3649,18 @@ void MyMesh::onUserGpioTimerCompleted(uint8_t pin, uint8_t state,
|
||||
#endif
|
||||
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
bool MyMesh::handleTelemetryHistoryCommand(const char* command, char* reply) {
|
||||
bool MyMesh::handleTelemetryHistoryCommand(const char* command, char* reply,
|
||||
size_t reply_capacity, bool include_prompt) {
|
||||
const char* noise_commands[] = {"get telemetry.noise", "get telemetry.noise2"};
|
||||
for (uint8_t profile = 0; profile < 2; ++profile) {
|
||||
const size_t length = strlen(noise_commands[profile]);
|
||||
if (strncmp(command, noise_commands[profile], length) == 0
|
||||
&& (command[length] == 0 || command[length] == ' ')) {
|
||||
noise_floor_history.formatPageReply(profile, command + length, reply,
|
||||
reply_capacity, include_prompt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
static const char temperature_command[] = "get telemetry.temp";
|
||||
static const char battery_command[] = "get telemetry.volt";
|
||||
static const char external_voltage_command[] =
|
||||
@@ -3663,7 +3674,7 @@ bool MyMesh::handleTelemetryHistoryCommand(const char* command, char* reply) {
|
||||
&& (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);
|
||||
command + sizeof(external_voltage_command) - 1U, reply, reply_capacity);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3691,10 +3702,15 @@ bool MyMesh::handleTelemetryHistoryCommand(const char* command, char* reply) {
|
||||
}
|
||||
|
||||
if (args == NULL) return false;
|
||||
telemetry_history.formatPageReply(series, args, reply, 160);
|
||||
telemetry_history.formatPageReply(series, args, reply, reply_capacity);
|
||||
return true;
|
||||
}
|
||||
|
||||
void MyMesh::sampleNoiseFloorHistory() {
|
||||
noise_floor_sampler.poll(*_radio, noise_floor_history,
|
||||
getRTCClock()->getCurrentTime(), millis());
|
||||
}
|
||||
|
||||
void MyMesh::sampleTelemetryHistory() {
|
||||
const uint32_t now = getRTCClock()->getCurrentTime();
|
||||
if (!telemetry_history.sampleDue(now)) return;
|
||||
@@ -3803,6 +3819,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
|
||||
while (*command == ' ')
|
||||
command++; // skip leading spaces
|
||||
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
const bool prefixed_reply = strlen(command) > 4 && command[2] == '|';
|
||||
#endif
|
||||
if (strlen(command) > 4 && command[2] == '|') { // optional prefix (for companion radio CLI)
|
||||
memcpy(reply, command, 3); // reflect the prefix back
|
||||
reply += 3;
|
||||
@@ -3840,7 +3859,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
|
||||
#endif
|
||||
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
if (handleTelemetryHistoryCommand(command, reply)) return;
|
||||
if (handleTelemetryHistoryCommand(command, reply,
|
||||
prefixed_reply ? 157 : 160, !prefixed_reply)) return;
|
||||
#endif
|
||||
|
||||
// handle ACL related commands
|
||||
@@ -4000,6 +4020,7 @@ void MyMesh::loop() {
|
||||
#endif
|
||||
_clock_sync.loop();
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
sampleNoiseFloorHistory();
|
||||
sampleTelemetryHistory();
|
||||
#endif
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
|
||||
@@ -66,6 +66,7 @@ namespace mesh { class RoomJsonWriter; }
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
#include <helpers/ExternalVoltageHistory.h>
|
||||
#include <helpers/TelemetryHistory.h>
|
||||
#include <helpers/NoiseFloorHistorySampler.h>
|
||||
#endif
|
||||
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
@@ -253,6 +254,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
mesh::TelemetryHistory telemetry_history;
|
||||
mesh::ExternalVoltageHistory external_voltage_history;
|
||||
mesh::NoiseFloorHistory noise_floor_history;
|
||||
mesh::NoiseFloorHistorySampler noise_floor_sampler;
|
||||
#endif
|
||||
RegionEntry* load_stack[8];
|
||||
RegionEntry* recv_pkt_region;
|
||||
@@ -493,8 +496,10 @@ protected:
|
||||
|
||||
bool 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);
|
||||
bool handleTelemetryHistoryCommand(const char* command, char* reply,
|
||||
size_t reply_capacity = 160, bool include_prompt = true);
|
||||
void sampleTelemetryHistory();
|
||||
void sampleNoiseFloorHistory();
|
||||
#endif
|
||||
|
||||
public:
|
||||
|
||||
+2
-2
@@ -123,7 +123,7 @@ extra_css:
|
||||
- https://unpkg.com/leaflet@1.9.4/dist/leaflet.css
|
||||
- _stylesheets/extra.css
|
||||
- _stylesheets/firmware_picker.css?v=20261013-pick-first-1
|
||||
- _stylesheets/telemetry_decoder.css
|
||||
- _stylesheets/telemetry_decoder.css?v=20261022-noise-history
|
||||
- _stylesheets/management_decoder.css
|
||||
- _stylesheets/filter_tool.css
|
||||
- _stylesheets/preset_test.css?v=20260923-2
|
||||
@@ -134,7 +134,7 @@ extra_javascript:
|
||||
- _javascript/notification_builder.js?v=20261006-channel9
|
||||
- https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
|
||||
- _javascript/firmware_picker.js?v=20261018-variant-consolidation-1
|
||||
- _javascript/telemetry_decoder.js?v=20261007-hosted-form
|
||||
- _javascript/telemetry_decoder.js?v=20261022-noise-history
|
||||
- _javascript/management_decoder.js
|
||||
- _javascript/filter_tool.js?v=20261002-hashbytes-2plus
|
||||
- _javascript/preset_test.js?v=20260923-2
|
||||
|
||||
@@ -64,6 +64,41 @@ function externalPacket(epoch, channel, codes) {
|
||||
]).toString("hex").toUpperCase();
|
||||
}
|
||||
|
||||
function pack5(codes) {
|
||||
const output = Buffer.alloc(Math.ceil(codes.length * 5 / 8));
|
||||
codes.forEach((code, index) => {
|
||||
for (let bit = 0; bit < 5; bit += 1) {
|
||||
const offset = index * 5 + bit;
|
||||
output[offset >> 3] |= ((code >> (4 - bit)) & 1) << (7 - (offset % 8));
|
||||
}
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function noisePageBytes(epoch, profile, codes, interval = 15) {
|
||||
const header = Buffer.alloc(7);
|
||||
header[0] = profile === 1 ? 0x15 : 0x16;
|
||||
header.writeUInt32LE(epoch, 1);
|
||||
header[5] = interval;
|
||||
header[6] = codes.length;
|
||||
return Buffer.concat([header, pack5(codes)]);
|
||||
}
|
||||
|
||||
function noisePage(epoch, profile, codes, interval = 15) {
|
||||
return `> ${noisePageBytes(epoch, profile, codes, interval).toString("base64")}`;
|
||||
}
|
||||
|
||||
function noisePacketBytes(epoch, profile, codes, interval = 15,
|
||||
source = "1122334455667788") {
|
||||
const header = Buffer.alloc(19);
|
||||
header.write(profile === 1 ? "N1B1" : "N2B1", 0, "ascii");
|
||||
Buffer.from(source, "hex").copy(header, 4);
|
||||
header.writeUInt32LE(epoch, 12);
|
||||
header.writeUInt16LE(interval, 16);
|
||||
header[18] = codes.length;
|
||||
return Buffer.concat([header, pack5(codes)]);
|
||||
}
|
||||
|
||||
// Run the real browser entry point. The DOM boundary starts with the empty host
|
||||
// shipped in Markdown and exposes only controls inserted by the actual script.
|
||||
function browserFixture(readyState = "complete") {
|
||||
@@ -78,6 +113,7 @@ function browserFixture(readyState = "complete") {
|
||||
tagName, value: "", checked: false, hidden: false, textContent: "", children: [],
|
||||
classList: { add() {} },
|
||||
getAttribute(name) { return attributes[name] ?? null; },
|
||||
setAttribute(name, value) { attributes[name] = value; },
|
||||
addEventListener(name, callback) { handlers.set(name, callback); },
|
||||
emit(name, event = {}) {
|
||||
assert.ok(handlers.has(name), `Missing ${name} handler on ${tagName}`);
|
||||
@@ -128,6 +164,7 @@ function browserFixture(readyState = "complete") {
|
||||
},
|
||||
document: {
|
||||
readyState, body: element("body"), createElement: element,
|
||||
createElementNS: (_namespace, tagName) => element(tagName),
|
||||
querySelector: (selector) => selector === "[data-telemetry-decoder]" ? root : null,
|
||||
addEventListener: (name, callback) => events.set(name, callback),
|
||||
},
|
||||
@@ -169,6 +206,7 @@ test("browser initialization creates the form and wires every example", () => {
|
||||
for (const [example, count] of [
|
||||
["packetTemperature", 8], ["packetVoltage", 8],
|
||||
["packetExternalVoltage", 8], ["externalVoltage", 48],
|
||||
["packetNoise", 8], ["noiseSecondary", 8],
|
||||
]) {
|
||||
fixture.examples.get(example).emit("click");
|
||||
assert.strictEqual(fixture.fields.get("results").hidden, false,
|
||||
@@ -344,7 +382,7 @@ test("retains legacy CLI Base64 decoding", () => {
|
||||
test("rejects unrelated, truncated, and ambiguous hex", () => {
|
||||
assertDecoderError(
|
||||
() => decoder.decodeRawTelemetryHex("00000000000000000000000000000000000000"),
|
||||
/does not contain TTB1, TVB1, or IVB1/
|
||||
/does not contain TTB1, TVB1, IVB1, N1B1, or N2B1/
|
||||
);
|
||||
assertDecoderError(
|
||||
() => decoder.decodeRawTelemetryHex(TEMPERATURE_PACKET.slice(0, -2)),
|
||||
@@ -364,4 +402,153 @@ test("exports raw telemetry rows as CSV", () => {
|
||||
assert.strictEqual(csv.split("\r\n").length, 9);
|
||||
});
|
||||
|
||||
test("all 32 noise codes agree between CLI and the existing RAW_CUSTOM header", () => {
|
||||
const codes = Array.from({ length: 32 }, (_, index) => index);
|
||||
for (const profile of [1, 2]) {
|
||||
const cli = decoder.decodeTelemetry(noisePage(1704067200, profile, codes));
|
||||
const rawBytes = noisePacketBytes(1704067200, profile, codes);
|
||||
const raw = decoder.decodeTelemetry(`3e4212345678${rawBytes.toString("hex")}`);
|
||||
assert.strictEqual(cli.profile, profile);
|
||||
assert.strictEqual(raw.profile, profile);
|
||||
assert.strictEqual(raw.sourceId, "1122334455667788");
|
||||
assert.strictEqual(raw.payloadOffset, 6);
|
||||
assert.strictEqual(raw.packet.hopCount, 2);
|
||||
assert.strictEqual(raw.intervalMinutes, 15);
|
||||
assert.strictEqual(raw.firstEpoch, 1704067200);
|
||||
assert.strictEqual(raw.byteLength, 19 + 20);
|
||||
assert.deepStrictEqual(raw.rows, cli.rows);
|
||||
assert.deepStrictEqual(cli.rows.slice(0, 3).map((row) => row.noiseDbm), [-120, -119, -118]);
|
||||
assert.deepStrictEqual(cli.rows.slice(3, 30).map((row) => row.noiseDbm),
|
||||
Array.from({ length: 27 }, (_, index) => -116 + index * 2));
|
||||
assert.deepStrictEqual(cli.rows.slice(30).map((row) => [row.status, row.noiseDbm]),
|
||||
[["MAX", null], ["Missing", null]]);
|
||||
assert.strictEqual(cli.rows[31].epoch, 1704067200 + 31 * 900);
|
||||
}
|
||||
});
|
||||
|
||||
test("noise pages fit the current CLI buffer and merge all eight days", () => {
|
||||
const epoch = 1704067200;
|
||||
const pages = [];
|
||||
for (let first = 0; first < 768; first += 176) {
|
||||
const count = Math.min(176, 768 - first);
|
||||
const page = noisePage(epoch + first * 900, 1, Array(count).fill(12));
|
||||
assert.ok(page.length + 1 <= 160, "Reply and terminating NUL fit the existing buffer.");
|
||||
pages.unshift(page);
|
||||
}
|
||||
const decoded = decoder.decodeTelemetry(pages.join("\n"));
|
||||
assert.strictEqual(decoded.pageCount, 5);
|
||||
assert.strictEqual(decoded.count, 768);
|
||||
assert.strictEqual(decoded.rows[0].epoch, epoch);
|
||||
assert.strictEqual(decoded.rows[767].epoch, epoch + 767 * 900);
|
||||
assert.strictEqual(decoder.decodeTelemetry(pages[4]).byteLength, 117);
|
||||
const fullRaw = decoder.decodeTelemetry(
|
||||
noisePacketBytes(epoch, 1, Array(255).fill(31)).toString("hex")
|
||||
);
|
||||
assert.strictEqual(fullRaw.count, 255);
|
||||
assert.strictEqual(fullRaw.byteLength, 179);
|
||||
assert.ok(fullRaw.rows.every((row) => row.status === "Missing"));
|
||||
});
|
||||
|
||||
test("correlation-prefixed noise replies preserve full pages and merge by timestamp", () => {
|
||||
const epoch = 1704067200;
|
||||
for (const profile of [1, 2]) {
|
||||
const page = noisePage(epoch, profile, Array(176).fill(1));
|
||||
const bare = page.slice(2);
|
||||
assert.strictEqual(`01|${bare}`.length + 1, 160);
|
||||
for (const input of [`01|${bare}`, `02|>${bare}`, `03|> ${bare}`]) {
|
||||
const decoded = decoder.decodeTelemetry(input);
|
||||
assert.strictEqual(decoded.count, 176);
|
||||
assert.strictEqual(decoded.profile, profile);
|
||||
assert.ok(decoded.rows.every((row) => row.noiseDbm === -119));
|
||||
assert.strictEqual(decoder.extractBase64(input), bare);
|
||||
}
|
||||
const older = noisePage(epoch - 176 * 900, profile, Array(176).fill(0)).slice(2);
|
||||
const merged = decoder.decodeTelemetry(`01|${bare}\n02|>${older}`);
|
||||
assert.strictEqual(merged.count, 352);
|
||||
assert.strictEqual(merged.pageCount, 2);
|
||||
assert.strictEqual(merged.profile, profile);
|
||||
assert.strictEqual(merged.rows[0].noiseDbm, -120);
|
||||
assert.strictEqual(merged.rows[351].noiseDbm, -119);
|
||||
}
|
||||
});
|
||||
|
||||
test("noise decoder rejects wrong interval, count, size, padding and enclosing packet", () => {
|
||||
const epoch = 1704067200;
|
||||
for (const interval of [0, 14, 30]) {
|
||||
assertDecoderError(() => decoder.decodeTelemetry(noisePage(epoch, 1, [0], interval)),
|
||||
/interval/);
|
||||
assertDecoderError(() => decoder.decodeTelemetry(
|
||||
noisePacketBytes(epoch, 1, [0], interval).toString("hex")), /interval/);
|
||||
}
|
||||
assertDecoderError(() => decoder.decodeTelemetry(noisePage(epoch, 1, [])), /count/);
|
||||
assertDecoderError(() => decoder.decodeTelemetry(noisePage(epoch, 1, Array(177).fill(0))),
|
||||
/between 1 and 176/);
|
||||
const cli = noisePageBytes(epoch, 1, [0]);
|
||||
cli[7] = 1;
|
||||
assertDecoderError(() => decoder.decodeTelemetry(cli.toString("base64")), /padding bits/);
|
||||
const raw = noisePacketBytes(epoch, 1, [0]);
|
||||
raw[19] = 1;
|
||||
assertDecoderError(() => decoder.decodeTelemetry(raw.toString("hex")), /padding bits/);
|
||||
for (const bytes of [noisePageBytes(epoch, 1, [0]), noisePacketBytes(epoch, 1, [0])]) {
|
||||
const encode = (value) => bytes[0] === 0x15 ? value.toString("base64") : value.toString("hex");
|
||||
assertDecoderError(() => decoder.decodeTelemetry(encode(bytes.subarray(0, bytes.length - 1))),
|
||||
/bytes|shorter/);
|
||||
assertDecoderError(() => decoder.decodeTelemetry(encode(Buffer.concat([bytes, Buffer.from([0])]))),
|
||||
/should be/);
|
||||
}
|
||||
assertDecoderError(() => decoder.decodeTelemetry(`3a00${noisePacketBytes(epoch, 1, [0]).toString("hex")}`),
|
||||
/complete RAW_CUSTOM/);
|
||||
assertDecoderError(() => decoder.decodeTelemetry(`3e01${noisePacketBytes(epoch, 1, [0]).toString("hex")}`),
|
||||
/complete RAW_CUSTOM/);
|
||||
});
|
||||
|
||||
test("noise merge preserves source, profile and interval boundaries", () => {
|
||||
const epoch = 1704067200;
|
||||
const first = noisePage(epoch, 1, [0, 1]);
|
||||
assertDecoderError(() => decoder.decodeTelemetry(`${first}\n${noisePage(epoch, 2, [0, 1])}`),
|
||||
/radio profile/);
|
||||
const raw = noisePacketBytes(epoch, 1, [0, 1]).toString("hex");
|
||||
assertDecoderError(() => decoder.decodeTelemetry(`${raw}\n${noisePacketBytes(epoch, 2, [0, 1]).toString("hex")}`),
|
||||
/radio profile/);
|
||||
assertDecoderError(() => decoder.decodeTelemetry(`${raw}\n${noisePacketBytes(epoch, 1, [0, 1], 15, "8877665544332211").toString("hex")}`),
|
||||
/same type, source/);
|
||||
const decoded = decoder.decodeTelemetry(first);
|
||||
assertDecoderError(() => decoder.mergeDecodedTelemetry([decoded, { ...decoded, intervalMinutes: 30 }]),
|
||||
/interval/);
|
||||
});
|
||||
|
||||
test("noise table and CSV keep MAX and missing separate from numeric dBm", () => {
|
||||
const decoded = decoder.decodeTelemetry(noisePage(1704067200, 2, [0, 1, 29, 30, 31]));
|
||||
const model = decoder.tableModel(decoded, false);
|
||||
assert.strictEqual(model.rows[3].values[3], ">-64 dBm");
|
||||
assert.strictEqual(model.rows[4].values[2], "Missing");
|
||||
const csv = decoder.modelToCsv(model);
|
||||
assert.strictEqual(csv.split("\r\n")[0],
|
||||
"sample,timestamp_utc,radio_profile,status,noise_floor_dbm,raw_code");
|
||||
assert.match(csv, /,2,value,-120,0/);
|
||||
assert.match(csv, /,2,value,-119,1/);
|
||||
assert.match(csv, /,2,value,-64,29/);
|
||||
assert.match(csv, /,2,max,,30/);
|
||||
assert.match(csv, /,2,missing,,31/);
|
||||
const fixture = browserFixture();
|
||||
fixture.examples.get("noiseSecondary").emit("click");
|
||||
assert.strictEqual(fixture.fields.get("chart").hidden, false);
|
||||
fixture.fields.get("download").emit("click");
|
||||
assert.strictEqual(fixture.downloads[0].filename, "meshcore-telemetry-noise-radio2.csv");
|
||||
assert.match(fixture.downloads[0].blob.parts.join(""), /,2,max,,30/);
|
||||
fixture.examples.get("packetVoltage").emit("click");
|
||||
assert.strictEqual(fixture.fields.get("chart").hidden, true);
|
||||
});
|
||||
|
||||
test("noise chart leaves MAX, missing and unrecorded time gaps", () => {
|
||||
const decoded = decoder.decodeTelemetry(noisePage(1704067200, 1, [0, 1, 30, 29, 31, 2]));
|
||||
decoded.rows.push({ ...decoded.rows[0], index: 6, epoch: 1704067200 + 8 * 900 });
|
||||
const chart = decoder.noiseChartModel(decoded);
|
||||
assert.deepStrictEqual(chart.segments.map((segment) => segment.length), [2, 1, 1, 1]);
|
||||
assert.strictEqual(chart.segments[0][0].y, 150);
|
||||
assert.strictEqual(chart.segments[1][0].y, 20);
|
||||
assert.match(chart.description, /MAX and missing samples leave gaps/);
|
||||
assert.strictEqual(decoder.noiseChartModel(decoder.decodeTelemetry(VOLTAGE_PACKET)), null);
|
||||
});
|
||||
|
||||
process.stdout.write(`1..${passed}\n`);
|
||||
@@ -212,6 +212,16 @@ public:
|
||||
(void)profile;
|
||||
return getNoiseFloorDbm();
|
||||
}
|
||||
// Only successfully published estimates have a timestamp. Unsupported
|
||||
// transports and disabled/unmeasured profiles return false, not a zero dBm
|
||||
// reading. millis() may legitimately be zero or wrap between publications.
|
||||
virtual bool getNoiseFloorReading(uint8_t profile, float& dbm,
|
||||
uint32_t& published_at_ms) const {
|
||||
(void)profile; (void)dbm; (void)published_at_ms; return false;
|
||||
}
|
||||
// Join a bounded idle-RX collection without discarding a valid estimate or
|
||||
// interrupting reception, transmission, or another owner of the radio.
|
||||
virtual void requestNoiseFloorMeasurement() { }
|
||||
|
||||
// A zero result means there is no active sampled calibration window (or
|
||||
// that this transport does not expose one). The estimate itself may remain
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
#pragma once
|
||||
|
||||
#include <math.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
// Boot-local, UTC-aligned noise history. Only completed quarter-hours are
|
||||
// published; each needs a fresh estimate in all three five-minute subslots.
|
||||
class NoiseFloorHistory {
|
||||
public:
|
||||
static constexpr uint8_t PROFILE_COUNT = 2;
|
||||
static constexpr uint8_t SAMPLE_BITS = 5;
|
||||
static constexpr uint8_t MISSING = 31;
|
||||
static constexpr uint8_t MAX = 30;
|
||||
static constexpr uint32_t SAMPLE_INTERVAL_SECONDS = 900;
|
||||
static constexpr uint32_t READ_INTERVAL_SECONDS = 300;
|
||||
static constexpr uint16_t SAMPLES_PER_DAY = 96;
|
||||
static constexpr uint8_t RETENTION_DAYS = 8;
|
||||
static constexpr uint16_t RETENTION_SAMPLES = SAMPLES_PER_DAY * RETENTION_DAYS;
|
||||
static constexpr size_t BYTES_PER_PROFILE = RETENTION_SAMPLES * SAMPLE_BITS / 8U;
|
||||
static constexpr uint8_t CLI_PAGE_SAMPLES = 176;
|
||||
static constexpr uint8_t PAGE_COUNT = 5;
|
||||
static constexpr size_t PAGE_HEADER_SIZE = 7;
|
||||
static constexpr uint8_t PAGE_PAYLOAD_TYPE_V1 = 0x15;
|
||||
static constexpr uint8_t RADIO2_PAGE_PAYLOAD_TYPE_V1 = 0x16;
|
||||
static constexpr size_t BINARY_HEADER_SIZE = 19;
|
||||
static constexpr size_t BINARY_SOURCE_ID_SIZE = 8;
|
||||
static constexpr uint8_t BINARY_MAX_SAMPLES = 255;
|
||||
static constexpr size_t BINARY_PAYLOAD_SIZE = 184;
|
||||
|
||||
NoiseFloorHistory() { clear(); }
|
||||
|
||||
void clear() {
|
||||
memset(_samples, 0xFF, sizeof(_samples));
|
||||
memset(_pending, 0, sizeof(_pending));
|
||||
_next = _count = 0;
|
||||
_bucket = 0;
|
||||
_has_bucket = false;
|
||||
}
|
||||
|
||||
// Call with current RTC time before handing over newly published estimates.
|
||||
// A clock reset discards ambiguous timestamps. Forward gaps are bounded by
|
||||
// retention, so an RTC correction cannot cause an unbounded catch-up loop.
|
||||
void advance(uint32_t epoch_seconds) {
|
||||
const uint32_t bucket = epoch_seconds / SAMPLE_INTERVAL_SECONDS;
|
||||
if (!_has_bucket) {
|
||||
_bucket = bucket;
|
||||
_has_bucket = true;
|
||||
return;
|
||||
}
|
||||
if (bucket == _bucket) return;
|
||||
if (bucket < _bucket || bucket - _bucket > RETENTION_SAMPLES) {
|
||||
clear();
|
||||
_bucket = bucket;
|
||||
_has_bucket = true;
|
||||
return;
|
||||
}
|
||||
const uint32_t elapsed = bucket - _bucket;
|
||||
append(aggregate(_pending[0]), aggregate(_pending[1]));
|
||||
for (uint32_t i = 1; i < elapsed; ++i) append(MISSING, MISSING);
|
||||
for (uint8_t profile = 0; profile < PROFILE_COUNT; ++profile) {
|
||||
_pending[profile].mask = 0;
|
||||
}
|
||||
_bucket = bucket;
|
||||
}
|
||||
|
||||
// Publication IDs are the radio's millis() timestamp, not the polling time.
|
||||
// Repeated reads of one calibration must never fill multiple subslots.
|
||||
bool observe(uint8_t profile, uint32_t measured_epoch, float dbm,
|
||||
uint32_t publication_id) {
|
||||
if (profile >= PROFILE_COUNT || !isfinite(dbm) || dbm < -200.0f
|
||||
|| dbm > 0.0f || !_has_bucket
|
||||
|| measured_epoch / SAMPLE_INTERVAL_SECONDS != _bucket) return false;
|
||||
Pending& pending = _pending[profile];
|
||||
if (pending.has_publication) {
|
||||
if (publication_id == pending.publication_id
|
||||
|| measured_epoch < pending.measured_epoch) return false;
|
||||
// Within one RTC second, preserve publication ordering across millis wrap.
|
||||
if (measured_epoch == pending.measured_epoch
|
||||
&& (uint32_t)(publication_id - pending.publication_id) >= 0x80000000UL) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
pending.has_publication = true;
|
||||
pending.publication_id = publication_id;
|
||||
pending.measured_epoch = measured_epoch;
|
||||
const uint8_t slot = (uint8_t)((measured_epoch % SAMPLE_INTERVAL_SECONDS)
|
||||
/ READ_INTERVAL_SECONDS);
|
||||
pending.values[slot] = dbm;
|
||||
pending.mask |= (uint8_t)(1U << slot);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retuning/disabling a profile cannot combine estimates from two channels.
|
||||
// Keep the publication watermark and completed history for deduplication.
|
||||
void invalidateProfile(uint8_t profile) {
|
||||
if (profile < PROFILE_COUNT) _pending[profile].mask = 0;
|
||||
}
|
||||
|
||||
uint16_t sampleCount(uint8_t profile) const {
|
||||
return profile < PROFILE_COUNT ? _count : 0;
|
||||
}
|
||||
|
||||
bool hasValidSamples(uint8_t profile) const {
|
||||
if (profile >= PROFILE_COUNT) return false;
|
||||
for (uint16_t offset = 0; offset < _count; ++offset) {
|
||||
if (sampleCodeAtOffset(profile, offset) != MISSING) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static constexpr size_t storageBytes() { return PROFILE_COUNT * BYTES_PER_PROFILE; }
|
||||
|
||||
uint8_t sampleCodeAtOffset(uint8_t profile, uint16_t offset) const {
|
||||
if (profile >= PROFILE_COUNT || offset >= _count) return MISSING;
|
||||
const uint16_t index = (uint16_t)((_next + RETENTION_SAMPLES - 1U - offset)
|
||||
% RETENTION_SAMPLES);
|
||||
const size_t first_bit = (size_t)index * SAMPLE_BITS;
|
||||
uint8_t code = 0;
|
||||
for (uint8_t bit = 0; bit < SAMPLE_BITS; ++bit) {
|
||||
const size_t absolute = first_bit + bit;
|
||||
code = (uint8_t)((code << 1U)
|
||||
| ((_samples[profile][absolute / 8U] >> (7U - absolute % 8U)) & 1U));
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
// Page 1 contains the newest 176 completed positions, oldest first. The
|
||||
// oldest page is shortened to actual retained history, never padded preboot.
|
||||
// Correlated CLI replies already have a prompt prefix and use bare Base64.
|
||||
bool formatPageReply(uint8_t profile, const char* args, char* reply,
|
||||
size_t reply_size = 160, bool include_prompt = true) const {
|
||||
if (reply == NULL || reply_size == 0) return false;
|
||||
reply[0] = 0;
|
||||
if (profile >= PROFILE_COUNT) return error(reply, reply_size, "Err - invalid radio profile");
|
||||
unsigned page = 1;
|
||||
const char* cursor = args == NULL ? "" : args;
|
||||
while (*cursor == ' ') ++cursor;
|
||||
if (*cursor != 0) {
|
||||
page = 0;
|
||||
while (*cursor >= '0' && *cursor <= '9' && page <= PAGE_COUNT) {
|
||||
page = page * 10U + (unsigned)(*cursor++ - '0');
|
||||
}
|
||||
while (*cursor == ' ') ++cursor;
|
||||
if (*cursor != 0 || page < 1 || page > PAGE_COUNT) {
|
||||
return error(reply, reply_size, "Err - noise history page must be 1-5");
|
||||
}
|
||||
}
|
||||
const uint16_t newest_offset = (uint16_t)((page - 1U) * CLI_PAGE_SAMPLES);
|
||||
if (_count == 0 || newest_offset >= _count) {
|
||||
return error(reply, reply_size, "Err - noise history page is empty");
|
||||
}
|
||||
uint16_t count = (uint16_t)(_count - newest_offset);
|
||||
if (count > CLI_PAGE_SAMPLES) count = CLI_PAGE_SAMPLES;
|
||||
const uint16_t oldest_offset = (uint16_t)(newest_offset + count - 1U);
|
||||
uint8_t payload[PAGE_HEADER_SIZE + (CLI_PAGE_SAMPLES * SAMPLE_BITS + 7U) / 8U];
|
||||
const size_t length = PAGE_HEADER_SIZE + (count * SAMPLE_BITS + 7U) / 8U;
|
||||
memset(payload, 0, length);
|
||||
payload[0] = profile == 0 ? PAGE_PAYLOAD_TYPE_V1 : RADIO2_PAGE_PAYLOAD_TYPE_V1;
|
||||
putUint32LE(payload + 1, startEpoch(oldest_offset));
|
||||
payload[5] = (uint8_t)(SAMPLE_INTERVAL_SECONDS / 60U);
|
||||
payload[6] = (uint8_t)count;
|
||||
pack(profile, oldest_offset, count, payload + PAGE_HEADER_SIZE);
|
||||
return base64Reply(payload, length, reply, reply_size, include_prompt);
|
||||
}
|
||||
|
||||
// Reuses the telemetry RAW_CUSTOM envelope: magic, source ID, epoch,
|
||||
// uint16 interval in minutes, uint8 count; five-bit samples follow it.
|
||||
size_t formatBinarySnapshot(uint8_t profile,
|
||||
const uint8_t source_id[BINARY_SOURCE_ID_SIZE], uint8_t* dest,
|
||||
size_t dest_size) const {
|
||||
if (profile >= PROFILE_COUNT || source_id == NULL || dest == NULL
|
||||
|| dest_size <= BINARY_HEADER_SIZE || _count == 0) return 0;
|
||||
size_t available = dest_size - BINARY_HEADER_SIZE;
|
||||
if (available > BINARY_PAYLOAD_SIZE - BINARY_HEADER_SIZE) {
|
||||
available = BINARY_PAYLOAD_SIZE - BINARY_HEADER_SIZE;
|
||||
}
|
||||
uint16_t count = (uint16_t)(available * 8U / SAMPLE_BITS);
|
||||
if (count > BINARY_MAX_SAMPLES) count = BINARY_MAX_SAMPLES;
|
||||
if (count > _count) count = _count;
|
||||
if (count == 0) return 0;
|
||||
const size_t length = BINARY_HEADER_SIZE + (count * SAMPLE_BITS + 7U) / 8U;
|
||||
memset(dest, 0, length);
|
||||
memcpy(dest, profile == 0 ? "N1B1" : "N2B1", 4);
|
||||
memcpy(dest + 4, source_id, BINARY_SOURCE_ID_SIZE);
|
||||
putUint32LE(dest + 12, startEpoch((uint16_t)(count - 1U)));
|
||||
dest[16] = (uint8_t)(SAMPLE_INTERVAL_SECONDS / 60U);
|
||||
dest[17] = 0;
|
||||
dest[18] = (uint8_t)count;
|
||||
pack(profile, (uint16_t)(count - 1U), count, dest + BINARY_HEADER_SIZE);
|
||||
return length;
|
||||
}
|
||||
|
||||
private:
|
||||
struct Pending {
|
||||
float values[3];
|
||||
uint32_t publication_id;
|
||||
uint32_t measured_epoch;
|
||||
uint8_t mask;
|
||||
bool has_publication;
|
||||
};
|
||||
uint8_t _samples[PROFILE_COUNT][BYTES_PER_PROFILE];
|
||||
Pending _pending[PROFILE_COUNT];
|
||||
uint16_t _next;
|
||||
uint16_t _count;
|
||||
uint32_t _bucket;
|
||||
bool _has_bucket;
|
||||
|
||||
static float decode(uint8_t code) {
|
||||
return code == 0 ? -120.0f : code == 1 ? -119.0f : -122.0f + 2.0f * code;
|
||||
}
|
||||
|
||||
// direction=-1 rounds minima down, +1 rounds maxima up, 0 is nearest.
|
||||
static uint8_t encode(float value, int direction) {
|
||||
if (value <= -120.0f) return 0;
|
||||
if (value > -64.0f) return MAX;
|
||||
uint8_t lower = 0;
|
||||
for (uint8_t upper = 1; upper <= 29; ++upper) {
|
||||
const float high = decode(upper);
|
||||
if (value > high) { lower = upper; continue; }
|
||||
if (value == high || direction > 0) return upper;
|
||||
if (direction < 0) return lower;
|
||||
return value - decode(lower) < high - value ? lower : upper;
|
||||
}
|
||||
return 29;
|
||||
}
|
||||
|
||||
static uint8_t aggregate(const Pending& pending) {
|
||||
if (pending.mask != 7) return MISSING;
|
||||
float low = pending.values[0], high = low;
|
||||
float sum = 0;
|
||||
for (uint8_t i = 0; i < 3; ++i) {
|
||||
const float value = pending.values[i];
|
||||
if (value < low) low = value;
|
||||
if (value > high) high = value;
|
||||
sum += value;
|
||||
}
|
||||
if (low <= -100.0f) return encode(low, -1);
|
||||
if (high >= -80.0f) return encode(high, 1);
|
||||
return encode(sum / 3.0f, 0);
|
||||
}
|
||||
|
||||
void append(uint8_t first, uint8_t second) {
|
||||
const uint8_t codes[PROFILE_COUNT] = {first, second};
|
||||
for (uint8_t profile = 0; profile < PROFILE_COUNT; ++profile) {
|
||||
const size_t first_bit = (size_t)_next * SAMPLE_BITS;
|
||||
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 (codes[profile] & (1U << (SAMPLE_BITS - 1U - bit))) {
|
||||
_samples[profile][absolute / 8U] |= mask;
|
||||
} else {
|
||||
_samples[profile][absolute / 8U] &= (uint8_t)~mask;
|
||||
}
|
||||
}
|
||||
}
|
||||
_next = (uint16_t)((_next + 1U) % RETENTION_SAMPLES);
|
||||
if (_count < RETENTION_SAMPLES) ++_count;
|
||||
}
|
||||
|
||||
uint32_t startEpoch(uint16_t offset) const {
|
||||
return (_bucket - 1U - offset) * SAMPLE_INTERVAL_SECONDS;
|
||||
}
|
||||
|
||||
void pack(uint8_t profile, uint16_t oldest_offset, uint16_t count,
|
||||
uint8_t* dest) const {
|
||||
for (uint16_t index = 0; index < count; ++index) {
|
||||
const uint8_t code = sampleCodeAtOffset(profile, (uint16_t)(oldest_offset - index));
|
||||
for (uint8_t bit = 0; bit < SAMPLE_BITS; ++bit) {
|
||||
const size_t absolute = (size_t)index * SAMPLE_BITS + bit;
|
||||
if (code & (1U << (SAMPLE_BITS - 1U - bit))) {
|
||||
dest[absolute / 8U] |= (uint8_t)(1U << (7U - absolute % 8U));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void putUint32LE(uint8_t* dest, uint32_t value) {
|
||||
for (uint8_t i = 0; i < 4; ++i) dest[i] = (uint8_t)(value >> (8U * i));
|
||||
}
|
||||
|
||||
static bool error(char* reply, size_t size, const char* text) {
|
||||
if (reply != NULL && size != 0) snprintf(reply, size, "%s", text);
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool base64Reply(const uint8_t* payload, size_t length, char* reply,
|
||||
size_t size, bool include_prompt) {
|
||||
static const char alphabet[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const size_t encoded = ((length + 2U) / 3U) * 4U;
|
||||
const size_t prefix = include_prompt ? 2U : 0U;
|
||||
if (size < encoded + prefix + 1U) {
|
||||
return error(reply, size, "Err - telemetry reply buffer too small");
|
||||
}
|
||||
if (include_prompt) {
|
||||
reply[0] = '>';
|
||||
reply[1] = ' ';
|
||||
}
|
||||
size_t output = prefix;
|
||||
for (size_t i = 0; i < length; i += 3U) {
|
||||
const size_t remaining = length - i;
|
||||
const uint32_t value = ((uint32_t)payload[i] << 16U)
|
||||
| (remaining > 1 ? (uint32_t)payload[i + 1] << 8U : 0)
|
||||
| (remaining > 2 ? payload[i + 2] : 0);
|
||||
reply[output++] = alphabet[(value >> 18U) & 63U];
|
||||
reply[output++] = alphabet[(value >> 12U) & 63U];
|
||||
reply[output++] = remaining > 1 ? alphabet[(value >> 6U) & 63U] : '=';
|
||||
reply[output++] = remaining > 2 ? alphabet[value & 63U] : '=';
|
||||
}
|
||||
reply[output] = 0;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include "NoiseFloorHistory.h"
|
||||
|
||||
namespace mesh {
|
||||
|
||||
// Collect successful publications, not repeated reads of a cached estimate.
|
||||
// Calibration stays in the radio's existing bounded, packet-safe state machine.
|
||||
class NoiseFloorHistorySampler {
|
||||
uint32_t _request_bucket = 0;
|
||||
uint32_t _generation[2] = {};
|
||||
bool _armed = false;
|
||||
bool _enabled[2] = {};
|
||||
bool _valid[2] = {};
|
||||
|
||||
public:
|
||||
template<class RadioT>
|
||||
void poll(RadioT& radio, NoiseFloorHistory& history,
|
||||
uint32_t epoch, uint32_t now_ms) {
|
||||
history.advance(epoch);
|
||||
const auto* profiles = radio.profiles();
|
||||
const uint32_t slot = epoch / NoiseFloorHistory::READ_INTERVAL_SECONDS;
|
||||
bool fresh[2] = {};
|
||||
for (uint8_t profile = 0; profile < 2; ++profile) {
|
||||
const bool enabled = profile == 0 || (profiles && profiles->enabled());
|
||||
const uint32_t generation = profiles ? profiles->generation[profile] : 0;
|
||||
if (_enabled[profile] != enabled || _generation[profile] != generation) {
|
||||
history.invalidateProfile(profile);
|
||||
_valid[profile] = false;
|
||||
}
|
||||
_enabled[profile] = enabled;
|
||||
_generation[profile] = generation;
|
||||
float dbm = 0;
|
||||
uint32_t published_ms = 0;
|
||||
const bool valid = enabled
|
||||
&& radio.getNoiseFloorReading(profile, dbm, published_ms)
|
||||
&& isfinite(dbm) && dbm >= -200.0f && dbm <= 0.0f;
|
||||
if (!valid && _valid[profile]) history.invalidateProfile(profile);
|
||||
_valid[profile] = valid;
|
||||
if (!valid) continue;
|
||||
const uint32_t age_ms = now_ms - published_ms;
|
||||
if (age_ms >= NoiseFloorHistory::READ_INTERVAL_SECONDS * 1000UL
|
||||
|| age_ms / 1000UL > epoch) continue;
|
||||
const uint32_t measured_epoch = epoch - age_ms / 1000UL;
|
||||
history.observe(profile, measured_epoch, dbm, published_ms);
|
||||
fresh[profile] = measured_epoch / NoiseFloorHistory::READ_INTERVAL_SECONDS == slot;
|
||||
}
|
||||
if (!_armed || _request_bucket != slot) {
|
||||
_armed = true;
|
||||
_request_bucket = slot;
|
||||
if (!fresh[0] || (_enabled[1] && !fresh[1])) {
|
||||
radio.requestNoiseFloorMeasurement();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace mesh
|
||||
@@ -131,9 +131,8 @@ public:
|
||||
return ((CustomLR1110 *)_radio)->isChipBusy();
|
||||
}
|
||||
float getCurrentRSSI() override {
|
||||
float rssi = -110;
|
||||
((CustomLR1110 *)_radio)->getRssiInst(&rssi);
|
||||
return rssi;
|
||||
float rssi = NAN;
|
||||
return ((CustomLR1110 *)_radio)->getRssiInst(&rssi) == RADIOLIB_ERR_NONE ? rssi : NAN;
|
||||
}
|
||||
|
||||
uint32_t getEstAirtimeFor(int len_bytes) override {
|
||||
|
||||
@@ -110,9 +110,8 @@ public:
|
||||
return ((CustomLR1121 *)_radio)->isReceiving();
|
||||
}
|
||||
float getCurrentRSSI() override {
|
||||
float rssi = -110;
|
||||
((CustomLR1121 *)_radio)->getRssiInst(&rssi);
|
||||
return rssi;
|
||||
float rssi = NAN;
|
||||
return ((CustomLR1121 *)_radio)->getRssiInst(&rssi) == RADIOLIB_ERR_NONE ? rssi : NAN;
|
||||
}
|
||||
|
||||
void onSendFinished() override {
|
||||
|
||||
@@ -267,9 +267,8 @@ public:
|
||||
}
|
||||
|
||||
float getCurrentRSSI() override {
|
||||
float rssi = -110;
|
||||
((CustomLR2021 *)_radio)->getRssiInst(&rssi);
|
||||
return rssi;
|
||||
float rssi = NAN;
|
||||
return ((CustomLR2021 *)_radio)->getRssiInst(&rssi) == RADIOLIB_ERR_NONE ? rssi : NAN;
|
||||
}
|
||||
|
||||
void onSendFinished() override {
|
||||
|
||||
@@ -271,6 +271,7 @@ void RadioLibWrapper::begin() {
|
||||
_noise_floor_secondary_centi_dbm = 0;
|
||||
_noise_floor_valid = false;
|
||||
_noise_floor_secondary_valid = false;
|
||||
_nf_published_valid = 0;
|
||||
_threshold = 0;
|
||||
_cad_enabled = false;
|
||||
_rx_mode_checked_at = millis();
|
||||
@@ -919,6 +920,7 @@ void RadioLibWrapper::serviceProfileScan() {
|
||||
// values from two frequencies are never blended into one estimate.
|
||||
_noise_floor_valid = false;
|
||||
_noise_floor_secondary_valid = false;
|
||||
_nf_published_valid = 0;
|
||||
_nf_refresh_requested = true;
|
||||
_nf_last_calib = 0;
|
||||
_nf_calib_deadline = 0;
|
||||
@@ -964,6 +966,7 @@ void RadioLibWrapper::serviceProfileScan() {
|
||||
// the first visit on the replacement tuples.
|
||||
_noise_floor_valid = false;
|
||||
_noise_floor_secondary_valid = false;
|
||||
_nf_published_valid = 0;
|
||||
_nf_refresh_requested = true;
|
||||
_nf_last_calib = 0;
|
||||
_nf_calib_deadline = 0;
|
||||
@@ -1262,6 +1265,7 @@ void RadioLibWrapper::recalibrateNoiseFloor() {
|
||||
// available while discarding the old samples and rise-hold history.
|
||||
_noise_floor_valid = false;
|
||||
_noise_floor_secondary_valid = false;
|
||||
_nf_published_valid = 0;
|
||||
_nf_refresh_requested = true;
|
||||
_nf_last_calib = 0;
|
||||
_nf_lazy_refresh = _nf_refresh_attempted = false;
|
||||
@@ -2079,11 +2083,17 @@ void RadioLibWrapper::loop() {
|
||||
&& _secondary_floor_estimator.complete()) {
|
||||
if (_floor_estimator.publish(_noise_floor_centi_dbm, _noise_floor_valid)) {
|
||||
_noise_floor_valid = true;
|
||||
_nf_published_at[0] = static_cast<uint32_t>(now);
|
||||
_nf_published_generation[0] = _profiles.generation[0];
|
||||
_nf_published_valid |= 1U;
|
||||
_noise_floor = (_noise_floor_centi_dbm - 50) / 100;
|
||||
}
|
||||
if (_secondary_floor_estimator.publish(_noise_floor_secondary_centi_dbm,
|
||||
_noise_floor_secondary_valid)) {
|
||||
_noise_floor_secondary_valid = true;
|
||||
_nf_published_at[1] = static_cast<uint32_t>(now);
|
||||
_nf_published_generation[1] = _profiles.generation[1];
|
||||
_nf_published_valid |= 2U;
|
||||
}
|
||||
_nf_refresh_requested = false;
|
||||
_nf_last_calib = now;
|
||||
@@ -2143,6 +2153,9 @@ void RadioLibWrapper::loop() {
|
||||
_nf_lazy_refresh = _floor_estimator.publish(_noise_floor_centi_dbm, _noise_floor_valid);
|
||||
if (_nf_lazy_refresh) {
|
||||
_noise_floor_valid = true;
|
||||
_nf_published_at[0] = static_cast<uint32_t>(now);
|
||||
_nf_published_generation[0] = _profiles.generation[0];
|
||||
_nf_published_valid |= 1U;
|
||||
// Retain the whole-dB API/wire format and the fractional display value.
|
||||
_noise_floor = (_noise_floor_centi_dbm - 50) / 100;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ protected:
|
||||
uint32_t _cad_scan_timeout_override_ms;
|
||||
bool _noise_floor_valid;
|
||||
bool _noise_floor_secondary_valid = false;
|
||||
uint32_t _nf_published_at[2] = {};
|
||||
uint32_t _nf_published_generation[2] = {};
|
||||
uint8_t _nf_published_valid = 0;
|
||||
bool _nf_refresh_requested;
|
||||
NoiseFloorEstimator _floor_estimator;
|
||||
NoiseFloorEstimator _secondary_floor_estimator;
|
||||
@@ -552,6 +555,17 @@ public:
|
||||
}
|
||||
return getNoiseFloorDbm();
|
||||
}
|
||||
bool getNoiseFloorReading(uint8_t profile, float& dbm,
|
||||
uint32_t& published_at_ms) const override {
|
||||
if (profile > 1 || (profile == 1 && !_profiles.enabled())
|
||||
|| !(_nf_published_valid & (1U << profile))
|
||||
|| _nf_published_generation[profile] != _profiles.generation[profile]
|
||||
|| !(profile == 1 ? _noise_floor_secondary_valid : _noise_floor_valid)) return false;
|
||||
dbm = (profile == 1 ? _noise_floor_secondary_centi_dbm : _noise_floor_centi_dbm) / 100.0f;
|
||||
published_at_ms = _nf_published_at[profile];
|
||||
return true;
|
||||
}
|
||||
void requestNoiseFloorMeasurement() override { requestNoiseFloorRefresh(); }
|
||||
float getNoiseFloorCalibrationSecondsRemaining() const override {
|
||||
const float primary = profileNoiseFloorSecondsRemaining(0);
|
||||
if (!_profiles.enabled()) return primary;
|
||||
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
#include <helpers/NoiseFloorHistory.h>
|
||||
|
||||
#include <assert.h>
|
||||
#include <limits>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using mesh::NoiseFloorHistory;
|
||||
static constexpr uint32_t BASE = 1700000100UL;
|
||||
static const uint8_t SOURCE[8] = {1, 2, 3, 4, 5, 6, 7, 8};
|
||||
|
||||
static uint32_t little32(const uint8_t* bytes) {
|
||||
return (uint32_t)bytes[0] | ((uint32_t)bytes[1] << 8)
|
||||
| ((uint32_t)bytes[2] << 16) | ((uint32_t)bytes[3] << 24);
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> decode(const char* reply) {
|
||||
assert(strncmp(reply, "> ", 2) == 0);
|
||||
const char* alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::vector<uint8_t> bytes;
|
||||
const std::string text = reply + 2;
|
||||
assert(text.size() % 4 == 0);
|
||||
uint32_t accumulator = 0;
|
||||
unsigned bits = 0;
|
||||
bool padding = false;
|
||||
for (char ch : text) {
|
||||
if (ch == '=') { padding = true; continue; }
|
||||
assert(!padding);
|
||||
const char* found = strchr(alphabet, ch);
|
||||
assert(found != nullptr);
|
||||
accumulator = (accumulator << 6) | (uint32_t)(found - alphabet);
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
bytes.push_back((uint8_t)(accumulator >> bits));
|
||||
}
|
||||
}
|
||||
assert(bits == 0 || (accumulator & ((1U << bits) - 1U)) == 0);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static uint8_t code(const uint8_t* packed, size_t index) {
|
||||
const size_t byte = index * 5U / 8U;
|
||||
const unsigned shift = (unsigned)(index * 5U % 8U);
|
||||
const uint16_t word = (uint16_t)((uint16_t)packed[byte] << 8)
|
||||
| (shift > 3 ? packed[byte + 1] : 0);
|
||||
return (uint8_t)((word >> (11U - shift)) & 31U);
|
||||
}
|
||||
|
||||
static void collect(NoiseFloorHistory& history, uint32_t start,
|
||||
float a, float b, float c, uint8_t profile = 0) {
|
||||
history.advance(start);
|
||||
assert(history.observe(profile, start + 1, a, (start + 1) * 1000U));
|
||||
assert(history.observe(profile, start + 301, b, (start + 301) * 1000U));
|
||||
assert(history.observe(profile, start + 601, c, (start + 601) * 1000U));
|
||||
}
|
||||
|
||||
static uint8_t single(float a, float b, float c) {
|
||||
NoiseFloorHistory history;
|
||||
collect(history, BASE, a, b, c);
|
||||
assert(history.sampleCount(0) == 0);
|
||||
history.advance(BASE + 900);
|
||||
assert(history.sampleCount(0) == 1);
|
||||
return history.sampleCodeAtOffset(0, 0);
|
||||
}
|
||||
|
||||
static void selection() {
|
||||
assert(single(-120, -90, -60) == 0); // Low wins across the entire range.
|
||||
assert(single(-119, -80, -90) == 1);
|
||||
assert(single(-118, -117, -116) == 2);
|
||||
assert(single(-119.1f, -110, -100) == 0);
|
||||
assert(single(-118.1f, -110, -100) == 1);
|
||||
assert(single(-117, -110, -100) == 2);
|
||||
assert(single(-101, -90, -80) == 10); // Minimum rounds down to -102.
|
||||
assert(single(-100, -90, -70) == 11); // Threshold is inclusive.
|
||||
assert(single(-91, -90, -89) == 16); // Middle averages to -90.
|
||||
assert(single(-91, -90, -90) == 16); // Average rounds nearest, not down.
|
||||
assert(single(-99, -90, -80) == 21); // Maximum threshold is inclusive.
|
||||
assert(single(-90, -79, -85) == 22); // Maximum rounds up to -78.
|
||||
assert(single(-100.01f, -60, -60) == 10);
|
||||
assert(single(-130, -150, -121) == 0);
|
||||
assert(single(-70, -65, -90) == 29); // -65 rounds up to -64.
|
||||
assert(single(-70, -64, -90) == 29);
|
||||
assert(single(-70, -63.99f, -90) == NoiseFloorHistory::MAX);
|
||||
assert(single(-20, -70, -80) == NoiseFloorHistory::MAX);
|
||||
}
|
||||
|
||||
static void freshness() {
|
||||
NoiseFloorHistory history;
|
||||
assert(!history.observe(0, BASE, -110, 1)); // advance establishes RTC.
|
||||
history.advance(BASE);
|
||||
assert(history.observe(0, BASE + 1, -120, 1));
|
||||
assert(!history.observe(0, BASE + 301, -90, 1)); // Same calibration is stale.
|
||||
assert(!history.observe(0, BASE + 2, std::numeric_limits<float>::quiet_NaN(), 2));
|
||||
assert(!history.observe(0, BASE + 2, std::numeric_limits<float>::infinity(), 2));
|
||||
assert(!history.observe(0, BASE + 2, -201, 2));
|
||||
assert(!history.observe(0, BASE + 2, 1, 2));
|
||||
assert(!history.observe(2, BASE + 2, -90, 2));
|
||||
assert(!history.observe(0, BASE - 1, -90, 2));
|
||||
assert(!history.observe(0, BASE + 900, -90, 2));
|
||||
assert(history.observe(0, BASE + 2, -110, 2)); // Latest in one slot replaces.
|
||||
assert(!history.observe(0, BASE + 1, -120, 3)); // Older publication ignored.
|
||||
assert(history.observe(0, BASE + 301, -90, 3));
|
||||
assert(history.observe(0, BASE + 601, -80, 4));
|
||||
history.advance(BASE + 900);
|
||||
assert(history.sampleCodeAtOffset(0, 0) == 6); // -110, not superseded -120.
|
||||
assert(history.sampleCodeAtOffset(1, 0) == NoiseFloorHistory::MISSING);
|
||||
assert(history.hasValidSamples(0));
|
||||
assert(!history.hasValidSamples(1));
|
||||
assert(!history.observe(0, BASE + 601, -80, 4));
|
||||
assert(!history.observe(0, BASE + 901, -80, 4)); // Watermark crosses bucket.
|
||||
assert(history.observe(0, BASE + 901, -80, 5));
|
||||
assert(history.observe(0, BASE + 1501, -80, 6));
|
||||
history.advance(BASE + 1800); // Two subslots cannot masquerade as three.
|
||||
assert(history.sampleCodeAtOffset(0, 0) == NoiseFloorHistory::MISSING);
|
||||
assert(history.sampleCodeAtOffset(0, 1) == 6);
|
||||
|
||||
NoiseFloorHistory wrapped;
|
||||
wrapped.advance(BASE);
|
||||
assert(wrapped.observe(0, BASE + 1, -110, 0xFFFFFFFEUL));
|
||||
assert(wrapped.observe(0, BASE + 1, -110, 1));
|
||||
assert(!wrapped.observe(0, BASE + 1, -120, 0xFFFFFFFDUL));
|
||||
assert(wrapped.observe(0, BASE + 301, -110, 300001));
|
||||
assert(wrapped.observe(0, BASE + 601, -110, 600001));
|
||||
wrapped.advance(BASE + 900);
|
||||
assert(wrapped.sampleCodeAtOffset(0, 0) == 6);
|
||||
}
|
||||
|
||||
static void invalidation() {
|
||||
NoiseFloorHistory history;
|
||||
collect(history, BASE, -120, -119, -118);
|
||||
collect(history, BASE, -70, -71, -72, 1);
|
||||
history.invalidateProfile(0);
|
||||
assert(!history.observe(0, BASE + 601, -118, (BASE + 601) * 1000U));
|
||||
history.advance(BASE + 900);
|
||||
assert(history.sampleCodeAtOffset(0, 0) == NoiseFloorHistory::MISSING);
|
||||
assert(history.sampleCodeAtOffset(1, 0) == 26);
|
||||
collect(history, BASE + 900, -119, -118, -117);
|
||||
history.advance(BASE + 1800);
|
||||
history.invalidateProfile(0);
|
||||
history.invalidateProfile(255);
|
||||
assert(history.sampleCodeAtOffset(0, 0) == 1); // Completed data retained.
|
||||
assert(history.sampleCount(255) == 0);
|
||||
assert(!history.hasValidSamples(255));
|
||||
}
|
||||
|
||||
static void rollover() {
|
||||
NoiseFloorHistory history;
|
||||
assert(history.storageBytes() == 960);
|
||||
for (unsigned i = 0; i < 800; ++i) {
|
||||
collect(history, BASE + i * 900U, -120.0f + (float)(i % 3), -90, -85);
|
||||
history.advance(BASE + (i + 1U) * 900U);
|
||||
}
|
||||
assert(history.sampleCount(0) == 768);
|
||||
assert(history.sampleCount(1) == 768);
|
||||
for (unsigned offset = 0; offset < 768; ++offset) {
|
||||
assert(history.sampleCodeAtOffset(0, (uint16_t)offset) == (799 - offset) % 3);
|
||||
assert(history.sampleCodeAtOffset(1, (uint16_t)offset) == NoiseFloorHistory::MISSING);
|
||||
}
|
||||
assert(history.sampleCodeAtOffset(0, 768) == NoiseFloorHistory::MISSING);
|
||||
char reply[160];
|
||||
assert(history.formatPageReply(0, "5", reply));
|
||||
const auto bytes = decode(reply);
|
||||
assert(bytes[6] == 64);
|
||||
assert(little32(bytes.data() + 1) == BASE + 32U * 900U);
|
||||
for (unsigned i = 0; i < 64; ++i) assert(code(bytes.data() + 7, i) == (32 + i) % 3);
|
||||
}
|
||||
|
||||
static void clockChanges() {
|
||||
NoiseFloorHistory history;
|
||||
collect(history, BASE, -119, -90, -90);
|
||||
history.advance(BASE + 3600);
|
||||
assert(history.sampleCount(0) == 4);
|
||||
assert(history.sampleCodeAtOffset(0, 3) == 1);
|
||||
for (unsigned i = 0; i < 3; ++i) assert(history.sampleCodeAtOffset(0, i) == 31);
|
||||
history.advance(BASE + 3599); // Current bucket moves backwards.
|
||||
assert(history.sampleCount(0) == 0);
|
||||
assert(!history.hasValidSamples(0));
|
||||
collect(history, BASE + 2700, -118, -90, -90);
|
||||
history.advance(BASE + 3600);
|
||||
assert(history.sampleCodeAtOffset(0, 0) == 2);
|
||||
history.advance(BASE + 3600 + (768U + 1U) * 900U);
|
||||
assert(history.sampleCount(0) == 0); // Huge jump is bounded and starts fresh.
|
||||
const uint32_t high = 0xFFFFFFFFUL / 900U * 900U;
|
||||
history.advance(high);
|
||||
assert(history.sampleCount(0) == 0);
|
||||
history.advance(0);
|
||||
assert(history.sampleCount(0) == 0);
|
||||
assert(history.observe(0, 1, -120, 1));
|
||||
assert(history.observe(0, 301, -120, 2));
|
||||
assert(history.observe(0, 601, -120, 3));
|
||||
history.advance(900);
|
||||
char reply[160];
|
||||
assert(history.formatPageReply(0, "", reply));
|
||||
assert(little32(decode(reply).data() + 1) == 0);
|
||||
}
|
||||
|
||||
static void wire() {
|
||||
NoiseFloorHistory history;
|
||||
struct Guarded { char before; char reply[160]; char after; } guard;
|
||||
memset(&guard, 0x5A, sizeof(guard));
|
||||
assert(!history.formatPageReply(0, "", guard.reply));
|
||||
assert(strstr(guard.reply, "empty") != nullptr);
|
||||
assert(!history.formatPageReply(0, "1", nullptr));
|
||||
assert(!history.formatPageReply(0, "1", guard.reply, 0));
|
||||
for (unsigned i = 0; i < 300; ++i) {
|
||||
collect(history, BASE + i * 900U, -120.0f + (float)(i % 3), -90, -90);
|
||||
collect(history, BASE + i * 900U, -65, -70, -90, 1);
|
||||
history.advance(BASE + (i + 1U) * 900U);
|
||||
}
|
||||
assert(history.formatPageReply(0, " 1 ", guard.reply));
|
||||
assert(strlen(guard.reply) == 158);
|
||||
assert(guard.before == 0x5A && guard.after == 0x5A);
|
||||
auto bytes = decode(guard.reply);
|
||||
assert(bytes.size() == 117 && bytes[0] == 0x15);
|
||||
assert(little32(bytes.data() + 1) == BASE + 124U * 900U);
|
||||
assert(bytes[5] == 15 && bytes[6] == 176);
|
||||
for (unsigned i = 0; i < 176; ++i) assert(code(bytes.data() + 7, i) == (124 + i) % 3);
|
||||
assert(history.formatPageReply(1, "2", guard.reply));
|
||||
bytes = decode(guard.reply);
|
||||
assert(bytes[0] == 0x16 && bytes[6] == 124);
|
||||
assert(little32(bytes.data() + 1) == BASE);
|
||||
for (unsigned i = 0; i < 124; ++i) assert(code(bytes.data() + 7, i) == 29);
|
||||
assert((bytes.back() & 0x0FU) == 0); // Four final packing bits are padding.
|
||||
assert(!history.formatPageReply(0, "3", guard.reply));
|
||||
for (const char* arg : {"0", "6", "-1", "1x", "1 2", "9999999999999999999999"}) {
|
||||
assert(!history.formatPageReply(0, arg, guard.reply));
|
||||
}
|
||||
assert(!history.formatPageReply(2, "", guard.reply));
|
||||
assert(!history.formatPageReply(0, "1", guard.reply, 158));
|
||||
assert(history.formatPageReply(0, "1", guard.reply, 159));
|
||||
assert(guard.before == 0x5A && guard.after == 0x5A);
|
||||
struct Correlated { char before; char reply[160]; char after; } correlated;
|
||||
memset(&correlated, 0x5A, sizeof(correlated));
|
||||
memcpy(correlated.reply, "ab|", 3);
|
||||
assert(history.formatPageReply(0, "1", correlated.reply + 3, 157, false));
|
||||
assert(strlen(correlated.reply) == 159);
|
||||
assert(memcmp(correlated.reply, "ab|", 3) == 0);
|
||||
assert(strcmp(correlated.reply + 3, guard.reply + 2) == 0);
|
||||
assert(decode((std::string("> ") + (correlated.reply + 3)).c_str()) == decode(guard.reply));
|
||||
assert(correlated.before == 0x5A && correlated.after == 0x5A);
|
||||
struct Bare { char before; char reply[157]; char after; } bare;
|
||||
memset(&bare, 0x5A, sizeof(bare));
|
||||
assert(history.formatPageReply(0, "1", bare.reply, sizeof(bare.reply), false));
|
||||
assert(strlen(bare.reply) == 156 && strcmp(bare.reply, guard.reply + 2) == 0);
|
||||
assert(!history.formatPageReply(0, "1", bare.reply, 156, false));
|
||||
assert(strncmp(bare.reply, "Err", 3) == 0);
|
||||
assert(bare.before == 0x5A && bare.after == 0x5A);
|
||||
|
||||
uint8_t raw[186];
|
||||
memset(raw, 0xA5, sizeof(raw));
|
||||
assert(history.formatBinarySnapshot(0, SOURCE, raw + 1, 184) == 179);
|
||||
assert(memcmp(raw + 1, "N1B1", 4) == 0 && memcmp(raw + 5, SOURCE, 8) == 0);
|
||||
assert(little32(raw + 13) == BASE + 45U * 900U);
|
||||
assert(raw[17] == 15 && raw[18] == 0 && raw[19] == 255);
|
||||
for (unsigned i = 0; i < 255; ++i) assert(code(raw + 20, i) == (45 + i) % 3);
|
||||
assert((raw[179] & 0x1FU) == 0); // 255*5 leaves five padding bits.
|
||||
assert(raw[0] == 0xA5 && raw[180] == 0xA5 && raw[185] == 0xA5);
|
||||
assert(history.formatBinarySnapshot(1, SOURCE, raw + 1, 20) == 20);
|
||||
assert(memcmp(raw + 1, "N2B1", 4) == 0 && raw[19] == 1);
|
||||
assert(code(raw + 20, 0) == 29);
|
||||
assert(little32(raw + 13) == BASE + 299U * 900U);
|
||||
assert(history.formatBinarySnapshot(0, SOURCE, raw, 19) == 0);
|
||||
assert(history.formatBinarySnapshot(2, SOURCE, raw, sizeof(raw)) == 0);
|
||||
assert(history.formatBinarySnapshot(0, nullptr, raw, sizeof(raw)) == 0);
|
||||
assert(history.formatBinarySnapshot(0, SOURCE, nullptr, sizeof(raw)) == 0);
|
||||
// Missing is an explicit usable code, including disabled-profile history.
|
||||
NoiseFloorHistory missing;
|
||||
missing.advance(BASE);
|
||||
missing.advance(BASE + 900);
|
||||
assert(missing.formatBinarySnapshot(1, SOURCE, raw, sizeof(raw)) == 20);
|
||||
assert(raw[18] == 1 && code(raw + 19, 0) == 31);
|
||||
assert(missing.formatPageReply(1, "", guard.reply));
|
||||
bytes = decode(guard.reply);
|
||||
assert(bytes.size() == 8 && code(bytes.data() + 7, 0) == 31);
|
||||
assert(strchr(guard.reply, '=') != nullptr); // Standard padded Base64.
|
||||
|
||||
// Independent MSB-first wire vector: eight five-bit codes 0..7.
|
||||
NoiseFloorHistory golden;
|
||||
const float levels[] = {-120, -119, -118, -116, -114, -112, -110, -108};
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
collect(golden, BASE + i * 900U, levels[i], levels[i], levels[i]);
|
||||
golden.advance(BASE + (i + 1U) * 900U);
|
||||
}
|
||||
assert(golden.formatBinarySnapshot(0, SOURCE, raw, sizeof(raw)) == 24);
|
||||
const uint8_t expected[] = {0x00, 0x44, 0x32, 0x14, 0xC7};
|
||||
assert(memcmp(raw + 19, expected, sizeof(expected)) == 0);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
const std::string scenario(argv[1]);
|
||||
if (scenario == "selection") selection();
|
||||
else if (scenario == "freshness") freshness();
|
||||
else if (scenario == "invalidation") invalidation();
|
||||
else if (scenario == "rollover") rollover();
|
||||
else if (scenario == "clock") clockChanges();
|
||||
else if (scenario == "wire") wire();
|
||||
else return 2;
|
||||
puts("noise floor history: ok");
|
||||
}
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
#include <helpers/NoiseFloorHistorySampler.h>
|
||||
|
||||
#include <assert.h>
|
||||
#include <limits>
|
||||
#include <stdio.h>
|
||||
#include <string>
|
||||
|
||||
using mesh::NoiseFloorHistory;
|
||||
using mesh::NoiseFloorHistorySampler;
|
||||
static constexpr uint32_t BASE = 1700000100UL;
|
||||
static constexpr uint32_t MILLIS_BASE = 100000;
|
||||
|
||||
struct FakeProfiles {
|
||||
uint32_t generation[2] = {};
|
||||
bool second = false;
|
||||
bool enabled() const { return second; }
|
||||
};
|
||||
|
||||
struct FakeRadio {
|
||||
FakeProfiles config;
|
||||
bool has_profiles = true;
|
||||
bool valid[2] = {};
|
||||
float dbm[2] = {};
|
||||
uint32_t published[2] = {};
|
||||
unsigned requests = 0;
|
||||
unsigned reads[2] = {};
|
||||
unsigned hardware_operations = 0;
|
||||
|
||||
const FakeProfiles* profiles() const { return has_profiles ? &config : nullptr; }
|
||||
|
||||
bool getNoiseFloorReading(uint8_t profile, float& floor, uint32_t& publication) {
|
||||
assert(profile < 2);
|
||||
++reads[profile];
|
||||
if (!valid[profile]) return false;
|
||||
floor = dbm[profile];
|
||||
publication = published[profile];
|
||||
return true;
|
||||
}
|
||||
|
||||
void requestNoiseFloorMeasurement() { ++requests; }
|
||||
|
||||
void publish(uint8_t profile, uint32_t now_ms, float floor) {
|
||||
assert(profile < 2);
|
||||
valid[profile] = true;
|
||||
dbm[profile] = floor;
|
||||
published[profile] = now_ms;
|
||||
}
|
||||
};
|
||||
|
||||
static uint32_t ms(unsigned seconds) { return MILLIS_BASE + seconds * 1000UL; }
|
||||
|
||||
static void successful() {
|
||||
FakeRadio radio;
|
||||
NoiseFloorHistory history;
|
||||
NoiseFloorHistorySampler sampler;
|
||||
sampler.poll(radio, history, BASE, ms(0));
|
||||
assert(radio.requests == 1);
|
||||
for (unsigned offset : {4U, 304U, 604U}) {
|
||||
if (offset != 4) {
|
||||
sampler.poll(radio, history, BASE + offset - 4U, ms(offset - 4U));
|
||||
}
|
||||
radio.publish(0, ms(offset), offset == 4 ? -120 : offset == 304 ? -90 : -60);
|
||||
sampler.poll(radio, history, BASE + offset, ms(offset));
|
||||
const unsigned requests = radio.requests;
|
||||
for (unsigned i = offset; i < offset + 10; ++i) {
|
||||
sampler.poll(radio, history, BASE + i, ms(i));
|
||||
}
|
||||
assert(radio.requests == requests);
|
||||
}
|
||||
assert(radio.requests == 3 && history.sampleCount(0) == 0);
|
||||
sampler.poll(radio, history, BASE + 900, ms(900));
|
||||
assert(radio.requests == 4);
|
||||
assert(history.sampleCount(0) == 1 && history.sampleCodeAtOffset(0, 0) == 0);
|
||||
assert(history.sampleCodeAtOffset(1, 0) == 31);
|
||||
assert(radio.reads[1] == 0 && radio.hardware_operations == 0);
|
||||
}
|
||||
|
||||
static void cached() {
|
||||
FakeRadio radio;
|
||||
NoiseFloorHistory history;
|
||||
NoiseFloorHistorySampler sampler;
|
||||
radio.publish(0, ms(0), -110);
|
||||
sampler.poll(radio, history, BASE, ms(0));
|
||||
assert(radio.requests == 0);
|
||||
// A recent cached result may be read repeatedly, but is one publication.
|
||||
for (unsigned i = 1; i < 300; ++i) sampler.poll(radio, history, BASE + i, ms(i));
|
||||
assert(radio.requests == 0);
|
||||
sampler.poll(radio, history, BASE + 300, ms(300));
|
||||
assert(radio.requests == 1);
|
||||
for (unsigned i = 301; i < 600; ++i) sampler.poll(radio, history, BASE + i, ms(i));
|
||||
assert(radio.requests == 1);
|
||||
sampler.poll(radio, history, BASE + 600, ms(600));
|
||||
assert(radio.requests == 2);
|
||||
sampler.poll(radio, history, BASE + 900, ms(900));
|
||||
assert(history.sampleCount(0) == 1 && history.sampleCodeAtOffset(0, 0) == 31);
|
||||
assert(!history.hasValidSamples(0));
|
||||
|
||||
// Calibration completed just before a subslot boundary still belongs to
|
||||
// that earlier subslot; it must not suppress the next measurement request.
|
||||
NoiseFloorHistory second_history;
|
||||
NoiseFloorHistorySampler second_sampler;
|
||||
FakeRadio second;
|
||||
second.publish(0, ms(299), -100);
|
||||
second_sampler.poll(second, second_history, BASE + 300, ms(300));
|
||||
assert(second.requests == 1);
|
||||
}
|
||||
|
||||
static void rollover() {
|
||||
for (uint32_t first_ms : {0UL, 0xFFFFF000UL}) {
|
||||
FakeRadio radio;
|
||||
NoiseFloorHistory history;
|
||||
NoiseFloorHistorySampler sampler;
|
||||
for (unsigned offset : {0U, 300U, 600U}) {
|
||||
const uint32_t now = first_ms + offset * 1000UL;
|
||||
radio.publish(0, now, -119);
|
||||
sampler.poll(radio, history, BASE + offset, now);
|
||||
}
|
||||
assert(radio.requests == 0);
|
||||
sampler.poll(radio, history, BASE + 900, first_ms + 900000UL);
|
||||
assert(history.sampleCount(0) == 1 && history.sampleCodeAtOffset(0, 0) == 1);
|
||||
assert(radio.requests == 1);
|
||||
}
|
||||
}
|
||||
|
||||
static void generations() {
|
||||
FakeRadio radio;
|
||||
radio.config.second = true;
|
||||
NoiseFloorHistory history;
|
||||
NoiseFloorHistorySampler sampler;
|
||||
for (unsigned offset : {0U, 300U, 600U}) {
|
||||
if (offset == 600) ++radio.config.generation[0];
|
||||
radio.publish(0, ms(offset), -120);
|
||||
radio.publish(1, ms(offset), -70);
|
||||
sampler.poll(radio, history, BASE + offset, ms(offset));
|
||||
}
|
||||
sampler.poll(radio, history, BASE + 900, ms(900));
|
||||
assert(history.sampleCodeAtOffset(0, 0) == 31); // Pre-retune estimates discarded.
|
||||
assert(history.sampleCodeAtOffset(1, 0) == 26);
|
||||
for (unsigned offset : {904U, 1204U, 1504U}) {
|
||||
radio.publish(0, ms(offset), -118);
|
||||
radio.publish(1, ms(offset), -70);
|
||||
sampler.poll(radio, history, BASE + offset, ms(offset));
|
||||
}
|
||||
sampler.poll(radio, history, BASE + 1800, ms(1800));
|
||||
assert(history.sampleCodeAtOffset(0, 0) == 2);
|
||||
assert(history.sampleCodeAtOffset(1, 0) == 26);
|
||||
|
||||
// A fresh-reading API becoming invalid clears accumulated estimates even
|
||||
// if generation is unchanged (gain/AGC recalibration).
|
||||
for (unsigned offset : {1804U, 2104U}) {
|
||||
radio.publish(0, ms(offset), -118);
|
||||
sampler.poll(radio, history, BASE + offset, ms(offset));
|
||||
}
|
||||
radio.valid[0] = false;
|
||||
sampler.poll(radio, history, BASE + 2105, ms(2105));
|
||||
radio.publish(0, ms(2404), -118);
|
||||
sampler.poll(radio, history, BASE + 2404, ms(2404));
|
||||
sampler.poll(radio, history, BASE + 2700, ms(2700));
|
||||
assert(history.sampleCodeAtOffset(0, 0) == 31);
|
||||
assert(history.sampleCodeAtOffset(0, 1) == 2); // Completed data stays intact.
|
||||
}
|
||||
|
||||
static void enableDisable() {
|
||||
FakeRadio radio;
|
||||
radio.config.second = true;
|
||||
NoiseFloorHistory history;
|
||||
NoiseFloorHistorySampler sampler;
|
||||
for (unsigned offset : {0U, 300U, 600U}) {
|
||||
if (offset == 300) radio.config.second = false;
|
||||
if (offset == 600) radio.config.second = true;
|
||||
radio.publish(0, ms(offset), -119);
|
||||
radio.publish(1, ms(offset), -70);
|
||||
const unsigned secondary_reads = radio.reads[1];
|
||||
sampler.poll(radio, history, BASE + offset, ms(offset));
|
||||
if (offset == 300) assert(radio.reads[1] == secondary_reads);
|
||||
}
|
||||
sampler.poll(radio, history, BASE + 900, ms(900));
|
||||
assert(history.sampleCodeAtOffset(0, 0) == 1);
|
||||
assert(history.sampleCodeAtOffset(1, 0) == 31); // No mixing across enablement.
|
||||
for (unsigned offset : {904U, 1204U, 1504U}) {
|
||||
radio.publish(0, ms(offset), -119);
|
||||
radio.publish(1, ms(offset), -70);
|
||||
sampler.poll(radio, history, BASE + offset, ms(offset));
|
||||
}
|
||||
sampler.poll(radio, history, BASE + 1800, ms(1800));
|
||||
assert(history.sampleCodeAtOffset(1, 0) == 26);
|
||||
radio.config.second = false;
|
||||
sampler.poll(radio, history, BASE + 1801, ms(1801));
|
||||
assert(history.sampleCodeAtOffset(1, 0) == 26); // Disabling retains old history.
|
||||
}
|
||||
|
||||
static void clockChanges() {
|
||||
FakeRadio radio;
|
||||
NoiseFloorHistory history;
|
||||
NoiseFloorHistorySampler sampler;
|
||||
for (unsigned offset : {0U, 300U, 600U}) {
|
||||
radio.publish(0, ms(offset), -118);
|
||||
sampler.poll(radio, history, BASE + offset, ms(offset));
|
||||
}
|
||||
radio.valid[0] = false;
|
||||
sampler.poll(radio, history, BASE + 3600, ms(601));
|
||||
assert(history.sampleCount(0) == 4);
|
||||
assert(history.sampleCodeAtOffset(0, 3) == 2);
|
||||
assert(history.sampleCodeAtOffset(0, 0) == 31);
|
||||
sampler.poll(radio, history, BASE + 1800, ms(602));
|
||||
assert(history.sampleCount(0) == 0);
|
||||
for (unsigned offset : {0U, 300U, 600U}) {
|
||||
radio.publish(0, ms(603 + offset), -119);
|
||||
sampler.poll(radio, history, BASE + 1800 + offset, ms(603 + offset));
|
||||
}
|
||||
sampler.poll(radio, history, BASE + 2700, ms(1503));
|
||||
assert(history.sampleCount(0) == 1 && history.sampleCodeAtOffset(0, 0) == 1);
|
||||
sampler.poll(radio, history, BASE + 2700 + 769U * 900U, ms(1504));
|
||||
assert(history.sampleCount(0) == 0);
|
||||
}
|
||||
|
||||
static void unsupportedAndFresh() {
|
||||
FakeRadio unsupported;
|
||||
unsupported.has_profiles = false;
|
||||
NoiseFloorHistory empty;
|
||||
NoiseFloorHistorySampler no_hardware;
|
||||
for (unsigned offset : {0U, 300U, 600U, 900U}) {
|
||||
no_hardware.poll(unsupported, empty, BASE + offset, ms(offset));
|
||||
}
|
||||
assert(unsupported.requests == 4 && unsupported.reads[1] == 0);
|
||||
assert(unsupported.hardware_operations == 0);
|
||||
assert(empty.sampleCount(0) == 1 && !empty.hasValidSamples(0));
|
||||
assert(!empty.hasValidSamples(1));
|
||||
|
||||
FakeRadio both;
|
||||
both.config.second = true;
|
||||
NoiseFloorHistory history;
|
||||
NoiseFloorHistorySampler sampler;
|
||||
for (unsigned offset : {0U, 300U, 600U, 900U}) {
|
||||
both.publish(0, ms(offset), -119);
|
||||
both.publish(1, ms(offset), -70);
|
||||
sampler.poll(both, history, BASE + offset, ms(offset));
|
||||
}
|
||||
assert(both.requests == 0 && both.hardware_operations == 0);
|
||||
assert(history.sampleCodeAtOffset(0, 0) == 1 && history.sampleCodeAtOffset(1, 0) == 26);
|
||||
both.publish(0, ms(1200), -119);
|
||||
sampler.poll(both, history, BASE + 1200, ms(1200));
|
||||
assert(both.requests == 1); // Primary is fresh; secondary still needs a reading.
|
||||
}
|
||||
|
||||
static void invalidReadings() {
|
||||
const float invalid[] = {
|
||||
std::numeric_limits<float>::quiet_NaN(),
|
||||
std::numeric_limits<float>::infinity(),
|
||||
-std::numeric_limits<float>::infinity(), -200.01f, 0.01f
|
||||
};
|
||||
for (float value : invalid) {
|
||||
FakeRadio radio;
|
||||
radio.config.second = true;
|
||||
NoiseFloorHistory history;
|
||||
NoiseFloorHistorySampler sampler;
|
||||
for (unsigned offset : {0U, 300U, 600U}) {
|
||||
radio.publish(0, ms(offset), value);
|
||||
radio.publish(1, ms(offset), -118);
|
||||
sampler.poll(radio, history, BASE + offset, ms(offset));
|
||||
}
|
||||
assert(radio.requests == 3); // Invalid values never suppress a request.
|
||||
sampler.poll(radio, history, BASE + 900, ms(900));
|
||||
assert(history.sampleCodeAtOffset(0, 0) == 31);
|
||||
assert(history.sampleCodeAtOffset(1, 0) == 2);
|
||||
}
|
||||
// Invalid data appearing after a valid estimate clears that pending profile,
|
||||
// rather than retaining a partial calibration across the invalid interval.
|
||||
FakeRadio radio;
|
||||
NoiseFloorHistory history;
|
||||
NoiseFloorHistorySampler sampler;
|
||||
radio.publish(0, ms(0), -120);
|
||||
sampler.poll(radio, history, BASE, ms(0));
|
||||
radio.publish(0, ms(1), invalid[0]);
|
||||
sampler.poll(radio, history, BASE + 1, ms(1));
|
||||
for (unsigned offset : {300U, 600U}) {
|
||||
radio.publish(0, ms(offset), -120);
|
||||
sampler.poll(radio, history, BASE + offset, ms(offset));
|
||||
}
|
||||
sampler.poll(radio, history, BASE + 900, ms(900));
|
||||
assert(history.sampleCodeAtOffset(0, 0) == 31);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
const std::string scenario(argv[1]);
|
||||
if (scenario == "successful") successful();
|
||||
else if (scenario == "cached") cached();
|
||||
else if (scenario == "rollover") rollover();
|
||||
else if (scenario == "generations") generations();
|
||||
else if (scenario == "enable_disable") enableDisable();
|
||||
else if (scenario == "clock") clockChanges();
|
||||
else if (scenario == "unsupported_fresh") unsupportedAndFresh();
|
||||
else if (scenario == "invalid") invalidReadings();
|
||||
else return 2;
|
||||
puts("noise floor sampler: ok");
|
||||
}
|
||||
+141
@@ -10,6 +10,7 @@
|
||||
#endif
|
||||
#include <helpers/TelemetryHistory.h>
|
||||
#include <helpers/ExternalVoltageHistory.h>
|
||||
#include <helpers/NoiseFloorHistory.h>
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
@@ -81,6 +82,9 @@ public:
|
||||
struct Identity { uint8_t pub_key[32] = {}; } self_id;
|
||||
mesh::TelemetryHistory telemetry_history;
|
||||
mesh::ExternalVoltageHistory external_voltage_history;
|
||||
mesh::NoiseFloorHistory noise_floor_history;
|
||||
bool dual = false;
|
||||
bool isDualRadioActive() const { return dual; }
|
||||
bool telemetry_history_tx_prefs_healthy = false;
|
||||
bool telemetry_history_tx_enabled = false;
|
||||
uint8_t telemetry_history_tx_interval_days = 2;
|
||||
@@ -93,12 +97,16 @@ public:
|
||||
uint64_t uptime_millis = 0;
|
||||
uint32_t last_millis = 0;
|
||||
std::vector<mesh::Packet> packets;
|
||||
std::vector<std::string> attempts;
|
||||
std::string reject_magic;
|
||||
unsigned rejected_sends = 0;
|
||||
explicit MyMesh(FILESYSTEM& fs) : _fs(&fs) {}
|
||||
void loadTelemetryHistoryTxPrefs();
|
||||
bool saveTelemetryHistoryTxPrefs();
|
||||
void serviceTelemetryHistoryTx();
|
||||
bool sendTelemetryHistorySnapshot(mesh::TelemetryHistory::Series);
|
||||
bool sendExternalVoltageHistorySnapshot(uint8_t, uint8_t);
|
||||
bool sendNoiseFloorHistorySnapshot(uint8_t);
|
||||
void formatTelemetryHistoryTxStatus(char*, size_t) const;
|
||||
void handle(char*, char*, ClientInfo*);
|
||||
// Radio queue boundary. Payload formatting and producer scheduling are real.
|
||||
@@ -109,6 +117,11 @@ public:
|
||||
return packet;
|
||||
}
|
||||
bool sendDirect(mesh::Packet* packet, const uint8_t* path, uint8_t length) {
|
||||
const std::string magic(reinterpret_cast<char*>(packet->payload), 4);
|
||||
attempts.push_back(magic);
|
||||
if (magic == reject_magic && rejected_sends != 0) {
|
||||
--rejected_sends; delete packet; return false;
|
||||
}
|
||||
packet->path_len = mesh::Packet::copyPath(packet->path, path, length);
|
||||
packets.push_back(*packet); delete packet; return true;
|
||||
}
|
||||
@@ -163,6 +176,22 @@ struct Fixture {
|
||||
node.telemetry_history.record(1000000 + i * 1800, 22, true, 3750, 0, 0, false);
|
||||
}
|
||||
}
|
||||
void noiseHistory(uint16_t count = 300, bool missing = false) {
|
||||
const uint32_t start = 999900;
|
||||
node.noise_floor_history.advance(start);
|
||||
for (uint32_t index = 0; index < count; ++index) {
|
||||
for (uint8_t profile = 0; profile < 2; ++profile) {
|
||||
for (uint8_t slot = 0; slot < 3; ++slot) {
|
||||
if (missing) continue;
|
||||
assert(node.noise_floor_history.observe(profile,
|
||||
start + index * 900 + slot * 300, profile ? -66.0f : -119.0f,
|
||||
index * 3 + slot));
|
||||
}
|
||||
}
|
||||
node.noise_floor_history.advance(start + (index + 1) * 900);
|
||||
}
|
||||
assert(node.noise_floor_history.sampleCount(0) == count);
|
||||
}
|
||||
void expectFault() {
|
||||
assert(!node.telemetry_history_tx_prefs_healthy);
|
||||
const auto before = node.state();
|
||||
@@ -531,6 +560,116 @@ static void checkRouteReadbackAndRetry() {
|
||||
#endif
|
||||
}
|
||||
|
||||
static uint32_t read32(const uint8_t* bytes) {
|
||||
return uint32_t(bytes[0]) | uint32_t(bytes[1]) << 8
|
||||
| uint32_t(bytes[2]) << 16 | uint32_t(bytes[3]) << 24;
|
||||
}
|
||||
|
||||
static uint8_t packedNoiseCode(const mesh::Packet& packet, unsigned index) {
|
||||
uint8_t value = 0;
|
||||
for (unsigned bit = 0; bit < 5; ++bit) {
|
||||
const unsigned at = index * 5 + bit;
|
||||
value = uint8_t((value << 1)
|
||||
| ((packet.payload[19 + at / 8] >> (7 - at % 8)) & 1));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static void verifyNoisePacket(const MyMesh& node, const mesh::Packet& packet,
|
||||
unsigned profile, bool missing) {
|
||||
assert(packet.payload_len == 19 + (255 * 5 + 7) / 8);
|
||||
assert(!memcmp(packet.payload, profile ? "N2B1" : "N1B1", 4));
|
||||
assert(!memcmp(packet.payload + 4, node.self_id.pub_key, 8));
|
||||
assert(read32(packet.payload + 12) == 999900 + (300 - 255) * 900);
|
||||
assert(packet.payload[16] == 15 && packet.payload[17] == 0);
|
||||
assert(packet.payload[18] == 255);
|
||||
for (unsigned i = 0; i < 255; ++i) {
|
||||
assert(packedNoiseCode(packet, i) == (missing ? 31 : profile ? 28 : 1));
|
||||
}
|
||||
assert((packet.payload[packet.payload_len - 1] & 0x1f) == 0);
|
||||
}
|
||||
|
||||
static void serviceDue(MyMesh& node) {
|
||||
node.uptime_millis = node.telemetry_history_next_tx_uptime;
|
||||
node.last_millis = test_millis;
|
||||
node.serviceTelemetryHistoryTx();
|
||||
}
|
||||
|
||||
static void finishPending(MyMesh& node) {
|
||||
unsigned calls = 0;
|
||||
while (node.telemetry_history_tx_pending != 0) {
|
||||
assert(++calls < 20);
|
||||
serviceDue(node);
|
||||
}
|
||||
}
|
||||
|
||||
static void checkNoiseHistoryTransmission() {
|
||||
for (const bool manual : {false, true}) {
|
||||
for (const bool dual : {false, true}) {
|
||||
for (const bool missing : {false, true}) {
|
||||
Fixture f; f.seed(current(!manual, 2)); f.history(); f.noiseHistory(300, missing);
|
||||
f.node.dual = dual;
|
||||
for (unsigned i = 0; i < 8; ++i) f.node.self_id.pub_key[i] = uint8_t(0x80 + i);
|
||||
if (manual) {
|
||||
const auto reply = f.node.command("send telemetry.tx now");
|
||||
assert(reply.find(dual ? "noise=1/1" : "noise=1/0") != std::string::npos);
|
||||
assert(f.node.telemetry_history_tx_pending == (dual ? 27 : 11));
|
||||
assert(f.node.packets.empty());
|
||||
} else {
|
||||
f.node.serviceTelemetryHistoryTx();
|
||||
assert(f.node.packets.size() == 1);
|
||||
}
|
||||
finishPending(f.node);
|
||||
assert(f.node.packets.size() == (dual ? 4U : 3U));
|
||||
assert(f.node.attempts.size() == f.node.packets.size());
|
||||
assert(!memcmp(f.node.packets[0].payload, "TTB1", 4));
|
||||
assert(!memcmp(f.node.packets[1].payload, "TVB1", 4));
|
||||
verifyNoisePacket(f.node, f.node.packets[2], 0, missing);
|
||||
if (dual) verifyNoisePacket(f.node, f.node.packets[3], 1, missing);
|
||||
assert(!f.node.telemetry_history_tx_manual);
|
||||
assert(bool(f.node.telemetry_history_next_tx_uptime) == !manual);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void checkNoiseHistoryRetries() {
|
||||
for (const bool second : {false, true}) {
|
||||
Fixture f; f.seed(current(false, 2)); f.history(); f.noiseHistory();
|
||||
f.node.dual = true;
|
||||
f.node.reject_magic = second ? "N2B1" : "N1B1";
|
||||
f.node.rejected_sends = 1;
|
||||
assert(f.node.command("send telemetry.tx now").find("noise=1/1") != std::string::npos);
|
||||
while (f.node.rejected_sends != 0) serviceDue(f.node);
|
||||
const auto attempts = f.node.attempts;
|
||||
const uint64_t retry = f.node.telemetry_history_next_tx_uptime;
|
||||
assert(retry == f.node.uptime_millis + TELEMETRY_HISTORY_TX_RETRY_MILLIS);
|
||||
assert(f.node.telemetry_history_tx_pending == (second ? 16 : 24));
|
||||
f.node.uptime_millis = retry - 1;
|
||||
f.node.serviceTelemetryHistoryTx();
|
||||
assert(f.node.attempts == attempts); // failed packet waits for its retry deadline
|
||||
finishPending(f.node);
|
||||
const std::vector<std::string> expected = second
|
||||
? std::vector<std::string>{"TTB1", "TVB1", "N1B1", "N2B1", "N2B1"}
|
||||
: std::vector<std::string>{"TTB1", "TVB1", "N1B1", "N1B1", "N2B1"};
|
||||
assert(f.node.attempts == expected);
|
||||
assert(f.node.packets.size() == 4); // successful earlier packets were never resent
|
||||
assert(!f.node.telemetry_history_next_tx_uptime && !f.node.telemetry_history_tx_manual);
|
||||
}
|
||||
Fixture disabled; disabled.seed(current(false, 2));
|
||||
disabled.history(); disabled.noiseHistory(); disabled.node.dual = true;
|
||||
disabled.node.reject_magic = "N2B1"; disabled.node.rejected_sends = 1;
|
||||
assert(disabled.node.command("send telemetry.tx now").find("noise=1/1") != std::string::npos);
|
||||
while (disabled.node.rejected_sends != 0) serviceDue(disabled.node);
|
||||
assert(disabled.node.telemetry_history_tx_pending == TELEMETRY_HISTORY_TX_NOISE2);
|
||||
const auto attempts = disabled.node.attempts;
|
||||
disabled.node.dual = false;
|
||||
serviceDue(disabled.node);
|
||||
assert(!disabled.node.telemetry_history_tx_pending && disabled.node.attempts == attempts);
|
||||
assert(!disabled.node.telemetry_history_next_tx_uptime && !disabled.node.telemetry_history_tx_manual);
|
||||
assert(disabled.node.packets.size() == 3);
|
||||
}
|
||||
|
||||
int main() {
|
||||
checkFreshAndValid();
|
||||
checkLoadFailures();
|
||||
@@ -540,5 +679,7 @@ int main() {
|
||||
checkAmbiguousPublicationAndServiceGate();
|
||||
checkRoutePublicationAuthority();
|
||||
checkRouteReadbackAndRetry();
|
||||
checkNoiseHistoryTransmission();
|
||||
checkNoiseHistoryRetries();
|
||||
puts("telemetry persistence checks passed");
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Run real noise CLI dispatch, prefix handling, and room/repeater permission gates."""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <helpers/NoiseFloorHistory.h>
|
||||
#include <helpers/TelemetryHistory.h>
|
||||
#include <helpers/ExternalVoltageHistory.h>
|
||||
struct ClientInfo { bool admin=true; bool isAdmin() const { return admin; } };
|
||||
class Repeater {
|
||||
public:
|
||||
mesh::NoiseFloorHistory noise_floor_history;
|
||||
void handleCommand(char* command,char* reply,ClientInfo* sender) {
|
||||
@REPEATER_PREFIX@
|
||||
@REPEATER_NOISE@
|
||||
strcpy(reply,"Err - unrecognized command");
|
||||
}
|
||||
};
|
||||
class MyMesh {
|
||||
public:
|
||||
mesh::NoiseFloorHistory noise_floor_history;
|
||||
mesh::TelemetryHistory telemetry_history;
|
||||
mesh::ExternalVoltageHistory external_voltage_history;
|
||||
bool handleTelemetryHistoryCommand(const char*,char*,size_t=160,bool=true);
|
||||
void handleCommand(uint32_t sender_timestamp,char* command,char* reply,
|
||||
int client_index=-1,uint8_t path_hash_size=1) {
|
||||
(void)sender_timestamp; (void)client_index; (void)path_hash_size;
|
||||
@ROOM_PREFIX@
|
||||
@ROOM_DISPATCH@
|
||||
strcpy(reply,"Err - unrecognized command");
|
||||
}
|
||||
void remote(ClientInfo* client,char* text,char* reply) {
|
||||
uint8_t temp[165]={};
|
||||
const uint32_t sender_timestamp=100;
|
||||
const int i=0;
|
||||
struct Packet {uint8_t getPathHashSize() const {return 1;}} real_packet;
|
||||
Packet* packet=&real_packet;
|
||||
@ROOM_GATE@
|
||||
strcpy(reply,reinterpret_cast<char*>(&temp[5]));
|
||||
}
|
||||
};
|
||||
@ROOM_HISTORY@
|
||||
|
||||
static void seed(mesh::NoiseFloorHistory& history) {
|
||||
history.advance(999900);
|
||||
for (uint32_t bucket=0;bucket<176;++bucket) {
|
||||
for (uint8_t profile=0;profile<2;++profile) for (uint8_t slot=0;slot<3;++slot) {
|
||||
assert(history.observe(profile,999900+bucket*900+slot*300,
|
||||
profile ? -66.0f : -119.0f,bucket*3+slot));
|
||||
}
|
||||
history.advance(999900+(bucket+1)*900);
|
||||
}
|
||||
}
|
||||
static std::vector<uint8_t> decode(const char* text) {
|
||||
const char* alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::vector<uint8_t> result;
|
||||
uint32_t bits=0;unsigned count=0;
|
||||
for (;*text && *text!='=';++text) {
|
||||
const char* found=strchr(alphabet,*text);assert(found);
|
||||
bits=(bits<<6)|uint32_t(found-alphabet);count+=6;
|
||||
if (count>=8) {count-=8;result.push_back(uint8_t(bits>>count));}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
static void verify(const char* reply,unsigned profile,bool prefix) {
|
||||
assert(strlen(reply)==(prefix ? 159U : 158U));
|
||||
assert(!memcmp(reply,prefix ? "xy|" : "> ",prefix ? 3 : 2));
|
||||
const auto bytes=decode(reply+(prefix ? 3 : 2));
|
||||
assert(bytes.size()==7+110 && bytes[0]==(profile ? 0x16 : 0x15));
|
||||
assert(bytes[1]==uint8_t(999900) && bytes[2]==uint8_t(999900>>8)
|
||||
&& bytes[3]==uint8_t(999900>>16) && bytes[4]==uint8_t(999900>>24));
|
||||
assert(bytes[5]==15 && bytes[6]==176);
|
||||
for (unsigned sample=0;sample<176;++sample) {
|
||||
uint8_t code=0;
|
||||
for (unsigned bit=0;bit<5;++bit) {
|
||||
const unsigned at=sample*5+bit;
|
||||
code=uint8_t((code<<1)|((bytes[7+at/8]>>(7-at%8))&1));
|
||||
}
|
||||
assert(code==(profile ? 28 : 1));
|
||||
}
|
||||
}
|
||||
template<class Invoke> static void bounded(const std::string& text,Invoke invoke,
|
||||
bool success,unsigned profile,bool prefix) {
|
||||
std::vector<char> storage(176,'~');
|
||||
std::vector<char> command(text.begin(),text.end());command.push_back(0);
|
||||
invoke(command.data(),storage.data()+8);
|
||||
for (unsigned i=0;i<8;++i) assert(storage[i]=='~' && storage[168+i]=='~');
|
||||
assert(memchr(storage.data()+8,0,160));
|
||||
if (success) verify(storage.data()+8,profile,prefix);
|
||||
else assert(strstr(storage.data()+8,"Err - "));
|
||||
}
|
||||
int main() {
|
||||
Repeater repeater;MyMesh room;
|
||||
seed(repeater.noise_floor_history);seed(room.noise_floor_history);
|
||||
ClientInfo admin,denied;denied.admin=false;
|
||||
for (unsigned profile:{0U,1U}) for (bool prefix:{false,true}) {
|
||||
const std::string text=std::string(prefix ? "xy|" : "")
|
||||
+(profile ? "get telemetry.noise2" : "get telemetry.noise");
|
||||
for (const std::string& suffix:{std::string(),std::string(" 1")}) {
|
||||
bounded(text+suffix,[&](char* command,char* reply) {
|
||||
repeater.handleCommand(command,reply,nullptr);
|
||||
},true,profile,prefix);
|
||||
bounded(text+suffix,[&](char* command,char* reply) {
|
||||
repeater.handleCommand(command,reply,&admin);
|
||||
},true,profile,prefix);
|
||||
bounded(text+suffix,[&](char* command,char* reply) {
|
||||
room.handleCommand(0,command,reply);
|
||||
},true,profile,prefix);
|
||||
bounded(text+suffix,[&](char* command,char* reply) {
|
||||
room.remote(&admin,command,reply);
|
||||
},true,profile,prefix);
|
||||
bounded(text+suffix,[&](char* command,char* reply) {
|
||||
repeater.handleCommand(command,reply,&denied);
|
||||
},false,profile,prefix);
|
||||
bounded(text+suffix,[&](char* command,char* reply) {
|
||||
room.remote(&denied,command,reply);
|
||||
},false,profile,prefix);
|
||||
}
|
||||
}
|
||||
for (const char* bad:{"get telemetry.noisefake","get telemetry.noise2fake",
|
||||
"get telemetry.noise 0","get telemetry.noise2 6"}) {
|
||||
bounded(std::string("xy|")+bad,[&](char* command,char* reply) {
|
||||
repeater.handleCommand(command,reply,&admin);
|
||||
},false,0,true);
|
||||
bounded(std::string("xy|")+bad,[&](char* command,char* reply) {
|
||||
room.remote(&admin,command,reply);
|
||||
},false,0,true);
|
||||
}
|
||||
puts("noise CLI prefix, permission and payload checks passed");
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class NoiseFloorCLITests(unittest.TestCase):
|
||||
def test_actual_prefix_dispatch_permissions_and_full_pages_fit_160_bytes(self):
|
||||
repeater = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text()
|
||||
room = (ROOT / "examples/simple_room_server/MyMesh.cpp").read_text()
|
||||
repeater_handler = extract_braced(repeater, "void MyMesh::handleCommand(uint32_t sender_timestamp,")
|
||||
room_handler = extract_braced(room, "void MyMesh::handleCommand(uint32_t sender_timestamp,")
|
||||
|
||||
def prefix(handler):
|
||||
declaration = re.search(r"const bool prefixed_reply[^;]+;", handler).group()
|
||||
return declaration + "\n" + extract_braced(handler, "if (strlen(command) > 4 && command[2] == '|')")
|
||||
|
||||
start = repeater_handler.index(' const char* noise_commands[]')
|
||||
end = repeater_handler.index(' const char* telemetry_args', start)
|
||||
receiver = extract_braced(room, "void MyMesh::onPeerDataRecv(")
|
||||
gate = extract_braced(receiver[receiver.index("if (!handleClientPathCommand"):], "if (client->isAdmin())")
|
||||
end_gate = receiver[receiver.index(gate) + len(gate):]
|
||||
gate += " " + extract_braced(end_gate, "else")
|
||||
gate = gate.replace("_cli.morseReply().beginCommand(client->id.pub_key);", "")
|
||||
gate = gate.replace("_cli.morseReply().endCommand();", "")
|
||||
replacements = {
|
||||
"@REPEATER_PREFIX@": prefix(repeater_handler),
|
||||
"@REPEATER_NOISE@": repeater_handler[start:end],
|
||||
"@ROOM_PREFIX@": prefix(room_handler),
|
||||
"@ROOM_DISPATCH@": re.search(
|
||||
r"if \(handleTelemetryHistoryCommand\([\s\S]*?\)\) return;", room_handler).group(),
|
||||
"@ROOM_GATE@": gate,
|
||||
"@ROOM_HISTORY@": extract_braced(room, "bool MyMesh::handleTelemetryHistoryCommand("),
|
||||
}
|
||||
harness = HARNESS
|
||||
for marker, value in replacements.items():
|
||||
harness = harness.replace(marker, value)
|
||||
flags = [] if os.name == "nt" else ["-fsanitize=address,undefined",
|
||||
"-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-noise-cli-") as directory:
|
||||
directory = Path(directory)
|
||||
for legacy in (False, True):
|
||||
with self.subTest(old_prefix_plumbing=legacy):
|
||||
cpp, binary = directory / "cli.cpp", directory / "cli"
|
||||
code = harness.replace("prefixed_reply ? 157 : 160, !prefixed_reply",
|
||||
"prefixed_reply ? 160 : 160, true") if legacy else harness
|
||||
cpp.write_text(code, encoding="ascii")
|
||||
built = subprocess.run([os.environ.get("CXX") or shutil.which("g++") or "g++",
|
||||
"-std=c++17", "-Wall", "-Wextra", "-Werror", *flags,
|
||||
"-I", str(ROOT / "src"), str(cpp), "-o", str(binary)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
||||
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
|
||||
if legacy:
|
||||
self.assertNotEqual(checked.returncode, 0)
|
||||
self.assertIn("storage[i]", checked.stderr)
|
||||
else:
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
self.assertIn("noise CLI prefix, permission and payload checks passed", checked.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the production five-bit noise history and its CLI/raw encoders."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class NoiseFloorHistoryTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
|
||||
if not compiler:
|
||||
raise RuntimeError("noise history tests require a host C++ compiler")
|
||||
cls.work = tempfile.TemporaryDirectory(prefix="meshcore-noise-history-")
|
||||
cls.binary = Path(cls.work.name) / ("history.exe" if os.name == "nt" else "history")
|
||||
flags = ["-std=c++11", "-O1", "-g", "-Wall", "-Wextra", "-Werror"]
|
||||
if os.name != "nt":
|
||||
flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
|
||||
result = subprocess.run([
|
||||
compiler, *flags, "-I" + str(ROOT / "src"),
|
||||
str(ROOT / "test/fixtures/noise_floor_history/main.cpp"), "-o", str(cls.binary),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
if result.returncode:
|
||||
cls.work.cleanup()
|
||||
raise AssertionError(result.stdout + result.stderr)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.work.cleanup()
|
||||
|
||||
def run_case(self, scenario):
|
||||
result = subprocess.run([str(self.binary), scenario], capture_output=True,
|
||||
text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_selection_thresholds_directional_rounding_and_sentinels(self):
|
||||
self.run_case("selection")
|
||||
|
||||
def test_three_fresh_subslots_duplicate_rejection_and_millis_wrap(self):
|
||||
self.run_case("freshness")
|
||||
|
||||
def test_retunes_clear_pending_samples_and_keep_profile_histories_separate(self):
|
||||
self.run_case("invalidation")
|
||||
|
||||
def test_eight_day_packed_ring_rollover_and_oldest_partial_page(self):
|
||||
self.run_case("rollover")
|
||||
|
||||
def test_missing_buckets_and_rtc_corrections_are_bounded(self):
|
||||
self.run_case("clock")
|
||||
|
||||
def test_base64_160_byte_bounds_raw_header_255_samples_and_padding(self):
|
||||
self.run_case("wire")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Execute the production freshness API and publication paths with real estimators."""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_radio_receive_contract import ROOT, method, transition_harness
|
||||
|
||||
|
||||
FIXTURE = r'''
|
||||
struct UnsupportedRadio {
|
||||
@DEFAULT_GETTER@
|
||||
@DEFAULT_REQUEST@
|
||||
};
|
||||
|
||||
static void fill(NoiseFloorEstimator& estimator, float dbm) {
|
||||
for (unsigned i = 0; i < NoiseFloorEstimator::SAMPLE_COUNT; ++i) {
|
||||
now_ms += NoiseFloorEstimator::SAMPLE_INTERVAL_MS;
|
||||
assert(estimator.add(dbm, now_ms));
|
||||
}
|
||||
}
|
||||
static uint32_t reading(const RadioLibWrapper& w, unsigned profile, float expected) {
|
||||
float dbm = 999;
|
||||
uint32_t published = 999;
|
||||
assert(w.getNoiseFloorReading(profile, dbm, published));
|
||||
assert(fabsf(dbm - expected) < 0.02f);
|
||||
return published;
|
||||
}
|
||||
static void missing(const RadioLibWrapper& w, unsigned profile) {
|
||||
float dbm = 999;
|
||||
uint32_t published = 999;
|
||||
assert(!w.getNoiseFloorReading(profile, dbm, published));
|
||||
assert(dbm == 999 && published == 999);
|
||||
}
|
||||
static void baseline(RadioLibWrapper& w, float primary = -120, float secondary = -118) {
|
||||
w._noise_floor_valid = w._noise_floor_secondary_valid = false;
|
||||
fill(w._floor_estimator, primary);
|
||||
if (w._profiles.enabled()) fill(w._secondary_floor_estimator, secondary);
|
||||
w._nf_refresh_requested = true;
|
||||
w.loop();
|
||||
}
|
||||
static void dual(RadioLibWrapper& w) {
|
||||
mesh::RadioProfileConfig second;
|
||||
second.params.freq = 911.15f;
|
||||
second.params.bw = 250;
|
||||
second.params.sf = 7;
|
||||
second.params.cr = 5;
|
||||
second.mode = mesh::RadioProfileMode::RxTx;
|
||||
w._profiles.setSecondary(second, false);
|
||||
w._profiles.primary = second.params;
|
||||
w._profile_rxps_suspended = true;
|
||||
w._profile_scan_generation[0] = w._profiles.generation[0];
|
||||
w._profile_scan_generation[1] = w._profiles.generation[1];
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
const char* scenario = argv[1];
|
||||
if (!strcmp(scenario, "unsupported")) {
|
||||
UnsupportedRadio radio;
|
||||
float dbm = 123;
|
||||
uint32_t published = 456;
|
||||
for (unsigned profile : {0U, 1U, 2U, 255U}) {
|
||||
assert(!radio.getNoiseFloorReading(profile, dbm, published));
|
||||
assert(dbm == 123 && published == 456);
|
||||
radio.requestNoiseFloorMeasurement();
|
||||
}
|
||||
RadioLibWrapper w;
|
||||
for (unsigned profile : {0U, 1U, 2U, 255U}) missing(w, profile);
|
||||
} else if (!strcmp(scenario, "single")) {
|
||||
RadioLibWrapper w;
|
||||
baseline(w);
|
||||
const uint32_t first = now_ms;
|
||||
assert(reading(w, 0, -120) == first);
|
||||
missing(w, 1); missing(w, 2); missing(w, 255);
|
||||
w.requestNoiseFloorMeasurement();
|
||||
assert(reading(w, 0, -120) == first);
|
||||
fill(w._floor_estimator, -120);
|
||||
w.loop();
|
||||
assert(now_ms != first && reading(w, 0, -120) == now_ms);
|
||||
const uint32_t second = now_ms;
|
||||
w.requestNoiseFloorMeasurement();
|
||||
fill(w._floor_estimator, -60);
|
||||
w.loop();
|
||||
assert(reading(w, 0, -120) == second); // held rise is not a publication
|
||||
assert(w._nf_last_calib == now_ms && now_ms != second);
|
||||
w.requestNoiseFloorMeasurement();
|
||||
w.busy = true;
|
||||
w.loop(); // arm a bounded window, then let it expire without idle RSSI
|
||||
now_ms += NF_CONTINUOUS_TIMEOUT_MS;
|
||||
w.loop();
|
||||
assert(!w._nf_refresh_requested);
|
||||
assert(reading(w, 0, -120) == second);
|
||||
} else if (!strcmp(scenario, "dual")) {
|
||||
RadioLibWrapper w;
|
||||
dual(w); baseline(w);
|
||||
const uint32_t first = now_ms;
|
||||
assert(reading(w, 0, -120) == first && reading(w, 1, -118) == first);
|
||||
w.requestNoiseFloorMeasurement();
|
||||
fill(w._floor_estimator, -120);
|
||||
fill(w._secondary_floor_estimator, -60); // one profile may hold while the other publishes
|
||||
w.loop();
|
||||
assert(reading(w, 0, -120) == now_ms);
|
||||
assert(reading(w, 1, -118) == first);
|
||||
const uint32_t primary = now_ms;
|
||||
w.requestNoiseFloorMeasurement();
|
||||
w.busy = true; w.loop();
|
||||
now_ms += NF_CONTINUOUS_TIMEOUT_MS;
|
||||
w.loop();
|
||||
assert(!w._nf_refresh_requested);
|
||||
assert(reading(w, 0, -120) == primary && reading(w, 1, -118) == first);
|
||||
w._profiles.secondary.mode = mesh::RadioProfileMode::Off;
|
||||
missing(w, 1);
|
||||
} else if (!strcmp(scenario, "invalidate")) {
|
||||
RadioLibWrapper w;
|
||||
dual(w); baseline(w);
|
||||
++w._profiles.generation[1]; // reject the old profile before scanner service
|
||||
missing(w, 1);
|
||||
assert(reading(w, 0, -120) == now_ms);
|
||||
++w._profiles.generation[0]; missing(w, 0);
|
||||
w.recalibrateNoiseFloor();
|
||||
missing(w, 0); missing(w, 1);
|
||||
assert(w._nf_published_valid == 0 && w._nf_refresh_requested);
|
||||
RadioLibWrapper gain;
|
||||
baseline(gain);
|
||||
assert(gain.setRxBoostedGainMode(true));
|
||||
missing(gain, 0);
|
||||
RadioLibWrapper reset;
|
||||
baseline(reset);
|
||||
reset.resetAGC();
|
||||
assert(reset._noise_floor_valid); // legacy filter can retain its last value
|
||||
missing(reset, 0); // telemetry cannot claim that value was measured after reset
|
||||
} else if (!strcmp(scenario, "rollover")) {
|
||||
for (bool two : {false, true}) {
|
||||
RadioLibWrapper w;
|
||||
if (two) dual(w);
|
||||
w._noise_floor_valid = w._noise_floor_secondary_valid = false;
|
||||
now_ms = UINT32_MAX - 10000;
|
||||
fill(w._floor_estimator, -120);
|
||||
if (two) fill(w._secondary_floor_estimator, -118);
|
||||
now_ms = 0;
|
||||
w.loop();
|
||||
assert(reading(w, 0, -120) == 0);
|
||||
if (two) assert(reading(w, 1, -118) == 0);
|
||||
w.requestNoiseFloorMeasurement();
|
||||
fill(w._floor_estimator, -120);
|
||||
if (two) fill(w._secondary_floor_estimator, -118);
|
||||
w.loop();
|
||||
assert(reading(w, 0, -120) == now_ms && now_ms != 0);
|
||||
}
|
||||
} else if (!strcmp(scenario, "request")) {
|
||||
for (unsigned owner : {0U, 1U, 2U, 3U, 4U, 5U, 6U}) {
|
||||
RadioLibWrapper w;
|
||||
baseline(w);
|
||||
const uint32_t published = now_ms;
|
||||
if (owner == 0) w.packet = true;
|
||||
if (owner == 1) w.state = STATE_TX_WAIT;
|
||||
if (owner == 2) w._cw_active = true;
|
||||
if (owner == 3) w._morse_owned = true;
|
||||
if (owner == 4) w._morse_rx_owned = true;
|
||||
if (owner == 5) w._beacon_owned = true;
|
||||
if (owner == 6) w.busy = true;
|
||||
const unsigned arms = w.arms, stops = w.stops, reads = w.reads;
|
||||
const unsigned scans = w.radio.scans, resets = w.agc_resets;
|
||||
w.requestNoiseFloorMeasurement();
|
||||
assert(reading(w, 0, -120) == published && w._nf_refresh_requested);
|
||||
assert(w._floor_estimator.add(-120, now_ms));
|
||||
w.loop();
|
||||
const auto deadline = w._nf_calib_deadline;
|
||||
for (unsigned i = 0; i < 4; ++i) {
|
||||
now_ms += 1;
|
||||
w.requestNoiseFloorMeasurement();
|
||||
assert(w._floor_estimator.count() == 1 && w._nf_calib_deadline == deadline);
|
||||
}
|
||||
assert(w.arms == arms && w.stops == stops && w.reads == reads);
|
||||
assert(w.radio.scans == scans && w.agc_resets == resets);
|
||||
assert(reading(w, 0, -120) == published);
|
||||
}
|
||||
} else assert(false);
|
||||
printf("PASS: %s\n", scenario);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class NoiseFloorReadingTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.directory = tempfile.TemporaryDirectory(prefix="meshcore-noise-reading-")
|
||||
cls.addClassCleanup(cls.directory.cleanup)
|
||||
work = Path(cls.directory.name)
|
||||
source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text()
|
||||
header = (ROOT / "src/helpers/radiolib/RadioLibWrappers.h").read_text()
|
||||
dispatcher = (ROOT / "src/Dispatcher.h").read_text()
|
||||
harness = transition_harness(source).split("struct LegacyRadio {", 1)[0]
|
||||
api = "\n".join(method(header, signature).replace(" override", "") for signature in (
|
||||
"bool getNoiseFloorReading(", "void requestNoiseFloorMeasurement("))
|
||||
harness = harness.replace(" bool tryResetAGC();", api + "\n bool tryResetAGC();")
|
||||
harness = harness.replace("#include <cassert>", "#include <cassert>\n#include <cstring>")
|
||||
harness = harness.replace("bool serviceCarrierWave() { return false; }",
|
||||
"bool _morse_owned = false, _morse_rx_owned = false, _beacon_owned = false;\n"
|
||||
" bool serviceCarrierWave() { return _cw_active || _morse_owned || _morse_rx_owned; }")
|
||||
loop = method(source, "void RadioLibWrapper::loop(")
|
||||
harness = harness.replace(loop,
|
||||
"#define MESH_ENABLE_TX_BEACONS 1\n#define MESH_ENABLE_CARRIER_TOOLS 1\n"
|
||||
+ loop + "\n#undef MESH_ENABLE_TX_BEACONS\n#undef MESH_ENABLE_CARRIER_TOOLS\n")
|
||||
fixture = FIXTURE.replace("@DEFAULT_GETTER@", method(dispatcher,
|
||||
"virtual bool getNoiseFloorReading(")).replace("@DEFAULT_REQUEST@", method(dispatcher,
|
||||
"virtual void requestNoiseFloorMeasurement("))
|
||||
cls.binaries = {}
|
||||
flags = [] if not sys.platform.startswith("linux") else [
|
||||
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
|
||||
for family in ("sx1262", "other"):
|
||||
cpp, binary = work / (family + ".cpp"), work / family
|
||||
cpp.write_text((harness + fixture).replace("\u2014", "-"), encoding="ascii")
|
||||
built = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-g",
|
||||
"-Wall", "-Wextra", "-Werror", *flags,
|
||||
*(["-DUSE_SX1262=1"] if family == "sx1262" else []),
|
||||
"-I", str(ROOT / "src"), str(cpp), "-o", str(binary)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
if built.returncode:
|
||||
raise AssertionError(built.stdout + built.stderr)
|
||||
cls.binaries[family] = binary
|
||||
|
||||
def run_case(self, scenario):
|
||||
for family, binary in self.binaries.items():
|
||||
with self.subTest(family=family):
|
||||
result = subprocess.run([str(binary), scenario], capture_output=True,
|
||||
text=True, timeout=20)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("PASS: " + scenario, result.stdout)
|
||||
|
||||
def test_unsupported_and_unmeasured_profiles_are_missing(self):
|
||||
self.run_case("unsupported")
|
||||
|
||||
def test_single_only_successful_publications_advance_timestamp(self):
|
||||
self.run_case("single")
|
||||
|
||||
def test_dual_publication_timestamps_remain_independent(self):
|
||||
self.run_case("dual")
|
||||
|
||||
def test_profile_gain_and_rf_reset_invalidate_telemetry_readings(self):
|
||||
self.run_case("invalidate")
|
||||
|
||||
def test_millis_zero_and_rollover_are_valid_publication_times(self):
|
||||
self.run_case("rollover")
|
||||
|
||||
def test_requests_join_collection_without_interrupting_radio_owners(self):
|
||||
self.run_case("request")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Execute each LR instantaneous-RSSI method against failed hardware reads."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def rssi_method(header):
|
||||
start = header.index("float getCurrentRSSI() override {")
|
||||
end = header.index("\n }", start) + len("\n }")
|
||||
return header[start:end]
|
||||
|
||||
|
||||
class NoiseFloorRssiErrorTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
|
||||
if not compiler:
|
||||
raise RuntimeError("RSSI error tests require a host C++ compiler")
|
||||
cls.work = tempfile.TemporaryDirectory(prefix="meshcore-rssi-errors-")
|
||||
cls.addClassCleanup(cls.work.cleanup)
|
||||
binary = Path(cls.work.name) / ("rssi.exe" if os.name == "nt" else "rssi")
|
||||
classes = []
|
||||
for family in ("LR1110", "LR1121", "LR2021"):
|
||||
name = "Custom" + family + "Wrapper"
|
||||
source = (ROOT / ("src/helpers/radiolib/" + name + ".h")).read_text()
|
||||
classes.append("class " + name + " : public WrapperBase { public: "
|
||||
+ rssi_method(source) + " };\n")
|
||||
harness = r'''
|
||||
#include <assert.h>
|
||||
#include <math.h>
|
||||
#include <stdint.h>
|
||||
#include "helpers/radiolib/NoiseFloorEstimator.h"
|
||||
constexpr int RADIOLIB_ERR_NONE = 0;
|
||||
struct FakeRadio {
|
||||
int status = 0;
|
||||
float rssi = -119.25f;
|
||||
bool write_value = true;
|
||||
int getRssiInst(float* value) {
|
||||
if (write_value) *value = rssi;
|
||||
return status;
|
||||
}
|
||||
};
|
||||
using CustomLR1110 = FakeRadio;
|
||||
using CustomLR1121 = FakeRadio;
|
||||
using CustomLR2021 = FakeRadio;
|
||||
struct WrapperBase {
|
||||
FakeRadio radio;
|
||||
FakeRadio* _radio = &radio;
|
||||
virtual ~WrapperBase() = default;
|
||||
virtual float getCurrentRSSI() = 0;
|
||||
};
|
||||
@CLASSES@
|
||||
template<class Wrapper> void exercise() {
|
||||
Wrapper wrapper;
|
||||
assert(wrapper.getCurrentRSSI() == -119.25f);
|
||||
for (int status : {-1, -2, -16, 1}) {
|
||||
wrapper.radio.status = status;
|
||||
for (bool write : {false, true}) {
|
||||
wrapper.radio.write_value = write;
|
||||
NoiseFloorEstimator estimator;
|
||||
for (unsigned sample = 0; sample < NoiseFloorEstimator::SAMPLE_COUNT; ++sample) {
|
||||
const float rssi = wrapper.getCurrentRSSI();
|
||||
assert(isnan(rssi));
|
||||
assert(!estimator.add(rssi, sample * NoiseFloorEstimator::SAMPLE_INTERVAL_MS));
|
||||
}
|
||||
assert(estimator.count() == 0 && !estimator.complete());
|
||||
int32_t floor = -12000;
|
||||
assert(!estimator.publish(floor, false));
|
||||
assert(floor == -12000);
|
||||
}
|
||||
}
|
||||
wrapper.radio.status = 0;
|
||||
wrapper.radio.write_value = false;
|
||||
assert(isnan(wrapper.getCurrentRSSI()));
|
||||
wrapper.radio.write_value = true;
|
||||
assert(wrapper.getCurrentRSSI() == -119.25f);
|
||||
NoiseFloorEstimator estimator;
|
||||
for (unsigned sample = 0; sample < NoiseFloorEstimator::SAMPLE_COUNT; ++sample) {
|
||||
assert(estimator.add(wrapper.getCurrentRSSI(), sample * NoiseFloorEstimator::SAMPLE_INTERVAL_MS));
|
||||
}
|
||||
int32_t floor = 0;
|
||||
assert(estimator.publish(floor, false) && floor == -11925);
|
||||
}
|
||||
int main() {
|
||||
exercise<CustomLR1110Wrapper>();
|
||||
exercise<CustomLR1121Wrapper>();
|
||||
exercise<CustomLR2021Wrapper>();
|
||||
}
|
||||
'''.replace("@CLASSES@", "\n".join(classes))
|
||||
harness = harness.replace("#include <stdint.h>", "#include <stdint.h>\n#include <initializer_list>")
|
||||
source = Path(cls.work.name) / "rssi.cpp"
|
||||
source.write_text(harness, encoding="ascii")
|
||||
flags = ["-std=c++11", "-O1", "-g", "-Wall", "-Wextra", "-Werror"]
|
||||
if os.name != "nt":
|
||||
flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-pie", "-no-pie"]
|
||||
built = subprocess.run([compiler, *flags, "-I" + str(ROOT / "src"),
|
||||
str(source), "-o", str(binary)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
if built.returncode:
|
||||
raise AssertionError(built.stdout + built.stderr)
|
||||
cls.binary = binary
|
||||
|
||||
def test_failed_reads_cannot_fabricate_noise_floor_samples(self):
|
||||
result = subprocess.run([str(self.binary)], capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the production radio-to-history sampler with controlled publications."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class NoiseFloorSamplerTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
|
||||
if not compiler:
|
||||
raise RuntimeError("noise sampler tests require a host C++ compiler")
|
||||
cls.work = tempfile.TemporaryDirectory(prefix="meshcore-noise-sampler-")
|
||||
cls.binary = Path(cls.work.name) / ("sampler.exe" if os.name == "nt" else "sampler")
|
||||
flags = ["-std=c++11", "-O1", "-g", "-Wall", "-Wextra", "-Werror"]
|
||||
if os.name != "nt":
|
||||
flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
|
||||
result = subprocess.run([
|
||||
compiler, *flags, "-I" + str(ROOT / "src"),
|
||||
str(ROOT / "test/fixtures/noise_floor_sampler/main.cpp"), "-o", str(cls.binary),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
if result.returncode:
|
||||
cls.work.cleanup()
|
||||
raise AssertionError(result.stdout + result.stderr)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.work.cleanup()
|
||||
|
||||
def run_case(self, scenario):
|
||||
result = subprocess.run([str(self.binary), scenario], capture_output=True,
|
||||
text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_three_fresh_publications_complete_a_bucket_without_extra_requests(self):
|
||||
self.run_case("successful")
|
||||
|
||||
def test_cached_estimates_do_not_fill_multiple_subslots(self):
|
||||
self.run_case("cached")
|
||||
|
||||
def test_millis_wrap_and_a_zero_publication_timestamp_are_valid(self):
|
||||
self.run_case("rollover")
|
||||
|
||||
def test_retunes_and_validity_changes_discard_only_pending_profile_readings(self):
|
||||
self.run_case("generations")
|
||||
|
||||
def test_radio2_disable_reenable_keeps_history_and_restarts_pending_aggregation(self):
|
||||
self.run_case("enable_disable")
|
||||
|
||||
def test_rtc_jumps_start_new_buckets_and_do_not_loop_unbounded(self):
|
||||
self.run_case("clock")
|
||||
|
||||
def test_unsupported_radios_and_fresh_dual_profiles_need_no_hardware_actions(self):
|
||||
self.run_case("unsupported_fresh")
|
||||
|
||||
def test_invalid_rssi_never_suppresses_measurements_or_survives_as_pending_data(self):
|
||||
self.run_case("invalid")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -46,6 +46,7 @@ struct RadioLibWrapper {
|
||||
bool _profile_standby_held=false, _saved_standby_xosc=false;
|
||||
bool _nf_calib_active=false, _nf_refresh_requested=false, _noise_floor_valid=true, _noise_floor_secondary_valid=true, _profile_refresh_required=false;
|
||||
bool _nf_window_armed=false, _nf_lazy_refresh=false, _nf_refresh_attempted=false;
|
||||
uint8_t _nf_published_valid=0;
|
||||
unsigned long _nf_last_calib=0, _nf_calib_deadline=0, _nf_sample_from=0;
|
||||
NoiseFloorEstimator _floor_estimator;
|
||||
NoiseFloorEstimator _secondary_floor_estimator;
|
||||
|
||||
@@ -80,6 +80,8 @@ struct RadioLibWrapper {
|
||||
bool _nf_calib_active = false, _nf_refresh_requested = true, _noise_floor_valid = true,
|
||||
_noise_floor_secondary_valid = true;
|
||||
bool _nf_window_armed = false, _nf_lazy_refresh = false, _nf_refresh_attempted = false;
|
||||
uint32_t _nf_published_at[2] = {}, _nf_published_generation[2] = {};
|
||||
uint8_t _nf_published_valid = 0;
|
||||
bool _cad_enabled = true, packet = false, busy = false, inject_arm_irq = false;
|
||||
bool _rx_boosted_gain_valid = false, _cur_rx_boosted_gain = false, gain_apply_success = true;
|
||||
bool _wd_last_busy = false;
|
||||
|
||||
@@ -99,6 +99,7 @@ struct Chip : LR2021 {
|
||||
bool _params_valid=true,_profile_rxps_suspended=true,_profile_saved_rxps=false;
|
||||
bool _noise_floor_valid=true,_noise_floor_secondary_valid=true,_nf_refresh_requested=false;
|
||||
bool _nf_window_armed=false,_nf_lazy_refresh=false,_nf_refresh_attempted=false;
|
||||
uint8_t _nf_published_valid=0;
|
||||
uint32_t _profile_retry_at=0,_profile_generation=1,_profile_visit_stamp=0,_profile_scan_generation[2]={};
|
||||
uint8_t _active_profile=0,_cur_sf=7,_cur_cr=5;
|
||||
uint16_t _physical_preamble=32;
|
||||
|
||||
@@ -76,6 +76,7 @@ HISTORY_HARNESS = r'''
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/TelemetryHistory.h>
|
||||
#include <helpers/ExternalVoltageHistory.h>
|
||||
#include <helpers/NoiseFloorHistory.h>
|
||||
#include <helpers/ClientACLResponse.h>
|
||||
#include <Wire.h>
|
||||
#define ATOMIC_FILE_WRITER_IMPLEMENTATION
|
||||
@@ -138,12 +139,13 @@ public:
|
||||
uint8_t reply_data[184];
|
||||
mesh::TelemetryHistory telemetry_history;
|
||||
mesh::ExternalVoltageHistory external_voltage_history;
|
||||
mesh::NoiseFloorHistory noise_floor_history;
|
||||
Clock* getRTCClock() {return &clock;}
|
||||
void configureHistory() {
|
||||
@HISTORY_INIT@
|
||||
}
|
||||
void sampleTelemetryHistory();
|
||||
bool handleTelemetryHistoryCommand(const char*,char*);
|
||||
bool handleTelemetryHistoryCommand(const char*,char*,size_t=160,bool=true);
|
||||
int handleRequest(ClientInfo*,uint32_t,uint8_t*,size_t,size_t);
|
||||
void handleCommand(uint32_t sender_timestamp,char* command,char* reply,
|
||||
int gpio_client_index=-1,uint8_t gpio_path_hash_size=1) {
|
||||
|
||||
@@ -38,7 +38,7 @@ class TelemetryPersistenceTests(unittest.TestCase):
|
||||
"bool CommonCLI::handleDataTxCommand(")) + "\n"
|
||||
generated += '#define TELEMETRY_HISTORY_TX_PREFS_FILE "/telemetry_tx"\n'
|
||||
for name in ("RETRY_MILLIS", "PACKET_SPACING_MILLIS", "DEFAULT_DAYS", "MAX_DAYS",
|
||||
"TEMPERATURE", "VOLTAGE", "EXTERNAL_VOLTAGE"):
|
||||
"TEMPERATURE", "VOLTAGE", "EXTERNAL_VOLTAGE", "NOISE", "NOISE2"):
|
||||
generated += re.search(r"static const uint(?:8|64)_t TELEMETRY_HISTORY_TX_" +
|
||||
name + r"[^;]*;", source).group() + "\n"
|
||||
generated += "\n".join(extract_braced(source, signature) for signature in (
|
||||
@@ -46,6 +46,7 @@ class TelemetryPersistenceTests(unittest.TestCase):
|
||||
"static bool recoverTelemetryHistoryTxPrefs(",
|
||||
"static bool readTelemetryHistoryTxPrefsImage(", "void MyMesh::loadTelemetryHistoryTxPrefs(",
|
||||
"bool MyMesh::saveTelemetryHistoryTxPrefs(", "bool MyMesh::sendTelemetryHistorySnapshot(",
|
||||
"bool MyMesh::sendNoiseFloorHistorySnapshot(",
|
||||
"bool MyMesh::sendExternalVoltageHistorySnapshot(", "void MyMesh::serviceTelemetryHistoryTx(",
|
||||
"void MyMesh::formatTelemetryHistoryTxStatus(", "static char* trimSpaces(")) + "\n"
|
||||
generated += ('void MyMesh::handle(char* command, char* reply, ClientInfo* sender) {\n'
|
||||
|
||||
Reference in new issue
Block a user