The radio's noise: the GNSS receiver costs 8 dB; a setting pauses it (#20)

Debug Builds: `lora noise test` changes one thing at a time, Sweeps the
band, and reports the floor under each condition; it runs on the device
by itself, since one condition pauses Wi-Fi (not saved, so a restart
brings it back). Result, at 125 kHz: -117 dBm with the antenna switched
off, -106 with the GNSS receiver in standby, -98 with it running. The
receiver's serial line isn't it (one sentence a second changes nothing),
and neither are the main loop, the CPU frequency, Wi-Fi, the screen or
the radio's own regulator, all within 1 dB.

Settings > "Pause GNSS for LoRa", off by default: the receiver waits in
standby while the radio listens or sweeps, except during a Track, and
has a Fix again about 7 s after. The GNSS App says it's paused.

11 dB remain between the antenna with GNSS quiet and the chip alone,
untouched by anything that can be switched from the firmware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-06 03:41:15 +02:00
co-authored by Claude Opus 5.5
parent 06a593293d
commit 70fb37ebb5
17 changed files with 337 additions and 11 deletions
+3 -1
View File
@@ -80,7 +80,7 @@ On a page, `b` bookmarks it, `s` saves it to the SD card to read offline (a non-
## LoRa Scanner
The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **never transmits**. The Sniffer lists what it hears, newest first: time, RSSI, SNR, and for Meshtastic packets the sender and receiver (their last 4 hex digits) and hops. Enter shows a packet's details: the Meshtastic header (which is never encrypted) and a hex dump. `p` picks one of the 7 Meshtastic presets allowed in EU868 (LongFast by default), `c` starts or stops a Capture: a pcap file with LoRaTap headers in `/captures/lora/`, for Wireshark. A Capture keeps recording with the App closed; otherwise the radio sleeps when the App isn't open. Tab switches to **Sweep**: the signal strength across 863–870 MHz in 100 kHz steps, as bars with peak hold and a waterfall, with the Sniffer's frequency marked; the Sniffer is paused meanwhile and picks up where it was. The Status Bar shows `L` while the radio listens (bright for a moment on each packet), `SW` while sweeping, and `CAP` while capturing.
The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **never transmits**. The Sniffer lists what it hears, newest first: time, RSSI, SNR, and for Meshtastic packets the sender and receiver (their last 4 hex digits) and hops. Enter shows a packet's details: the Meshtastic header (which is never encrypted) and a hex dump. `p` picks one of the 7 Meshtastic presets allowed in EU868 (LongFast by default), `c` starts or stops a Capture: a pcap file with LoRaTap headers in `/captures/lora/`, for Wireshark. A Capture keeps recording with the App closed; otherwise the radio sleeps when the App isn't open. Tab switches to **Sweep**: the signal strength across 863–870 MHz in 100 kHz steps, as bars with peak hold and a waterfall, with the Sniffer's frequency marked; the Sniffer is paused meanwhile and picks up where it was. The GNSS receiver on the same Cap raises the radio's noise floor by 8 dB while it runs: Settings > "Pause GNSS for LoRa" (off by default) puts it in standby while the radio listens, except during a Track. The Status Bar shows `L` while the radio listens (bright for a moment on each packet), `SW` while sweeping, and `CAP` while capturing.
## Development aids
@@ -119,6 +119,8 @@ The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **neve
| `lora preset <name>` / `lora custom <MHz> <BW kHz> <SF> <CR> <sync hex> [preamble]` | Receive settings: a Meshtastic preset, or anything else (`lora custom 868.1 125 7 5 34 8` for LoRaWAN) |
| `lora capture start` / `lora capture stop` | A LoRa Capture, as `c` in the App |
| `lora sweep on [from MHz] [to MHz] [step kHz]` / `lora sweep off` / `lora sweep dump` | Sweep a band (863 870 100 by default), with a summary every 2 s (floor, strongest, peaks), or print the latest pass |
| `gnss quiet on` / `gnss quiet off` | The "Pause GNSS for LoRa" setting |
| `lora noise test [gnss\|quiet]` / `lora noise report` | Debug Builds: Sweep under one changed condition at a time to find what raises the noise floor (Wi-Fi goes off for a few seconds); then the result |
| `lora inject <hex> [rssi] [snr]` | Debug Builds: a packet into the Scanner as if received (nothing is sent) |
| `crash` | The last crash: which firmware, why, task, PC and backtrace (from the core dump in flash) |
| `coredump erase` | Forgets the core dump |
+24
View File
@@ -112,3 +112,27 @@ Nothing needs that. The keyboard controller buffers key events; the consoles and
- **Not measured:** the current drawn (no meter on the battery line), and how typing feels on the real keyboard: a key now waits up to 5 ms for the loop, 20 ms if it's the one that wakes the screen.
- **The radio's noise floor didn't move** (-97 to -99 dBm at 125 kHz either way): the spinning loop wasn't the source (issue #20).
- **Not done:** real sleep. The framework is built without power management (`CONFIG_PM_ENABLE` is off), so an idle core only halts until the next interrupt. Automatic light sleep would need the framework rebuilt with it, Wi-Fi in modem sleep, and the USB serial port's behaviour checked. A next step if battery life calls for it.
## The radio's noise: the GNSS receiver (issue #20)
M3 found the LoRa radio's noise floor about 15 dB above what the chip hears alone, and that the source travels with the device. Which part? Debug Builds got a self-test, `lora noise test`: it changes one thing at a time, Sweeps the band eight passes (568 readings), records the median as the floor, and puts the thing back. It runs on the device by itself, because one condition switches Wi-Fi off, and `lora noise report` prints the result afterwards.
### Measured (2026-10-06, indoors, on USB power, dBm at 125 kHz)
| Condition | Floor |
|---|---|
| Antenna switched off (the chip alone) | -117 |
| Antenna on, GNSS in standby | -106 |
| Antenna on, GNSS running (as shipped) | -98 |
- **The GNSS receiver, while it runs, raises the floor by 8 dB.** Three runs: -98 or -99 with it running, -106 in standby, every time. On LongFast (250 kHz) the Sniffer's own reading goes from about -93.5 to -101.5 dBm.
- **It's the receiver working, not its serial line:** with one NMEA sentence a second instead of twenty (`PCAS03`), the receiver still tracking, the floor stays at -98.
- **Nothing else moves it by more than 1 dB**, with GNSS running or in standby: the main loop spinning or resting, the CPU at 240, 160 or 80 MHz, Wi-Fi on or off, the screen on or off, the radio chip's regulator as DC-DC or LDO, its receive gain boosted or not.
- **11 dB remain** between the antenna connected with GNSS quiet (-106) and the chip alone (-117). It comes in through the antenna and none of those switches changes it: the surroundings, or parts of the Cardputer that can't be switched off. Not separated: that needs another place, or the antenna on a cable away from the case.
- M3's quick check had GNSS at "1 or 2 dB": it read one frequency for a few seconds, in a noisier spot. The median over the band is the better measure.
### What the firmware does about it
**Settings > Pause GNSS for LoRa**, off by default: while the LoRa radio listens or sweeps, the GNSS receiver waits in standby, and wakes when the radio goes back to sleep (a Fix again after about 7 s here). Never during a Track. The GNSS App says "GNSS is paused" meanwhile. `gnss quiet on|off` on the console.
It's off by default because GNSS on by default was decided in M2 (Q58), and from M4 the radio listens all the time: then "pause while listening" means GNSS mostly off, which is a decision about position, the clock and Tracks, for M4's design round (issue #23).
+4 -1
View File
@@ -20,7 +20,8 @@ const RowDef kRows[] = {
{Row::Region, Kind::Choice, "Region"}, {Row::Timezone, Kind::Choice, "Timezone"},
{Row::Brightness, Kind::Slider, "Brightness"}, {Row::DimTimeout, Kind::Choice, "Dim after"},
{Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Sound, Kind::Toggle, "Sound & LED"},
{Row::Gnss, Kind::Toggle, "GNSS"}, {Row::Coordinates, Kind::Toggle, "Coordinates"},
{Row::Gnss, Kind::Toggle, "GNSS"}, {Row::GnssQuiet, Kind::Toggle, "Pause GNSS for LoRa"},
{Row::Coordinates, Kind::Toggle, "Coordinates"},
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"},
{Row::Storage, Kind::Page, "Storage"},
{Row::Firmware, Kind::Page, "Firmware"},
@@ -81,6 +82,7 @@ std::string SettingsMenu::value(int i) const {
case Row::OffTimeout: return formatSeconds(settings_.getInt(Setting::OffTimeoutS));
case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off";
case Row::Gnss: return settings_.getBool(Setting::GnssEnabled) ? "On" : "Off";
case Row::GnssQuiet: return settings_.getBool(Setting::GnssQuietForLora) ? "On" : "Off";
case Row::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal";
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
@@ -126,6 +128,7 @@ std::string SettingsMenu::choose(int i, int c) {
void SettingsMenu::toggle(int i) {
if (row(i) == Row::Sound) settings_.setBool(Setting::Sound, !settings_.getBool(Setting::Sound));
if (row(i) == Row::Gnss) settings_.setBool(Setting::GnssEnabled, !settings_.getBool(Setting::GnssEnabled));
if (row(i) == Row::GnssQuiet) settings_.setBool(Setting::GnssQuietForLora, !settings_.getBool(Setting::GnssQuietForLora));
if (row(i) == Row::Coordinates) settings_.setBool(Setting::CoordinatesDms, !settings_.getBool(Setting::CoordinatesDms));
if (row(i) == Row::ProbeMacs) settings_.setBool(Setting::ProbeMacRaw, !settings_.getBool(Setting::ProbeMacRaw));
}
+1 -1
View File
@@ -11,7 +11,7 @@ namespace roro {
// values, choice lists and validation messages. Rendering and navigation live in the App.
class SettingsMenu {
public:
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, Coordinates, ProbeMacs, Wifi, Storage, Firmware, About };
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, Storage, Firmware, About };
enum class Kind { Text, Choice, Toggle, Slider, Page };
explicit SettingsMenu(Settings& settings) : settings_(settings) {}
+1
View File
@@ -39,6 +39,7 @@ const Definition kDefinitions[] = {
{"dns_always", Kind::Bool, 0, nullptr, 0, 1},
{"ntp1", Kind::String, 0, "pool.ntp.org", 1, 63},
{"ntp2", Kind::String, 0, "time.cloudflare.com", 0, 63},
{"gnss_quiet", Kind::Bool, 0, nullptr, 0, 1}, // off: GNSS stays on, as decided in M2 (Q58)
};
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
"every Setting needs a definition");
+1
View File
@@ -29,6 +29,7 @@ enum class Setting : uint8_t {
DnsAlways, // bool: use them on Automatic (DHCP) networks too, instead of DHCP's
Ntp1, // string: the first NTP server, a host name or an IPv4 address (S1, Q110)
Ntp2, // string: the second, or empty
GnssQuietForLora, // bool: put the GNSS receiver in standby while the LoRa radio listens (issue #20)
Count
};
+4 -2
View File
@@ -71,10 +71,12 @@ void GnssApp::draw(Canvas& c) {
if (!gnss_.on()) {
c.setFont(&fonts::bold);
c.setTextColor(theme::kText);
c.drawString("GNSS is off", 4, area.y + 4);
bool held = gnss_.heldForLora();
c.drawString(held ? "GNSS is paused" : "GNSS is off", 4, area.y + 4);
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
c.drawString("Settings > GNSS turns it on.", 4, area.y + 22);
c.drawString(held ? "The LoRa radio is listening, and" : "Settings > GNSS turns it on.", 4, area.y + 22);
if (held) c.drawString("Settings pauses GNSS for it.", 4, area.y + 22 + theme::kLineHeight);
return;
}
sky_ ? drawSky(c) : drawPosition(c);
+84
View File
@@ -38,6 +38,7 @@
#include "services/gnss_service.h"
#include "services/power_service.h"
#include "services/lora_capture_service.h"
#include "services/noise_test.h"
#include "services/radio_service.h"
#include "services/storage_service.h"
#include "services/update_service.h"
@@ -66,6 +67,9 @@ static ClockService* clockService;
static GnssService* gnssService;
static RadioService* radioService;
static LoraCaptureService* loraCapture;
#ifdef RORO_DEBUG
static NoiseTest* noiseTest;
#endif
static GeminiService* geminiService;
static SavedNetworks* savedNetworks;
static WifiService* wifi;
@@ -180,6 +184,9 @@ void setup() {
services.add(*gnssService);
services.add(*radioService);
loraCapture = new LoraCaptureService(*radioService, *storageService, *clockService, bus);
#ifdef RORO_DEBUG
noiseTest = new NoiseTest(*radioService);
#endif
services.add(*loraCapture);
services.add(*storageService);
services.add(*wifi);
@@ -414,6 +421,7 @@ static const char* const kHelp =
"lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)\n"
"lora sweep on [from MHz] [to MHz] [step kHz] | off | dump RSSI across a band (863 870 100)\n"
"lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)\n"
"gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)\n"
"gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
"crash the last crash: firmware, reason, task, backtrace\n"
"coredump erase forget the core dump in flash\n"
@@ -429,6 +437,7 @@ static const char* const kHelp =
"crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n"
"wifi ip ... try <seconds> | wifi ip keep a trial IP setting: back to the previous one unless kept\n"
"loop spin on|off make the main loop spin without resting, to compare load and radio noise\n"
"lora noise test [gnss|quiet] | lora noise report Sweep under one changed condition at a time (Wi-Fi goes off for a moment)\n"
"lora inject <hex> [rssi] [snr] a packet into the LoRa Scanner as if received (nothing is sent)\n"
"coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump\n"
"reset (Debug Console only) restart at once, even if the main loop is stuck\n"
@@ -503,6 +512,72 @@ static void runCommand(String line) {
if (line == "lora probe" && radioService) radioService->probe(console);
if (line == "lora status" && radioService) radioService->printStatus(console);
if ((line == "lora rx on" || line == "lora rx off") && radioService) radioService->setEcho(line.endsWith("on"));
#ifdef RORO_DEBUG
if (line == "lora noise report" && noiseTest) noiseTest->printReport(console);
if (line == "lora noise test" && noiseTest && !noiseTest->running()) {
// One thing changed at a time, each put back before the next (issue #20). Wi-Fi off cuts the
// Debug Console: the report prints when it's over, and `lora noise report` shows it again.
using C = NoiseTest::Condition;
auto radioOptions = [](bool ldo, bool boosted) { return [=]() { radioService->debugOptions(ldo, boosted); }; };
std::vector<C> conditions = {
{"as it is", nullptr, nullptr},
{"loop spinning", []() { loopSpin = true; }, []() { loopSpin = false; }},
{"GNSS in standby", []() { gnssService->send("PCAS12,30"); }, []() { gnssService->restart(millis()); }, 2500},
{"CPU at 160 MHz", []() { setCpuFrequencyMhz(160); }, []() { setCpuFrequencyMhz(240); }},
{"CPU at 80 MHz", []() { setCpuFrequencyMhz(80); }, []() { setCpuFrequencyMhz(240); }},
{"radio regulator: LDO", radioOptions(true, true), radioOptions(false, true)},
{"radio gain not boosted", radioOptions(false, false), radioOptions(false, true)},
{"as it is, again", nullptr, nullptr},
{"Wi-Fi off", []() { wifi->debugPause(true); }, []() { wifi->debugPause(false); }, 4000},
{"screen on", []() { power->onKey(millis()); }, nullptr, 1000},
{"as it is, at the end", nullptr, nullptr, 8000},
};
noiseTest->start(std::move(conditions));
}
if (line == "lora noise test quiet" && noiseTest && !noiseTest->running()) {
// The same conditions as the first test, with the GNSS receiver in standby throughout: with
// the loudest source out of the way, what else shows?
using C = NoiseTest::Condition;
auto radioOptions = [](bool ldo, bool boosted) { return [=]() { radioService->debugOptions(ldo, boosted); }; };
std::vector<C> conditions = {
{"GNSS on (reference)", nullptr, nullptr},
{"GNSS in standby", []() { gnssService->send("PCAS12,300"); }, nullptr, 2500},
{"+ loop spinning", []() { loopSpin = true; }, []() { loopSpin = false; }},
{"+ CPU at 160 MHz", []() { setCpuFrequencyMhz(160); }, []() { setCpuFrequencyMhz(240); }},
{"+ CPU at 80 MHz", []() { setCpuFrequencyMhz(80); }, []() { setCpuFrequencyMhz(240); }},
{"+ radio regulator: LDO", radioOptions(true, true), radioOptions(false, true)},
{"+ radio gain not boosted", radioOptions(false, false), radioOptions(false, true)},
{"GNSS in standby, again", nullptr, nullptr},
{"+ Wi-Fi off", []() { wifi->debugPause(true); }, []() { wifi->debugPause(false); }, 4000},
{"+ screen on", []() { power->onKey(millis()); }, nullptr, 1000},
{"+ antenna switched off", []() { radioService->debugAntenna(false); }, []() { radioService->debugAntenna(true); }},
{"+ antenna off, gain not boosted", []() { radioService->debugAntenna(false); radioService->debugOptions(false, false); },
[]() { radioService->debugAntenna(true); radioService->debugOptions(false, true); }},
{"GNSS in standby, at the end", nullptr, []() { gnssService->restart(millis()); }, 8000},
};
noiseTest->start(std::move(conditions));
}
if (line == "lora noise test gnss" && noiseTest && !noiseTest->running()) {
// The first test pointed at the GNSS receiver. Is it the receiver working, or its serial
// line (about 20 sentences a second, next to the antenna)? Standby stops both; one sentence
// a second quietens the line and leaves the receiver tracking. PCAS03 picks the sentences:
// GGA, GLL, GSA, GSV, RMC, VTG, ZDA, ANT; all eight are on by default.
using C = NoiseTest::Condition;
auto standby = []() { gnssService->send("PCAS12,30"); };
auto wake = []() { gnssService->restart(millis()); };
std::vector<C> conditions = {
{"as it is", nullptr, nullptr},
{"GNSS in standby", standby, wake, 2500},
{"GNSS on again", nullptr, nullptr, 5000},
{"GNSS: RMC only", []() { gnssService->send("PCAS03,0,0,0,0,1,0,0,0,0,0,,,0,0"); },
[]() { gnssService->send("PCAS03,1,1,1,1,1,1,1,1,0,0,,,0,0"); }, 2500},
{"GNSS: all sentences again", nullptr, nullptr, 3000},
{"GNSS in standby, again", standby, wake, 2500},
{"GNSS on again", nullptr, nullptr, 5000},
};
noiseTest->start(std::move(conditions));
}
#endif
if (line.startsWith("lora sweep") && radioService) { // on [from MHz] [to MHz] [step kHz] | off | dump
if (line == "lora sweep dump") radioService->printSweep(console);
else if (line == "lora sweep off") {
@@ -550,6 +625,10 @@ static void runCommand(String line) {
if (p) radioService->setPreset(*p);
console.printf("lora preset: %s\n", p ? p->name : "unknown (LongFast LongSlow MediumSlow MediumFast ShortSlow ShortFast LongMod)");
}
if (line == "gnss quiet on" || line == "gnss quiet off") { // Settings > Pause GNSS for LoRa (issue #20)
settings.setBool(Setting::GnssQuietForLora, line.endsWith("on"));
console.printf("gnss quiet: %s\n", line.endsWith("on") ? "GNSS pauses while the LoRa radio listens" : "GNSS stays on");
}
if (line == "gnss status" && gnssService) gnssService->printStatus(console, millis());
if ((line == "gnss nmea on" || line == "gnss nmea off") && gnssService) gnssService->setEcho(line.endsWith("on"));
if (line == "gnss restart" && gnssService) gnssService->restart(millis());
@@ -830,6 +909,7 @@ static void loopPass() {
tasksStep();
#ifdef RORO_DEBUG
ipTrialStep();
if (noiseTest) noiseTest->step(now);
#endif
noteStableOnce(now);
uploadStep();
@@ -843,6 +923,10 @@ static void loopPass() {
for (auto& e : events) apps->handleKey(e);
}
// Issue #20: the GNSS receiver costs the LoRa radio 8 dB while it runs. If the user chose so,
// it waits in standby while the radio listens or sweeps; never while a Track is recording.
gnssService->holdForLora(settings.getBool(Setting::GnssQuietForLora) && !gnssService->tracking() &&
(radioService->listening() || radioService->sweeping()));
services.tick(now);
bus.dispatch();
notifier->update(now);
+1 -1
View File
@@ -66,7 +66,7 @@ void GnssService::close() {
void GnssService::tick(uint32_t nowMs) {
if (!open_) open(nowMs);
bool wanted = settings_.getBool(Setting::GnssEnabled);
bool wanted = settings_.getBool(Setting::GnssEnabled) && !held_;
if (wanted && !active_) wake(nowMs);
if (!wanted && (active_ || !standbyMs_ || nowMs - standbyMs_ >= kStandbyRenewMs)) standby(nowMs);
+5 -1
View File
@@ -35,6 +35,10 @@ class GnssService : public Service {
void tick(uint32_t nowMs) override;
bool on() const { return active_; }
// Standby while the LoRa radio listens, when the user chose that (issue #20): the receiver
// raises the radio's noise floor by 8 dB while it runs. Never asked for during a Track.
void holdForLora(bool hold) { held_ = hold; }
bool heldForLora() const { return held_ && !active_; }
bool receiving(uint32_t nowMs) const { return active_ && lastLineMs_ && nowMs - lastLineMs_ < kSilentAfterMs; }
const gnss::GnssState& state() const { return parser_.state(); }
uint32_t onSinceMs() const { return openedMs_; } // when the receiver was last woken
@@ -71,7 +75,7 @@ class GnssService : public Service {
StorageService& storage_;
EventBus& bus_;
gnss::NmeaParser parser_;
bool open_ = false, active_ = false, echo_ = false;
bool open_ = false, active_ = false, echo_ = false, held_ = false;
uint32_t bytes_ = 0, standbyMs_ = 0;
uint32_t openedMs_ = 0, lastLineMs_ = 0, firstFixMs_ = 0, clockSetMs_ = 0;
gnss::FixType lastFix_ = gnss::FixType::None;
+110
View File
@@ -0,0 +1,110 @@
#ifdef RORO_DEBUG
#include "services/noise_test.h"
#include <Arduino.h>
#include <algorithm>
#include <cstdio>
#include "platform/console.h"
#include "sweep_view.h"
namespace roro {
void NoiseTest::start(std::vector<Condition> conditions, int passes) {
if (running()) return;
conditions_ = std::move(conditions);
results_.clear();
report_.clear();
passes_ = passes;
index_ = 0;
console.printf("noise test: %u conditions, %d passes each\n", (unsigned)conditions_.size(), passes_);
begin(millis());
}
void NoiseTest::begin(uint32_t nowMs) {
const Condition& c = conditions_[index_];
if (c.apply) c.apply();
radio_.sweep(true); // sets the radio up again, with whatever the condition changed
std::fill(std::begin(lowest_), std::end(lowest_), 0);
std::fill(std::begin(counts_), std::end(counts_), 0);
seen_ = 0;
phase_ = Phase::Settle;
phaseMs_ = nowMs;
}
void NoiseTest::step(uint32_t nowMs) {
if (phase_ == Phase::Idle) return;
const Condition& c = conditions_[index_];
if (phase_ == Phase::Settle) {
if (nowMs - phaseMs_ < c.settleMs) return;
lastSeq_ = radio_.sweeps();
phase_ = Phase::Sweep;
phaseMs_ = nowMs;
return;
}
SweepFrame f;
if (radio_.sweeps() != lastSeq_ && radio_.sweepFrame(f)) {
lastSeq_ = f.seq;
fromHz_ = f.fromHz, stepHz_ = f.stepHz, steps_ = f.steps;
for (uint16_t i = 0; i < f.steps; i++) {
lowest_[i] = seen_ ? std::min(lowest_[i], f.dbm[i]) : f.dbm[i];
counts_[std::clamp<int>(-f.dbm[i], 0, 127)]++;
}
seen_++;
}
bool stuck = nowMs - phaseMs_ > 20000; // the radio never swept: don't hang the test
if (seen_ < passes_ && !stuck) return;
Result r;
r.name = c.name;
if (seen_) {
// The floor: the median of every reading. The top and the peaks: from the lowest reading
// at each step, so a burst in one pass doesn't count and a steady carrier does.
uint32_t total = 0, half = static_cast<uint32_t>(seen_) * steps_ / 2;
for (int level = 127; level >= 0; level--) {
total += counts_[level];
if (total > half) {
r.floor = -level;
break;
}
}
lora::SweepStats st = lora::summarize(lowest_, steps_, fromHz_, stepHz_);
r.top = st.top;
char p[32];
for (auto& peak : st.peaks) {
std::snprintf(p, sizeof p, "%s%.1f %d", r.peaks.empty() ? "" : ", ", peak.hz / 1e6, peak.dbm);
r.peaks += p;
}
} else {
r.name += " (no sweep)";
}
results_.push_back(r);
radio_.sweep(false);
if (c.restore) c.restore();
if (++index_ < conditions_.size()) begin(nowMs);
else finish();
}
void NoiseTest::finish() {
phase_ = Phase::Idle;
char line[160];
report_.push_back("noise test: floor = median of every reading; top and peaks = steady (lowest of the passes), dBm at 125 kHz");
for (auto& r : results_) {
std::snprintf(line, sizeof line, "noise test: %-26s floor %4d top %4d %s", r.name.c_str(), r.floor, r.top,
r.peaks.empty() ? "no steady peak" : r.peaks.c_str());
report_.push_back(line);
}
report_.push_back("noise test: done");
printReport(console);
}
void NoiseTest::printReport(Print& out) const {
if (report_.empty()) return (void)out.println(running() ? "noise test: still running" : "noise test: none run yet");
for (auto& l : report_) out.println(l.c_str());
}
} // namespace roro
#endif // RORO_DEBUG
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#ifdef RORO_DEBUG
#include <Print.h>
#include <functional>
#include <string>
#include <vector>
#include "services/radio_service.h"
namespace roro {
// `lora noise test` (Debug Builds, issue #20): which part of the Cardputer raises the radio's
// noise floor? Applies one condition at a time, Sweeps the band a few passes, records the floor
// and the steady peaks, and puts things back. It runs from the main loop on its own, because one
// condition switches Wi-Fi off, and prints its report once the console can be reached again.
class NoiseTest {
public:
struct Condition {
std::string name;
std::function<void()> apply, restore; // either may be empty
uint32_t settleMs = 1500;
};
explicit NoiseTest(RadioService& radio) : radio_(radio) {}
void start(std::vector<Condition> conditions, int passes = 8);
void step(uint32_t nowMs); // from the main loop
bool running() const { return phase_ != Phase::Idle; }
void printReport(Print& out) const;
private:
enum class Phase { Idle, Settle, Sweep };
struct Result {
std::string name;
int floor = 0, top = 0;
std::string peaks; // steady ones: the lowest reading at each step over the passes
};
void begin(uint32_t nowMs);
void finish();
RadioService& radio_;
std::vector<Condition> conditions_;
std::vector<Result> results_;
size_t index_ = 0;
int passes_ = 8, seen_ = 0;
Phase phase_ = Phase::Idle;
uint32_t phaseMs_ = 0, lastSeq_ = 0;
int8_t lowest_[SweepFrame::kMaxSteps];
uint16_t counts_[128]; // how often each level (in -dBm) was read: for the median
uint32_t fromHz_ = 0, stepHz_ = 0;
uint16_t steps_ = 0;
std::vector<std::string> report_;
};
} // namespace roro
#endif // RORO_DEBUG
+9 -3
View File
@@ -71,19 +71,19 @@ bool RadioService::beginRadio(Print* report) {
Config c = config();
float mhz = c.frequencyHz / 1e6f;
int16_t state = radio_->begin(mhz, c.bandwidthKHz, c.spreadingFactor, c.codingRate, c.syncWord, 0, c.preamble,
tcxo_, false);
tcxo_, ldo_);
if (state != RADIOLIB_ERR_NONE && tcxo_ > 0) { // Meshtastic's TCXO_OPTIONAL: fall back to the crystal
if (report) report->printf("lora probe: begin with TCXO %.1f V: error %d, trying the crystal\n", tcxo_, state);
tcxo_ = 0;
state = radio_->begin(mhz, c.bandwidthKHz, c.spreadingFactor, c.codingRate, c.syncWord, 0, c.preamble, tcxo_,
false);
ldo_);
}
if (state != RADIOLIB_ERR_NONE) {
if (report) report->printf("lora probe: error %d, no SX1262 found\n", state);
return false;
}
radio_->setDio2AsRfSwitch(true); // DIO2 selects TX or RX in the switch, as in Meshtastic
radio_->setRxBoostedGainMode(true); // about 2 dB more sensitivity for about 2 mA
radio_->setRxBoostedGainMode(boostedGain_); // about 2 dB more sensitivity for about 2 mA
return true;
}
@@ -191,6 +191,12 @@ void RadioService::store(RadioPacket& p) {
}
#ifdef RORO_DEBUG
void RadioService::debugAntenna(bool on) {
auto& i2c = M5.In_I2C;
uint8_t out = i2c.readRegister8(kExpander, kOutput, kI2cFreq);
i2c.writeRegister8(kExpander, kOutput, on ? (out | 1) : (out & ~1), kI2cFreq);
}
void RadioService::inject(const uint8_t* data, size_t len, float rssi, float snr) {
if (!listening_) return (void)console.println("lora inject: not listening");
RadioPacket p;
+10
View File
@@ -100,6 +100,15 @@ class RadioService : public Service {
void setEcho(bool echo);
void probe(Print& out); // `lora probe`: runs on the radio task
#ifdef RORO_DEBUG
// For the noise self-test (issue #20): the chip's regulator as an LDO instead of its DC-DC
// converter, and receive gain boosted or not. Used the next time the radio is set up.
void debugOptions(bool ldo, bool boostedGain) {
ldo_ = ldo;
boostedGain_ = boostedGain;
}
// The antenna switch (the Cap's expander, P0), for the reference "what the chip hears alone".
// From the main loop only, which owns the I2C bus.
void debugAntenna(bool on);
// `lora inject`: a packet into the ring as if received, to test the App and Captures with no
// transmitter in range. Nothing goes on air.
void inject(const uint8_t* data, size_t len, float rssi, float snr);
@@ -132,6 +141,7 @@ class RadioService : public Service {
Config config_;
bool present_ = false, expander_ = false;
float tcxo_ = 1.8f;
std::atomic<bool> ldo_{false}, boostedGain_{true};
std::atomic<uint8_t> clients_{0};
std::atomic<bool> listening_{false}, configChanged_{false}, probeWanted_{false}, echo_{false};
std::atomic<uint32_t> seq_{0}, packets_{0}, crcErrors_{0}, radioErrors_{0}, restarts_{0};
+1 -1
View File
@@ -247,7 +247,7 @@ void WifiService::tick(uint32_t nowMs) {
apply(controller_.disconnected(nowMs));
}
apply(controller_.update(nowMs, settings_.getBool(Setting::WifiEnabled)));
apply(controller_.update(nowMs, settings_.getBool(Setting::WifiEnabled) && !paused_));
if (controller_.state() == State::Connected && nowMs - serversCheckedMs_ >= kServersEveryMs) applyServers(Why::Check);
+6
View File
@@ -56,6 +56,11 @@ class WifiService : public Service {
void ipSettingChanged(const std::string& ssid);
// The DNS or NTP settings changed: use them now.
void serversChanged() { applyServers(Why::SettingsChanged); }
#ifdef RORO_DEBUG
// The noise self-test switches the radio off for a few seconds. Not saved anywhere: a restart
// during the test brings Wi-Fi back, which a changed setting wouldn't.
void debugPause(bool paused) { paused_ = paused; }
#endif
// A Saved Network was added: try it now rather than after the retry delay.
void savedNetworksChanged() { controller_.retryNow(millis()); }
@@ -82,6 +87,7 @@ class WifiService : public Service {
uint32_t listScanSeq_ = 0;
bool ntpWaiting_ = false;
bool radioInitialised_ = false;
bool paused_ = false; // Debug Builds: off for a moment, whatever the setting says
bool fixed_ = false; // the network in use has a Fixed address
bool dnsFromSettings_ = false;
std::string ntpNames_[2]; // lwIP keeps the pointers, so the names live here
@@ -107,6 +107,19 @@ void test_names_are_text_rows_with_their_byte_limits() {
TEST_ASSERT_EQUAL_STRING("RORO", f.menu.value(shortName).c_str());
}
// Issue #20: the GNSS receiver raises the LoRa radio's noise floor by 8 dB while it runs. Pausing
// it while the radio listens is offered, and off unless chosen (GNSS on by default: M2, Q58).
void test_pause_gnss_for_lora_is_off_by_default() {
Fixture f;
int quiet = f.row(SettingsMenu::Row::GnssQuiet);
TEST_ASSERT_EQUAL_STRING("Pause GNSS for LoRa", f.menu.label(quiet).c_str());
TEST_ASSERT_EQUAL_STRING("Off", f.menu.value(quiet).c_str());
f.menu.toggle(quiet);
TEST_ASSERT_TRUE(f.settings.getBool(Setting::GnssQuietForLora));
TEST_ASSERT_EQUAL_STRING("On", f.menu.value(quiet).c_str());
TEST_ASSERT_TRUE(f.settings.getBool(Setting::GnssEnabled)); // the GNSS switch itself is untouched
}
void test_gnss_and_coordinate_rows_toggle() {
Fixture f;
int gnss = f.row(SettingsMenu::Row::Gnss), coords = f.row(SettingsMenu::Row::Coordinates);
@@ -131,6 +144,7 @@ int main() {
RUN_TEST(test_brightness_steps_with_left_right_within_bounds);
RUN_TEST(test_wifi_is_a_page);
RUN_TEST(test_names_are_text_rows_with_their_byte_limits);
RUN_TEST(test_pause_gnss_for_lora_is_off_by_default);
RUN_TEST(test_gnss_and_coordinate_rows_toggle);
return UNITY_END();
}