Public Access
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
341 lines
13 KiB
C++
341 lines
13 KiB
C++
#include "lora_scanner_app.h"
|
|
|
|
#include <Arduino.h>
|
|
|
|
#include <algorithm>
|
|
#include <cstdio>
|
|
#include <ctime>
|
|
|
|
#include "meshtastic_presets.h"
|
|
#include "packet_view.h"
|
|
#include "sweep_view.h"
|
|
#include "ui/canvas.h"
|
|
#include "ui/fonts.h"
|
|
#include "ui/widgets.h"
|
|
|
|
namespace roro {
|
|
|
|
namespace {
|
|
constexpr uint32_t kMessageMs = 4000;
|
|
}
|
|
|
|
void LoraScannerApp::onEnter() {
|
|
int i = std::clamp<int>(settings_.getInt(Setting::LoraPreset), 0, meshtastic::kEu868PresetCount - 1);
|
|
const meshtastic::Preset& p = meshtastic::kEu868Presets[i];
|
|
if (std::string(radio_.config().name) != p.name) radio_.setPreset(p);
|
|
radio_.listen(RadioService::App, true);
|
|
view_ = View::Packets;
|
|
selectedSeq_ = 0;
|
|
followNewest_ = true;
|
|
shownSeq_ = 0;
|
|
requestRedraw();
|
|
}
|
|
|
|
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; }
|
|
|
|
void LoraScannerApp::say(const std::string& text) {
|
|
message_ = text;
|
|
messageMs_ = millis();
|
|
requestRedraw();
|
|
}
|
|
|
|
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<int>(0, static_cast<int>(details_.size()) - 8);
|
|
if (e.key == Key::Up) detailsTop_ = std::max(0, detailsTop_ - 1);
|
|
else if (e.key == Key::Down) detailsTop_ = std::min(last, detailsTop_ + 1);
|
|
else if (e.key == Key::Back || e.key == Key::Select) view_ = View::Packets;
|
|
else return true;
|
|
requestRedraw();
|
|
return true;
|
|
}
|
|
if (view_ == View::Presets) {
|
|
if (e.key == Key::Up) presets_.up();
|
|
else if (e.key == Key::Down) presets_.down();
|
|
else if (e.key == Key::Select) {
|
|
int i = presets_.selected();
|
|
settings_.setInt(Setting::LoraPreset, i);
|
|
radio_.setPreset(meshtastic::kEu868Presets[i]);
|
|
say(std::string("Listening on ") + meshtastic::kEu868Presets[i].name);
|
|
view_ = View::Packets;
|
|
} else if (e.key == Key::Back) view_ = View::Packets;
|
|
else return true;
|
|
requestRedraw();
|
|
return true;
|
|
}
|
|
// The packet list.
|
|
if (e.key == Key::Up || e.key == Key::Down) {
|
|
e.key == Key::Up ? packets_.up() : packets_.down();
|
|
selectedSeq_ = packets_.selected() >= 0 ? radio_.received() - packets_.selected() : 0;
|
|
followNewest_ = packets_.selected() <= 0;
|
|
requestRedraw();
|
|
return true;
|
|
}
|
|
if (e.key == Key::Select) {
|
|
openDetails();
|
|
return true;
|
|
}
|
|
if (e.key == Key::Char && (e.ch == 'p' || e.ch == 'P')) {
|
|
presets_.setCount(meshtastic::kEu868PresetCount);
|
|
presets_.select(settings_.getInt(Setting::LoraPreset));
|
|
view_ = View::Presets;
|
|
requestRedraw();
|
|
return true;
|
|
}
|
|
if (e.key == Key::Char && (e.ch == 'c' || e.ch == 'C')) {
|
|
if (capture_.capturing()) capture_.stop();
|
|
else {
|
|
std::string why = capture_.start(millis());
|
|
if (!why.empty()) say(why);
|
|
}
|
|
requestRedraw();
|
|
return true;
|
|
}
|
|
return false; // Back leaves the App
|
|
}
|
|
|
|
void LoraScannerApp::openDetails() {
|
|
RadioPacket p;
|
|
if (!selectedSeq_ || !radio_.packet(selectedSeq_, p)) return say("That packet has left the list");
|
|
details_.clear();
|
|
char line[64];
|
|
std::snprintf(line, sizeof line, "%s, %u bytes%s", timeOf(p).c_str(), p.len, p.crcOk ? "" : ", bad CRC");
|
|
details_.push_back(line);
|
|
std::snprintf(line, sizeof line, "%.0f dBm, SNR %.1f dB, noise %.0f", p.rx.rssi, p.rx.snr, p.rx.noiseFloor);
|
|
details_.push_back(line);
|
|
std::snprintf(line, sizeof line, "%.4f MHz, off by %.0f Hz", p.rx.frequencyHz / 1e6, p.frequencyError);
|
|
details_.push_back(line);
|
|
if (meshtasticSettings())
|
|
for (auto& l : lora::headerLines(p.data, p.len)) details_.push_back(l);
|
|
for (auto& l : lora::hexDump(p.data, p.len)) details_.push_back(l);
|
|
detailsTop_ = 0;
|
|
view_ = View::Details;
|
|
requestRedraw();
|
|
}
|
|
|
|
std::string LoraScannerApp::timeOf(const RadioPacket& p) const {
|
|
int64_t now = clock_.utcNow();
|
|
if (now < 0) { // no Clock yet: how long ago
|
|
char ago[16];
|
|
std::snprintf(ago, sizeof ago, "-%lus", (unsigned long)((millis() - p.ms) / 1000));
|
|
return ago;
|
|
}
|
|
time_t t = static_cast<time_t>(now - static_cast<int64_t>((millis() - p.ms) / 1000));
|
|
struct tm local;
|
|
localtime_r(&t, &local);
|
|
char buf[12];
|
|
std::snprintf(buf, sizeof buf, "%02d:%02d:%02d", local.tm_hour, local.tm_min, local.tm_sec);
|
|
return buf;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
void LoraScannerApp::draw(Canvas& c) {
|
|
lastDrawMs_ = millis();
|
|
c.setTextDatum(top_left);
|
|
if (!radio_.present()) {
|
|
c.setFont(&fonts::bold);
|
|
c.setTextColor(theme::kWarning);
|
|
c.drawString("No LoRa radio found", 4, theme::kContent.y + 4);
|
|
c.setFont(&fonts::body);
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString("Is the Cap LoRa-1262 attached?", 4, theme::kContent.y + 22);
|
|
return;
|
|
}
|
|
switch (view_) {
|
|
case View::Packets: drawPackets(c); break;
|
|
case View::Details: drawDetails(c); break;
|
|
case View::Presets: drawPresets(c); break;
|
|
case View::Sweep: drawSweep(c); break;
|
|
}
|
|
}
|
|
|
|
void LoraScannerApp::drawPackets(Canvas& c) {
|
|
const auto& area = theme::kContent;
|
|
RadioService::Config cfg = radio_.config();
|
|
uint32_t newest = radio_.received();
|
|
shownSeq_ = newest;
|
|
|
|
// What it's listening to, and the noise floor.
|
|
char head[64];
|
|
std::snprintf(head, sizeof head, "%s %.3f MHz, noise %.0f", cfg.name, cfg.frequencyHz / 1e6, radio_.noiseFloor());
|
|
c.setFont(&fonts::small);
|
|
c.setTextColor(radio_.listening() ? theme::kMuted : theme::kWarning);
|
|
c.drawString(radio_.listening() ? head : "Starting the radio...", 4, area.y + 2);
|
|
if (capture_.capturing()) {
|
|
c.setTextDatum(top_right);
|
|
c.setTextColor(theme::kWarning);
|
|
c.drawString(("CAP " + std::to_string(capture_.packets())).c_str(), area.w - 3, area.y + 2);
|
|
c.setTextDatum(top_left);
|
|
}
|
|
|
|
// The list, newest first; the selection stays on its packet as new ones arrive.
|
|
int count = static_cast<int>(radio_.available());
|
|
packets_.setCount(count);
|
|
if (!followNewest_ && selectedSeq_ && newest >= selectedSeq_ && newest - selectedSeq_ < static_cast<uint32_t>(count))
|
|
packets_.select(static_cast<int>(newest - selectedSeq_));
|
|
else if (count > 0) {
|
|
followNewest_ = true;
|
|
packets_.select(0);
|
|
selectedSeq_ = newest;
|
|
}
|
|
theme::Rect list{area.x, area.y + 12, area.w, kListRows * theme::kLineHeight};
|
|
if (count == 0) {
|
|
c.setFont(&fonts::body);
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString("No packets yet.", 4, list.y + 4);
|
|
c.drawString(meshtasticSettings() ? "Meshtastic nodes in range show here." : "Custom settings.", 4,
|
|
list.y + 4 + theme::kLineHeight);
|
|
} else {
|
|
bool mesh = meshtasticSettings();
|
|
widgets::list(c, packets_, list, [&](int i) {
|
|
RadioPacket p;
|
|
if (!radio_.packet(newest - i, p)) return std::string("(gone)");
|
|
return lora::row({p.data, p.len, p.rx.rssi, p.rx.snr, p.crcOk, mesh}, timeOf(p));
|
|
});
|
|
}
|
|
|
|
// Footer: a message for a few seconds, or the keys.
|
|
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 Tab: sweep c: ") + (capture_.capturing() ? "stop" : "capture");
|
|
c.drawString(showMessage ? message_.c_str() : keys.c_str(), 4, area.y + area.h - 9);
|
|
}
|
|
|
|
void LoraScannerApp::drawDetails(Canvas& c) {
|
|
const auto& area = theme::kContent;
|
|
widgets::textLines(c, details_, detailsTop_, {area.x + 2, area.y + 2, area.w - 2, area.h - 2});
|
|
}
|
|
|
|
void LoraScannerApp::drawPresets(Canvas& c) {
|
|
const auto& area = theme::kContent;
|
|
c.setFont(&fonts::small);
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString("Meshtastic presets allowed in EU868", 4, area.y + 2);
|
|
widgets::list(
|
|
c, presets_, {area.x, area.y + 12, area.w, (kListRows + 1) * theme::kLineHeight},
|
|
[](int i) { return std::string(meshtastic::kEu868Presets[i].name); },
|
|
[](int i) {
|
|
char v[24];
|
|
const auto& p = meshtastic::kEu868Presets[i];
|
|
std::snprintf(v, sizeof v, "SF%u %.0fk %.3f", p.sf, p.bwKHz, meshtastic::eu868FrequencyHz(p) / 1e6);
|
|
return std::string(v);
|
|
});
|
|
}
|
|
|
|
} // 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
|