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
+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