Public Access
M3 step 5: Sweep, the band's signal strength as bars and a waterfall
Tab in the LoRa Scanner sweeps 863-870 MHz in 100 kHz steps (the strongest of three RSSI readings at each, at 125 kHz), shown as bars with peak hold over a waterfall, the Sniffer's frequency marked (Q98). The Sweep pauses the Sniffer and keeps its packets; Tab resumes it (Q99). Status Bar: SW. The floor, top and peaks are host-tested; `lora sweep on|off|dump` prints them on the console. At the desk: about 607 ms a pass, a flat floor at -100 to -102 dBm (15 dB above the chip's own) and a steady carrier at 863.2 MHz. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -62,7 +62,7 @@ On a page, `b` bookmarks it, `s` saves it to the SD card to read offline (a non-
|
|||||||
|
|
||||||
## LoRa Scanner
|
## 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
|
## 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 rx on` / `lora rx off` | Listens and prints each packet on the console |
|
||||||
| `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 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 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 <hex> [rssi] [snr]` | Debug Builds: a packet into the Scanner as if received (nothing is sent) |
|
| `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) |
|
| `crash` | The last crash: which firmware, why, task, PC and backtrace (from the core dump in flash) |
|
||||||
| `coredump erase` | Forgets the core dump |
|
| `coredump erase` | Forgets the core dump |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# M3 — Radio bring-up: the LoRa Scanner
|
# 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).
|
**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.
|
- **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).
|
- **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 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).
|
- **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)
|
## Decisions (design round 2026-10-05)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#include "sweep_view.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
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<int8_t> 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<uint32_t>(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<uint8_t>((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<uint16_t>((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
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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<SweepPeak> 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
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
#include "meshtastic_presets.h"
|
#include "meshtastic_presets.h"
|
||||||
#include "packet_view.h"
|
#include "packet_view.h"
|
||||||
|
#include "sweep_view.h"
|
||||||
#include "ui/canvas.h"
|
#include "ui/canvas.h"
|
||||||
#include "ui/fonts.h"
|
#include "ui/fonts.h"
|
||||||
#include "ui/widgets.h"
|
#include "ui/widgets.h"
|
||||||
@@ -30,7 +31,21 @@ void LoraScannerApp::onEnter() {
|
|||||||
requestRedraw();
|
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<int8_t>().swap(waterfall_); // its memory only while shown
|
||||||
|
sweepSteps_ = 0;
|
||||||
|
shownSweep_ = radio_.sweeps();
|
||||||
|
requestRedraw();
|
||||||
|
}
|
||||||
|
|
||||||
bool LoraScannerApp::meshtasticSettings() const { return radio_.config().syncWord == meshtastic::kSyncWord; }
|
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) {
|
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) {
|
if (view_ == View::Details) {
|
||||||
int last = std::max<int>(0, static_cast<int>(details_.size()) - 8);
|
int last = std::max<int>(0, static_cast<int>(details_.size()) - 8);
|
||||||
if (e.key == Key::Up) detailsTop_ = std::max(0, detailsTop_ - 1);
|
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) {
|
void LoraScannerApp::update(uint32_t nowMs) {
|
||||||
|
if (view_ == View::Sweep) {
|
||||||
|
if (radio_.sweeps() != shownSweep_) requestRedraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (radio_.received() != shownSeq_ || nowMs - lastDrawMs_ >= 1000 ||
|
if (radio_.received() != shownSeq_ || nowMs - lastDrawMs_ >= 1000 ||
|
||||||
(!message_.empty() && nowMs - messageMs_ >= kMessageMs))
|
(!message_.empty() && nowMs - messageMs_ >= kMessageMs))
|
||||||
requestRedraw();
|
requestRedraw();
|
||||||
@@ -151,6 +181,7 @@ void LoraScannerApp::draw(Canvas& c) {
|
|||||||
case View::Packets: drawPackets(c); break;
|
case View::Packets: drawPackets(c); break;
|
||||||
case View::Details: drawDetails(c); break;
|
case View::Details: drawDetails(c); break;
|
||||||
case View::Presets: drawPresets(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);
|
c.setFont(&fonts::small);
|
||||||
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
|
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
|
||||||
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
|
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);
|
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
|
||||||
|
|
||||||
|
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<size_t>(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<int>(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<int>((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<size_t>(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
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ namespace roro {
|
|||||||
|
|
||||||
// The LoRa Scanner (docs/milestones/M3.md). Sniffer: the packets the radio hears, newest first,
|
// 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
|
// 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 {
|
class LoraScannerApp : public App {
|
||||||
public:
|
public:
|
||||||
LoraScannerApp(RadioService& radio, LoraCaptureService& capture, Settings& settings, ClockService& clock)
|
LoraScannerApp(RadioService& radio, LoraCaptureService& capture, Settings& settings, ClockService& clock)
|
||||||
@@ -27,12 +28,14 @@ class LoraScannerApp : public App {
|
|||||||
void draw(Canvas& c) override;
|
void draw(Canvas& c) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
enum class View { Packets, Details, Presets };
|
enum class View { Packets, Details, Presets, Sweep };
|
||||||
static constexpr int kListRows = 7;
|
static constexpr int kListRows = 7;
|
||||||
|
|
||||||
void drawPackets(Canvas& c);
|
void drawPackets(Canvas& c);
|
||||||
void drawDetails(Canvas& c);
|
void drawDetails(Canvas& c);
|
||||||
void drawPresets(Canvas& c);
|
void drawPresets(Canvas& c);
|
||||||
|
void drawSweep(Canvas& c);
|
||||||
|
void showSweep(bool on);
|
||||||
void openDetails();
|
void openDetails();
|
||||||
std::string timeOf(const RadioPacket& p) const;
|
std::string timeOf(const RadioPacket& p) const;
|
||||||
bool meshtasticSettings() const;
|
bool meshtasticSettings() const;
|
||||||
@@ -53,6 +56,12 @@ class LoraScannerApp : public App {
|
|||||||
uint32_t lastDrawMs_ = 0;
|
uint32_t lastDrawMs_ = 0;
|
||||||
std::string message_;
|
std::string message_;
|
||||||
uint32_t messageMs_ = 0;
|
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<int8_t> peak_;
|
||||||
|
std::vector<int8_t> waterfall_; // kWaterfallRows x steps
|
||||||
|
uint16_t sweepSteps_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace roro
|
} // namespace roro
|
||||||
|
|||||||
+15
-1
@@ -109,7 +109,8 @@ static StatusInfo currentStatus() {
|
|||||||
s.gnssSatellites = g.satellitesUsed;
|
s.gnssSatellites = g.satellitesUsed;
|
||||||
s.tracking = gnssService->tracking();
|
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();
|
uint32_t last = radioService->lastPacketMs();
|
||||||
s.radio = last && millis() - last < 400 ? StatusInfo::Radio::Packet : StatusInfo::Radio::Listening;
|
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"
|
"log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n"
|
||||||
"lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only\n"
|
"lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only\n"
|
||||||
"lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)\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 <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)\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 status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\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"
|
"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 probe" && radioService) radioService->probe(console);
|
||||||
if (line == "lora status" && radioService) radioService->printStatus(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 == "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) {
|
if (line == "lora capture start" && loraCapture) {
|
||||||
std::string why = loraCapture->start(millis());
|
std::string why = loraCapture->start(millis());
|
||||||
console.printf("lora capture: %s\n", why.empty() ? loraCapture->path().c_str() : why.c_str());
|
console.printf("lora capture: %s\n", why.empty() ? loraCapture->path().c_str() : why.c_str());
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <new>
|
#include <new>
|
||||||
|
|
||||||
#include "meshtastic_header.h"
|
#include "meshtastic_header.h"
|
||||||
|
#include "sweep_view.h"
|
||||||
#include "platform/console.h"
|
#include "platform/console.h"
|
||||||
#include "platform/pins.h"
|
#include "platform/pins.h"
|
||||||
#include "platform/shared_spi.h"
|
#include "platform/shared_spi.h"
|
||||||
@@ -93,9 +94,15 @@ void RadioService::run() {
|
|||||||
: "no radio found");
|
: "no radio found");
|
||||||
for (;;) {
|
for (;;) {
|
||||||
uint32_t bits = 0;
|
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 (probeWanted_.exchange(false)) runProbe();
|
||||||
if (!present_) continue;
|
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;
|
bool want = clients_ != 0;
|
||||||
if (want && !listening_) startListening();
|
if (want && !listening_) startListening();
|
||||||
else if (!want && listening_) stopListening();
|
else if (!want && listening_) stopListening();
|
||||||
@@ -226,6 +233,84 @@ bool RadioService::packet(uint32_t seq, RadioPacket& out) const {
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RadioService::sweep(bool on, uint32_t fromHz, uint32_t toHz, uint32_t stepHz) {
|
||||||
|
if (on) {
|
||||||
|
stepHz = std::max<uint32_t>(stepHz, 10000);
|
||||||
|
toHz = std::max(toHz, fromHz);
|
||||||
|
toHz = std::min<uint32_t>(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<uint16_t>((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<int8_t>(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<uint16_t>(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 RadioService::available() const {
|
||||||
uint32_t newest = seq_, first = ringFirst_;
|
uint32_t newest = seq_, first = ringFirst_;
|
||||||
if (!listening_ || newest < first) return 0;
|
if (!listening_ || newest < first) return 0;
|
||||||
@@ -270,7 +355,14 @@ void RadioService::setEcho(bool echo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void RadioService::tick(uint32_t nowMs) {
|
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;
|
if (!echo_) return;
|
||||||
for (uint32_t s = std::max(echoed_ + 1, seq_ > kRing ? seq_ - kRing + 1 : 1u); s <= seq_; ++s) {
|
for (uint32_t s = std::max(echoed_ + 1, seq_ > kRing ? seq_ - kRing + 1 : 1u); s <= seq_; ++s) {
|
||||||
RadioPacket p;
|
RadioPacket p;
|
||||||
@@ -287,13 +379,25 @@ void RadioService::tick(uint32_t nowMs) {
|
|||||||
echoed_ = seq_;
|
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 {
|
void RadioService::printStatus(Print& out) const {
|
||||||
Config c = config();
|
Config c = config();
|
||||||
Counters n = counters();
|
Counters n = counters();
|
||||||
out.printf("radio: %s, %s, antenna switch %s\n", present_ ? "SX1262" : "no radio",
|
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");
|
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",
|
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);
|
c.codingRate, c.syncWord, c.preamble);
|
||||||
out.printf("radio: clients%s%s%s%s\n", clients_ ? "" : " none", clients_ & App ? " App" : "",
|
out.printf("radio: clients%s%s%s%s\n", clients_ ? "" : " none", clients_ & App ? " App" : "",
|
||||||
clients_ & Capture ? " Capture" : "", clients_ & Console ? " Console" : "");
|
clients_ & Capture ? " Capture" : "", clients_ & Console ? " Console" : "");
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ struct RadioPacket {
|
|||||||
uint8_t data[255];
|
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
|
// 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
|
// 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
|
// 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_};
|
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).
|
// Debug aids (Q102).
|
||||||
void printStatus(Print& out) const;
|
void printStatus(Print& out) const;
|
||||||
void setEcho(bool echo);
|
void setEcho(bool echo);
|
||||||
@@ -100,6 +119,8 @@ class RadioService : public Service {
|
|||||||
void store(RadioPacket& p);
|
void store(RadioPacket& p);
|
||||||
void sampleNoise();
|
void sampleNoise();
|
||||||
void runProbe();
|
void runProbe();
|
||||||
|
void sweepPass();
|
||||||
|
void endSweep();
|
||||||
void wake(Notify why);
|
void wake(Notify why);
|
||||||
|
|
||||||
std::unique_ptr<ArduinoHal> hal_;
|
std::unique_ptr<ArduinoHal> hal_;
|
||||||
@@ -117,8 +138,12 @@ class RadioService : public Service {
|
|||||||
std::atomic<uint32_t> preambles_{0}, headers_{0}, headerErrors_{0};
|
std::atomic<uint32_t> preambles_{0}, headers_{0}, headerErrors_{0};
|
||||||
std::atomic<uint32_t> lastPacketMs_{0};
|
std::atomic<uint32_t> lastPacketMs_{0};
|
||||||
std::atomic<uint32_t> ringFirst_{1}; // the first seq the current ring can hold
|
std::atomic<uint32_t> ringFirst_{1}; // the first seq the current ring can hold
|
||||||
|
std::atomic<bool> sweepWanted_{false}, sweeping_{false};
|
||||||
|
std::atomic<uint32_t> sweepFrom_{863000000}, sweepTo_{870000000}, sweepStep_{100000}, sweepSeq_{0};
|
||||||
|
SweepFrame frame_; // under lock_
|
||||||
std::atomic<float> noise_{0};
|
std::atomic<float> noise_{0};
|
||||||
uint32_t lastNoiseMs_ = 0, echoed_ = 0;
|
uint32_t lastNoiseMs_ = 0, echoed_ = 0, sweepEchoMs_ = 0;
|
||||||
|
bool sweepEcho_ = false;
|
||||||
Print* probeOut_ = nullptr;
|
Print* probeOut_ = nullptr;
|
||||||
static RadioService* instance_;
|
static RadioService* instance_;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
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::Listening: right("L", kMuted); break;
|
||||||
case StatusInfo::Radio::Packet: right("L", kAccent); 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;
|
case StatusInfo::Radio::None: break;
|
||||||
}
|
}
|
||||||
switch (info.gnss) { // Q61: muted while searching, normal with a Fix, the count with a 3D Fix
|
switch (info.gnss) { // Q61: muted while searching, normal with a Fix, the count with a 3D Fix
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ struct StatusInfo {
|
|||||||
enum class Gnss { None, Searching, TwoD, ThreeD } gnss = Gnss::None; // None: GNSS off (Q61)
|
enum class Gnss { None, Searching, TwoD, ThreeD } gnss = Gnss::None; // None: GNSS off (Q61)
|
||||||
int gnssSatellites = 0; // used in the Fix
|
int gnssSatellites = 0; // used in the Fix
|
||||||
bool tracking = false; // a Track is recording (Q63)
|
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 capturing = false; // a LoRa Capture is recording (Q97)
|
||||||
|
|
||||||
bool operator==(const StatusInfo& o) const {
|
bool operator==(const StatusInfo& o) const {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#include <unity.h>
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<int8_t> band(int8_t floor) { return std::vector<int8_t>(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<int8_t>(-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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user