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:
2026-10-05 20:14:58 +02:00
co-authored by Claude Opus 5.5
parent 594748e99a
commit 2fce79aebd
23 changed files with 723 additions and 13 deletions
+62
View File
@@ -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
+44
View File
@@ -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
+26
View File
@@ -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;
+10
View File
@@ -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;
+18 -10
View File
@@ -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();
+8 -1
View File
@@ -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;