diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml
index fbe3ffc72..b4e9d5547 100644
--- a/.github/workflows/run-unit-tests.yml
+++ b/.github/workflows/run-unit-tests.yml
@@ -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
diff --git a/docs/_javascript/telemetry_decoder.js b/docs/_javascript/telemetry_decoder.js
index 22f133a66..7e2c39389 100644
--- a/docs/_javascript/telemetry_decoder.js
+++ b/docs/_javascript/telemetry_decoder.js
@@ -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 @@
+
+
@@ -60,6 +69,7 @@
Decode notes
@@ -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;
diff --git a/docs/_stylesheets/telemetry_decoder.css b/docs/_stylesheets/telemetry_decoder.css
index c6d6e8355..172018b44 100644
--- a/docs/_stylesheets/telemetry_decoder.css
+++ b/docs/_stylesheets/telemetry_decoder.css
@@ -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%;
diff --git a/docs/cli_commands.md b/docs/cli_commands.md
index 247da2492..34466f5b8 100644
--- a/docs/cli_commands.md
+++ b/docs/cli_commands.md
@@ -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
`
- `get telemetry.tx`
- `set telemetry.tx `
@@ -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 |
diff --git a/docs/telemetry_decoder.md b/docs/telemetry_decoder.md
index 3b51b5716..4070585b6 100644
--- a/docs/telemetry_decoder.md
+++ b/docs/telemetry_decoder.md
@@ -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.
diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp
index 7998e06b4..0a0499b80 100644
--- a/examples/simple_repeater/MyMesh.cpp
+++ b/examples/simple_repeater/MyMesh.cpp
@@ -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
diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h
index f29ea7069..3631e7ac8 100644
--- a/examples/simple_repeater/MyMesh.h
+++ b/examples/simple_repeater/MyMesh.h
@@ -126,6 +126,7 @@
#if MESH_ENABLE_TELEMETRY_HISTORY
#include
#include
+#include
#endif
#include
#include
@@ -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;
diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp
index 327d8233b..2cfb06388 100644
--- a/examples/simple_room_server/MyMesh.cpp
+++ b/examples/simple_room_server/MyMesh.cpp
@@ -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
diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h
index ee5d4b15c..f27821748 100644
--- a/examples/simple_room_server/MyMesh.h
+++ b/examples/simple_room_server/MyMesh.h
@@ -66,6 +66,7 @@ namespace mesh { class RoomJsonWriter; }
#if MESH_ENABLE_TELEMETRY_HISTORY
#include
#include
+#include
#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:
diff --git a/mkdocs.yml b/mkdocs.yml
index acf2dec5a..768a47aca 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -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
diff --git a/scripts/test_telemetry_decoder.js b/scripts/test_telemetry_decoder.js
index dd43aff9c..ebfc4f5fe 100644
--- a/scripts/test_telemetry_decoder.js
+++ b/scripts/test_telemetry_decoder.js
@@ -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`);
diff --git a/src/Dispatcher.h b/src/Dispatcher.h
index c96914e77..fcce7ad9b 100644
--- a/src/Dispatcher.h
+++ b/src/Dispatcher.h
@@ -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
diff --git a/src/helpers/NoiseFloorHistory.h b/src/helpers/NoiseFloorHistory.h
new file mode 100644
index 000000000..c04180ea4
--- /dev/null
+++ b/src/helpers/NoiseFloorHistory.h
@@ -0,0 +1,320 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+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
diff --git a/src/helpers/NoiseFloorHistorySampler.h b/src/helpers/NoiseFloorHistorySampler.h
new file mode 100644
index 000000000..eb12a312a
--- /dev/null
+++ b/src/helpers/NoiseFloorHistorySampler.h
@@ -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
+ 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
diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h
index 6930c2c24..74dad28aa 100644
--- a/src/helpers/radiolib/CustomLR1110Wrapper.h
+++ b/src/helpers/radiolib/CustomLR1110Wrapper.h
@@ -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 {
diff --git a/src/helpers/radiolib/CustomLR1121Wrapper.h b/src/helpers/radiolib/CustomLR1121Wrapper.h
index bf519ae17..20970174d 100644
--- a/src/helpers/radiolib/CustomLR1121Wrapper.h
+++ b/src/helpers/radiolib/CustomLR1121Wrapper.h
@@ -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 {
diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h
index b1a105efc..112cebe3f 100644
--- a/src/helpers/radiolib/CustomLR2021Wrapper.h
+++ b/src/helpers/radiolib/CustomLR2021Wrapper.h
@@ -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 {
diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp
index bfbe9ef89..174509771 100644
--- a/src/helpers/radiolib/RadioLibWrappers.cpp
+++ b/src/helpers/radiolib/RadioLibWrappers.cpp
@@ -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(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(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(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;
}
diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h
index 8d8ca2d13..5c9e2631e 100644
--- a/src/helpers/radiolib/RadioLibWrappers.h
+++ b/src/helpers/radiolib/RadioLibWrappers.h
@@ -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;
diff --git a/test/fixtures/noise_floor_history/main.cpp b/test/fixtures/noise_floor_history/main.cpp
new file mode 100644
index 000000000..cedde97ae
--- /dev/null
+++ b/test/fixtures/noise_floor_history/main.cpp
@@ -0,0 +1,303 @@
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+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 decode(const char* reply) {
+ assert(strncmp(reply, "> ", 2) == 0);
+ const char* alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ std::vector 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::quiet_NaN(), 2));
+ assert(!history.observe(0, BASE + 2, std::numeric_limits::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");
+}
diff --git a/test/fixtures/noise_floor_sampler/main.cpp b/test/fixtures/noise_floor_sampler/main.cpp
new file mode 100644
index 000000000..7cdd2496c
--- /dev/null
+++ b/test/fixtures/noise_floor_sampler/main.cpp
@@ -0,0 +1,297 @@
+#include
+
+#include
+#include
+#include
+#include
+
+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::quiet_NaN(),
+ std::numeric_limits::infinity(),
+ -std::numeric_limits::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");
+}
diff --git a/test/fixtures/telemetry_tx_persistence/fixture.cpp b/test/fixtures/telemetry_tx_persistence/fixture.cpp
index 36bf5fc66..5d260c61d 100644
--- a/test/fixtures/telemetry_tx_persistence/fixture.cpp
+++ b/test/fixtures/telemetry_tx_persistence/fixture.cpp
@@ -10,6 +10,7 @@
#endif
#include
#include
+#include
#include
#include
#include
@@ -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 packets;
+ std::vector 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(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 expected = second
+ ? std::vector{"TTB1", "TVB1", "N1B1", "N2B1", "N2B1"}
+ : std::vector{"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");
}
diff --git a/test/test_noise_floor_cli.py b/test/test_noise_floor_cli.py
new file mode 100644
index 000000000..034c0bc24
--- /dev/null
+++ b/test/test_noise_floor_cli.py
@@ -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
+#include
+#include
+#include
+#include
+#include
+#include
+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(&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 decode(const char* text) {
+ const char* alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ std::vector 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 static void bounded(const std::string& text,Invoke invoke,
+ bool success,unsigned profile,bool prefix) {
+ std::vector storage(176,'~');
+ std::vector 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()
diff --git a/test/test_noise_floor_history.py b/test/test_noise_floor_history.py
new file mode 100644
index 000000000..4be43b1d4
--- /dev/null
+++ b/test/test_noise_floor_history.py
@@ -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()
diff --git a/test/test_noise_floor_readings.py b/test/test_noise_floor_readings.py
new file mode 100644
index 000000000..cb9189378
--- /dev/null
+++ b/test/test_noise_floor_readings.py
@@ -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 ", "#include \n#include ")
+ 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()
diff --git a/test/test_noise_floor_rssi_errors.py b/test/test_noise_floor_rssi_errors.py
new file mode 100644
index 000000000..bba64bce2
--- /dev/null
+++ b/test/test_noise_floor_rssi_errors.py
@@ -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
+#include
+#include
+#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 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();
+ exercise();
+ exercise();
+}
+'''.replace("@CLASSES@", "\n".join(classes))
+ harness = harness.replace("#include ", "#include \n#include ")
+ 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()
diff --git a/test/test_noise_floor_sampler.py b/test/test_noise_floor_sampler.py
new file mode 100644
index 000000000..e3739c215
--- /dev/null
+++ b/test/test_noise_floor_sampler.py
@@ -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()
diff --git a/test/test_radio_profile_scan.py b/test/test_radio_profile_scan.py
index 8a9956034..af796f1b3 100644
--- a/test/test_radio_profile_scan.py
+++ b/test/test_radio_profile_scan.py
@@ -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;
diff --git a/test/test_radio_receive_contract.py b/test/test_radio_receive_contract.py
index 43f791a70..50653bbd0 100644
--- a/test/test_radio_receive_contract.py
+++ b/test/test_radio_receive_contract.py
@@ -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;
diff --git a/test/test_radio_rx_activity_epoch.py b/test/test_radio_rx_activity_epoch.py
index d561928e8..b526ad7f1 100644
--- a/test/test_radio_rx_activity_epoch.py
+++ b/test/test_radio_rx_activity_epoch.py
@@ -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;
diff --git a/test/test_solarxiao_room.py b/test/test_solarxiao_room.py
index 098f1e59b..1dddc4e98 100644
--- a/test/test_solarxiao_room.py
+++ b/test/test_solarxiao_room.py
@@ -76,6 +76,7 @@ HISTORY_HARNESS = r'''
#include
#include
#include
+#include
#include
#include
#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) {
diff --git a/test/test_telemetry_tx_persistence.py b/test/test_telemetry_tx_persistence.py
index 8e4019d79..1dae05714 100644
--- a/test/test_telemetry_tx_persistence.py
+++ b/test/test_telemetry_tx_persistence.py
@@ -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'