diff --git a/README.md b/README.md index 38f7b83..3e8878a 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,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. The Status Bar shows `L` while the radio listens (bright for a moment on each packet) 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 Status Bar shows `L` while the radio listens (bright for a moment on each packet), `SW` while sweeping, and `CAP` while capturing. ## Development aids @@ -96,6 +96,7 @@ The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **neve | `lora rx on` / `lora rx off` | Listens and prints each packet on the console | | `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 | | `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/M3.md b/docs/milestones/M3.md index f9cfd55..c89a3a7 100644 --- a/docs/milestones/M3.md +++ b/docs/milestones/M3.md @@ -1,6 +1,6 @@ # M3 — Radio bring-up: the LoRa Scanner -**Status:** in progress (branch `m3`): steps 1 to 4 done. +**Status:** in progress (branch `m3`): steps 1 to 5 done; the listening hour (step 6) is left. **Goal:** the LoRa radio on the Cap works, receive only: a Radio Service owns it and shares the SPI bus with the SD card safely, and a LoRa Scanner App shows what's on the air, either packets (Sniffer) or energy across the band (Sweep). Nothing in M3 can transmit. The mesh comes on top of this in M4 (receive) and M5 (transmit). @@ -19,6 +19,7 @@ - **Step 3:** the DIO1 interrupt works (a 100 ms receive timeout wakes the task after 105 ms). While listening: four 1.7 MB uploads and Gemini pages to the card, no radio or card errors (the card refuses a write about once in five uploads with the radio asleep too; `put` now catches it). The ring takes 9.8 KB while listening; the radio task's stack peaks at 2.0 KB. - **No packets yet, and a loud desk.** Twenty minutes on LongFast and on LoRaWAN's three uplink frequencies (SF7, SF9, SF12): no packet and no header, valid or not. The noise floor reads -83 to -94 dBm, against -112 dBm with the antenna switched off: 20 to 30 dB lost to something nearby, not the screen and not the GNSS receiver. Preamble detections are false alarms at this noise level (more on an empty frequency, 869.0 MHz, than on LongFast). - **Step 4:** a Capture made on the device reads back in TShark field for field (time, frequency, SF, RSSI, SNR, payload). A Capture keeps the radio listening with the App closed; stopping it puts the radio back to sleep. +- **Step 5:** a pass across 863–870 MHz (71 steps, the strongest of three RSSI readings at each, measured at 125 kHz) takes about 607 ms. At the desk the band is **flat at -100 to -102 dBm**, about 15 dB above the chip's own floor at 125 kHz, with a steady carrier at 863.2 MHz (-89 dBm at its strongest) and fainter lines elsewhere: broadband noise from nearby electronics rather than a transmitter. Sweep's waterfall takes 2.6 KB while shown; 91.9 KB free during a Sweep. The radio task's stack peaks at 1.9 KB. - **Cost:** RadioLib 7.8.1 and the probe add 23.6 KB of flash and 656 bytes of static RAM to the release firmware (1,679,843 bytes of 3,342,336). ## Decisions (design round 2026-10-05) diff --git a/lib/lora/src/sweep_view.cpp b/lib/lora/src/sweep_view.cpp new file mode 100644 index 0000000..be0ef81 --- /dev/null +++ b/lib/lora/src/sweep_view.cpp @@ -0,0 +1,48 @@ +#include "sweep_view.h" + +#include + +namespace roro::lora { + +namespace { +constexpr int kPeakAboveFloorDb = 10; +constexpr size_t kMaxPeaks = 3; +} // namespace + +SweepStats summarize(const int8_t* dbm, size_t steps, uint32_t fromHz, uint32_t stepHz) { + SweepStats s; + if (!steps) return s; + std::vector sorted(dbm, dbm + steps); + std::nth_element(sorted.begin(), sorted.begin() + steps / 2, sorted.end()); + s.floor = sorted[steps / 2]; + s.top = *std::max_element(dbm, dbm + steps); + for (size_t i = 0; i < steps; ++i) { + int v = dbm[i]; + bool localMax = (i == 0 || v >= dbm[i - 1]) && (i + 1 == steps || v > dbm[i + 1]); + if (localMax && v >= s.floor + kPeakAboveFloorDb) s.peaks.push_back({fromHz + static_cast(i) * stepHz, v}); + } + std::stable_sort(s.peaks.begin(), s.peaks.end(), [](const SweepPeak& a, const SweepPeak& b) { return a.dbm > b.dbm; }); + if (s.peaks.size() > kMaxPeaks) s.peaks.resize(kMaxPeaks); + return s; +} + +uint8_t heatLevel(int dbm, int low, int high) { + if (dbm <= low) return 0; + if (dbm >= high) return 255; + return static_cast((dbm - low) * 255 / (high - low) + ((dbm - low) * 255 % (high - low) ? 1 : 0)); +} + +uint16_t heatColor(uint8_t level) { + // Five segments of 51 steps each. + auto rgb = [](int r, int g, int b) { return static_cast((r >> 3) << 11 | (g >> 2) << 5 | (b >> 3)); }; + int seg = std::min(level / 51, 4), t = (level - seg * 51) * 255 / 51; + switch (seg) { + case 0: return rgb(0, 0, t); // black to blue + case 1: return rgb(0, t, 255); // blue to cyan + case 2: return rgb(0, 255, 255 - t); // cyan to green + case 3: return rgb(t, 255, 0); // green to yellow + default: return rgb(255, 255 - t, 0); // yellow to red + } +} + +} // namespace roro::lora diff --git a/lib/lora/src/sweep_view.h b/lib/lora/src/sweep_view.h new file mode 100644 index 0000000..a08f8da --- /dev/null +++ b/lib/lora/src/sweep_view.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +namespace roro::lora { + +// What a Sweep pass says about the band (M3, Q98): one RSSI reading (dBm) per step. +struct SweepPeak { + uint32_t hz; + int dbm; +}; +struct SweepStats { + int floor = 0; // the median: the band's noise floor, whatever a few signals do + int top = 0; // the strongest reading + std::vector peaks; // at most 3, strongest first, each a local maximum >= 10 dB above the floor +}; + +SweepStats summarize(const int8_t* dbm, size_t steps, uint32_t fromHz, uint32_t stepHz); + +// The waterfall's colours: a reading between `low` and `high` dBm as 0..255, then RGB565 along +// black, blue, cyan, green, yellow, red. +uint8_t heatLevel(int dbm, int low, int high); +uint16_t heatColor(uint8_t level); + +} // namespace roro::lora diff --git a/src/apps/lora_scanner_app.cpp b/src/apps/lora_scanner_app.cpp index bcdba92..0d4f350 100644 --- a/src/apps/lora_scanner_app.cpp +++ b/src/apps/lora_scanner_app.cpp @@ -8,6 +8,7 @@ #include "meshtastic_presets.h" #include "packet_view.h" +#include "sweep_view.h" #include "ui/canvas.h" #include "ui/fonts.h" #include "ui/widgets.h" @@ -30,7 +31,21 @@ void LoraScannerApp::onEnter() { requestRedraw(); } -void LoraScannerApp::onExit() { radio_.listen(RadioService::App, false); } +void LoraScannerApp::onExit() { + showSweep(false); + radio_.listen(RadioService::App, false); +} + +void LoraScannerApp::showSweep(bool on) { + if (on == (view_ == View::Sweep)) return; + radio_.sweep(on); + view_ = on ? View::Sweep : View::Packets; + peak_.clear(); + std::vector().swap(waterfall_); // its memory only while shown + sweepSteps_ = 0; + shownSweep_ = radio_.sweeps(); + requestRedraw(); +} bool LoraScannerApp::meshtasticSettings() const { return radio_.config().syncWord == meshtastic::kSyncWord; } @@ -41,6 +56,17 @@ void LoraScannerApp::say(const std::string& text) { } bool LoraScannerApp::onKey(const KeyEvent& e) { + if (e.key == Key::Tab && (view_ == View::Packets || view_ == View::Sweep)) { + showSweep(view_ == View::Packets); + return true; + } + if (view_ == View::Sweep) { + if (e.key == Key::Back) { + showSweep(false); + return true; + } + return e.key != Key::Home; // everything else stays here + } if (view_ == View::Details) { int last = std::max(0, static_cast(details_.size()) - 8); if (e.key == Key::Up) detailsTop_ = std::max(0, detailsTop_ - 1); @@ -130,6 +156,10 @@ std::string LoraScannerApp::timeOf(const RadioPacket& p) const { } void LoraScannerApp::update(uint32_t nowMs) { + if (view_ == View::Sweep) { + if (radio_.sweeps() != shownSweep_) requestRedraw(); + return; + } if (radio_.received() != shownSeq_ || nowMs - lastDrawMs_ >= 1000 || (!message_.empty() && nowMs - messageMs_ >= kMessageMs)) requestRedraw(); @@ -151,6 +181,7 @@ void LoraScannerApp::draw(Canvas& c) { case View::Packets: drawPackets(c); break; case View::Details: drawDetails(c); break; case View::Presets: drawPresets(c); break; + case View::Sweep: drawSweep(c); break; } } @@ -203,7 +234,7 @@ void LoraScannerApp::drawPackets(Canvas& c) { c.setFont(&fonts::small); bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs; c.setTextColor(showMessage ? theme::kWarning : theme::kMuted); - std::string keys = std::string("Enter: details p: preset c: ") + (capture_.capturing() ? "stop capture" : "capture"); + std::string keys = std::string("Enter: details p: preset Tab: sweep c: ") + (capture_.capturing() ? "stop" : "capture"); c.drawString(showMessage ? message_.c_str() : keys.c_str(), 4, area.y + area.h - 9); } @@ -229,3 +260,81 @@ void LoraScannerApp::drawPresets(Canvas& c) { } } // namespace roro + +namespace roro { + +// Sweep (Q98): bars of the latest pass with peak hold, the waterfall underneath (newest at the +// top), and where the Sniffer's preset sits in the band. +void LoraScannerApp::drawSweep(Canvas& c) { + const auto& area = theme::kContent; + constexpr int kLow = -120, kHigh = -60; // the scale, dBm: about the chip's floor to a near signal + SweepFrame f; + bool have = radio_.sweepFrame(f) && f.seq != 0; + c.setFont(&fonts::small); + c.setTextColor(theme::kMuted); + c.drawString("Sweep, the Sniffer is paused", 4, area.y + 2); + if (!have) { + c.drawString("Measuring...", 4, area.y + 20); + return; + } + // Keep peak hold and the waterfall for this band. + if (f.steps != sweepSteps_) { + sweepSteps_ = f.steps; + peak_.assign(f.steps, -127); + waterfall_.assign(static_cast(kWaterfallRows) * f.steps, -127); + } + if (f.seq != shownSweep_) { + shownSweep_ = f.seq; + for (uint16_t i = 0; i < f.steps; ++i) peak_[i] = std::max(peak_[i], f.dbm[i]); + std::copy_backward(waterfall_.begin(), waterfall_.end() - f.steps, waterfall_.end()); + std::copy(f.dbm, f.dbm + f.steps, waterfall_.begin()); + } + + // Layout: up to 3 px a step, left-aligned after the scale. + int x0 = 26, cell = std::max(1, std::min(3, (area.w - x0 - 2) / std::max(1, f.steps))); + int barTop = area.y + 12, barH = 46, barBottom = barTop + barH; + auto height = [&](int dbm) { return std::clamp((dbm - kLow) * barH / (kHigh - kLow), 0, barH); }; + c.setTextColor(theme::kMuted); + c.drawString("-60", 2, barTop - 1); + c.drawString("-120", 0, barBottom - 7); + for (uint16_t i = 0; i < f.steps; ++i) { + int x = x0 + i * cell, h = height(f.dbm[i]); + if (h > 0) c.fillRect(x, barBottom - h, std::max(1, cell - 1), h, theme::kAccent); + int ph = height(peak_[i]); + if (ph > 0) c.drawFastHLine(x, barBottom - ph, std::max(1, cell - 1), theme::kText); + } + // The Sniffer's frequency, when it's in the band. + uint32_t hz = radio_.config().frequencyHz; + if (hz >= f.fromHz && hz <= f.fromHz + (f.steps - 1) * f.stepHz) { + int x = x0 + static_cast((hz - f.fromHz) / f.stepHz) * cell + cell / 2; + for (int y = barTop; y < barBottom; y += 3) c.drawPixel(x, y, theme::kMessage); + } + + // The waterfall. + int wfTop = barBottom + 2; + for (int r = 0; r < kWaterfallRows; ++r) + for (uint16_t i = 0; i < f.steps; ++i) { + int8_t v = waterfall_[static_cast(r) * f.steps + i]; + if (v > -127) c.fillRect(x0 + i * cell, wfTop + r, cell, 1, lora::heatColor(lora::heatLevel(v, kLow, kHigh))); + } + + // The band's edges, its floor and its strongest signal. + lora::SweepStats st = lora::summarize(f.dbm, f.steps, f.fromHz, f.stepHz); + char line[64]; + int y = area.y + area.h - 9; + std::snprintf(line, sizeof line, "%.1f", f.fromHz / 1e6); + c.drawString(line, x0, wfTop + kWaterfallRows + 1); + std::snprintf(line, sizeof line, "%.1f MHz", (f.fromHz + (f.steps - 1) * f.stepHz) / 1e6); + c.setTextDatum(top_right); + c.drawString(line, x0 + f.steps * cell, wfTop + kWaterfallRows + 1); + c.setTextDatum(top_left); + if (st.peaks.empty()) + std::snprintf(line, sizeof line, "floor %d dBm, nothing above it Tab: sniffer", st.floor); + else + std::snprintf(line, sizeof line, "floor %d, %.1f MHz at %d dBm Tab: sniffer", st.floor, st.peaks[0].hz / 1e6, + st.peaks[0].dbm); + c.setTextColor(theme::kMuted); + c.drawString(line, 4, y); +} + +} // namespace roro diff --git a/src/apps/lora_scanner_app.h b/src/apps/lora_scanner_app.h index 3ee36c1..b8fe326 100644 --- a/src/apps/lora_scanner_app.h +++ b/src/apps/lora_scanner_app.h @@ -15,7 +15,8 @@ namespace roro { // The LoRa Scanner (docs/milestones/M3.md). Sniffer: the packets the radio hears, newest first, // with the Meshtastic header read (Q96) and a hex dump on Enter; p picks the preset (Q95), c starts -// or stops a Capture (Q97). The radio listens while the App is open (Q100). +// or stops a Capture (Q97). The radio listens while the App is open (Q100). Tab: Sweep, the band's +// RSSI as bars with peak hold and a waterfall (Q98); it pauses the Sniffer while shown (Q99). class LoraScannerApp : public App { public: LoraScannerApp(RadioService& radio, LoraCaptureService& capture, Settings& settings, ClockService& clock) @@ -27,12 +28,14 @@ class LoraScannerApp : public App { void draw(Canvas& c) override; private: - enum class View { Packets, Details, Presets }; + enum class View { Packets, Details, Presets, Sweep }; static constexpr int kListRows = 7; void drawPackets(Canvas& c); void drawDetails(Canvas& c); void drawPresets(Canvas& c); + void drawSweep(Canvas& c); + void showSweep(bool on); void openDetails(); std::string timeOf(const RadioPacket& p) const; bool meshtasticSettings() const; @@ -53,6 +56,12 @@ class LoraScannerApp : public App { uint32_t lastDrawMs_ = 0; std::string message_; uint32_t messageMs_ = 0; + // Sweep, only while shown: peak hold per step, and the waterfall, newest row first. + static constexpr int kWaterfallRows = 36; + uint32_t shownSweep_ = 0; + std::vector peak_; + std::vector waterfall_; // kWaterfallRows x steps + uint16_t sweepSteps_ = 0; }; } // namespace roro diff --git a/src/main.cpp b/src/main.cpp index b697342..c71a078 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -109,7 +109,8 @@ static StatusInfo currentStatus() { s.gnssSatellites = g.satellitesUsed; s.tracking = gnssService->tracking(); } - if (radioService && radioService->listening()) { + if (radioService && radioService->sweeping()) s.radio = StatusInfo::Radio::Sweep; + else if (radioService && radioService->listening()) { uint32_t last = radioService->lastPacketMs(); s.radio = last && millis() - last < 400 ? StatusInfo::Radio::Packet : StatusInfo::Radio::Listening; } @@ -364,6 +365,7 @@ static const char* const kHelp = "log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n" "lora probe | lora status | lora rx on|off | lora preset the LoRa radio, receive only\n" "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 status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send \n" "crash the last crash: firmware, reason, task, backtrace\n" @@ -432,6 +434,18 @@ 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")); + 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") { + radioService->setSweepEcho(false); + radioService->sweep(false); + } else if (line.startsWith("lora sweep on")) { + double from = 863, to = 870, step = 100; + sscanf(line.c_str() + 13, "%lf %lf %lf", &from, &to, &step); + radioService->setSweepEcho(true); + radioService->sweep(true, from * 1e6 + 0.5, to * 1e6 + 0.5, step * 1e3 + 0.5); + } + } if (line == "lora capture start" && loraCapture) { std::string why = loraCapture->start(millis()); console.printf("lora capture: %s\n", why.empty() ? loraCapture->path().c_str() : why.c_str()); diff --git a/src/services/radio_service.cpp b/src/services/radio_service.cpp index 7acccba..c000285 100644 --- a/src/services/radio_service.cpp +++ b/src/services/radio_service.cpp @@ -7,6 +7,7 @@ #include #include "meshtastic_header.h" +#include "sweep_view.h" #include "platform/console.h" #include "platform/pins.h" #include "platform/shared_spi.h" @@ -93,9 +94,15 @@ void RadioService::run() { : "no radio found"); for (;;) { uint32_t bits = 0; - xTaskNotifyWait(0, UINT32_MAX, &bits, listening_ ? pdMS_TO_TICKS(kNoiseEveryMs) : portMAX_DELAY); + xTaskNotifyWait(0, UINT32_MAX, &bits, + sweepWanted_ ? pdMS_TO_TICKS(20) : listening_ ? pdMS_TO_TICKS(kNoiseEveryMs) : portMAX_DELAY); if (probeWanted_.exchange(false)) runProbe(); if (!present_) continue; + if (sweepWanted_) { // a Sweep has the radio: the Sniffer waits, its packets kept (Q99) + sweepPass(); + continue; + } + if (sweeping_) endSweep(); bool want = clients_ != 0; if (want && !listening_) startListening(); else if (!want && listening_) stopListening(); @@ -226,6 +233,84 @@ bool RadioService::packet(uint32_t seq, RadioPacket& out) const { return ok; } +void RadioService::sweep(bool on, uint32_t fromHz, uint32_t toHz, uint32_t stepHz) { + if (on) { + stepHz = std::max(stepHz, 10000); + toHz = std::max(toHz, fromHz); + toHz = std::min(toHz, fromHz + stepHz * (SweepFrame::kMaxSteps - 1)); + sweepFrom_ = fromHz, sweepTo_ = toHz, sweepStep_ = stepHz; + } + sweepWanted_ = on; + wake(kRequest); +} + +bool RadioService::sweepFrame(SweepFrame& out) const { + if (!sweepSeq_) return false; + xSemaphoreTake(lock_, portMAX_DELAY); + out = frame_; + xSemaphoreGive(lock_); + return true; +} + +// One pass across the band. The first one sets the radio up: the Sniffer is paused, not stopped. +void RadioService::sweepPass() { + if (!sweeping_) { + if (listening_) { + radio_->clearPacketReceivedAction(); + listening_ = false; // paused; startListening() resumes on the same ring + } + if (!beginRadio(nullptr)) { + ++radioErrors_; + sweepWanted_ = false; + return; + } + radio_->setBandwidth(125); + radio_->setSpreadingFactor(7); + sweeping_ = true; + console.println("radio: sweeping, the Sniffer is paused"); + } + uint32_t from = sweepFrom_, to = sweepTo_, step = sweepStep_; + uint16_t steps = static_cast((to - from) / step + 1); + int8_t dbm[SweepFrame::kMaxSteps]; + uint32_t t0 = millis(); + for (uint16_t i = 0; i < steps && sweepWanted_; ++i) { + radio_->standby(); + // Image calibration is per band and slow; one calibration covers EU868, so skip it per step. + radio_->setFrequency((from + i * step) / 1e6f, i != 0); + radio_->startReceive(); + delayMicroseconds(1500); // RSSI settles + float best = -200; + for (int k = 0; k < 3; ++k) { + best = std::max(best, radio_->getRSSI(false)); + delayMicroseconds(300); + } + dbm[i] = static_cast(std::clamp(best, -127.0f, 0.0f)); + } + radio_->standby(); + if (!sweepWanted_) return; + xSemaphoreTake(lock_, portMAX_DELAY); + frame_.seq = sweepSeq_ + 1; + frame_.fromHz = from; + frame_.stepHz = step; + frame_.steps = steps; + frame_.passMs = static_cast(millis() - t0); + std::copy(dbm, dbm + steps, frame_.dbm); + xSemaphoreGive(lock_); + sweepSeq_ = frame_.seq; +} + +void RadioService::endSweep() { + sweeping_ = false; + console.println("radio: sweep stopped"); + if (clients_) startListening(); // the Sniffer again, with its settings and its packets + else { + radio_->sleep(); + xSemaphoreTake(lock_, portMAX_DELAY); + ring_.reset(); + xSemaphoreGive(lock_); + } +} + uint32_t RadioService::available() const { uint32_t newest = seq_, first = ringFirst_; if (!listening_ || newest < first) return 0; @@ -270,7 +355,14 @@ void RadioService::setEcho(bool echo) { } void RadioService::tick(uint32_t nowMs) { - (void)nowMs; + SweepFrame f; + if (sweepEcho_ && sweeping_ && nowMs - sweepEchoMs_ >= 2000 && sweepFrame(f)) { + sweepEchoMs_ = nowMs; + lora::SweepStats st = lora::summarize(f.dbm, f.steps, f.fromHz, f.stepHz); + console.printf("sweep: #%lu floor %d, top %d dBm", (unsigned long)f.seq, st.floor, st.top); + for (auto& p : st.peaks) console.printf(", %.1f MHz %d", p.hz / 1e6, p.dbm); + console.printf(" (%u ms a pass)\n", f.passMs); + } if (!echo_) return; for (uint32_t s = std::max(echoed_ + 1, seq_ > kRing ? seq_ - kRing + 1 : 1u); s <= seq_; ++s) { RadioPacket p; @@ -287,13 +379,25 @@ void RadioService::tick(uint32_t nowMs) { echoed_ = seq_; } +void RadioService::printSweep(Print& out) const { + SweepFrame f; + if (!sweepFrame(f)) return (void)out.println("sweep: none yet (lora sweep on)"); + out.printf("sweep: #%lu, %u steps of %lu kHz from %.3f MHz, %u ms\n", (unsigned long)f.seq, f.steps, + (unsigned long)(f.stepHz / 1000), f.fromHz / 1e6, f.passMs); + for (uint16_t i = 0; i < f.steps; i += 8) { + out.printf("%8.3f", (f.fromHz + i * f.stepHz) / 1e6); + for (uint16_t k = i; k < f.steps && k < i + 8; ++k) out.printf(" %4d", f.dbm[k]); + out.println(); + } +} + void RadioService::printStatus(Print& out) const { Config c = config(); Counters n = counters(); out.printf("radio: %s, %s, antenna switch %s\n", present_ ? "SX1262" : "no radio", tcxo_ > 0 ? "TCXO 1.8 V" : "crystal", expander_ ? "on (expander 0x43 P0)" : "expander missing"); out.printf("radio: %s, %s %.3f MHz, BW %.0f kHz, SF %u, CR 4/%u, sync 0x%02X, preamble %u\n", - listening_ ? "listening" : "asleep", c.name, c.frequencyHz / 1e6, c.bandwidthKHz, c.spreadingFactor, + sweeping_ ? "sweeping" : listening_ ? "listening" : "asleep", c.name, c.frequencyHz / 1e6, c.bandwidthKHz, c.spreadingFactor, c.codingRate, c.syncWord, c.preamble); out.printf("radio: clients%s%s%s%s\n", clients_ ? "" : " none", clients_ & App ? " App" : "", clients_ & Capture ? " Capture" : "", clients_ & Console ? " Console" : ""); diff --git a/src/services/radio_service.h b/src/services/radio_service.h index 0eb7d30..1a9c09d 100644 --- a/src/services/radio_service.h +++ b/src/services/radio_service.h @@ -30,6 +30,16 @@ struct RadioPacket { uint8_t data[255]; }; +// One Sweep pass (M3, Q98): the strongest of three instantaneous RSSI readings at each step. +struct SweepFrame { + static constexpr size_t kMaxSteps = 160; + uint32_t seq = 0; // 1, 2, 3… per pass + uint32_t fromHz = 0, stepHz = 0; + uint16_t steps = 0; + uint16_t passMs = 0; // how long the pass took + int8_t dbm[kMaxSteps]; +}; + // The LoRa radio on the Cap (CONTEXT.md: Radio Service; docs/milestones/M3.md). One task owns the // SX1262 and does all its SPI traffic, behind the bus lock the SD card uses (Q93); the main loop // only posts requests, and DIO1's interrupt only wakes the task. **Receive only (Q94): there is @@ -76,6 +86,15 @@ class RadioService : public Service { return {packets_, crcErrors_, radioErrors_, restarts_, preambles_, headers_, headerErrors_}; } + // Sweep (Q98, Q99): takes the radio across a band, pausing the Sniffer (its packets are kept) + // until stopped. EU868 by default: 863 to 870 MHz in 100 kHz steps, measured at 125 kHz. + void sweep(bool on, uint32_t fromHz = 863000000, uint32_t toHz = 870000000, uint32_t stepHz = 100000); + bool sweeping() const { return sweeping_; } + bool sweepFrame(SweepFrame& out) const; // the latest pass; false before the first + uint32_t sweeps() const { return sweepSeq_; } + void setSweepEcho(bool echo) { sweepEcho_ = echo; } // a summary on the console every 2 s + void printSweep(Print& out) const; // `lora sweep dump`: the latest pass + // Debug aids (Q102). void printStatus(Print& out) const; void setEcho(bool echo); @@ -100,6 +119,8 @@ class RadioService : public Service { void store(RadioPacket& p); void sampleNoise(); void runProbe(); + void sweepPass(); + void endSweep(); void wake(Notify why); std::unique_ptr hal_; @@ -117,8 +138,12 @@ class RadioService : public Service { std::atomic preambles_{0}, headers_{0}, headerErrors_{0}; std::atomic lastPacketMs_{0}; std::atomic ringFirst_{1}; // the first seq the current ring can hold + std::atomic sweepWanted_{false}, sweeping_{false}; + std::atomic sweepFrom_{863000000}, sweepTo_{870000000}, sweepStep_{100000}, sweepSeq_{0}; + SweepFrame frame_; // under lock_ std::atomic noise_{0}; - uint32_t lastNoiseMs_ = 0, echoed_ = 0; + uint32_t lastNoiseMs_ = 0, echoed_ = 0, sweepEchoMs_ = 0; + bool sweepEcho_ = false; Print* probeOut_ = nullptr; static RadioService* instance_; }; diff --git a/src/ui/widgets.cpp b/src/ui/widgets.cpp index 7438543..6ef8f05 100644 --- a/src/ui/widgets.cpp +++ b/src/ui/widgets.cpp @@ -53,6 +53,7 @@ void statusBar(Canvas& c, const StatusInfo& info) { switch (info.radio) { // Q101: muted while listening, bright for a moment on each packet case StatusInfo::Radio::Listening: right("L", kMuted); break; case StatusInfo::Radio::Packet: right("L", kAccent); break; + case StatusInfo::Radio::Sweep: right("SW", kMuted); break; // the Sniffer is paused case StatusInfo::Radio::None: break; } switch (info.gnss) { // Q61: muted while searching, normal with a Fix, the count with a 3D Fix diff --git a/src/ui/widgets.h b/src/ui/widgets.h index 9ab6253..7f8ec7a 100644 --- a/src/ui/widgets.h +++ b/src/ui/widgets.h @@ -27,7 +27,7 @@ struct StatusInfo { enum class Gnss { None, Searching, TwoD, ThreeD } gnss = Gnss::None; // None: GNSS off (Q61) int gnssSatellites = 0; // used in the Fix bool tracking = false; // a Track is recording (Q63) - enum class Radio { None, Listening, Packet } radio = Radio::None; // M3, Q101: Packet flashes + enum class Radio { None, Listening, Packet, Sweep } radio = Radio::None; // M3, Q101: Packet flashes bool capturing = false; // a LoRa Capture is recording (Q97) bool operator==(const StatusInfo& o) const { diff --git a/test/test_sweep/test_sweep.cpp b/test/test_sweep/test_sweep.cpp new file mode 100644 index 0000000..e739e49 --- /dev/null +++ b/test/test_sweep/test_sweep.cpp @@ -0,0 +1,74 @@ +#include + +#include + +#include "sweep_view.h" + +using namespace roro::lora; + +void setUp() {} +void tearDown() {} + +// 863.0 to 870.0 MHz in 100 kHz steps: 71 readings. +static std::vector band(int8_t floor) { return std::vector(71, floor); } + +void test_floor_is_the_median() { + auto b = band(-95); + b[10] = -60; // a few strong steps don't move the median + b[11] = -62; + b[50] = -70; + SweepStats s = summarize(b.data(), b.size(), 863000000, 100000); + TEST_ASSERT_EQUAL_INT(-95, s.floor); + TEST_ASSERT_EQUAL_INT(-60, s.top); +} + +void test_peaks_strongest_first_and_merged() { + auto b = band(-95); + b[11] = -70; // 864.1 MHz, with shoulders: one peak, not three + b[10] = -75; + b[12] = -78; + b[65] = -55; // 869.5 MHz + b[30] = -90; // only 5 dB above the floor: not a peak + SweepStats s = summarize(b.data(), b.size(), 863000000, 100000); + TEST_ASSERT_EQUAL_size_t(2, s.peaks.size()); + TEST_ASSERT_EQUAL_UINT32(869500000u, s.peaks[0].hz); + TEST_ASSERT_EQUAL_INT(-55, s.peaks[0].dbm); + TEST_ASSERT_EQUAL_UINT32(864100000u, s.peaks[1].hz); +} + +void test_at_most_three_peaks() { + auto b = band(-100); + for (int i = 5; i < 70; i += 10) b[i] = static_cast(-80 + i / 10); + SweepStats s = summarize(b.data(), b.size(), 863000000, 100000); + TEST_ASSERT_EQUAL_size_t(3, s.peaks.size()); + TEST_ASSERT_EQUAL_INT(-74, s.peaks[0].dbm); // the strongest three +} + +void test_flat_band_has_no_peaks() { + auto b = band(-88); + SweepStats s = summarize(b.data(), b.size(), 863000000, 100000); + TEST_ASSERT_EQUAL_size_t(0, s.peaks.size()); + TEST_ASSERT_EQUAL_INT(-88, s.floor); +} + +// The waterfall's scale: dark at the bottom, through blue and green to red at the top. +void test_heat_scale() { + TEST_ASSERT_EQUAL_UINT8(0, heatLevel(-130, -120, -60)); + TEST_ASSERT_EQUAL_UINT8(0, heatLevel(-120, -120, -60)); + TEST_ASSERT_EQUAL_UINT8(255, heatLevel(-60, -120, -60)); + TEST_ASSERT_EQUAL_UINT8(255, heatLevel(-20, -120, -60)); + TEST_ASSERT_EQUAL_UINT8(128, heatLevel(-90, -120, -60)); + TEST_ASSERT_TRUE(heatColor(0) != heatColor(255)); + TEST_ASSERT_EQUAL_HEX16(0x0000, heatColor(0)); // black + TEST_ASSERT_EQUAL_HEX16(0xF800, heatColor(255)); // red +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_floor_is_the_median); + RUN_TEST(test_peaks_strongest_first_and_merged); + RUN_TEST(test_at_most_three_peaks); + RUN_TEST(test_flat_band_has_no_peaks); + RUN_TEST(test_heat_scale); + return UNITY_END(); +}