#include "lora_scanner_app.h" #include #include #include #include #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(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().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(0, static_cast(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(now - static_cast((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(radio_.available()); packets_.setCount(count); if (!followNewest_ && selectedSeq_ && newest >= selectedSeq_ && newest - selectedSeq_ < static_cast(count)) packets_.select(static_cast(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(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