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
+83
View File
@@ -0,0 +1,83 @@
#include "packet_view.h"
#include <cmath>
#include <cstdio>
#include "meshtastic_header.h"
#include "meshtastic_presets.h"
namespace roro::lora {
using namespace meshtastic;
std::string row(const PacketSummary& p, const std::string& time) {
char s[64];
int n = std::snprintf(s, sizeof s, "%s %ld %.1f ", time.c_str(), std::lround(p.rssi), p.snr);
std::string out(s, n);
PacketHeader h;
if (!p.crcOk) return out + "bad CRC, " + std::to_string(p.len) + " B";
if (!p.meshtastic || !parseHeader(p.data, p.len, h)) return out + std::to_string(p.len) + " B";
auto shortId = [](uint32_t node) {
if (node == kBroadcast) return std::string("all");
char id[8];
std::snprintf(id, sizeof id, "%04x", static_cast<unsigned>(node & 0xFFFF));
return std::string(id);
};
out += shortId(h.from) + ">" + shortId(h.to);
if (h.hopsAway() >= 0) out += " " + std::to_string(h.hopsAway()) + "/" + std::to_string(h.hopStart());
return out;
}
std::vector<std::string> hexDump(const uint8_t* data, size_t len) {
std::vector<std::string> lines;
for (size_t at = 0; at < len; at += 8) {
char s[48];
int n = std::snprintf(s, sizeof s, "%04x", static_cast<unsigned>(at));
std::string text;
for (size_t i = 0; i < 8; ++i) {
if (at + i < len) {
n += std::snprintf(s + n, sizeof s - n, " %02x", data[at + i]);
uint8_t b = data[at + i];
text += b >= 0x20 && b < 0x7F ? static_cast<char>(b) : '.';
} else {
n += std::snprintf(s + n, sizeof s - n, " ");
}
}
lines.push_back(std::string(s, n) + " " + text);
}
return lines;
}
std::vector<std::string> headerLines(const uint8_t* data, size_t len) {
PacketHeader h;
if (!parseHeader(data, len, h)) return {};
char s[64];
std::vector<std::string> lines;
lines.push_back("From " + nodeId(h.from) + " to " + nodeId(h.to));
std::snprintf(s, sizeof s, "Packet %08x%s%s", static_cast<unsigned>(h.id), h.wantAck() ? ", wants an ack" : "",
h.viaMqtt() ? ", via MQTT" : "");
lines.push_back(s);
if (h.hopsAway() >= 0)
std::snprintf(s, sizeof s, "Hops %d of %u, limit %u left", h.hopsAway(), h.hopStart(), h.hopLimit());
else
std::snprintf(s, sizeof s, "Hop limit %u left (old firmware)", h.hopLimit());
lines.push_back(s);
// Which preset's default Channel (named after it, with the public key) has this hash.
std::string channel;
for (size_t i = 0; i < kEu868PresetCount && channel.empty(); ++i)
if (channelHash(kEu868Presets[i].name, kDefaultKey, sizeof kDefaultKey) == h.channelHash)
channel = std::string(" (") + kEu868Presets[i].name + ", default key)";
std::snprintf(s, sizeof s, "Channel 0x%02x%s", h.channelHash, channel.c_str());
lines.push_back(s);
if (h.relayNode) {
std::snprintf(s, sizeof s, "Relayed by ..%02x", h.relayNode);
lines.push_back(s);
}
if (h.nextHop) {
std::snprintf(s, sizeof s, "Next hop ..%02x", h.nextHop);
lines.push_back(s);
}
return lines;
}
} // namespace roro::lora