Public Access
M3 step 4: the LoRa Scanner App, Sniffer and Captures
The Sniffer lists packets newest first (time, RSSI, SNR, and for Meshtastic the sender, receiver and hops), with the clear header and a hex dump on Enter (Q96); `p` picks an EU868 preset, kept in Settings (Q95). `c` starts a Capture: pcap with LoRaTap in /captures/lora, its own Clean-up category, recorded by a small Service so it carries on with the App closed (Q97, Q100). The Status Bar shows L while listening, bright on each packet, and CAP while capturing (Q101). StorageService gains raw appends for binary files. Debug Builds get `lora inject` to test all of this with no transmitter in range: a Capture made on the device reads back in TShark field for field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
#include "lora_scanner_app.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
|
||||
#include "meshtastic_presets.h"
|
||||
#include "packet_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() { radio_.listen(RadioService::App, false); }
|
||||
|
||||
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 (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 (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;
|
||||
}
|
||||
}
|
||||
|
||||
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 c: ") + (capture_.capturing() ? "stop capture" : "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
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app.h"
|
||||
#include "list_model.h"
|
||||
#include "services/clock_service.h"
|
||||
#include "services/lora_capture_service.h"
|
||||
#include "services/radio_service.h"
|
||||
#include "settings.h"
|
||||
#include "ui/theme.h"
|
||||
|
||||
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).
|
||||
class LoraScannerApp : public App {
|
||||
public:
|
||||
LoraScannerApp(RadioService& radio, LoraCaptureService& capture, Settings& settings, ClockService& clock)
|
||||
: radio_(radio), capture_(capture), settings_(settings), clock_(clock) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
|
||||
private:
|
||||
enum class View { Packets, Details, Presets };
|
||||
static constexpr int kListRows = 7;
|
||||
|
||||
void drawPackets(Canvas& c);
|
||||
void drawDetails(Canvas& c);
|
||||
void drawPresets(Canvas& c);
|
||||
void openDetails();
|
||||
std::string timeOf(const RadioPacket& p) const;
|
||||
bool meshtasticSettings() const;
|
||||
void say(const std::string& text); // a line in the footer for a few seconds
|
||||
|
||||
RadioService& radio_;
|
||||
LoraCaptureService& capture_;
|
||||
Settings& settings_;
|
||||
ClockService& clock_;
|
||||
View view_ = View::Packets;
|
||||
ListModel packets_{kListRows};
|
||||
ListModel presets_{kListRows + 1};
|
||||
uint32_t shownSeq_ = 0; // the newest packet at the last redraw
|
||||
uint32_t selectedSeq_ = 0; // what the selection points at, kept as packets arrive
|
||||
bool followNewest_ = true; // until the user moves the selection off the top
|
||||
std::vector<std::string> details_;
|
||||
int detailsTop_ = 0;
|
||||
uint32_t lastDrawMs_ = 0;
|
||||
std::string message_;
|
||||
uint32_t messageMs_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "apps/gnss_app.h"
|
||||
#include "apps/irc_app.h"
|
||||
#include "apps/launcher_app.h"
|
||||
#include "apps/lora_scanner_app.h"
|
||||
#include "apps/settings_app.h"
|
||||
#include "apps/wifi_tools_app.h"
|
||||
#include "apps/setup_app.h"
|
||||
@@ -31,6 +32,7 @@
|
||||
#include "services/gemini_service.h"
|
||||
#include "services/gnss_service.h"
|
||||
#include "services/power_service.h"
|
||||
#include "services/lora_capture_service.h"
|
||||
#include "services/radio_service.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "services/update_service.h"
|
||||
@@ -58,6 +60,7 @@ static PowerService* power;
|
||||
static ClockService* clockService;
|
||||
static GnssService* gnssService;
|
||||
static RadioService* radioService;
|
||||
static LoraCaptureService* loraCapture;
|
||||
static GeminiService* geminiService;
|
||||
static SavedNetworks* savedNetworks;
|
||||
static WifiService* wifi;
|
||||
@@ -106,6 +109,11 @@ static StatusInfo currentStatus() {
|
||||
s.gnssSatellites = g.satellitesUsed;
|
||||
s.tracking = gnssService->tracking();
|
||||
}
|
||||
if (radioService && radioService->listening()) {
|
||||
uint32_t last = radioService->lastPacketMs();
|
||||
s.radio = last && millis() - last < 400 ? StatusInfo::Radio::Packet : StatusInfo::Radio::Listening;
|
||||
}
|
||||
s.capturing = loraCapture && loraCapture->capturing();
|
||||
using WifiState = WifiController::State;
|
||||
switch (wifi->state()) {
|
||||
case WifiState::Connected: {
|
||||
@@ -165,6 +173,8 @@ void setup() {
|
||||
services.add(*battery);
|
||||
services.add(*gnssService);
|
||||
services.add(*radioService);
|
||||
loraCapture = new LoraCaptureService(*radioService, *storageService, *clockService, bus);
|
||||
services.add(*loraCapture);
|
||||
services.add(*storageService);
|
||||
services.add(*wifi);
|
||||
services.add(*irc);
|
||||
@@ -180,6 +190,7 @@ void setup() {
|
||||
apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService)});
|
||||
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
|
||||
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)});
|
||||
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
|
||||
apps->registerApp({"settings", "Settings", false,
|
||||
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update})});
|
||||
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
|
||||
@@ -352,6 +363,7 @@ static const char* const kHelp =
|
||||
"boot other restart into the other app slot (manual Rollback)\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 capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)\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"
|
||||
"crash the last crash: firmware, reason, task, backtrace\n"
|
||||
@@ -364,6 +376,7 @@ static const char* const kHelp =
|
||||
"sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
|
||||
#ifdef RORO_DEBUG
|
||||
"crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n"
|
||||
"lora inject <hex> [rssi] [snr] a packet into the LoRa Scanner as if received (nothing is sent)\n"
|
||||
"coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump\n"
|
||||
"reset (Debug Console only) restart at once, even if the main loop is stuck\n"
|
||||
"get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py\n"
|
||||
@@ -419,6 +432,23 @@ 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 == "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());
|
||||
}
|
||||
if (line == "lora capture stop" && loraCapture) loraCapture->stop();
|
||||
#ifdef RORO_DEBUG
|
||||
if (line.startsWith("lora inject ") && radioService) { // <hex> [rssi] [snr]: as if received
|
||||
String hex = line.substring(12);
|
||||
int space = hex.indexOf(' ');
|
||||
float rssi = -100, snr = 5;
|
||||
if (space > 0) sscanf(hex.c_str() + space + 1, "%f %f", &rssi, &snr), hex = hex.substring(0, space);
|
||||
uint8_t data[255];
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i + 1 < hex.length() && n < sizeof data; i += 2) data[n++] = strtoul(hex.substring(i, i + 2).c_str(), nullptr, 16);
|
||||
radioService->inject(data, n, rssi, snr);
|
||||
}
|
||||
#endif
|
||||
if (line.startsWith("lora custom ") && radioService) {
|
||||
double mhz = 0, bw = 0;
|
||||
unsigned sf = 0, cr = 0, sync = 0, preamble = 8;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#include "services/lora_capture_service.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "loratap.h"
|
||||
#include "platform/console.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
std::string LoraCaptureService::start(uint32_t nowMs) {
|
||||
if (capturing()) return "";
|
||||
if (!radio_.present()) return "No LoRa radio";
|
||||
StorageState card = storage_.state();
|
||||
if (!card.present) return "No SD card";
|
||||
if (!card.capturesAllowed) return "The SD card is full";
|
||||
int64_t now = clock_.utcNow();
|
||||
if (now < 0) return "Waiting for the time (GNSS or Wi-Fi)";
|
||||
path_ = lora::capturePath(now);
|
||||
std::vector<uint8_t> header;
|
||||
lora::appendPcapHeader(header);
|
||||
storage_.appendBytes(path_, std::string(header.begin(), header.end()), true);
|
||||
startUtc_ = now;
|
||||
startMs_ = nowMs;
|
||||
written_ = radio_.received(); // from now on
|
||||
packets_ = 0;
|
||||
radio_.listen(RadioService::Capture, true);
|
||||
console.printf("lora: Capture %s started\n", path_.c_str());
|
||||
notify("LoRa Capture started");
|
||||
return "";
|
||||
}
|
||||
|
||||
void LoraCaptureService::stop() {
|
||||
if (!capturing()) return;
|
||||
radio_.listen(RadioService::Capture, false);
|
||||
console.printf("lora: Capture %s stopped, %lu packets\n", path_.c_str(), (unsigned long)packets_);
|
||||
notify("LoRa Capture stopped: " + std::to_string(packets_) + " packets");
|
||||
path_.clear();
|
||||
}
|
||||
|
||||
void LoraCaptureService::tick(uint32_t nowMs) {
|
||||
(void)nowMs;
|
||||
if (!capturing()) return;
|
||||
uint32_t newest = radio_.received();
|
||||
for (uint32_t seq = written_ + 1; seq <= newest; ++seq) {
|
||||
RadioPacket p;
|
||||
if (!radio_.packet(seq, p)) continue; // already left the ring: too many at once
|
||||
uint32_t sinceStart = p.ms - startMs_;
|
||||
std::vector<uint8_t> record;
|
||||
record.reserve(lora::kRecordOverhead + p.len);
|
||||
lora::appendRecord(record, static_cast<uint32_t>(startUtc_ + sinceStart / 1000), (sinceStart % 1000) * 1000,
|
||||
p.rx, p.data, p.len);
|
||||
storage_.appendBytes(path_, std::string(record.begin(), record.end()), true);
|
||||
++packets_;
|
||||
}
|
||||
written_ = newest;
|
||||
}
|
||||
|
||||
void LoraCaptureService::notify(const std::string& text) {
|
||||
bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(NotificationLevel::Info)));
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "event_bus.h"
|
||||
#include "service.h"
|
||||
#include "services/clock_service.h"
|
||||
#include "services/radio_service.h"
|
||||
#include "services/storage_service.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// A LoRa Sniffer Capture (CONTEXT.md: Capture; M3, Q97): every packet the radio receives, as pcap
|
||||
// with LoRaTap headers, in /captures/lora/. Started and stopped by hand; keeps the radio listening
|
||||
// and keeps recording whatever App is open (Q100).
|
||||
class LoraCaptureService : public Service {
|
||||
public:
|
||||
LoraCaptureService(RadioService& radio, StorageService& storage, ClockService& clock, EventBus& bus)
|
||||
: radio_(radio), storage_(storage), clock_(clock), bus_(bus) {}
|
||||
const char* name() const override { return "lora-capture"; }
|
||||
uint32_t tickIntervalMs() const override { return 200; }
|
||||
void tick(uint32_t nowMs) override;
|
||||
|
||||
std::string start(uint32_t nowMs); // why it can't, or ""
|
||||
void stop();
|
||||
bool capturing() const { return !path_.empty(); }
|
||||
const std::string& path() const { return path_; }
|
||||
uint32_t packets() const { return packets_; }
|
||||
|
||||
private:
|
||||
void notify(const std::string& text);
|
||||
|
||||
RadioService& radio_;
|
||||
StorageService& storage_;
|
||||
ClockService& clock_;
|
||||
EventBus& bus_;
|
||||
std::string path_;
|
||||
uint32_t written_ = 0; // the radio's seq last written
|
||||
uint32_t packets_ = 0;
|
||||
int64_t startUtc_ = 0; // the Clock and millis() when it started: packet times follow from both
|
||||
uint32_t startMs_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -108,6 +108,7 @@ void RadioService::run() {
|
||||
void RadioService::startListening() {
|
||||
if (!ring_) {
|
||||
ring_.reset(new (std::nothrow) RadioPacket[kRing]);
|
||||
ringFirst_ = seq_ + 1;
|
||||
if (!ring_) {
|
||||
console.println("radio: not enough memory to listen");
|
||||
clients_ = 0;
|
||||
@@ -169,14 +170,33 @@ void RadioService::readPacket() {
|
||||
if (receive() != RADIOLIB_ERR_NONE) ++radioErrors_;
|
||||
++restarts_;
|
||||
|
||||
store(p);
|
||||
}
|
||||
|
||||
void RadioService::store(RadioPacket& p) {
|
||||
xSemaphoreTake(lock_, portMAX_DELAY);
|
||||
p.seq = seq_ + 1;
|
||||
if (ring_) ring_[p.seq % kRing] = p;
|
||||
seq_ = p.seq;
|
||||
xSemaphoreGive(lock_);
|
||||
++packets_;
|
||||
lastPacketMs_ = p.ms;
|
||||
}
|
||||
|
||||
#ifdef RORO_DEBUG
|
||||
void RadioService::inject(const uint8_t* data, size_t len, float rssi, float snr) {
|
||||
if (!listening_) return (void)console.println("lora inject: not listening");
|
||||
RadioPacket p;
|
||||
Config c = config();
|
||||
p.ms = millis();
|
||||
p.len = static_cast<uint8_t>(std::min(len, sizeof p.data));
|
||||
std::copy(data, data + p.len, p.data);
|
||||
p.rx = {c.frequencyHz, c.bandwidthKHz, c.spreadingFactor, rssi, snr, noise_, c.syncWord};
|
||||
store(p);
|
||||
console.printf("lora inject: #%lu, %u B\n", (unsigned long)p.seq, p.len);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Continuous receive. Only RX done raises DIO1; preambles and headers are only recorded in the
|
||||
// IRQ status, which sampleNoise() reads.
|
||||
int16_t RadioService::receive() {
|
||||
@@ -206,6 +226,12 @@ bool RadioService::packet(uint32_t seq, RadioPacket& out) const {
|
||||
return ok;
|
||||
}
|
||||
|
||||
uint32_t RadioService::available() const {
|
||||
uint32_t newest = seq_, first = ringFirst_;
|
||||
if (!listening_ || newest < first) return 0;
|
||||
return std::min<uint32_t>(newest - first + 1, kRing);
|
||||
}
|
||||
|
||||
void RadioService::listen(Client client, bool on) {
|
||||
if (on) clients_ |= client;
|
||||
else clients_ &= ~client;
|
||||
|
||||
@@ -68,7 +68,9 @@ class RadioService : public Service {
|
||||
void setConfig(const Config& config); // custom settings (Q95), named "Custom"
|
||||
|
||||
uint32_t received() const { return seq_; } // seq of the newest packet
|
||||
uint32_t lastPacketMs() const { return lastPacketMs_; } // millis(), 0 before the first
|
||||
bool packet(uint32_t seq, RadioPacket& out) const; // false once it has left the ring
|
||||
uint32_t available() const; // packets in the ring: seqs received()-available()+1 to received()
|
||||
float noiseFloor() const { return noise_; }
|
||||
Counters counters() const {
|
||||
return {packets_, crcErrors_, radioErrors_, restarts_, preambles_, headers_, headerErrors_};
|
||||
@@ -78,6 +80,11 @@ class RadioService : public Service {
|
||||
void printStatus(Print& out) const;
|
||||
void setEcho(bool echo);
|
||||
void probe(Print& out); // `lora probe`: runs on the radio task
|
||||
#ifdef RORO_DEBUG
|
||||
// `lora inject`: a packet into the ring as if received, to test the App and Captures with no
|
||||
// transmitter in range. Nothing goes on air.
|
||||
void inject(const uint8_t* data, size_t len, float rssi, float snr);
|
||||
#endif
|
||||
|
||||
private:
|
||||
enum Notify : uint32_t { kIrq = 1, kRequest = 2 };
|
||||
@@ -90,6 +97,7 @@ class RadioService : public Service {
|
||||
void stopListening();
|
||||
int16_t receive();
|
||||
void readPacket();
|
||||
void store(RadioPacket& p);
|
||||
void sampleNoise();
|
||||
void runProbe();
|
||||
void wake(Notify why);
|
||||
@@ -107,6 +115,8 @@ class RadioService : public Service {
|
||||
std::atomic<bool> listening_{false}, configChanged_{false}, probeWanted_{false}, echo_{false};
|
||||
std::atomic<uint32_t> seq_{0}, packets_{0}, crcErrors_{0}, radioErrors_{0}, restarts_{0};
|
||||
std::atomic<uint32_t> preambles_{0}, headers_{0}, headerErrors_{0};
|
||||
std::atomic<uint32_t> lastPacketMs_{0};
|
||||
std::atomic<uint32_t> ringFirst_{1}; // the first seq the current ring can hold
|
||||
std::atomic<float> noise_{0};
|
||||
uint32_t lastNoiseMs_ = 0, echoed_ = 0;
|
||||
Print* probeOut_ = nullptr;
|
||||
|
||||
@@ -34,12 +34,20 @@ StorageState StorageService::state() const {
|
||||
}
|
||||
|
||||
void StorageService::appendLine(const std::string& path, const std::string& line, bool capture) {
|
||||
append(path, line, capture, true);
|
||||
}
|
||||
|
||||
void StorageService::appendBytes(const std::string& path, const std::string& bytes, bool capture) {
|
||||
append(path, bytes, capture, false);
|
||||
}
|
||||
|
||||
void StorageService::append(const std::string& path, const std::string& data, bool capture, bool newline) {
|
||||
lock();
|
||||
size_t bytes = path.size() + line.size();
|
||||
size_t bytes = path.size() + data.size();
|
||||
bool allowed = capture ? monitor_.state().capturesAllowed : monitor_.state().logsAllowed;
|
||||
bool accept = allowed && pendingBytes_ + bytes <= kMaxPendingBytes;
|
||||
if (accept) {
|
||||
pending_.emplace_back(path, line);
|
||||
pending_.push_back({path, data, newline});
|
||||
pendingBytes_ += bytes;
|
||||
} else {
|
||||
dropped_++;
|
||||
@@ -199,25 +207,25 @@ void StorageService::poll() {
|
||||
|
||||
void StorageService::writePending() {
|
||||
lock();
|
||||
std::deque<std::pair<std::string, std::string>> batch;
|
||||
std::deque<Pending> batch;
|
||||
batch.swap(pending_);
|
||||
pendingBytes_ = 0;
|
||||
unlock();
|
||||
if (batch.empty() || !mounted_) return;
|
||||
|
||||
// Keep each file's lines in order while opening each file once.
|
||||
std::stable_sort(batch.begin(), batch.end(), [](const auto& a, const auto& b) { return a.first < b.first; });
|
||||
std::stable_sort(batch.begin(), batch.end(), [](const auto& a, const auto& b) { return a.path < b.path; });
|
||||
File file;
|
||||
std::string openPath;
|
||||
for (auto& [path, line] : batch) {
|
||||
if (path != openPath) {
|
||||
for (auto& p : batch) {
|
||||
if (p.path != openPath) {
|
||||
if (file) file.close();
|
||||
file = SD.open(path.c_str(), FILE_APPEND, true); // true: create missing folders
|
||||
openPath = path;
|
||||
file = SD.open(p.path.c_str(), FILE_APPEND, true); // true: create missing folders
|
||||
openPath = p.path;
|
||||
}
|
||||
if (file) {
|
||||
file.write(reinterpret_cast<const uint8_t*>(line.data()), line.size());
|
||||
file.write('\n');
|
||||
file.write(reinterpret_cast<const uint8_t*>(p.data.data()), p.data.size());
|
||||
if (p.newline) file.write('\n');
|
||||
}
|
||||
}
|
||||
if (file) file.close();
|
||||
|
||||
@@ -32,6 +32,8 @@ class StorageService : public Service {
|
||||
// paused (over 90 % full, or no card) or if too much is already waiting. A Capture (or a Track,
|
||||
// something the user started) keeps going past 90 %, until the card is full.
|
||||
void appendLine(const std::string& path, const std::string& line, bool capture = false);
|
||||
// The same, for binary files (a pcap Capture): the bytes as they are, no newline.
|
||||
void appendBytes(const std::string& path, const std::string& bytes, bool capture = false);
|
||||
uint32_t droppedLines() const { return dropped_; }
|
||||
|
||||
// Storage Clean-up: list every file of every category, then delete a selection.
|
||||
@@ -73,7 +75,12 @@ class StorageService : public Service {
|
||||
mutable SemaphoreHandle_t lock_ = nullptr;
|
||||
|
||||
// Shared with other tasks, guarded by lock_.
|
||||
std::deque<std::pair<std::string, std::string>> pending_;
|
||||
struct Pending {
|
||||
std::string path, data;
|
||||
bool newline;
|
||||
};
|
||||
std::deque<Pending> pending_;
|
||||
void append(const std::string& path, const std::string& data, bool capture, bool newline);
|
||||
size_t pendingBytes_ = 0;
|
||||
uint32_t dropped_ = 0;
|
||||
bool listingRequested_ = false;
|
||||
|
||||
@@ -49,6 +49,12 @@ void statusBar(Canvas& c, const StatusInfo& info) {
|
||||
case StatusInfo::Wifi::None: break;
|
||||
}
|
||||
if (info.tracking) right("REC", kWarning);
|
||||
if (info.capturing) right("CAP", kWarning);
|
||||
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::None: break;
|
||||
}
|
||||
switch (info.gnss) { // Q61: muted while searching, normal with a Fix, the count with a 3D Fix
|
||||
case StatusInfo::Gnss::Searching: right("G", kMuted); break;
|
||||
case StatusInfo::Gnss::TwoD: right("G", kText); break;
|
||||
|
||||
+3
-1
@@ -27,12 +27,14 @@ 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
|
||||
bool capturing = false; // a LoRa Capture is recording (Q97)
|
||||
|
||||
bool operator==(const StatusInfo& o) const {
|
||||
return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock &&
|
||||
sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose && wifi == o.wifi &&
|
||||
wifiBars == o.wifiBars && unread == o.unread && gnss == o.gnss && gnssSatellites == o.gnssSatellites &&
|
||||
tracking == o.tracking;
|
||||
tracking == o.tracking && radio == o.radio && capturing == o.capturing;
|
||||
}
|
||||
bool operator!=(const StatusInfo& o) const { return !(*this == o); }
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user