diff --git a/README.md b/README.md index b409bb1..c9ae822 100644 --- a/README.md +++ b/README.md @@ -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 ` / `lora custom [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 [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 | diff --git a/docs/milestones/S1.md b/docs/milestones/S1.md index 5b27276..132bff3 100644 --- a/docs/milestones/S1.md +++ b/docs/milestones/S1.md @@ -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). diff --git a/lib/apps_model/src/settings_menu.cpp b/lib/apps_model/src/settings_menu.cpp index bd8c019..176246e 100644 --- a/lib/apps_model/src/settings_menu.cpp +++ b/lib/apps_model/src/settings_menu.cpp @@ -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)); } diff --git a/lib/apps_model/src/settings_menu.h b/lib/apps_model/src/settings_menu.h index 367b58c..176cea9 100644 --- a/lib/apps_model/src/settings_menu.h +++ b/lib/apps_model/src/settings_menu.h @@ -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) {} diff --git a/lib/services/src/settings.cpp b/lib/services/src/settings.cpp index bd69a54..1bb9bfa 100644 --- a/lib/services/src/settings.cpp +++ b/lib/services/src/settings.cpp @@ -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(Setting::Count), "every Setting needs a definition"); diff --git a/lib/services/src/settings.h b/lib/services/src/settings.h index 5b589a7..fc5d158 100644 --- a/lib/services/src/settings.h +++ b/lib/services/src/settings.h @@ -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 }; diff --git a/src/apps/gnss_app.cpp b/src/apps/gnss_app.cpp index a92a381..839d9bf 100644 --- a/src/apps/gnss_app.cpp +++ b/src/apps/gnss_app.cpp @@ -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); diff --git a/src/main.cpp b/src/main.cpp index 6e4d2b4..e40b49b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 [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 \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 | 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 [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 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 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 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); diff --git a/src/services/gnss_service.cpp b/src/services/gnss_service.cpp index a769c11..e6b44bb 100644 --- a/src/services/gnss_service.cpp +++ b/src/services/gnss_service.cpp @@ -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); diff --git a/src/services/gnss_service.h b/src/services/gnss_service.h index 8051a5a..7faee41 100644 --- a/src/services/gnss_service.h +++ b/src/services/gnss_service.h @@ -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; diff --git a/src/services/noise_test.cpp b/src/services/noise_test.cpp new file mode 100644 index 0000000..c019ca4 --- /dev/null +++ b/src/services/noise_test.cpp @@ -0,0 +1,110 @@ +#ifdef RORO_DEBUG + +#include "services/noise_test.h" + +#include + +#include +#include + +#include "platform/console.h" +#include "sweep_view.h" + +namespace roro { + +void NoiseTest::start(std::vector 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(-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(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 diff --git a/src/services/noise_test.h b/src/services/noise_test.h new file mode 100644 index 0000000..4b33524 --- /dev/null +++ b/src/services/noise_test.h @@ -0,0 +1,59 @@ +#pragma once + +#ifdef RORO_DEBUG + +#include + +#include +#include +#include + +#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 apply, restore; // either may be empty + uint32_t settleMs = 1500; + }; + + explicit NoiseTest(RadioService& radio) : radio_(radio) {} + void start(std::vector 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 conditions_; + std::vector 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 report_; +}; + +} // namespace roro + +#endif // RORO_DEBUG diff --git a/src/services/radio_service.cpp b/src/services/radio_service.cpp index c000285..68e50cd 100644 --- a/src/services/radio_service.cpp +++ b/src/services/radio_service.cpp @@ -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; diff --git a/src/services/radio_service.h b/src/services/radio_service.h index 1a9c09d..0b896e6 100644 --- a/src/services/radio_service.h +++ b/src/services/radio_service.h @@ -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 ldo_{false}, boostedGain_{true}; std::atomic clients_{0}; std::atomic listening_{false}, configChanged_{false}, probeWanted_{false}, echo_{false}; std::atomic seq_{0}, packets_{0}, crcErrors_{0}, radioErrors_{0}, restarts_{0}; diff --git a/src/services/wifi_service.cpp b/src/services/wifi_service.cpp index f7971a9..25b76a8 100644 --- a/src/services/wifi_service.cpp +++ b/src/services/wifi_service.cpp @@ -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); diff --git a/src/services/wifi_service.h b/src/services/wifi_service.h index 53be347..5d77a84 100644 --- a/src/services/wifi_service.h +++ b/src/services/wifi_service.h @@ -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 diff --git a/test/test_settings_menu/test_settings_menu.cpp b/test/test_settings_menu/test_settings_menu.cpp index 1eebeb2..7506742 100644 --- a/test/test_settings_menu/test_settings_menu.cpp +++ b/test/test_settings_menu/test_settings_menu.cpp @@ -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(); }