Add packed noise-floor telemetry history for both radio profiles

This commit is contained in:
mikecarper committed 2026-10-22 16:50:31 -07:00
1 parent 52a36c89dc
commit f4ade738df
32 files changed
+2520 -53

No files matched your search

+7
View File
@@ -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
+202 -20
View File
@@ -5,9 +5,16 @@
const TYPE_VOLTAGE = 0x12;
const TYPE_GPS = 0x13;
const TYPE_EXTERNAL_VOLTAGE = 0x14;
const TYPE_NOISE = 0x15;
const TYPE_NOISE_SECONDARY = 0x16;
const BINARY_TEMPERATURE_MAGIC = "TTB1";
const BINARY_VOLTAGE_MAGIC = "TVB1";
const BINARY_EXTERNAL_VOLTAGE_MAGIC = "IVB1";
const BINARY_NOISE_MAGIC = "N1B1";
const BINARY_NOISE_SECONDARY_MAGIC = "N2B1";
const NOISE_BITS = 5;
const NOISE_CLI_MAX_SAMPLES = 176;
const NOISE_BINARY_MAX_SAMPLES = 255;
const BINARY_HEADER_SIZE = 19;
const BINARY_MAX_SAMPLES = 165;
const BINARY_EXTERNAL_HEADER_SIZE = 20;
@@ -25,6 +32,8 @@
<button type="button" data-telemetry-example="packetVoltage">Voltage packet</button>
<button type="button" data-telemetry-example="packetExternalVoltage">I2C voltage packet</button>
<button type="button" data-telemetry-example="externalVoltage">I2C voltage CLI page</button>
<button type="button" data-telemetry-example="packetNoise">Radio1 noise packet</button>
<button type="button" data-telemetry-example="noiseSecondary">Radio2 noise CLI page</button>
</div>
<label for="telemetry-reply-input">Raw packet or payload hex</label>
@@ -60,6 +69,7 @@
<button type="button" data-role="download">Download CSV</button>
</div>
<dl class="telemetry-summary" data-role="summary"></dl>
<div class="telemetry-noise-chart" data-role="chart" hidden></div>
<div class="telemetry-warnings" data-role="warnings" hidden>
<strong>Decode notes</strong>
<ul data-role="warning-list"></ul>
@@ -87,6 +97,16 @@
reply:
"3E00495642311122334455667788800092651E00020800000004019026927109C427107FFF",
}),
packetNoise: Object.freeze({
label: "Analyzer radio1 noise-floor packet",
command: "send telemetry.tx now",
reply: "3E004E3142311122334455667788800092650F00080044CB77DF",
}),
noiseSecondary: Object.freeze({
label: "Radio2 noise-floor CLI page",
command: "get telemetry.noise2",
reply: "> FoAAkmUPCABEy3ff",
}),
temperature: Object.freeze({
label: "Temperature example",
command: "get telemetry.temp",
@@ -120,6 +140,15 @@
}
}
function stripBase64ReplyPrefix(line) {
return line.trim().replace(/^[^|\r\n]{2}\|\s*/, "").replace(/^>\s*/, "");
}
function isBase64ReplyLine(line) {
return /^(?:[^|\r\n]{2}\||>)/.test(line)
&& /^[A-Za-z0-9+/]+={0,2}$/.test(stripBase64ReplyPrefix(line));
}
function extractBase64(input) {
if (typeof input !== "string" || input.trim() === "") {
throw new TelemetryDecodeError("Paste a telemetry reply or Base64 payload first.");
@@ -133,15 +162,16 @@
const replyLine = text
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => /^>\s*[A-Za-z0-9+/]+={0,2}$/.test(line));
.find(isBase64ReplyLine);
if (replyLine) {
text = replyLine.replace(/^>\s*/, "");
text = stripBase64ReplyPrefix(replyLine);
} else {
text = text
.replace(/^```(?:text)?\s*/i, "")
.replace(/```\s*$/, "")
.trim()
.replace(/^[^|\r\n]{2}\|\s*/, "")
.replace(/^>\s*/, "")
.replace(/\s+/g, "");
}
@@ -164,8 +194,8 @@
const replies = input
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => /^>\s*[A-Za-z0-9+/]+={0,2}$/.test(line))
.map((line) => line.replace(/^>\s*/, ""));
.filter(isBase64ReplyLine)
.map(stripBase64ReplyPrefix);
return replies.length === 0 ? [extractBase64(input)] : replies;
}
@@ -204,6 +234,19 @@
return value;
}
function noiseProfile(type) {
if (type === TYPE_NOISE || type === BINARY_NOISE_MAGIC) return 1;
if (type === TYPE_NOISE_SECONDARY || type === BINARY_NOISE_SECONDARY_MAGIC) return 2;
return null;
}
function binaryMagicSupported(magic) {
return magic === BINARY_TEMPERATURE_MAGIC
|| magic === BINARY_VOLTAGE_MAGIC
|| magic === BINARY_EXTERNAL_VOLTAGE_MAGIC
|| noiseProfile(magic) !== null;
}
function extractHexBytes(input) {
if (typeof input !== "string" || input.trim() === "") {
throw new TelemetryDecodeError("Paste raw packet hex from the analyzer first.");
@@ -246,9 +289,7 @@
if (firstValid === null) firstValid = bytes;
for (let offset = 0; offset <= bytes.length - 4; offset += 1) {
const magic = asciiAt(bytes, offset, 4);
if (magic === BINARY_TEMPERATURE_MAGIC
|| magic === BINARY_VOLTAGE_MAGIC
|| magic === BINARY_EXTERNAL_VOLTAGE_MAGIC) {
if (binaryMagicSupported(magic)) {
return bytes;
}
}
@@ -256,7 +297,7 @@
if (firstValid) {
throw new TelemetryDecodeError(
"Hex was found, but it does not contain TTB1, TVB1, or IVB1 telemetry."
"Hex was found, but it does not contain TTB1, TVB1, IVB1, N1B1, or N2B1 telemetry."
);
}
throw new TelemetryDecodeError(
@@ -360,9 +401,10 @@
function decodeBinarySnapshot(bytes, offset) {
const magic = asciiAt(bytes, offset, 4);
const externalVoltage = magic === BINARY_EXTERNAL_VOLTAGE_MAGIC;
const profile = noiseProfile(magic);
const headerSize = externalVoltage
? BINARY_EXTERNAL_HEADER_SIZE : BINARY_HEADER_SIZE;
const maximumSamples = externalVoltage
const maximumSamples = profile ? NOISE_BINARY_MAX_SAMPLES : externalVoltage
? BINARY_EXTERNAL_MAX_SAMPLES : BINARY_MAX_SAMPLES;
if (bytes.length - offset < headerSize) {
throw new TelemetryDecodeError("The binary telemetry header is incomplete.");
@@ -377,7 +419,7 @@
if (intervalMinutes === 0) {
throw new TelemetryDecodeError("Binary telemetry sample interval cannot be zero.");
}
const dataLength = externalVoltage
const dataLength = profile ? Math.ceil(count * NOISE_BITS / 8) : externalVoltage
? Math.ceil((count * EXTERNAL_VOLTAGE_BITS) / 8) : count;
const payloadLength = headerSize + dataLength;
if (bytes.length - offset < payloadLength) {
@@ -400,6 +442,13 @@
packet: decodePacketEnvelope(bytes, offset),
channel: externalVoltage ? bytes[offset + 18] : null,
};
if (profile) {
assertLength(bytes, offset + payloadLength, "Noise-floor");
if (offset > 0 && (!header.packet || !header.packet.isRawCustom)) {
throw new TelemetryDecodeError("Noise-floor telemetry must be a payload or a complete RAW_CUSTOM packet.");
}
return decodeNoise(bytes, header, offset + headerSize, NOISE_BINARY_MAX_SAMPLES);
}
const rows = [];
for (let index = 0; index < count; index += 1) {
const rawCode = externalVoltage
@@ -484,9 +533,7 @@
let lastError = null;
for (let offset = 0; offset <= bytes.length - 4; offset += 1) {
const magic = asciiAt(bytes, offset, 4);
if (magic !== BINARY_TEMPERATURE_MAGIC
&& magic !== BINARY_VOLTAGE_MAGIC
&& magic !== BINARY_EXTERNAL_VOLTAGE_MAGIC) continue;
if (!binaryMagicSupported(magic)) continue;
try {
decodedCandidates.push(decodeBinarySnapshot(bytes, offset));
} catch (error) {
@@ -619,6 +666,45 @@
};
}
function decodeNoise(bytes, header, dataOffset = 7,
maximumSamples = NOISE_CLI_MAX_SAMPLES) {
if (header.intervalMinutes !== 15) {
throw new TelemetryDecodeError("Noise-floor telemetry requires a 15-minute sample interval.");
}
if (header.count < 1 || header.count > maximumSamples) {
throw new TelemetryDecodeError(
`Noise-floor sample count must be between 1 and ${maximumSamples}.`
);
}
const dataBytes = Math.ceil(header.count * NOISE_BITS / 8);
assertLength(bytes, dataOffset + dataBytes, "Noise-floor");
const paddingBits = dataBytes * 8 - header.count * NOISE_BITS;
if (paddingBits && (bytes[bytes.length - 1] & ((1 << paddingBits) - 1))) {
throw new TelemetryDecodeError("Noise-floor padding bits must be zero.");
}
const profile = noiseProfile(header.typeCode);
const rows = [];
for (let index = 0; index < header.count; index += 1) {
const rawCode = readPackedBits(bytes, dataOffset, index * NOISE_BITS, NOISE_BITS);
rows.push({
index,
epoch: sampleEpoch(header, index),
status: rawCode === 30 ? "MAX" : rawCode === 31 ? "Missing" : "Value",
rawCode,
noiseDbm: rawCode >= 30 ? null
: rawCode <= 2 ? -120 + rawCode : -118 + (rawCode - 2) * 2,
});
}
return {
...header,
profile,
kind: "noise",
label: `Radio${profile} noise floor`,
rows,
warnings: [],
};
}
function decodeGps(bytes, header) {
if (bytes.length < 17) {
throw new TelemetryDecodeError("GPS payload is shorter than its 17-byte header.");
@@ -754,9 +840,11 @@
decoded = decodeGps(bytes, header);
} else if (header.typeCode === TYPE_EXTERNAL_VOLTAGE) {
decoded = decodeExternalVoltage(bytes, header);
} else if (noiseProfile(header.typeCode)) {
decoded = decodeNoise(bytes, header);
} else {
throw new TelemetryDecodeError(
`Unsupported telemetry type 0x${header.typeCode.toString(16).padStart(2, "0")}; expected 0x11, 0x12, 0x13, or 0x14.`
`Unsupported telemetry type 0x${header.typeCode.toString(16).padStart(2, "0")}; expected 0x11 through 0x16.`
);
}
decoded.encoded = encoded;
@@ -769,11 +857,12 @@
decodedItems.slice(1).forEach((item) => {
const sameSource = (item.sourceId || null) === (first.sourceId || null);
const sameChannel = (item.channel || null) === (first.channel || null);
const sameProfile = (item.profile || null) === (first.profile || null);
if (item.kind !== first.kind || item.typeCode !== first.typeCode
|| item.intervalMinutes !== first.intervalMinutes
|| !sameSource || !sameChannel) {
|| !sameSource || !sameChannel || !sameProfile) {
throw new TelemetryDecodeError(
"Multiple telemetry replies must use the same type, source, channel, and interval."
"Multiple telemetry replies must use the same type, source, channel, and interval, including the radio profile."
);
}
});
@@ -812,7 +901,7 @@
function decodeTelemetry(input) {
const rawLines = typeof input === "string"
? input.split(/\r?\n/).filter(
(line) => /54544231|54564231|49564231/i.test(line)
(line) => /54544231|54564231|49564231|4e314231|4e324231/i.test(line)
)
: [];
if (rawLines.length > 1) {
@@ -829,7 +918,7 @@
.replace(/^['"]|['",;]$/g, "")
: "";
if ((/^[0-9a-f]+$/i.test(compactHex) && compactHex.length % 2 === 0)
|| /54544231|54564231|49564231/i.test(String(input))) {
|| /54544231|54564231|49564231|4e314231|4e324231/i.test(String(input))) {
return decodeRawTelemetryHex(input);
}
return mergeDecodedTelemetry(
@@ -857,6 +946,23 @@
(Intl.DateTimeFormat().resolvedOptions() || {}).timeZone || "browser local";
const timestampHeader = localTime ? `Timestamp (${localZone})` : "Timestamp (UTC)";
if (decoded.kind === "noise") {
return {
headers: ["#", timestampHeader, "Status", decoded.label, "Raw code"],
csvHeaders: ["sample", localTime ? `timestamp (${localZone})` : "timestamp_utc",
"radio_profile", "status", "noise_floor_dbm", "raw_code"],
rows: decoded.rows.map((row) => ({
muted: row.status === "Missing",
values: [row.index + 1, timestampText(row.epoch, localTime), row.status,
row.status === "MAX" ? ">-64 dBm"
: row.noiseDbm === null ? "—" : `${row.noiseDbm} dBm`, row.rawCode],
csvValues: [row.index + 1, timestampText(row.epoch, localTime), decoded.profile,
row.status.toLowerCase(), row.noiseDbm === null ? "" : row.noiseDbm,
row.rawCode],
})),
};
}
if (decoded.kind === "temperature") {
return {
headers: [
@@ -977,6 +1083,9 @@
if (decoded.channel) {
items.splice(1, 0, ["LPP channel", String(decoded.channel)]);
}
if (decoded.profile) {
items.splice(1, 0, ["Radio profile", `radio${decoded.profile}`]);
}
if (decoded.sourceId) {
items.splice(1, 0, ["Source ID", decoded.sourceId]);
items.push([
@@ -1014,11 +1123,80 @@
}
function modelToCsv(model) {
return [model.headers, ...model.rows.map((row) => row.values)]
return [model.csvHeaders || model.headers,
...model.rows.map((row) => row.csvValues || row.values)]
.map((row) => row.map(csvCell).join(","))
.join("\r\n");
}
function noiseChartModel(decoded) {
if (decoded.kind !== "noise") return null;
const first = decoded.rows[0].epoch;
const last = decoded.rows[decoded.rows.length - 1].epoch;
const span = Math.max(900, last - first);
const segments = [];
let segment = [];
let previousEpoch = null;
decoded.rows.forEach((row) => {
if (row.noiseDbm === null || (previousEpoch !== null && row.epoch - previousEpoch !== 900)) {
if (segment.length) segments.push(segment);
segment = [];
}
if (row.noiseDbm !== null) {
segment.push({
x: 50 + (row.epoch - first) * 570 / span,
y: 150 - (row.noiseDbm + 120) * 130 / 56,
label: `${timestampText(row.epoch, false)}: ${row.noiseDbm} dBm`,
});
}
previousEpoch = row.epoch;
});
if (segment.length) segments.push(segment);
return {
segments,
description: `${decoded.label}. Numeric readings only; MAX and missing samples leave gaps.`,
};
}
function renderNoiseChart(container, decoded) {
container.replaceChildren();
const model = noiseChartModel(decoded);
container.hidden = model === null;
if (!model) return;
const svgElement = (tag, attributes = {}) => {
const node = document.createElementNS("http://www.w3.org/2000/svg", tag);
Object.entries(attributes).forEach(([name, value]) => node.setAttribute(name, String(value)));
return node;
};
const svg = svgElement("svg", { viewBox: "0 0 640 175", role: "img",
"aria-label": model.description });
for (const [value, y] of [[-64, 20], [-92, 85], [-120, 150]]) {
svg.appendChild(svgElement("line", { x1: 50, x2: 620, y1: y, y2: y,
class: "telemetry-chart-grid" }));
const label = svgElement("text", { x: 42, y: y + 4, "text-anchor": "end" });
label.textContent = `${value}`;
svg.appendChild(label);
}
model.segments.forEach((segment) => {
svg.appendChild(svgElement("polyline", {
points: segment.map((point) => `${point.x},${point.y}`).join(" "),
class: "telemetry-chart-line",
}));
segment.forEach((point) => {
const dot = svgElement("circle", { cx: point.x, cy: point.y, r: 2,
class: "telemetry-chart-point" });
const title = svgElement("title");
title.textContent = point.label;
dot.appendChild(title);
svg.appendChild(dot);
});
});
container.appendChild(svg);
const caption = document.createElement("p");
caption.textContent = "Noise floor (dBm), oldest to newest. MAX (>−64 dBm) and missing samples leave gaps.";
container.appendChild(caption);
}
function appendCell(row, tagName, value) {
const cell = document.createElement(tagName);
cell.textContent = String(value);
@@ -1041,6 +1219,7 @@
const results = root.querySelector("[data-role='results']");
const resultTitle = root.querySelector("[data-role='result-title']");
const summary = root.querySelector("[data-role='summary']");
const chart = root.querySelector("[data-role='chart']");
const warningBox = root.querySelector("[data-role='warnings']");
const warningList = root.querySelector("[data-role='warning-list']");
const table = root.querySelector("[data-role='table']");
@@ -1068,6 +1247,7 @@
const useLocalTime = localTime.checked;
const model = tableModel(current, useLocalTime);
resultTitle.textContent = `${current.label} telemetry`;
renderNoiseChart(chart, current);
summary.replaceChildren();
summaryItems(current, useLocalTime).forEach(([term, description]) => {
@@ -1150,7 +1330,8 @@
link.href = url;
const sourcePart = current.sourceId ? `${current.sourceId.toLowerCase()}-` : "";
const channelPart = current.channel ? `-ch${current.channel}` : "";
link.download = `meshcore-telemetry-${sourcePart}${current.kind}${channelPart}.csv`;
const profilePart = current.profile ? `-radio${current.profile}` : "";
link.download = `meshcore-telemetry-${sourcePart}${current.kind}${channelPart}${profilePart}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
@@ -1175,6 +1356,7 @@
mergeDecodedTelemetry,
tableModel,
modelToCsv,
noiseChartModel,
});
global.MeshCoreTelemetryDecoder = api;
+32
View File
@@ -184,6 +184,38 @@
border-radius: 0.4rem;
}
.telemetry-noise-chart {
margin: 1rem 0;
color: var(--md-default-fg-color);
}
.telemetry-noise-chart svg {
display: block;
width: 100%;
max-height: 18rem;
font: 12px sans-serif;
fill: currentColor;
}
.telemetry-noise-chart p {
margin: 0.2rem 0;
font-size: 0.78rem;
}
.telemetry-chart-grid {
stroke: var(--telemetry-border);
}
.telemetry-chart-line {
fill: none;
stroke: var(--md-primary-fg-color);
stroke-width: 2;
}
.telemetry-chart-point {
fill: var(--md-primary-fg-color);
}
.telemetry-tool .telemetry-table {
display: table;
width: 100%;
+72 -6
View File
@@ -749,6 +749,11 @@ default; repeaters request a seven-day default at startup. Builds without a GPS
provider omit the GPS history commands to conserve flash. All history and any
runtime retention change are held in RAM and reset after a reboot.
Radio noise-floor history is recorded separately every 15 minutes, retaining
768 positions (eight rolling days) per profile. It uses idle RSSI calibration
estimates rather than received-packet RSSI. Main-radio and radio2 history are
kept separate; failed measurements retain a Missing position in the timeline.
The feature is omitted from flash-constrained STM32 repeater and room images.
**Usage:**
@@ -757,6 +762,8 @@ The feature is omitted from flash-constrained STM32 repeater and room images.
- `get telemetry.volt [page]`
- `get telemetry.volt.i2c [channel [page]]`
- `get telemetry.gps [page]`
- `get telemetry.noise [page]`
- `get telemetry.noise2 [page]`
- `set telemetry.gps <days>`
- `get telemetry.tx`
- `set telemetry.tx <off|direct|path>`
@@ -773,7 +780,9 @@ The feature is omitted from flash-constrained STM32 repeater and room images.
- `page`: Page `1` is always newest. Temperature and voltage pages each hold
24 hours and accept `1`-`7`. GPS pages each hold 12 hours and accept `1`
through twice the current GPS retention in days. Omitting the page selects
page `1`.
page `1`. Noise pages contain up to 176 quarter-hour positions (44 hours)
and accept only pages already collected, up to `1`-`5`. Use `telemetry.noise`
for the main profile and `telemetry.noise2` for radio2.
- `channel`: Cayenne LPP channel assigned to an external voltage monitor.
`get telemetry.volt.i2c` lists channels that have at least one non-zero
sample. Supplying a channel returns 48 points (24 hours) per page; pages
@@ -803,11 +812,24 @@ voltage monitors, and an already-valid onboard GPS fix. It does not wake GPS,
so an off, sleeping, or unfixed GPS produces a missing location sample without
changing its power-saving schedule.
Noise history combines three fresh estimates collected roughly five minutes
apart in each UTC-aligned quarter-hour. If any is at or below `-100 dBm`, use
the minimum. Otherwise, if any is at or above `-80 dBm`, use the maximum.
Otherwise, average all three. Thus a bucket containing both low and high
values uses the minimum. Minimums round down, maximums round up, and averages
round to the nearest encoded step. Incomplete buckets are Missing. A busy
channel can prevent a fresh estimate; old calibration results are not reused
as fresh measurements.
External voltage history allocates 360 bytes for each detected monitor channel
(1,080 bytes for all three INA3221 inputs). The allocation reserves all
detected inputs so a sensor connected later can start recording, but all-zero
inputs are omitted from command replies and LoRa transmission.
Packed noise history uses 480 bytes per retained profile, plus its collection
state. It does not add a 15-minute transmission schedule. USB and administrator
LoRa CLI return the same Base64 format in the existing reply buffer.
Replies contain `> ` followed by standard padded Base64. After decoding, all
multi-byte integers are little-endian. Packed fields are written most
significant bit first, oldest sample first.
@@ -825,7 +847,8 @@ shared path and enables the default `2d` schedule; new configuration should use
An automatic run waits until 165 half-hour positions are
available, then sends one maximum-size temperature packet, one maximum-size
battery-voltage packet, and up to three `IVB1` packets for every populated I2C
voltage channel. GPS is never included in this raw transmission. The history
voltage channel, plus the collected noise-floor snapshots. GPS is never included
in this raw transmission. The history
remains boot-local, so the first scheduled run after a reboot needs about 82.5
hours to fill. On the default two-day interval, each 82.5-hour temperature or
battery packet overlaps its predecessor by 34.5 hours. Packets are paced two
@@ -834,14 +857,19 @@ that did not queue; successfully queued packets are not duplicated.
`send telemetry.tx now` is an administrator-only test action. It queues
temperature and battery-voltage snapshots plus the available I2C voltage
chunks over the configured path. It uses all currently available base
chunks over the configured path. It also queues one main-radio `N1B1` packet
once a noise bucket has completed and one `N2B1` packet when radio2 is enabled
and has a completed bucket. Missing positions are included; an all-Missing
history is still sent. It uses all currently available base
positions up to 165 and all available I2C positions up to 192 per populated
channel. It works while the schedule is off and does not move the next
channel, plus the latest 255 positions per noise profile (63 hours 45 minutes).
It works while the schedule is off and does not move the next
scheduled send time. RAW_CUSTOM direct packets do not enter the normal
encrypted direct-message retry mechanism.
Full snapshots are 184-byte RAW_CUSTOM payloads. Manual tests can be shorter
while history fills. Both formats are binary, not Base64 and not encrypted:
Full temperature and battery snapshots are 184-byte RAW_CUSTOM payloads.
Manual tests can be shorter while history fills. Both formats are binary,
not Base64 and not encrypted:
| Bytes | Meaning |
|---|---|
@@ -857,6 +885,14 @@ for above `+77 C`. Codes `3` through `130` represent exact whole degrees from
`-50 C` through `+77 C`; decode them as `code - 53`. Voltage codes use the
same encoding as the paged voltage payload documented below.
Noise snapshots reuse this 19-byte telemetry header. Their magic is `N1B1`
for the main radio or `N2B1` for radio2; the interval is `15`, the count is
`1`-`255`, and data begins at byte `19`. Samples are packed 5-bit codes,
oldest first and most significant bit first, with zero padding in the last
byte. The payload is `19 + ceil(count * 5 / 8)` bytes: 179 bytes for 255
positions. No GPS coordinates are included in a noise packet. Noise packets
are additional RAW_CUSTOM telemetry snapshots, not a new MeshCore packet type.
External voltage snapshots use a separate packed layout. A full four-day
channel takes three 140-byte `IVB1` payloads of 64 points each:
@@ -925,6 +961,36 @@ External I2C voltage payload (`0x14`, 98 bytes):
The codes have the same `0.02 V` through `655.34 V` meaning as `IVB1`.
Noise-floor payload (`0x15` main radio, `0x16` radio2; up to 117 bytes):
| Bytes | Meaning |
|---|---|
| `0` | Format/type `0x15` or `0x16` |
| `1`-`4` | First sample UTC epoch, unsigned 32-bit |
| `5` | Sample interval in minutes (`15`) |
| `6` | Sample count (`1`-`176`) |
| `7` onward | Oldest-to-newest packed 5-bit noise-floor codes |
The same codes are used by `N1B1` and `N2B1`:
| Code | Meaning |
|---|---|
| `0` | `-120 dBm` (also clamps lower values) |
| `1` | `-119 dBm` |
| `2`-`29` | `-118` through `-64 dBm`, in 2 dB steps |
| `30` | MAX: above `-64 dBm` |
| `31` | Missing: fewer than three fresh, valid estimates |
The last byte's unused low bits are zero. A full page encodes as 156 Base64
characters, plus the existing `> ` reply prefix. The decoder keeps radio1 and
radio2 separate and rejects wrong lengths, intervals, or padding. MAX is a
status, not an invented numeric `-62 dBm` reading.
When a Companion CLI command includes its three-character correlation prefix
(for example `01|`), noise replies retain that prefix and omit the `> ` prompt.
A full `01|Base64` response then fits in 160 bytes including its terminating
NUL. The decoder accepts either reply style.
GPS payload (`0x13`, 101 bytes):
| Bytes | Meaning |
+47 -6
View File
@@ -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.
+54 -2
View File
@@ -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
+5
View File
@@ -126,6 +126,7 @@
#if MESH_ENABLE_TELEMETRY_HISTORY
#include <helpers/ExternalVoltageHistory.h>
#include <helpers/TelemetryHistory.h>
#include <helpers/NoiseFloorHistorySampler.h>
#endif
#include <helpers/TxtDataHelpers.h>
#include <helpers/RegionMap.h>
@@ -575,6 +576,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
#if MESH_ENABLE_TELEMETRY_HISTORY
mesh::TelemetryHistory telemetry_history;
mesh::ExternalVoltageHistory external_voltage_history;
mesh::NoiseFloorHistory noise_floor_history;
mesh::NoiseFloorHistorySampler noise_floor_sampler;
bool telemetry_history_tx_prefs_healthy;
bool telemetry_history_tx_enabled;
uint8_t telemetry_history_tx_interval_days;
@@ -859,6 +862,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
void servicePostMeshLoop();
#if MESH_ENABLE_TELEMETRY_HISTORY
void sampleTelemetryHistory();
void sampleNoiseFloorHistory();
#if MESH_ENABLE_TELEMETRY_GPS_HISTORY
uint8_t resizeTelemetryGpsDays(uint8_t requested_days);
#endif
@@ -866,6 +870,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
bool saveTelemetryHistoryTxPrefs();
void serviceTelemetryHistoryTx();
bool sendTelemetryHistorySnapshot(mesh::TelemetryHistory::Series series);
bool sendNoiseFloorHistorySnapshot(uint8_t profile);
bool sendExternalVoltageHistorySnapshot(uint8_t channel_index,
uint8_t chunk_index);
void formatTelemetryHistoryTxStatus(char* reply, size_t reply_size) const;
+25 -4
View File
@@ -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
+6 -1
View File
@@ -66,6 +66,7 @@ namespace mesh { class RoomJsonWriter; }
#if MESH_ENABLE_TELEMETRY_HISTORY
#include <helpers/ExternalVoltageHistory.h>
#include <helpers/TelemetryHistory.h>
#include <helpers/NoiseFloorHistorySampler.h>
#endif
#ifdef WITH_MQTT_BRIDGE
@@ -253,6 +254,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
#if MESH_ENABLE_TELEMETRY_HISTORY
mesh::TelemetryHistory telemetry_history;
mesh::ExternalVoltageHistory external_voltage_history;
mesh::NoiseFloorHistory noise_floor_history;
mesh::NoiseFloorHistorySampler noise_floor_sampler;
#endif
RegionEntry* load_stack[8];
RegionEntry* recv_pkt_region;
@@ -493,8 +496,10 @@ protected:
bool sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size);
#if MESH_ENABLE_TELEMETRY_HISTORY
bool handleTelemetryHistoryCommand(const char* command, char* reply);
bool handleTelemetryHistoryCommand(const char* command, char* reply,
size_t reply_capacity = 160, bool include_prompt = true);
void sampleTelemetryHistory();
void sampleNoiseFloorHistory();
#endif
public:
+2 -2
View File
@@ -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
+188 -1
View File
@@ -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`);
+10
View File
@@ -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
+320
View File
@@ -0,0 +1,320 @@
#pragma once
#include <math.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
namespace mesh {
// Boot-local, UTC-aligned noise history. Only completed quarter-hours are
// published; each needs a fresh estimate in all three five-minute subslots.
class NoiseFloorHistory {
public:
static constexpr uint8_t PROFILE_COUNT = 2;
static constexpr uint8_t SAMPLE_BITS = 5;
static constexpr uint8_t MISSING = 31;
static constexpr uint8_t MAX = 30;
static constexpr uint32_t SAMPLE_INTERVAL_SECONDS = 900;
static constexpr uint32_t READ_INTERVAL_SECONDS = 300;
static constexpr uint16_t SAMPLES_PER_DAY = 96;
static constexpr uint8_t RETENTION_DAYS = 8;
static constexpr uint16_t RETENTION_SAMPLES = SAMPLES_PER_DAY * RETENTION_DAYS;
static constexpr size_t BYTES_PER_PROFILE = RETENTION_SAMPLES * SAMPLE_BITS / 8U;
static constexpr uint8_t CLI_PAGE_SAMPLES = 176;
static constexpr uint8_t PAGE_COUNT = 5;
static constexpr size_t PAGE_HEADER_SIZE = 7;
static constexpr uint8_t PAGE_PAYLOAD_TYPE_V1 = 0x15;
static constexpr uint8_t RADIO2_PAGE_PAYLOAD_TYPE_V1 = 0x16;
static constexpr size_t BINARY_HEADER_SIZE = 19;
static constexpr size_t BINARY_SOURCE_ID_SIZE = 8;
static constexpr uint8_t BINARY_MAX_SAMPLES = 255;
static constexpr size_t BINARY_PAYLOAD_SIZE = 184;
NoiseFloorHistory() { clear(); }
void clear() {
memset(_samples, 0xFF, sizeof(_samples));
memset(_pending, 0, sizeof(_pending));
_next = _count = 0;
_bucket = 0;
_has_bucket = false;
}
// Call with current RTC time before handing over newly published estimates.
// A clock reset discards ambiguous timestamps. Forward gaps are bounded by
// retention, so an RTC correction cannot cause an unbounded catch-up loop.
void advance(uint32_t epoch_seconds) {
const uint32_t bucket = epoch_seconds / SAMPLE_INTERVAL_SECONDS;
if (!_has_bucket) {
_bucket = bucket;
_has_bucket = true;
return;
}
if (bucket == _bucket) return;
if (bucket < _bucket || bucket - _bucket > RETENTION_SAMPLES) {
clear();
_bucket = bucket;
_has_bucket = true;
return;
}
const uint32_t elapsed = bucket - _bucket;
append(aggregate(_pending[0]), aggregate(_pending[1]));
for (uint32_t i = 1; i < elapsed; ++i) append(MISSING, MISSING);
for (uint8_t profile = 0; profile < PROFILE_COUNT; ++profile) {
_pending[profile].mask = 0;
}
_bucket = bucket;
}
// Publication IDs are the radio's millis() timestamp, not the polling time.
// Repeated reads of one calibration must never fill multiple subslots.
bool observe(uint8_t profile, uint32_t measured_epoch, float dbm,
uint32_t publication_id) {
if (profile >= PROFILE_COUNT || !isfinite(dbm) || dbm < -200.0f
|| dbm > 0.0f || !_has_bucket
|| measured_epoch / SAMPLE_INTERVAL_SECONDS != _bucket) return false;
Pending& pending = _pending[profile];
if (pending.has_publication) {
if (publication_id == pending.publication_id
|| measured_epoch < pending.measured_epoch) return false;
// Within one RTC second, preserve publication ordering across millis wrap.
if (measured_epoch == pending.measured_epoch
&& (uint32_t)(publication_id - pending.publication_id) >= 0x80000000UL) {
return false;
}
}
pending.has_publication = true;
pending.publication_id = publication_id;
pending.measured_epoch = measured_epoch;
const uint8_t slot = (uint8_t)((measured_epoch % SAMPLE_INTERVAL_SECONDS)
/ READ_INTERVAL_SECONDS);
pending.values[slot] = dbm;
pending.mask |= (uint8_t)(1U << slot);
return true;
}
// Retuning/disabling a profile cannot combine estimates from two channels.
// Keep the publication watermark and completed history for deduplication.
void invalidateProfile(uint8_t profile) {
if (profile < PROFILE_COUNT) _pending[profile].mask = 0;
}
uint16_t sampleCount(uint8_t profile) const {
return profile < PROFILE_COUNT ? _count : 0;
}
bool hasValidSamples(uint8_t profile) const {
if (profile >= PROFILE_COUNT) return false;
for (uint16_t offset = 0; offset < _count; ++offset) {
if (sampleCodeAtOffset(profile, offset) != MISSING) return true;
}
return false;
}
static constexpr size_t storageBytes() { return PROFILE_COUNT * BYTES_PER_PROFILE; }
uint8_t sampleCodeAtOffset(uint8_t profile, uint16_t offset) const {
if (profile >= PROFILE_COUNT || offset >= _count) return MISSING;
const uint16_t index = (uint16_t)((_next + RETENTION_SAMPLES - 1U - offset)
% RETENTION_SAMPLES);
const size_t first_bit = (size_t)index * SAMPLE_BITS;
uint8_t code = 0;
for (uint8_t bit = 0; bit < SAMPLE_BITS; ++bit) {
const size_t absolute = first_bit + bit;
code = (uint8_t)((code << 1U)
| ((_samples[profile][absolute / 8U] >> (7U - absolute % 8U)) & 1U));
}
return code;
}
// Page 1 contains the newest 176 completed positions, oldest first. The
// oldest page is shortened to actual retained history, never padded preboot.
// Correlated CLI replies already have a prompt prefix and use bare Base64.
bool formatPageReply(uint8_t profile, const char* args, char* reply,
size_t reply_size = 160, bool include_prompt = true) const {
if (reply == NULL || reply_size == 0) return false;
reply[0] = 0;
if (profile >= PROFILE_COUNT) return error(reply, reply_size, "Err - invalid radio profile");
unsigned page = 1;
const char* cursor = args == NULL ? "" : args;
while (*cursor == ' ') ++cursor;
if (*cursor != 0) {
page = 0;
while (*cursor >= '0' && *cursor <= '9' && page <= PAGE_COUNT) {
page = page * 10U + (unsigned)(*cursor++ - '0');
}
while (*cursor == ' ') ++cursor;
if (*cursor != 0 || page < 1 || page > PAGE_COUNT) {
return error(reply, reply_size, "Err - noise history page must be 1-5");
}
}
const uint16_t newest_offset = (uint16_t)((page - 1U) * CLI_PAGE_SAMPLES);
if (_count == 0 || newest_offset >= _count) {
return error(reply, reply_size, "Err - noise history page is empty");
}
uint16_t count = (uint16_t)(_count - newest_offset);
if (count > CLI_PAGE_SAMPLES) count = CLI_PAGE_SAMPLES;
const uint16_t oldest_offset = (uint16_t)(newest_offset + count - 1U);
uint8_t payload[PAGE_HEADER_SIZE + (CLI_PAGE_SAMPLES * SAMPLE_BITS + 7U) / 8U];
const size_t length = PAGE_HEADER_SIZE + (count * SAMPLE_BITS + 7U) / 8U;
memset(payload, 0, length);
payload[0] = profile == 0 ? PAGE_PAYLOAD_TYPE_V1 : RADIO2_PAGE_PAYLOAD_TYPE_V1;
putUint32LE(payload + 1, startEpoch(oldest_offset));
payload[5] = (uint8_t)(SAMPLE_INTERVAL_SECONDS / 60U);
payload[6] = (uint8_t)count;
pack(profile, oldest_offset, count, payload + PAGE_HEADER_SIZE);
return base64Reply(payload, length, reply, reply_size, include_prompt);
}
// Reuses the telemetry RAW_CUSTOM envelope: magic, source ID, epoch,
// uint16 interval in minutes, uint8 count; five-bit samples follow it.
size_t formatBinarySnapshot(uint8_t profile,
const uint8_t source_id[BINARY_SOURCE_ID_SIZE], uint8_t* dest,
size_t dest_size) const {
if (profile >= PROFILE_COUNT || source_id == NULL || dest == NULL
|| dest_size <= BINARY_HEADER_SIZE || _count == 0) return 0;
size_t available = dest_size - BINARY_HEADER_SIZE;
if (available > BINARY_PAYLOAD_SIZE - BINARY_HEADER_SIZE) {
available = BINARY_PAYLOAD_SIZE - BINARY_HEADER_SIZE;
}
uint16_t count = (uint16_t)(available * 8U / SAMPLE_BITS);
if (count > BINARY_MAX_SAMPLES) count = BINARY_MAX_SAMPLES;
if (count > _count) count = _count;
if (count == 0) return 0;
const size_t length = BINARY_HEADER_SIZE + (count * SAMPLE_BITS + 7U) / 8U;
memset(dest, 0, length);
memcpy(dest, profile == 0 ? "N1B1" : "N2B1", 4);
memcpy(dest + 4, source_id, BINARY_SOURCE_ID_SIZE);
putUint32LE(dest + 12, startEpoch((uint16_t)(count - 1U)));
dest[16] = (uint8_t)(SAMPLE_INTERVAL_SECONDS / 60U);
dest[17] = 0;
dest[18] = (uint8_t)count;
pack(profile, (uint16_t)(count - 1U), count, dest + BINARY_HEADER_SIZE);
return length;
}
private:
struct Pending {
float values[3];
uint32_t publication_id;
uint32_t measured_epoch;
uint8_t mask;
bool has_publication;
};
uint8_t _samples[PROFILE_COUNT][BYTES_PER_PROFILE];
Pending _pending[PROFILE_COUNT];
uint16_t _next;
uint16_t _count;
uint32_t _bucket;
bool _has_bucket;
static float decode(uint8_t code) {
return code == 0 ? -120.0f : code == 1 ? -119.0f : -122.0f + 2.0f * code;
}
// direction=-1 rounds minima down, +1 rounds maxima up, 0 is nearest.
static uint8_t encode(float value, int direction) {
if (value <= -120.0f) return 0;
if (value > -64.0f) return MAX;
uint8_t lower = 0;
for (uint8_t upper = 1; upper <= 29; ++upper) {
const float high = decode(upper);
if (value > high) { lower = upper; continue; }
if (value == high || direction > 0) return upper;
if (direction < 0) return lower;
return value - decode(lower) < high - value ? lower : upper;
}
return 29;
}
static uint8_t aggregate(const Pending& pending) {
if (pending.mask != 7) return MISSING;
float low = pending.values[0], high = low;
float sum = 0;
for (uint8_t i = 0; i < 3; ++i) {
const float value = pending.values[i];
if (value < low) low = value;
if (value > high) high = value;
sum += value;
}
if (low <= -100.0f) return encode(low, -1);
if (high >= -80.0f) return encode(high, 1);
return encode(sum / 3.0f, 0);
}
void append(uint8_t first, uint8_t second) {
const uint8_t codes[PROFILE_COUNT] = {first, second};
for (uint8_t profile = 0; profile < PROFILE_COUNT; ++profile) {
const size_t first_bit = (size_t)_next * SAMPLE_BITS;
for (uint8_t bit = 0; bit < SAMPLE_BITS; ++bit) {
const size_t absolute = first_bit + bit;
const uint8_t mask = (uint8_t)(1U << (7U - absolute % 8U));
if (codes[profile] & (1U << (SAMPLE_BITS - 1U - bit))) {
_samples[profile][absolute / 8U] |= mask;
} else {
_samples[profile][absolute / 8U] &= (uint8_t)~mask;
}
}
}
_next = (uint16_t)((_next + 1U) % RETENTION_SAMPLES);
if (_count < RETENTION_SAMPLES) ++_count;
}
uint32_t startEpoch(uint16_t offset) const {
return (_bucket - 1U - offset) * SAMPLE_INTERVAL_SECONDS;
}
void pack(uint8_t profile, uint16_t oldest_offset, uint16_t count,
uint8_t* dest) const {
for (uint16_t index = 0; index < count; ++index) {
const uint8_t code = sampleCodeAtOffset(profile, (uint16_t)(oldest_offset - index));
for (uint8_t bit = 0; bit < SAMPLE_BITS; ++bit) {
const size_t absolute = (size_t)index * SAMPLE_BITS + bit;
if (code & (1U << (SAMPLE_BITS - 1U - bit))) {
dest[absolute / 8U] |= (uint8_t)(1U << (7U - absolute % 8U));
}
}
}
}
static void putUint32LE(uint8_t* dest, uint32_t value) {
for (uint8_t i = 0; i < 4; ++i) dest[i] = (uint8_t)(value >> (8U * i));
}
static bool error(char* reply, size_t size, const char* text) {
if (reply != NULL && size != 0) snprintf(reply, size, "%s", text);
return false;
}
static bool base64Reply(const uint8_t* payload, size_t length, char* reply,
size_t size, bool include_prompt) {
static const char alphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const size_t encoded = ((length + 2U) / 3U) * 4U;
const size_t prefix = include_prompt ? 2U : 0U;
if (size < encoded + prefix + 1U) {
return error(reply, size, "Err - telemetry reply buffer too small");
}
if (include_prompt) {
reply[0] = '>';
reply[1] = ' ';
}
size_t output = prefix;
for (size_t i = 0; i < length; i += 3U) {
const size_t remaining = length - i;
const uint32_t value = ((uint32_t)payload[i] << 16U)
| (remaining > 1 ? (uint32_t)payload[i + 1] << 8U : 0)
| (remaining > 2 ? payload[i + 2] : 0);
reply[output++] = alphabet[(value >> 18U) & 63U];
reply[output++] = alphabet[(value >> 12U) & 63U];
reply[output++] = remaining > 1 ? alphabet[(value >> 6U) & 63U] : '=';
reply[output++] = remaining > 2 ? alphabet[value & 63U] : '=';
}
reply[output] = 0;
return true;
}
};
} // namespace mesh
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include "NoiseFloorHistory.h"
namespace mesh {
// Collect successful publications, not repeated reads of a cached estimate.
// Calibration stays in the radio's existing bounded, packet-safe state machine.
class NoiseFloorHistorySampler {
uint32_t _request_bucket = 0;
uint32_t _generation[2] = {};
bool _armed = false;
bool _enabled[2] = {};
bool _valid[2] = {};
public:
template<class RadioT>
void poll(RadioT& radio, NoiseFloorHistory& history,
uint32_t epoch, uint32_t now_ms) {
history.advance(epoch);
const auto* profiles = radio.profiles();
const uint32_t slot = epoch / NoiseFloorHistory::READ_INTERVAL_SECONDS;
bool fresh[2] = {};
for (uint8_t profile = 0; profile < 2; ++profile) {
const bool enabled = profile == 0 || (profiles && profiles->enabled());
const uint32_t generation = profiles ? profiles->generation[profile] : 0;
if (_enabled[profile] != enabled || _generation[profile] != generation) {
history.invalidateProfile(profile);
_valid[profile] = false;
}
_enabled[profile] = enabled;
_generation[profile] = generation;
float dbm = 0;
uint32_t published_ms = 0;
const bool valid = enabled
&& radio.getNoiseFloorReading(profile, dbm, published_ms)
&& isfinite(dbm) && dbm >= -200.0f && dbm <= 0.0f;
if (!valid && _valid[profile]) history.invalidateProfile(profile);
_valid[profile] = valid;
if (!valid) continue;
const uint32_t age_ms = now_ms - published_ms;
if (age_ms >= NoiseFloorHistory::READ_INTERVAL_SECONDS * 1000UL
|| age_ms / 1000UL > epoch) continue;
const uint32_t measured_epoch = epoch - age_ms / 1000UL;
history.observe(profile, measured_epoch, dbm, published_ms);
fresh[profile] = measured_epoch / NoiseFloorHistory::READ_INTERVAL_SECONDS == slot;
}
if (!_armed || _request_bucket != slot) {
_armed = true;
_request_bucket = slot;
if (!fresh[0] || (_enabled[1] && !fresh[1])) {
radio.requestNoiseFloorMeasurement();
}
}
}
};
} // namespace mesh
+2 -3
View File
@@ -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 {
+2 -3
View File
@@ -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 {
+2 -3
View File
@@ -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 {
+13
View File
@@ -271,6 +271,7 @@ void RadioLibWrapper::begin() {
_noise_floor_secondary_centi_dbm = 0;
_noise_floor_valid = false;
_noise_floor_secondary_valid = false;
_nf_published_valid = 0;
_threshold = 0;
_cad_enabled = false;
_rx_mode_checked_at = millis();
@@ -919,6 +920,7 @@ void RadioLibWrapper::serviceProfileScan() {
// values from two frequencies are never blended into one estimate.
_noise_floor_valid = false;
_noise_floor_secondary_valid = false;
_nf_published_valid = 0;
_nf_refresh_requested = true;
_nf_last_calib = 0;
_nf_calib_deadline = 0;
@@ -964,6 +966,7 @@ void RadioLibWrapper::serviceProfileScan() {
// the first visit on the replacement tuples.
_noise_floor_valid = false;
_noise_floor_secondary_valid = false;
_nf_published_valid = 0;
_nf_refresh_requested = true;
_nf_last_calib = 0;
_nf_calib_deadline = 0;
@@ -1262,6 +1265,7 @@ void RadioLibWrapper::recalibrateNoiseFloor() {
// available while discarding the old samples and rise-hold history.
_noise_floor_valid = false;
_noise_floor_secondary_valid = false;
_nf_published_valid = 0;
_nf_refresh_requested = true;
_nf_last_calib = 0;
_nf_lazy_refresh = _nf_refresh_attempted = false;
@@ -2079,11 +2083,17 @@ void RadioLibWrapper::loop() {
&& _secondary_floor_estimator.complete()) {
if (_floor_estimator.publish(_noise_floor_centi_dbm, _noise_floor_valid)) {
_noise_floor_valid = true;
_nf_published_at[0] = static_cast<uint32_t>(now);
_nf_published_generation[0] = _profiles.generation[0];
_nf_published_valid |= 1U;
_noise_floor = (_noise_floor_centi_dbm - 50) / 100;
}
if (_secondary_floor_estimator.publish(_noise_floor_secondary_centi_dbm,
_noise_floor_secondary_valid)) {
_noise_floor_secondary_valid = true;
_nf_published_at[1] = static_cast<uint32_t>(now);
_nf_published_generation[1] = _profiles.generation[1];
_nf_published_valid |= 2U;
}
_nf_refresh_requested = false;
_nf_last_calib = now;
@@ -2143,6 +2153,9 @@ void RadioLibWrapper::loop() {
_nf_lazy_refresh = _floor_estimator.publish(_noise_floor_centi_dbm, _noise_floor_valid);
if (_nf_lazy_refresh) {
_noise_floor_valid = true;
_nf_published_at[0] = static_cast<uint32_t>(now);
_nf_published_generation[0] = _profiles.generation[0];
_nf_published_valid |= 1U;
// Retain the whole-dB API/wire format and the fractional display value.
_noise_floor = (_noise_floor_centi_dbm - 50) / 100;
}
+14
View File
@@ -56,6 +56,9 @@ protected:
uint32_t _cad_scan_timeout_override_ms;
bool _noise_floor_valid;
bool _noise_floor_secondary_valid = false;
uint32_t _nf_published_at[2] = {};
uint32_t _nf_published_generation[2] = {};
uint8_t _nf_published_valid = 0;
bool _nf_refresh_requested;
NoiseFloorEstimator _floor_estimator;
NoiseFloorEstimator _secondary_floor_estimator;
@@ -552,6 +555,17 @@ public:
}
return getNoiseFloorDbm();
}
bool getNoiseFloorReading(uint8_t profile, float& dbm,
uint32_t& published_at_ms) const override {
if (profile > 1 || (profile == 1 && !_profiles.enabled())
|| !(_nf_published_valid & (1U << profile))
|| _nf_published_generation[profile] != _profiles.generation[profile]
|| !(profile == 1 ? _noise_floor_secondary_valid : _noise_floor_valid)) return false;
dbm = (profile == 1 ? _noise_floor_secondary_centi_dbm : _noise_floor_centi_dbm) / 100.0f;
published_at_ms = _nf_published_at[profile];
return true;
}
void requestNoiseFloorMeasurement() override { requestNoiseFloorRefresh(); }
float getNoiseFloorCalibrationSecondsRemaining() const override {
const float primary = profileNoiseFloorSecondsRemaining(0);
if (!_profiles.enabled()) return primary;
+303
View File
@@ -0,0 +1,303 @@
#include <helpers/NoiseFloorHistory.h>
#include <assert.h>
#include <limits>
#include <stdio.h>
#include <string.h>
#include <string>
#include <vector>
using mesh::NoiseFloorHistory;
static constexpr uint32_t BASE = 1700000100UL;
static const uint8_t SOURCE[8] = {1, 2, 3, 4, 5, 6, 7, 8};
static uint32_t little32(const uint8_t* bytes) {
return (uint32_t)bytes[0] | ((uint32_t)bytes[1] << 8)
| ((uint32_t)bytes[2] << 16) | ((uint32_t)bytes[3] << 24);
}
static std::vector<uint8_t> decode(const char* reply) {
assert(strncmp(reply, "> ", 2) == 0);
const char* alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::vector<uint8_t> bytes;
const std::string text = reply + 2;
assert(text.size() % 4 == 0);
uint32_t accumulator = 0;
unsigned bits = 0;
bool padding = false;
for (char ch : text) {
if (ch == '=') { padding = true; continue; }
assert(!padding);
const char* found = strchr(alphabet, ch);
assert(found != nullptr);
accumulator = (accumulator << 6) | (uint32_t)(found - alphabet);
bits += 6;
if (bits >= 8) {
bits -= 8;
bytes.push_back((uint8_t)(accumulator >> bits));
}
}
assert(bits == 0 || (accumulator & ((1U << bits) - 1U)) == 0);
return bytes;
}
static uint8_t code(const uint8_t* packed, size_t index) {
const size_t byte = index * 5U / 8U;
const unsigned shift = (unsigned)(index * 5U % 8U);
const uint16_t word = (uint16_t)((uint16_t)packed[byte] << 8)
| (shift > 3 ? packed[byte + 1] : 0);
return (uint8_t)((word >> (11U - shift)) & 31U);
}
static void collect(NoiseFloorHistory& history, uint32_t start,
float a, float b, float c, uint8_t profile = 0) {
history.advance(start);
assert(history.observe(profile, start + 1, a, (start + 1) * 1000U));
assert(history.observe(profile, start + 301, b, (start + 301) * 1000U));
assert(history.observe(profile, start + 601, c, (start + 601) * 1000U));
}
static uint8_t single(float a, float b, float c) {
NoiseFloorHistory history;
collect(history, BASE, a, b, c);
assert(history.sampleCount(0) == 0);
history.advance(BASE + 900);
assert(history.sampleCount(0) == 1);
return history.sampleCodeAtOffset(0, 0);
}
static void selection() {
assert(single(-120, -90, -60) == 0); // Low wins across the entire range.
assert(single(-119, -80, -90) == 1);
assert(single(-118, -117, -116) == 2);
assert(single(-119.1f, -110, -100) == 0);
assert(single(-118.1f, -110, -100) == 1);
assert(single(-117, -110, -100) == 2);
assert(single(-101, -90, -80) == 10); // Minimum rounds down to -102.
assert(single(-100, -90, -70) == 11); // Threshold is inclusive.
assert(single(-91, -90, -89) == 16); // Middle averages to -90.
assert(single(-91, -90, -90) == 16); // Average rounds nearest, not down.
assert(single(-99, -90, -80) == 21); // Maximum threshold is inclusive.
assert(single(-90, -79, -85) == 22); // Maximum rounds up to -78.
assert(single(-100.01f, -60, -60) == 10);
assert(single(-130, -150, -121) == 0);
assert(single(-70, -65, -90) == 29); // -65 rounds up to -64.
assert(single(-70, -64, -90) == 29);
assert(single(-70, -63.99f, -90) == NoiseFloorHistory::MAX);
assert(single(-20, -70, -80) == NoiseFloorHistory::MAX);
}
static void freshness() {
NoiseFloorHistory history;
assert(!history.observe(0, BASE, -110, 1)); // advance establishes RTC.
history.advance(BASE);
assert(history.observe(0, BASE + 1, -120, 1));
assert(!history.observe(0, BASE + 301, -90, 1)); // Same calibration is stale.
assert(!history.observe(0, BASE + 2, std::numeric_limits<float>::quiet_NaN(), 2));
assert(!history.observe(0, BASE + 2, std::numeric_limits<float>::infinity(), 2));
assert(!history.observe(0, BASE + 2, -201, 2));
assert(!history.observe(0, BASE + 2, 1, 2));
assert(!history.observe(2, BASE + 2, -90, 2));
assert(!history.observe(0, BASE - 1, -90, 2));
assert(!history.observe(0, BASE + 900, -90, 2));
assert(history.observe(0, BASE + 2, -110, 2)); // Latest in one slot replaces.
assert(!history.observe(0, BASE + 1, -120, 3)); // Older publication ignored.
assert(history.observe(0, BASE + 301, -90, 3));
assert(history.observe(0, BASE + 601, -80, 4));
history.advance(BASE + 900);
assert(history.sampleCodeAtOffset(0, 0) == 6); // -110, not superseded -120.
assert(history.sampleCodeAtOffset(1, 0) == NoiseFloorHistory::MISSING);
assert(history.hasValidSamples(0));
assert(!history.hasValidSamples(1));
assert(!history.observe(0, BASE + 601, -80, 4));
assert(!history.observe(0, BASE + 901, -80, 4)); // Watermark crosses bucket.
assert(history.observe(0, BASE + 901, -80, 5));
assert(history.observe(0, BASE + 1501, -80, 6));
history.advance(BASE + 1800); // Two subslots cannot masquerade as three.
assert(history.sampleCodeAtOffset(0, 0) == NoiseFloorHistory::MISSING);
assert(history.sampleCodeAtOffset(0, 1) == 6);
NoiseFloorHistory wrapped;
wrapped.advance(BASE);
assert(wrapped.observe(0, BASE + 1, -110, 0xFFFFFFFEUL));
assert(wrapped.observe(0, BASE + 1, -110, 1));
assert(!wrapped.observe(0, BASE + 1, -120, 0xFFFFFFFDUL));
assert(wrapped.observe(0, BASE + 301, -110, 300001));
assert(wrapped.observe(0, BASE + 601, -110, 600001));
wrapped.advance(BASE + 900);
assert(wrapped.sampleCodeAtOffset(0, 0) == 6);
}
static void invalidation() {
NoiseFloorHistory history;
collect(history, BASE, -120, -119, -118);
collect(history, BASE, -70, -71, -72, 1);
history.invalidateProfile(0);
assert(!history.observe(0, BASE + 601, -118, (BASE + 601) * 1000U));
history.advance(BASE + 900);
assert(history.sampleCodeAtOffset(0, 0) == NoiseFloorHistory::MISSING);
assert(history.sampleCodeAtOffset(1, 0) == 26);
collect(history, BASE + 900, -119, -118, -117);
history.advance(BASE + 1800);
history.invalidateProfile(0);
history.invalidateProfile(255);
assert(history.sampleCodeAtOffset(0, 0) == 1); // Completed data retained.
assert(history.sampleCount(255) == 0);
assert(!history.hasValidSamples(255));
}
static void rollover() {
NoiseFloorHistory history;
assert(history.storageBytes() == 960);
for (unsigned i = 0; i < 800; ++i) {
collect(history, BASE + i * 900U, -120.0f + (float)(i % 3), -90, -85);
history.advance(BASE + (i + 1U) * 900U);
}
assert(history.sampleCount(0) == 768);
assert(history.sampleCount(1) == 768);
for (unsigned offset = 0; offset < 768; ++offset) {
assert(history.sampleCodeAtOffset(0, (uint16_t)offset) == (799 - offset) % 3);
assert(history.sampleCodeAtOffset(1, (uint16_t)offset) == NoiseFloorHistory::MISSING);
}
assert(history.sampleCodeAtOffset(0, 768) == NoiseFloorHistory::MISSING);
char reply[160];
assert(history.formatPageReply(0, "5", reply));
const auto bytes = decode(reply);
assert(bytes[6] == 64);
assert(little32(bytes.data() + 1) == BASE + 32U * 900U);
for (unsigned i = 0; i < 64; ++i) assert(code(bytes.data() + 7, i) == (32 + i) % 3);
}
static void clockChanges() {
NoiseFloorHistory history;
collect(history, BASE, -119, -90, -90);
history.advance(BASE + 3600);
assert(history.sampleCount(0) == 4);
assert(history.sampleCodeAtOffset(0, 3) == 1);
for (unsigned i = 0; i < 3; ++i) assert(history.sampleCodeAtOffset(0, i) == 31);
history.advance(BASE + 3599); // Current bucket moves backwards.
assert(history.sampleCount(0) == 0);
assert(!history.hasValidSamples(0));
collect(history, BASE + 2700, -118, -90, -90);
history.advance(BASE + 3600);
assert(history.sampleCodeAtOffset(0, 0) == 2);
history.advance(BASE + 3600 + (768U + 1U) * 900U);
assert(history.sampleCount(0) == 0); // Huge jump is bounded and starts fresh.
const uint32_t high = 0xFFFFFFFFUL / 900U * 900U;
history.advance(high);
assert(history.sampleCount(0) == 0);
history.advance(0);
assert(history.sampleCount(0) == 0);
assert(history.observe(0, 1, -120, 1));
assert(history.observe(0, 301, -120, 2));
assert(history.observe(0, 601, -120, 3));
history.advance(900);
char reply[160];
assert(history.formatPageReply(0, "", reply));
assert(little32(decode(reply).data() + 1) == 0);
}
static void wire() {
NoiseFloorHistory history;
struct Guarded { char before; char reply[160]; char after; } guard;
memset(&guard, 0x5A, sizeof(guard));
assert(!history.formatPageReply(0, "", guard.reply));
assert(strstr(guard.reply, "empty") != nullptr);
assert(!history.formatPageReply(0, "1", nullptr));
assert(!history.formatPageReply(0, "1", guard.reply, 0));
for (unsigned i = 0; i < 300; ++i) {
collect(history, BASE + i * 900U, -120.0f + (float)(i % 3), -90, -90);
collect(history, BASE + i * 900U, -65, -70, -90, 1);
history.advance(BASE + (i + 1U) * 900U);
}
assert(history.formatPageReply(0, " 1 ", guard.reply));
assert(strlen(guard.reply) == 158);
assert(guard.before == 0x5A && guard.after == 0x5A);
auto bytes = decode(guard.reply);
assert(bytes.size() == 117 && bytes[0] == 0x15);
assert(little32(bytes.data() + 1) == BASE + 124U * 900U);
assert(bytes[5] == 15 && bytes[6] == 176);
for (unsigned i = 0; i < 176; ++i) assert(code(bytes.data() + 7, i) == (124 + i) % 3);
assert(history.formatPageReply(1, "2", guard.reply));
bytes = decode(guard.reply);
assert(bytes[0] == 0x16 && bytes[6] == 124);
assert(little32(bytes.data() + 1) == BASE);
for (unsigned i = 0; i < 124; ++i) assert(code(bytes.data() + 7, i) == 29);
assert((bytes.back() & 0x0FU) == 0); // Four final packing bits are padding.
assert(!history.formatPageReply(0, "3", guard.reply));
for (const char* arg : {"0", "6", "-1", "1x", "1 2", "9999999999999999999999"}) {
assert(!history.formatPageReply(0, arg, guard.reply));
}
assert(!history.formatPageReply(2, "", guard.reply));
assert(!history.formatPageReply(0, "1", guard.reply, 158));
assert(history.formatPageReply(0, "1", guard.reply, 159));
assert(guard.before == 0x5A && guard.after == 0x5A);
struct Correlated { char before; char reply[160]; char after; } correlated;
memset(&correlated, 0x5A, sizeof(correlated));
memcpy(correlated.reply, "ab|", 3);
assert(history.formatPageReply(0, "1", correlated.reply + 3, 157, false));
assert(strlen(correlated.reply) == 159);
assert(memcmp(correlated.reply, "ab|", 3) == 0);
assert(strcmp(correlated.reply + 3, guard.reply + 2) == 0);
assert(decode((std::string("> ") + (correlated.reply + 3)).c_str()) == decode(guard.reply));
assert(correlated.before == 0x5A && correlated.after == 0x5A);
struct Bare { char before; char reply[157]; char after; } bare;
memset(&bare, 0x5A, sizeof(bare));
assert(history.formatPageReply(0, "1", bare.reply, sizeof(bare.reply), false));
assert(strlen(bare.reply) == 156 && strcmp(bare.reply, guard.reply + 2) == 0);
assert(!history.formatPageReply(0, "1", bare.reply, 156, false));
assert(strncmp(bare.reply, "Err", 3) == 0);
assert(bare.before == 0x5A && bare.after == 0x5A);
uint8_t raw[186];
memset(raw, 0xA5, sizeof(raw));
assert(history.formatBinarySnapshot(0, SOURCE, raw + 1, 184) == 179);
assert(memcmp(raw + 1, "N1B1", 4) == 0 && memcmp(raw + 5, SOURCE, 8) == 0);
assert(little32(raw + 13) == BASE + 45U * 900U);
assert(raw[17] == 15 && raw[18] == 0 && raw[19] == 255);
for (unsigned i = 0; i < 255; ++i) assert(code(raw + 20, i) == (45 + i) % 3);
assert((raw[179] & 0x1FU) == 0); // 255*5 leaves five padding bits.
assert(raw[0] == 0xA5 && raw[180] == 0xA5 && raw[185] == 0xA5);
assert(history.formatBinarySnapshot(1, SOURCE, raw + 1, 20) == 20);
assert(memcmp(raw + 1, "N2B1", 4) == 0 && raw[19] == 1);
assert(code(raw + 20, 0) == 29);
assert(little32(raw + 13) == BASE + 299U * 900U);
assert(history.formatBinarySnapshot(0, SOURCE, raw, 19) == 0);
assert(history.formatBinarySnapshot(2, SOURCE, raw, sizeof(raw)) == 0);
assert(history.formatBinarySnapshot(0, nullptr, raw, sizeof(raw)) == 0);
assert(history.formatBinarySnapshot(0, SOURCE, nullptr, sizeof(raw)) == 0);
// Missing is an explicit usable code, including disabled-profile history.
NoiseFloorHistory missing;
missing.advance(BASE);
missing.advance(BASE + 900);
assert(missing.formatBinarySnapshot(1, SOURCE, raw, sizeof(raw)) == 20);
assert(raw[18] == 1 && code(raw + 19, 0) == 31);
assert(missing.formatPageReply(1, "", guard.reply));
bytes = decode(guard.reply);
assert(bytes.size() == 8 && code(bytes.data() + 7, 0) == 31);
assert(strchr(guard.reply, '=') != nullptr); // Standard padded Base64.
// Independent MSB-first wire vector: eight five-bit codes 0..7.
NoiseFloorHistory golden;
const float levels[] = {-120, -119, -118, -116, -114, -112, -110, -108};
for (unsigned i = 0; i < 8; ++i) {
collect(golden, BASE + i * 900U, levels[i], levels[i], levels[i]);
golden.advance(BASE + (i + 1U) * 900U);
}
assert(golden.formatBinarySnapshot(0, SOURCE, raw, sizeof(raw)) == 24);
const uint8_t expected[] = {0x00, 0x44, 0x32, 0x14, 0xC7};
assert(memcmp(raw + 19, expected, sizeof(expected)) == 0);
}
int main(int argc, char** argv) {
assert(argc == 2);
const std::string scenario(argv[1]);
if (scenario == "selection") selection();
else if (scenario == "freshness") freshness();
else if (scenario == "invalidation") invalidation();
else if (scenario == "rollover") rollover();
else if (scenario == "clock") clockChanges();
else if (scenario == "wire") wire();
else return 2;
puts("noise floor history: ok");
}
+297
View File
@@ -0,0 +1,297 @@
#include <helpers/NoiseFloorHistorySampler.h>
#include <assert.h>
#include <limits>
#include <stdio.h>
#include <string>
using mesh::NoiseFloorHistory;
using mesh::NoiseFloorHistorySampler;
static constexpr uint32_t BASE = 1700000100UL;
static constexpr uint32_t MILLIS_BASE = 100000;
struct FakeProfiles {
uint32_t generation[2] = {};
bool second = false;
bool enabled() const { return second; }
};
struct FakeRadio {
FakeProfiles config;
bool has_profiles = true;
bool valid[2] = {};
float dbm[2] = {};
uint32_t published[2] = {};
unsigned requests = 0;
unsigned reads[2] = {};
unsigned hardware_operations = 0;
const FakeProfiles* profiles() const { return has_profiles ? &config : nullptr; }
bool getNoiseFloorReading(uint8_t profile, float& floor, uint32_t& publication) {
assert(profile < 2);
++reads[profile];
if (!valid[profile]) return false;
floor = dbm[profile];
publication = published[profile];
return true;
}
void requestNoiseFloorMeasurement() { ++requests; }
void publish(uint8_t profile, uint32_t now_ms, float floor) {
assert(profile < 2);
valid[profile] = true;
dbm[profile] = floor;
published[profile] = now_ms;
}
};
static uint32_t ms(unsigned seconds) { return MILLIS_BASE + seconds * 1000UL; }
static void successful() {
FakeRadio radio;
NoiseFloorHistory history;
NoiseFloorHistorySampler sampler;
sampler.poll(radio, history, BASE, ms(0));
assert(radio.requests == 1);
for (unsigned offset : {4U, 304U, 604U}) {
if (offset != 4) {
sampler.poll(radio, history, BASE + offset - 4U, ms(offset - 4U));
}
radio.publish(0, ms(offset), offset == 4 ? -120 : offset == 304 ? -90 : -60);
sampler.poll(radio, history, BASE + offset, ms(offset));
const unsigned requests = radio.requests;
for (unsigned i = offset; i < offset + 10; ++i) {
sampler.poll(radio, history, BASE + i, ms(i));
}
assert(radio.requests == requests);
}
assert(radio.requests == 3 && history.sampleCount(0) == 0);
sampler.poll(radio, history, BASE + 900, ms(900));
assert(radio.requests == 4);
assert(history.sampleCount(0) == 1 && history.sampleCodeAtOffset(0, 0) == 0);
assert(history.sampleCodeAtOffset(1, 0) == 31);
assert(radio.reads[1] == 0 && radio.hardware_operations == 0);
}
static void cached() {
FakeRadio radio;
NoiseFloorHistory history;
NoiseFloorHistorySampler sampler;
radio.publish(0, ms(0), -110);
sampler.poll(radio, history, BASE, ms(0));
assert(radio.requests == 0);
// A recent cached result may be read repeatedly, but is one publication.
for (unsigned i = 1; i < 300; ++i) sampler.poll(radio, history, BASE + i, ms(i));
assert(radio.requests == 0);
sampler.poll(radio, history, BASE + 300, ms(300));
assert(radio.requests == 1);
for (unsigned i = 301; i < 600; ++i) sampler.poll(radio, history, BASE + i, ms(i));
assert(radio.requests == 1);
sampler.poll(radio, history, BASE + 600, ms(600));
assert(radio.requests == 2);
sampler.poll(radio, history, BASE + 900, ms(900));
assert(history.sampleCount(0) == 1 && history.sampleCodeAtOffset(0, 0) == 31);
assert(!history.hasValidSamples(0));
// Calibration completed just before a subslot boundary still belongs to
// that earlier subslot; it must not suppress the next measurement request.
NoiseFloorHistory second_history;
NoiseFloorHistorySampler second_sampler;
FakeRadio second;
second.publish(0, ms(299), -100);
second_sampler.poll(second, second_history, BASE + 300, ms(300));
assert(second.requests == 1);
}
static void rollover() {
for (uint32_t first_ms : {0UL, 0xFFFFF000UL}) {
FakeRadio radio;
NoiseFloorHistory history;
NoiseFloorHistorySampler sampler;
for (unsigned offset : {0U, 300U, 600U}) {
const uint32_t now = first_ms + offset * 1000UL;
radio.publish(0, now, -119);
sampler.poll(radio, history, BASE + offset, now);
}
assert(radio.requests == 0);
sampler.poll(radio, history, BASE + 900, first_ms + 900000UL);
assert(history.sampleCount(0) == 1 && history.sampleCodeAtOffset(0, 0) == 1);
assert(radio.requests == 1);
}
}
static void generations() {
FakeRadio radio;
radio.config.second = true;
NoiseFloorHistory history;
NoiseFloorHistorySampler sampler;
for (unsigned offset : {0U, 300U, 600U}) {
if (offset == 600) ++radio.config.generation[0];
radio.publish(0, ms(offset), -120);
radio.publish(1, ms(offset), -70);
sampler.poll(radio, history, BASE + offset, ms(offset));
}
sampler.poll(radio, history, BASE + 900, ms(900));
assert(history.sampleCodeAtOffset(0, 0) == 31); // Pre-retune estimates discarded.
assert(history.sampleCodeAtOffset(1, 0) == 26);
for (unsigned offset : {904U, 1204U, 1504U}) {
radio.publish(0, ms(offset), -118);
radio.publish(1, ms(offset), -70);
sampler.poll(radio, history, BASE + offset, ms(offset));
}
sampler.poll(radio, history, BASE + 1800, ms(1800));
assert(history.sampleCodeAtOffset(0, 0) == 2);
assert(history.sampleCodeAtOffset(1, 0) == 26);
// A fresh-reading API becoming invalid clears accumulated estimates even
// if generation is unchanged (gain/AGC recalibration).
for (unsigned offset : {1804U, 2104U}) {
radio.publish(0, ms(offset), -118);
sampler.poll(radio, history, BASE + offset, ms(offset));
}
radio.valid[0] = false;
sampler.poll(radio, history, BASE + 2105, ms(2105));
radio.publish(0, ms(2404), -118);
sampler.poll(radio, history, BASE + 2404, ms(2404));
sampler.poll(radio, history, BASE + 2700, ms(2700));
assert(history.sampleCodeAtOffset(0, 0) == 31);
assert(history.sampleCodeAtOffset(0, 1) == 2); // Completed data stays intact.
}
static void enableDisable() {
FakeRadio radio;
radio.config.second = true;
NoiseFloorHistory history;
NoiseFloorHistorySampler sampler;
for (unsigned offset : {0U, 300U, 600U}) {
if (offset == 300) radio.config.second = false;
if (offset == 600) radio.config.second = true;
radio.publish(0, ms(offset), -119);
radio.publish(1, ms(offset), -70);
const unsigned secondary_reads = radio.reads[1];
sampler.poll(radio, history, BASE + offset, ms(offset));
if (offset == 300) assert(radio.reads[1] == secondary_reads);
}
sampler.poll(radio, history, BASE + 900, ms(900));
assert(history.sampleCodeAtOffset(0, 0) == 1);
assert(history.sampleCodeAtOffset(1, 0) == 31); // No mixing across enablement.
for (unsigned offset : {904U, 1204U, 1504U}) {
radio.publish(0, ms(offset), -119);
radio.publish(1, ms(offset), -70);
sampler.poll(radio, history, BASE + offset, ms(offset));
}
sampler.poll(radio, history, BASE + 1800, ms(1800));
assert(history.sampleCodeAtOffset(1, 0) == 26);
radio.config.second = false;
sampler.poll(radio, history, BASE + 1801, ms(1801));
assert(history.sampleCodeAtOffset(1, 0) == 26); // Disabling retains old history.
}
static void clockChanges() {
FakeRadio radio;
NoiseFloorHistory history;
NoiseFloorHistorySampler sampler;
for (unsigned offset : {0U, 300U, 600U}) {
radio.publish(0, ms(offset), -118);
sampler.poll(radio, history, BASE + offset, ms(offset));
}
radio.valid[0] = false;
sampler.poll(radio, history, BASE + 3600, ms(601));
assert(history.sampleCount(0) == 4);
assert(history.sampleCodeAtOffset(0, 3) == 2);
assert(history.sampleCodeAtOffset(0, 0) == 31);
sampler.poll(radio, history, BASE + 1800, ms(602));
assert(history.sampleCount(0) == 0);
for (unsigned offset : {0U, 300U, 600U}) {
radio.publish(0, ms(603 + offset), -119);
sampler.poll(radio, history, BASE + 1800 + offset, ms(603 + offset));
}
sampler.poll(radio, history, BASE + 2700, ms(1503));
assert(history.sampleCount(0) == 1 && history.sampleCodeAtOffset(0, 0) == 1);
sampler.poll(radio, history, BASE + 2700 + 769U * 900U, ms(1504));
assert(history.sampleCount(0) == 0);
}
static void unsupportedAndFresh() {
FakeRadio unsupported;
unsupported.has_profiles = false;
NoiseFloorHistory empty;
NoiseFloorHistorySampler no_hardware;
for (unsigned offset : {0U, 300U, 600U, 900U}) {
no_hardware.poll(unsupported, empty, BASE + offset, ms(offset));
}
assert(unsupported.requests == 4 && unsupported.reads[1] == 0);
assert(unsupported.hardware_operations == 0);
assert(empty.sampleCount(0) == 1 && !empty.hasValidSamples(0));
assert(!empty.hasValidSamples(1));
FakeRadio both;
both.config.second = true;
NoiseFloorHistory history;
NoiseFloorHistorySampler sampler;
for (unsigned offset : {0U, 300U, 600U, 900U}) {
both.publish(0, ms(offset), -119);
both.publish(1, ms(offset), -70);
sampler.poll(both, history, BASE + offset, ms(offset));
}
assert(both.requests == 0 && both.hardware_operations == 0);
assert(history.sampleCodeAtOffset(0, 0) == 1 && history.sampleCodeAtOffset(1, 0) == 26);
both.publish(0, ms(1200), -119);
sampler.poll(both, history, BASE + 1200, ms(1200));
assert(both.requests == 1); // Primary is fresh; secondary still needs a reading.
}
static void invalidReadings() {
const float invalid[] = {
std::numeric_limits<float>::quiet_NaN(),
std::numeric_limits<float>::infinity(),
-std::numeric_limits<float>::infinity(), -200.01f, 0.01f
};
for (float value : invalid) {
FakeRadio radio;
radio.config.second = true;
NoiseFloorHistory history;
NoiseFloorHistorySampler sampler;
for (unsigned offset : {0U, 300U, 600U}) {
radio.publish(0, ms(offset), value);
radio.publish(1, ms(offset), -118);
sampler.poll(radio, history, BASE + offset, ms(offset));
}
assert(radio.requests == 3); // Invalid values never suppress a request.
sampler.poll(radio, history, BASE + 900, ms(900));
assert(history.sampleCodeAtOffset(0, 0) == 31);
assert(history.sampleCodeAtOffset(1, 0) == 2);
}
// Invalid data appearing after a valid estimate clears that pending profile,
// rather than retaining a partial calibration across the invalid interval.
FakeRadio radio;
NoiseFloorHistory history;
NoiseFloorHistorySampler sampler;
radio.publish(0, ms(0), -120);
sampler.poll(radio, history, BASE, ms(0));
radio.publish(0, ms(1), invalid[0]);
sampler.poll(radio, history, BASE + 1, ms(1));
for (unsigned offset : {300U, 600U}) {
radio.publish(0, ms(offset), -120);
sampler.poll(radio, history, BASE + offset, ms(offset));
}
sampler.poll(radio, history, BASE + 900, ms(900));
assert(history.sampleCodeAtOffset(0, 0) == 31);
}
int main(int argc, char** argv) {
assert(argc == 2);
const std::string scenario(argv[1]);
if (scenario == "successful") successful();
else if (scenario == "cached") cached();
else if (scenario == "rollover") rollover();
else if (scenario == "generations") generations();
else if (scenario == "enable_disable") enableDisable();
else if (scenario == "clock") clockChanges();
else if (scenario == "unsupported_fresh") unsupportedAndFresh();
else if (scenario == "invalid") invalidReadings();
else return 2;
puts("noise floor sampler: ok");
}
+141
View File
@@ -10,6 +10,7 @@
#endif
#include <helpers/TelemetryHistory.h>
#include <helpers/ExternalVoltageHistory.h>
#include <helpers/NoiseFloorHistory.h>
#include <array>
#include <cassert>
#include <cstdio>
@@ -81,6 +82,9 @@ public:
struct Identity { uint8_t pub_key[32] = {}; } self_id;
mesh::TelemetryHistory telemetry_history;
mesh::ExternalVoltageHistory external_voltage_history;
mesh::NoiseFloorHistory noise_floor_history;
bool dual = false;
bool isDualRadioActive() const { return dual; }
bool telemetry_history_tx_prefs_healthy = false;
bool telemetry_history_tx_enabled = false;
uint8_t telemetry_history_tx_interval_days = 2;
@@ -93,12 +97,16 @@ public:
uint64_t uptime_millis = 0;
uint32_t last_millis = 0;
std::vector<mesh::Packet> packets;
std::vector<std::string> attempts;
std::string reject_magic;
unsigned rejected_sends = 0;
explicit MyMesh(FILESYSTEM& fs) : _fs(&fs) {}
void loadTelemetryHistoryTxPrefs();
bool saveTelemetryHistoryTxPrefs();
void serviceTelemetryHistoryTx();
bool sendTelemetryHistorySnapshot(mesh::TelemetryHistory::Series);
bool sendExternalVoltageHistorySnapshot(uint8_t, uint8_t);
bool sendNoiseFloorHistorySnapshot(uint8_t);
void formatTelemetryHistoryTxStatus(char*, size_t) const;
void handle(char*, char*, ClientInfo*);
// Radio queue boundary. Payload formatting and producer scheduling are real.
@@ -109,6 +117,11 @@ public:
return packet;
}
bool sendDirect(mesh::Packet* packet, const uint8_t* path, uint8_t length) {
const std::string magic(reinterpret_cast<char*>(packet->payload), 4);
attempts.push_back(magic);
if (magic == reject_magic && rejected_sends != 0) {
--rejected_sends; delete packet; return false;
}
packet->path_len = mesh::Packet::copyPath(packet->path, path, length);
packets.push_back(*packet); delete packet; return true;
}
@@ -163,6 +176,22 @@ struct Fixture {
node.telemetry_history.record(1000000 + i * 1800, 22, true, 3750, 0, 0, false);
}
}
void noiseHistory(uint16_t count = 300, bool missing = false) {
const uint32_t start = 999900;
node.noise_floor_history.advance(start);
for (uint32_t index = 0; index < count; ++index) {
for (uint8_t profile = 0; profile < 2; ++profile) {
for (uint8_t slot = 0; slot < 3; ++slot) {
if (missing) continue;
assert(node.noise_floor_history.observe(profile,
start + index * 900 + slot * 300, profile ? -66.0f : -119.0f,
index * 3 + slot));
}
}
node.noise_floor_history.advance(start + (index + 1) * 900);
}
assert(node.noise_floor_history.sampleCount(0) == count);
}
void expectFault() {
assert(!node.telemetry_history_tx_prefs_healthy);
const auto before = node.state();
@@ -531,6 +560,116 @@ static void checkRouteReadbackAndRetry() {
#endif
}
static uint32_t read32(const uint8_t* bytes) {
return uint32_t(bytes[0]) | uint32_t(bytes[1]) << 8
| uint32_t(bytes[2]) << 16 | uint32_t(bytes[3]) << 24;
}
static uint8_t packedNoiseCode(const mesh::Packet& packet, unsigned index) {
uint8_t value = 0;
for (unsigned bit = 0; bit < 5; ++bit) {
const unsigned at = index * 5 + bit;
value = uint8_t((value << 1)
| ((packet.payload[19 + at / 8] >> (7 - at % 8)) & 1));
}
return value;
}
static void verifyNoisePacket(const MyMesh& node, const mesh::Packet& packet,
unsigned profile, bool missing) {
assert(packet.payload_len == 19 + (255 * 5 + 7) / 8);
assert(!memcmp(packet.payload, profile ? "N2B1" : "N1B1", 4));
assert(!memcmp(packet.payload + 4, node.self_id.pub_key, 8));
assert(read32(packet.payload + 12) == 999900 + (300 - 255) * 900);
assert(packet.payload[16] == 15 && packet.payload[17] == 0);
assert(packet.payload[18] == 255);
for (unsigned i = 0; i < 255; ++i) {
assert(packedNoiseCode(packet, i) == (missing ? 31 : profile ? 28 : 1));
}
assert((packet.payload[packet.payload_len - 1] & 0x1f) == 0);
}
static void serviceDue(MyMesh& node) {
node.uptime_millis = node.telemetry_history_next_tx_uptime;
node.last_millis = test_millis;
node.serviceTelemetryHistoryTx();
}
static void finishPending(MyMesh& node) {
unsigned calls = 0;
while (node.telemetry_history_tx_pending != 0) {
assert(++calls < 20);
serviceDue(node);
}
}
static void checkNoiseHistoryTransmission() {
for (const bool manual : {false, true}) {
for (const bool dual : {false, true}) {
for (const bool missing : {false, true}) {
Fixture f; f.seed(current(!manual, 2)); f.history(); f.noiseHistory(300, missing);
f.node.dual = dual;
for (unsigned i = 0; i < 8; ++i) f.node.self_id.pub_key[i] = uint8_t(0x80 + i);
if (manual) {
const auto reply = f.node.command("send telemetry.tx now");
assert(reply.find(dual ? "noise=1/1" : "noise=1/0") != std::string::npos);
assert(f.node.telemetry_history_tx_pending == (dual ? 27 : 11));
assert(f.node.packets.empty());
} else {
f.node.serviceTelemetryHistoryTx();
assert(f.node.packets.size() == 1);
}
finishPending(f.node);
assert(f.node.packets.size() == (dual ? 4U : 3U));
assert(f.node.attempts.size() == f.node.packets.size());
assert(!memcmp(f.node.packets[0].payload, "TTB1", 4));
assert(!memcmp(f.node.packets[1].payload, "TVB1", 4));
verifyNoisePacket(f.node, f.node.packets[2], 0, missing);
if (dual) verifyNoisePacket(f.node, f.node.packets[3], 1, missing);
assert(!f.node.telemetry_history_tx_manual);
assert(bool(f.node.telemetry_history_next_tx_uptime) == !manual);
}
}
}
}
static void checkNoiseHistoryRetries() {
for (const bool second : {false, true}) {
Fixture f; f.seed(current(false, 2)); f.history(); f.noiseHistory();
f.node.dual = true;
f.node.reject_magic = second ? "N2B1" : "N1B1";
f.node.rejected_sends = 1;
assert(f.node.command("send telemetry.tx now").find("noise=1/1") != std::string::npos);
while (f.node.rejected_sends != 0) serviceDue(f.node);
const auto attempts = f.node.attempts;
const uint64_t retry = f.node.telemetry_history_next_tx_uptime;
assert(retry == f.node.uptime_millis + TELEMETRY_HISTORY_TX_RETRY_MILLIS);
assert(f.node.telemetry_history_tx_pending == (second ? 16 : 24));
f.node.uptime_millis = retry - 1;
f.node.serviceTelemetryHistoryTx();
assert(f.node.attempts == attempts); // failed packet waits for its retry deadline
finishPending(f.node);
const std::vector<std::string> expected = second
? std::vector<std::string>{"TTB1", "TVB1", "N1B1", "N2B1", "N2B1"}
: std::vector<std::string>{"TTB1", "TVB1", "N1B1", "N1B1", "N2B1"};
assert(f.node.attempts == expected);
assert(f.node.packets.size() == 4); // successful earlier packets were never resent
assert(!f.node.telemetry_history_next_tx_uptime && !f.node.telemetry_history_tx_manual);
}
Fixture disabled; disabled.seed(current(false, 2));
disabled.history(); disabled.noiseHistory(); disabled.node.dual = true;
disabled.node.reject_magic = "N2B1"; disabled.node.rejected_sends = 1;
assert(disabled.node.command("send telemetry.tx now").find("noise=1/1") != std::string::npos);
while (disabled.node.rejected_sends != 0) serviceDue(disabled.node);
assert(disabled.node.telemetry_history_tx_pending == TELEMETRY_HISTORY_TX_NOISE2);
const auto attempts = disabled.node.attempts;
disabled.node.dual = false;
serviceDue(disabled.node);
assert(!disabled.node.telemetry_history_tx_pending && disabled.node.attempts == attempts);
assert(!disabled.node.telemetry_history_next_tx_uptime && !disabled.node.telemetry_history_tx_manual);
assert(disabled.node.packets.size() == 3);
}
int main() {
checkFreshAndValid();
checkLoadFailures();
@@ -540,5 +679,7 @@ int main() {
checkAmbiguousPublicationAndServiceGate();
checkRoutePublicationAuthority();
checkRouteReadbackAndRetry();
checkNoiseHistoryTransmission();
checkNoiseHistoryRetries();
puts("telemetry persistence checks passed");
}
+204
View File
@@ -0,0 +1,204 @@
"""Run real noise CLI dispatch, prefix handling, and room/repeater permission gates."""
from pathlib import Path
import os
import re
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cstring>
#include <string>
#include <vector>
#include <helpers/NoiseFloorHistory.h>
#include <helpers/TelemetryHistory.h>
#include <helpers/ExternalVoltageHistory.h>
struct ClientInfo { bool admin=true; bool isAdmin() const { return admin; } };
class Repeater {
public:
mesh::NoiseFloorHistory noise_floor_history;
void handleCommand(char* command,char* reply,ClientInfo* sender) {
@REPEATER_PREFIX@
@REPEATER_NOISE@
strcpy(reply,"Err - unrecognized command");
}
};
class MyMesh {
public:
mesh::NoiseFloorHistory noise_floor_history;
mesh::TelemetryHistory telemetry_history;
mesh::ExternalVoltageHistory external_voltage_history;
bool handleTelemetryHistoryCommand(const char*,char*,size_t=160,bool=true);
void handleCommand(uint32_t sender_timestamp,char* command,char* reply,
int client_index=-1,uint8_t path_hash_size=1) {
(void)sender_timestamp; (void)client_index; (void)path_hash_size;
@ROOM_PREFIX@
@ROOM_DISPATCH@
strcpy(reply,"Err - unrecognized command");
}
void remote(ClientInfo* client,char* text,char* reply) {
uint8_t temp[165]={};
const uint32_t sender_timestamp=100;
const int i=0;
struct Packet {uint8_t getPathHashSize() const {return 1;}} real_packet;
Packet* packet=&real_packet;
@ROOM_GATE@
strcpy(reply,reinterpret_cast<char*>(&temp[5]));
}
};
@ROOM_HISTORY@
static void seed(mesh::NoiseFloorHistory& history) {
history.advance(999900);
for (uint32_t bucket=0;bucket<176;++bucket) {
for (uint8_t profile=0;profile<2;++profile) for (uint8_t slot=0;slot<3;++slot) {
assert(history.observe(profile,999900+bucket*900+slot*300,
profile ? -66.0f : -119.0f,bucket*3+slot));
}
history.advance(999900+(bucket+1)*900);
}
}
static std::vector<uint8_t> decode(const char* text) {
const char* alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::vector<uint8_t> result;
uint32_t bits=0;unsigned count=0;
for (;*text && *text!='=';++text) {
const char* found=strchr(alphabet,*text);assert(found);
bits=(bits<<6)|uint32_t(found-alphabet);count+=6;
if (count>=8) {count-=8;result.push_back(uint8_t(bits>>count));}
}
return result;
}
static void verify(const char* reply,unsigned profile,bool prefix) {
assert(strlen(reply)==(prefix ? 159U : 158U));
assert(!memcmp(reply,prefix ? "xy|" : "> ",prefix ? 3 : 2));
const auto bytes=decode(reply+(prefix ? 3 : 2));
assert(bytes.size()==7+110 && bytes[0]==(profile ? 0x16 : 0x15));
assert(bytes[1]==uint8_t(999900) && bytes[2]==uint8_t(999900>>8)
&& bytes[3]==uint8_t(999900>>16) && bytes[4]==uint8_t(999900>>24));
assert(bytes[5]==15 && bytes[6]==176);
for (unsigned sample=0;sample<176;++sample) {
uint8_t code=0;
for (unsigned bit=0;bit<5;++bit) {
const unsigned at=sample*5+bit;
code=uint8_t((code<<1)|((bytes[7+at/8]>>(7-at%8))&1));
}
assert(code==(profile ? 28 : 1));
}
}
template<class Invoke> static void bounded(const std::string& text,Invoke invoke,
bool success,unsigned profile,bool prefix) {
std::vector<char> storage(176,'~');
std::vector<char> command(text.begin(),text.end());command.push_back(0);
invoke(command.data(),storage.data()+8);
for (unsigned i=0;i<8;++i) assert(storage[i]=='~' && storage[168+i]=='~');
assert(memchr(storage.data()+8,0,160));
if (success) verify(storage.data()+8,profile,prefix);
else assert(strstr(storage.data()+8,"Err - "));
}
int main() {
Repeater repeater;MyMesh room;
seed(repeater.noise_floor_history);seed(room.noise_floor_history);
ClientInfo admin,denied;denied.admin=false;
for (unsigned profile:{0U,1U}) for (bool prefix:{false,true}) {
const std::string text=std::string(prefix ? "xy|" : "")
+(profile ? "get telemetry.noise2" : "get telemetry.noise");
for (const std::string& suffix:{std::string(),std::string(" 1")}) {
bounded(text+suffix,[&](char* command,char* reply) {
repeater.handleCommand(command,reply,nullptr);
},true,profile,prefix);
bounded(text+suffix,[&](char* command,char* reply) {
repeater.handleCommand(command,reply,&admin);
},true,profile,prefix);
bounded(text+suffix,[&](char* command,char* reply) {
room.handleCommand(0,command,reply);
},true,profile,prefix);
bounded(text+suffix,[&](char* command,char* reply) {
room.remote(&admin,command,reply);
},true,profile,prefix);
bounded(text+suffix,[&](char* command,char* reply) {
repeater.handleCommand(command,reply,&denied);
},false,profile,prefix);
bounded(text+suffix,[&](char* command,char* reply) {
room.remote(&denied,command,reply);
},false,profile,prefix);
}
}
for (const char* bad:{"get telemetry.noisefake","get telemetry.noise2fake",
"get telemetry.noise 0","get telemetry.noise2 6"}) {
bounded(std::string("xy|")+bad,[&](char* command,char* reply) {
repeater.handleCommand(command,reply,&admin);
},false,0,true);
bounded(std::string("xy|")+bad,[&](char* command,char* reply) {
room.remote(&admin,command,reply);
},false,0,true);
}
puts("noise CLI prefix, permission and payload checks passed");
}
'''
class NoiseFloorCLITests(unittest.TestCase):
def test_actual_prefix_dispatch_permissions_and_full_pages_fit_160_bytes(self):
repeater = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text()
room = (ROOT / "examples/simple_room_server/MyMesh.cpp").read_text()
repeater_handler = extract_braced(repeater, "void MyMesh::handleCommand(uint32_t sender_timestamp,")
room_handler = extract_braced(room, "void MyMesh::handleCommand(uint32_t sender_timestamp,")
def prefix(handler):
declaration = re.search(r"const bool prefixed_reply[^;]+;", handler).group()
return declaration + "\n" + extract_braced(handler, "if (strlen(command) > 4 && command[2] == '|')")
start = repeater_handler.index(' const char* noise_commands[]')
end = repeater_handler.index(' const char* telemetry_args', start)
receiver = extract_braced(room, "void MyMesh::onPeerDataRecv(")
gate = extract_braced(receiver[receiver.index("if (!handleClientPathCommand"):], "if (client->isAdmin())")
end_gate = receiver[receiver.index(gate) + len(gate):]
gate += " " + extract_braced(end_gate, "else")
gate = gate.replace("_cli.morseReply().beginCommand(client->id.pub_key);", "")
gate = gate.replace("_cli.morseReply().endCommand();", "")
replacements = {
"@REPEATER_PREFIX@": prefix(repeater_handler),
"@REPEATER_NOISE@": repeater_handler[start:end],
"@ROOM_PREFIX@": prefix(room_handler),
"@ROOM_DISPATCH@": re.search(
r"if \(handleTelemetryHistoryCommand\([\s\S]*?\)\) return;", room_handler).group(),
"@ROOM_GATE@": gate,
"@ROOM_HISTORY@": extract_braced(room, "bool MyMesh::handleTelemetryHistoryCommand("),
}
harness = HARNESS
for marker, value in replacements.items():
harness = harness.replace(marker, value)
flags = [] if os.name == "nt" else ["-fsanitize=address,undefined",
"-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
with tempfile.TemporaryDirectory(prefix="meshcore-noise-cli-") as directory:
directory = Path(directory)
for legacy in (False, True):
with self.subTest(old_prefix_plumbing=legacy):
cpp, binary = directory / "cli.cpp", directory / "cli"
code = harness.replace("prefixed_reply ? 157 : 160, !prefixed_reply",
"prefixed_reply ? 160 : 160, true") if legacy else harness
cpp.write_text(code, encoding="ascii")
built = subprocess.run([os.environ.get("CXX") or shutil.which("g++") or "g++",
"-std=c++17", "-Wall", "-Wextra", "-Werror", *flags,
"-I", str(ROOT / "src"), str(cpp), "-o", str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
if legacy:
self.assertNotEqual(checked.returncode, 0)
self.assertIn("storage[i]", checked.stderr)
else:
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("noise CLI prefix, permission and payload checks passed", checked.stdout)
if __name__ == "__main__":
unittest.main()
+64
View File
@@ -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()
+254
View File
@@ -0,0 +1,254 @@
"""Execute the production freshness API and publication paths with real estimators."""
from pathlib import Path
import os
import subprocess
import sys
import tempfile
import unittest
from test_radio_receive_contract import ROOT, method, transition_harness
FIXTURE = r'''
struct UnsupportedRadio {
@DEFAULT_GETTER@
@DEFAULT_REQUEST@
};
static void fill(NoiseFloorEstimator& estimator, float dbm) {
for (unsigned i = 0; i < NoiseFloorEstimator::SAMPLE_COUNT; ++i) {
now_ms += NoiseFloorEstimator::SAMPLE_INTERVAL_MS;
assert(estimator.add(dbm, now_ms));
}
}
static uint32_t reading(const RadioLibWrapper& w, unsigned profile, float expected) {
float dbm = 999;
uint32_t published = 999;
assert(w.getNoiseFloorReading(profile, dbm, published));
assert(fabsf(dbm - expected) < 0.02f);
return published;
}
static void missing(const RadioLibWrapper& w, unsigned profile) {
float dbm = 999;
uint32_t published = 999;
assert(!w.getNoiseFloorReading(profile, dbm, published));
assert(dbm == 999 && published == 999);
}
static void baseline(RadioLibWrapper& w, float primary = -120, float secondary = -118) {
w._noise_floor_valid = w._noise_floor_secondary_valid = false;
fill(w._floor_estimator, primary);
if (w._profiles.enabled()) fill(w._secondary_floor_estimator, secondary);
w._nf_refresh_requested = true;
w.loop();
}
static void dual(RadioLibWrapper& w) {
mesh::RadioProfileConfig second;
second.params.freq = 911.15f;
second.params.bw = 250;
second.params.sf = 7;
second.params.cr = 5;
second.mode = mesh::RadioProfileMode::RxTx;
w._profiles.setSecondary(second, false);
w._profiles.primary = second.params;
w._profile_rxps_suspended = true;
w._profile_scan_generation[0] = w._profiles.generation[0];
w._profile_scan_generation[1] = w._profiles.generation[1];
}
int main(int argc, char** argv) {
assert(argc == 2);
const char* scenario = argv[1];
if (!strcmp(scenario, "unsupported")) {
UnsupportedRadio radio;
float dbm = 123;
uint32_t published = 456;
for (unsigned profile : {0U, 1U, 2U, 255U}) {
assert(!radio.getNoiseFloorReading(profile, dbm, published));
assert(dbm == 123 && published == 456);
radio.requestNoiseFloorMeasurement();
}
RadioLibWrapper w;
for (unsigned profile : {0U, 1U, 2U, 255U}) missing(w, profile);
} else if (!strcmp(scenario, "single")) {
RadioLibWrapper w;
baseline(w);
const uint32_t first = now_ms;
assert(reading(w, 0, -120) == first);
missing(w, 1); missing(w, 2); missing(w, 255);
w.requestNoiseFloorMeasurement();
assert(reading(w, 0, -120) == first);
fill(w._floor_estimator, -120);
w.loop();
assert(now_ms != first && reading(w, 0, -120) == now_ms);
const uint32_t second = now_ms;
w.requestNoiseFloorMeasurement();
fill(w._floor_estimator, -60);
w.loop();
assert(reading(w, 0, -120) == second); // held rise is not a publication
assert(w._nf_last_calib == now_ms && now_ms != second);
w.requestNoiseFloorMeasurement();
w.busy = true;
w.loop(); // arm a bounded window, then let it expire without idle RSSI
now_ms += NF_CONTINUOUS_TIMEOUT_MS;
w.loop();
assert(!w._nf_refresh_requested);
assert(reading(w, 0, -120) == second);
} else if (!strcmp(scenario, "dual")) {
RadioLibWrapper w;
dual(w); baseline(w);
const uint32_t first = now_ms;
assert(reading(w, 0, -120) == first && reading(w, 1, -118) == first);
w.requestNoiseFloorMeasurement();
fill(w._floor_estimator, -120);
fill(w._secondary_floor_estimator, -60); // one profile may hold while the other publishes
w.loop();
assert(reading(w, 0, -120) == now_ms);
assert(reading(w, 1, -118) == first);
const uint32_t primary = now_ms;
w.requestNoiseFloorMeasurement();
w.busy = true; w.loop();
now_ms += NF_CONTINUOUS_TIMEOUT_MS;
w.loop();
assert(!w._nf_refresh_requested);
assert(reading(w, 0, -120) == primary && reading(w, 1, -118) == first);
w._profiles.secondary.mode = mesh::RadioProfileMode::Off;
missing(w, 1);
} else if (!strcmp(scenario, "invalidate")) {
RadioLibWrapper w;
dual(w); baseline(w);
++w._profiles.generation[1]; // reject the old profile before scanner service
missing(w, 1);
assert(reading(w, 0, -120) == now_ms);
++w._profiles.generation[0]; missing(w, 0);
w.recalibrateNoiseFloor();
missing(w, 0); missing(w, 1);
assert(w._nf_published_valid == 0 && w._nf_refresh_requested);
RadioLibWrapper gain;
baseline(gain);
assert(gain.setRxBoostedGainMode(true));
missing(gain, 0);
RadioLibWrapper reset;
baseline(reset);
reset.resetAGC();
assert(reset._noise_floor_valid); // legacy filter can retain its last value
missing(reset, 0); // telemetry cannot claim that value was measured after reset
} else if (!strcmp(scenario, "rollover")) {
for (bool two : {false, true}) {
RadioLibWrapper w;
if (two) dual(w);
w._noise_floor_valid = w._noise_floor_secondary_valid = false;
now_ms = UINT32_MAX - 10000;
fill(w._floor_estimator, -120);
if (two) fill(w._secondary_floor_estimator, -118);
now_ms = 0;
w.loop();
assert(reading(w, 0, -120) == 0);
if (two) assert(reading(w, 1, -118) == 0);
w.requestNoiseFloorMeasurement();
fill(w._floor_estimator, -120);
if (two) fill(w._secondary_floor_estimator, -118);
w.loop();
assert(reading(w, 0, -120) == now_ms && now_ms != 0);
}
} else if (!strcmp(scenario, "request")) {
for (unsigned owner : {0U, 1U, 2U, 3U, 4U, 5U, 6U}) {
RadioLibWrapper w;
baseline(w);
const uint32_t published = now_ms;
if (owner == 0) w.packet = true;
if (owner == 1) w.state = STATE_TX_WAIT;
if (owner == 2) w._cw_active = true;
if (owner == 3) w._morse_owned = true;
if (owner == 4) w._morse_rx_owned = true;
if (owner == 5) w._beacon_owned = true;
if (owner == 6) w.busy = true;
const unsigned arms = w.arms, stops = w.stops, reads = w.reads;
const unsigned scans = w.radio.scans, resets = w.agc_resets;
w.requestNoiseFloorMeasurement();
assert(reading(w, 0, -120) == published && w._nf_refresh_requested);
assert(w._floor_estimator.add(-120, now_ms));
w.loop();
const auto deadline = w._nf_calib_deadline;
for (unsigned i = 0; i < 4; ++i) {
now_ms += 1;
w.requestNoiseFloorMeasurement();
assert(w._floor_estimator.count() == 1 && w._nf_calib_deadline == deadline);
}
assert(w.arms == arms && w.stops == stops && w.reads == reads);
assert(w.radio.scans == scans && w.agc_resets == resets);
assert(reading(w, 0, -120) == published);
}
} else assert(false);
printf("PASS: %s\n", scenario);
}
'''
class NoiseFloorReadingTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.directory = tempfile.TemporaryDirectory(prefix="meshcore-noise-reading-")
cls.addClassCleanup(cls.directory.cleanup)
work = Path(cls.directory.name)
source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text()
header = (ROOT / "src/helpers/radiolib/RadioLibWrappers.h").read_text()
dispatcher = (ROOT / "src/Dispatcher.h").read_text()
harness = transition_harness(source).split("struct LegacyRadio {", 1)[0]
api = "\n".join(method(header, signature).replace(" override", "") for signature in (
"bool getNoiseFloorReading(", "void requestNoiseFloorMeasurement("))
harness = harness.replace(" bool tryResetAGC();", api + "\n bool tryResetAGC();")
harness = harness.replace("#include <cassert>", "#include <cassert>\n#include <cstring>")
harness = harness.replace("bool serviceCarrierWave() { return false; }",
"bool _morse_owned = false, _morse_rx_owned = false, _beacon_owned = false;\n"
" bool serviceCarrierWave() { return _cw_active || _morse_owned || _morse_rx_owned; }")
loop = method(source, "void RadioLibWrapper::loop(")
harness = harness.replace(loop,
"#define MESH_ENABLE_TX_BEACONS 1\n#define MESH_ENABLE_CARRIER_TOOLS 1\n"
+ loop + "\n#undef MESH_ENABLE_TX_BEACONS\n#undef MESH_ENABLE_CARRIER_TOOLS\n")
fixture = FIXTURE.replace("@DEFAULT_GETTER@", method(dispatcher,
"virtual bool getNoiseFloorReading(")).replace("@DEFAULT_REQUEST@", method(dispatcher,
"virtual void requestNoiseFloorMeasurement("))
cls.binaries = {}
flags = [] if not sys.platform.startswith("linux") else [
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
for family in ("sx1262", "other"):
cpp, binary = work / (family + ".cpp"), work / family
cpp.write_text((harness + fixture).replace("\u2014", "-"), encoding="ascii")
built = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-g",
"-Wall", "-Wextra", "-Werror", *flags,
*(["-DUSE_SX1262=1"] if family == "sx1262" else []),
"-I", str(ROOT / "src"), str(cpp), "-o", str(binary)],
capture_output=True, text=True, timeout=60)
if built.returncode:
raise AssertionError(built.stdout + built.stderr)
cls.binaries[family] = binary
def run_case(self, scenario):
for family, binary in self.binaries.items():
with self.subTest(family=family):
result = subprocess.run([str(binary), scenario], capture_output=True,
text=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("PASS: " + scenario, result.stdout)
def test_unsupported_and_unmeasured_profiles_are_missing(self):
self.run_case("unsupported")
def test_single_only_successful_publications_advance_timestamp(self):
self.run_case("single")
def test_dual_publication_timestamps_remain_independent(self):
self.run_case("dual")
def test_profile_gain_and_rf_reset_invalidate_telemetry_readings(self):
self.run_case("invalidate")
def test_millis_zero_and_rollover_are_valid_publication_times(self):
self.run_case("rollover")
def test_requests_join_collection_without_interrupting_radio_owners(self):
self.run_case("request")
if __name__ == "__main__":
unittest.main()
+117
View File
@@ -0,0 +1,117 @@
"""Execute each LR instantaneous-RSSI method against failed hardware reads."""
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
def rssi_method(header):
start = header.index("float getCurrentRSSI() override {")
end = header.index("\n }", start) + len("\n }")
return header[start:end]
class NoiseFloorRssiErrorTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if not compiler:
raise RuntimeError("RSSI error tests require a host C++ compiler")
cls.work = tempfile.TemporaryDirectory(prefix="meshcore-rssi-errors-")
cls.addClassCleanup(cls.work.cleanup)
binary = Path(cls.work.name) / ("rssi.exe" if os.name == "nt" else "rssi")
classes = []
for family in ("LR1110", "LR1121", "LR2021"):
name = "Custom" + family + "Wrapper"
source = (ROOT / ("src/helpers/radiolib/" + name + ".h")).read_text()
classes.append("class " + name + " : public WrapperBase { public: "
+ rssi_method(source) + " };\n")
harness = r'''
#include <assert.h>
#include <math.h>
#include <stdint.h>
#include "helpers/radiolib/NoiseFloorEstimator.h"
constexpr int RADIOLIB_ERR_NONE = 0;
struct FakeRadio {
int status = 0;
float rssi = -119.25f;
bool write_value = true;
int getRssiInst(float* value) {
if (write_value) *value = rssi;
return status;
}
};
using CustomLR1110 = FakeRadio;
using CustomLR1121 = FakeRadio;
using CustomLR2021 = FakeRadio;
struct WrapperBase {
FakeRadio radio;
FakeRadio* _radio = &radio;
virtual ~WrapperBase() = default;
virtual float getCurrentRSSI() = 0;
};
@CLASSES@
template<class Wrapper> void exercise() {
Wrapper wrapper;
assert(wrapper.getCurrentRSSI() == -119.25f);
for (int status : {-1, -2, -16, 1}) {
wrapper.radio.status = status;
for (bool write : {false, true}) {
wrapper.radio.write_value = write;
NoiseFloorEstimator estimator;
for (unsigned sample = 0; sample < NoiseFloorEstimator::SAMPLE_COUNT; ++sample) {
const float rssi = wrapper.getCurrentRSSI();
assert(isnan(rssi));
assert(!estimator.add(rssi, sample * NoiseFloorEstimator::SAMPLE_INTERVAL_MS));
}
assert(estimator.count() == 0 && !estimator.complete());
int32_t floor = -12000;
assert(!estimator.publish(floor, false));
assert(floor == -12000);
}
}
wrapper.radio.status = 0;
wrapper.radio.write_value = false;
assert(isnan(wrapper.getCurrentRSSI()));
wrapper.radio.write_value = true;
assert(wrapper.getCurrentRSSI() == -119.25f);
NoiseFloorEstimator estimator;
for (unsigned sample = 0; sample < NoiseFloorEstimator::SAMPLE_COUNT; ++sample) {
assert(estimator.add(wrapper.getCurrentRSSI(), sample * NoiseFloorEstimator::SAMPLE_INTERVAL_MS));
}
int32_t floor = 0;
assert(estimator.publish(floor, false) && floor == -11925);
}
int main() {
exercise<CustomLR1110Wrapper>();
exercise<CustomLR1121Wrapper>();
exercise<CustomLR2021Wrapper>();
}
'''.replace("@CLASSES@", "\n".join(classes))
harness = harness.replace("#include <stdint.h>", "#include <stdint.h>\n#include <initializer_list>")
source = Path(cls.work.name) / "rssi.cpp"
source.write_text(harness, encoding="ascii")
flags = ["-std=c++11", "-O1", "-g", "-Wall", "-Wextra", "-Werror"]
if os.name != "nt":
flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"]
built = subprocess.run([compiler, *flags, "-I" + str(ROOT / "src"),
str(source), "-o", str(binary)],
capture_output=True, text=True, timeout=60)
if built.returncode:
raise AssertionError(built.stdout + built.stderr)
cls.binary = binary
def test_failed_reads_cannot_fabricate_noise_floor_samples(self):
result = subprocess.run([str(self.binary)], capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()
+70
View File
@@ -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()
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -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;
+3 -1
View File
@@ -76,6 +76,7 @@ HISTORY_HARNESS = r'''
#include <helpers/SensorManager.h>
#include <helpers/TelemetryHistory.h>
#include <helpers/ExternalVoltageHistory.h>
#include <helpers/NoiseFloorHistory.h>
#include <helpers/ClientACLResponse.h>
#include <Wire.h>
#define ATOMIC_FILE_WRITER_IMPLEMENTATION
@@ -138,12 +139,13 @@ public:
uint8_t reply_data[184];
mesh::TelemetryHistory telemetry_history;
mesh::ExternalVoltageHistory external_voltage_history;
mesh::NoiseFloorHistory noise_floor_history;
Clock* getRTCClock() {return &clock;}
void configureHistory() {
@HISTORY_INIT@
}
void sampleTelemetryHistory();
bool handleTelemetryHistoryCommand(const char*,char*);
bool handleTelemetryHistoryCommand(const char*,char*,size_t=160,bool=true);
int handleRequest(ClientInfo*,uint32_t,uint8_t*,size_t,size_t);
void handleCommand(uint32_t sender_timestamp,char* command,char* reply,
int gpio_client_index=-1,uint8_t gpio_path_hash_size=1) {
+2 -1
View File
@@ -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'