diff --git a/README.md b/README.md index cb42945..46d2d31 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262* What it does today: -- **LoRa Scanner:** every packet it hears, with the Meshtastic header read; a spectrum Sweep; captures for Wireshark. It listens and never transmits: the mesh messenger is the next milestone. +- **LoRa Scanner:** every packet it hears, with the Meshtastic or MeshCore header read; a spectrum Sweep; captures for Wireshark. It listens and never transmits: the mesh messenger is the next milestone. - **GNSS:** position, sky view, tracks as GPX. - **Gemini:** a browser, with bookmarks and pages saved for offline. - **IRC:** over TLS, with logs on the card. @@ -138,7 +138,7 @@ On a page, `b` bookmarks it, `s` saves it to the SD card to read offline (a non- ## LoRa Scanner -The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **never transmits**. The Sniffer lists what it hears, newest first: time, RSSI, SNR, and for Meshtastic packets the sender and receiver (their last 4 hex digits) and hops. Enter shows a packet's details: the Meshtastic header (which is never encrypted) and a hex dump. `p` picks one of the 7 Meshtastic presets allowed in EU868 (LongFast by default), `c` starts or stops a Capture: a pcap file with LoRaTap headers in `/captures/lora/`, for Wireshark. A Capture keeps recording with the App closed; otherwise the radio sleeps when the App isn't open. Tab switches to **Sweep**: the signal strength across 863–870 MHz in 100 kHz steps, as bars with peak hold and a waterfall, with the Sniffer's frequency marked; the Sniffer is paused meanwhile and picks up where it was. The GNSS receiver on the same Cap raises the radio's noise floor by 8 dB while it runs: Settings > "Pause GNSS for LoRa" (off by default) puts it in standby while the radio listens, except during a Track. The Status Bar shows `L` while the radio listens (bright for a moment on each packet), `SW` while sweeping, and `CAP` while capturing. +The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **never transmits**. The Sniffer lists what it hears, newest first: time, RSSI, SNR, and for Meshtastic packets the sender and receiver (their last 4 hex digits) and hops; for MeshCore packets what they are (an advert with the node's name, a message with the first byte of each node's key, a channel message with the channel's byte) and the hops in their path. Enter shows a packet's details: what is never encrypted (the Meshtastic header; MeshCore's header, path and, in an advert, the node's name, kind, key and position) and a hex dump. `p` picks one of the 7 Meshtastic presets allowed in EU868 (LongFast by default) or **MeshCore** (its EU/UK Narrow setting: 869.618 MHz, 62.5 kHz, SF8, CR 4/8), `c` starts or stops a Capture: a pcap file with LoRaTap headers in `/captures/lora/`, for Wireshark. A Capture keeps recording with the App closed; otherwise the radio sleeps when the App isn't open. Tab switches to **Sweep**: the signal strength across 863–870 MHz in 100 kHz steps, as bars with peak hold and a waterfall, with the Sniffer's frequency marked; the Sniffer is paused meanwhile and picks up where it was. The GNSS receiver on the same Cap raises the radio's noise floor by 8 dB while it runs: Settings > "Pause GNSS for LoRa" (off by default) puts it in standby while the radio listens, except during a Track. The Status Bar shows `L` while the radio listens (bright for a moment on each packet), `SW` while sweeping, and `CAP` while capturing. ## Storage @@ -161,7 +161,7 @@ Enter on a file opens it by type; Tab switches to the same file as a hex dump or - **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only the screen's worth is read from the card, so a file of any size opens at once. Logs open at the end. Up and Down move a line, Left and Right a page, `t` and `b` go to the top and the end, `e` edits it, whatever its size (see Notes). - **Pictures** (`.png`, `.jpg`, `.bmp`, `.gif`; issue #45): shrunk to fit the screen, or at their own size with Enter, the arrows then moving half a screen at a time; `i` gives the size in pixels. Dithered to the screen's 256 colours, decoded straight into the screen's buffer with no copy in memory, on the storage task so the keys keep working (12 megapixels of JPEG: 7 s). A GIF shows its first picture. A progressive JPEG or an interlaced PNG opens as hex, with the reason. -- **Captures** (`.pcap`): the packets as the LoRa Scanner lists them; Enter shows one with its Meshtastic header and bytes. +- **Captures** (`.pcap`): the packets as the LoRa Scanner lists them; Enter shows one with its Meshtastic or MeshCore header and bytes. - **Tracks** (`.gpx`): the number of points, the start, the duration and the distance. - **Update Files** (`.ota`): the version, and whether the file would install: it's checked as an install checks it (signature and contents) without writing anything. Enter then installs it. - **Anything else:** a hex dump. @@ -234,7 +234,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow | `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor | | `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack | | `lora rx on` / `lora rx off` | Listens and prints each packet on the console | -| `lora preset ` / `lora custom [preamble]` | Receive settings: a Meshtastic preset, or anything else (`lora custom 868.1 125 7 5 34 8` for LoRaWAN) | +| `lora preset ` / `lora custom [preamble]` | Receive settings: a Meshtastic preset or `MeshCore`, or anything else (`lora custom 868.1 125 7 5 34 8` for LoRaWAN) | | `lora capture start` / `lora capture stop` | A LoRa Capture, as `c` in the App | | `lora sweep on [from MHz] [to MHz] [step kHz]` / `lora sweep off` / `lora sweep dump` | Sweep a band (863 870 100 by default), with a summary every 2 s (floor, strongest, peaks), or print the latest pass | | `gnss quiet on` / `gnss quiet off` | The "Pause GNSS for LoRa" setting | diff --git a/lib/core/src/app_keys.h b/lib/core/src/app_keys.h index 207a255..73067b7 100644 --- a/lib/core/src/app_keys.h +++ b/lib/core/src/app_keys.h @@ -188,7 +188,7 @@ inline constexpr KeyHelp kGeminiAnswer[] = { inline constexpr KeyHelp kLora[] = { {"; .", "up, down"}, {"Enter", "the packet's details"}, - {"p", "pick a Meshtastic preset"}, + {"p", "pick a preset"}, {"c", "start a Capture, or stop it"}, {"Tab", "the Sweep"}, }; diff --git a/lib/lora/src/packet_view.cpp b/lib/lora/src/packet_view.cpp index 765a677..fce62f2 100644 --- a/lib/lora/src/packet_view.cpp +++ b/lib/lora/src/packet_view.cpp @@ -3,6 +3,7 @@ #include #include +#include "meshcore_packet.h" #include "meshtastic_header.h" #include "meshtastic_presets.h" @@ -10,13 +11,97 @@ namespace roro::lora { using namespace meshtastic; +namespace { +std::string hex(const uint8_t* p, size_t n) { + std::string out; + char s[4]; + for (size_t i = 0; i < n; ++i) { + std::snprintf(s, sizeof s, "%02x", p[i]); + out += s; + } + return out; +} + +// `room` characters at most: a node's name is cut to fit. +std::string meshcoreRow(const meshcore::Packet& m, size_t room) { + std::string out = meshcore::payloadShort(m.type); + std::string hops = m.hops ? " " + std::to_string(m.hops) + "h" : ""; + meshcore::Advert a; + if (meshcore::parseAdvert(m, a)) { + size_t used = out.size() + 1 + hops.size(); + out += " " + (a.name.empty() ? hex(a.key, 2) : a.name.substr(0, room > used ? room - used : 0)); + } else if (m.addressed() && m.payloadLen >= 2) + out += " " + hex(m.payload + 1, 1) + ">" + hex(m.payload, 1); + else if (m.group() && m.payloadLen >= 1) + out += " #" + hex(m.payload, 1); + else if (m.type == meshcore::Payload::AnonRequest && m.payloadLen >= 1) + out += " >" + hex(m.payload, 1); + return out + hops; +} + +std::vector meshcoreLines(const uint8_t* data, size_t len) { + meshcore::Packet m; + if (!meshcore::parsePacket(data, len, m)) return {}; + char s[64]; + std::vector lines; + lines.push_back("MeshCore " + std::string(meshcore::payloadName(m.type)) + ", " + meshcore::routeName(m.route)); + if (m.hasTransport) { + std::snprintf(s, sizeof s, "Region code %04x", m.transport[0]); + lines.push_back(s); + } + // A flood gathers who repeated it; a direct packet carries who is still to repeat it. + if (m.hops) { + std::string path = std::string(m.flood() ? "Through" : "Route"); + for (uint8_t i = 0; i < m.hops; ++i) { + std::string hop = " " + hex(m.path + i * m.hashSize, m.hashSize); + if (path.size() + hop.size() > 36) { // onto another line + lines.push_back(path); + path = " "; + } + path += hop; + } + lines.push_back(path); + } else { + lines.push_back(m.flood() ? "Heard first hand: no repeater yet" : "No route in it: to a neighbour"); + } + meshcore::Advert a; + if (meshcore::parseAdvert(m, a)) { + lines.push_back(std::string(meshcore::kindName(a.kind)) + (a.name.empty() ? "" : " " + a.name)); + lines.push_back("Key " + hex(a.key, 8) + "..."); + if (a.hasLocation) { + std::snprintf(s, sizeof s, "At %.5f, %.5f", a.latE6 / 1e6, a.lonE6 / 1e6); + lines.push_back(s); + } + } else if (m.addressed() && m.payloadLen >= 4) { + std::snprintf(s, sizeof s, "From ..%02x to ..%02x, %u B encrypted", m.payload[1], m.payload[0], static_cast(m.payloadLen - 4)); + lines.push_back(s); + } else if (m.group() && m.payloadLen >= 3) { + std::snprintf(s, sizeof s, "Channel 0x%02x%s, %u B encrypted", m.payload[0], m.payload[0] == meshcore::kPublicChannelHash ? " (public?)" : "", + static_cast(m.payloadLen - 3)); + lines.push_back(s); + } else if (m.type == meshcore::Payload::AnonRequest && m.payloadLen >= 35) { + std::snprintf(s, sizeof s, "To ..%02x from key %s...", m.payload[0], hex(m.payload + 1, 4).c_str()); + lines.push_back(s); + } else if (m.type == meshcore::Payload::Ack && m.payloadLen >= 4) { + lines.push_back("Acknowledges " + hex(m.payload, 4)); + } + return lines; +} +} // namespace + +Protocol protocolOf(uint8_t syncWord) { + return syncWord == meshtastic::kSyncWord ? Protocol::Meshtastic : syncWord == meshcore::kSyncWord ? Protocol::MeshCore : Protocol::None; +} + 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"; + meshcore::Packet m; + if (p.protocol == Protocol::MeshCore && meshcore::parsePacket(p.data, p.len, m)) return out + meshcoreRow(m, out.size() < 38 ? 38 - out.size() : 0); + if (p.protocol != Protocol::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]; @@ -48,7 +133,9 @@ std::vector hexDump(const uint8_t* data, size_t len) { return lines; } -std::vector headerLines(const uint8_t* data, size_t len) { +std::vector headerLines(const uint8_t* data, size_t len, Protocol protocol) { + if (protocol == Protocol::MeshCore) return meshcoreLines(data, len); + if (protocol != Protocol::Meshtastic) return {}; PacketHeader h; if (!parseHeader(data, len, h)) return {}; char s[64]; diff --git a/lib/lora/src/packet_view.h b/lib/lora/src/packet_view.h index c32439b..bc924ac 100644 --- a/lib/lora/src/packet_view.h +++ b/lib/lora/src/packet_view.h @@ -7,23 +7,29 @@ namespace roro::lora { +// Whose packets the radio's settings were for, by their sync word: whose clear header to read. +enum class Protocol : uint8_t { None, Meshtastic, MeshCore }; +Protocol protocolOf(uint8_t syncWord); + // What the LoRa Scanner shows of one packet (M3, Q96). struct PacketSummary { const uint8_t* data; size_t len; float rssi, snr; bool crcOk; - bool meshtastic; // received on Meshtastic settings (sync word 0x2B): read its clear header + Protocol protocol; // what the settings it was received on were for }; -// One list row, at most about 36 characters: "21:45:07 -97 6.2 5678>all 1/3". Nodes by their default -// short name (the last 4 hex digits); headerLines() has the full numbers. +// One list row, at most about 36 characters: "21:45:07 -97 6.2 5678>all 1/3". Meshtastic nodes by their +// default short name (the last 4 hex digits); headerLines() has the full numbers. MeshCore: what +// it is, then who ("adv Brussels-R1", "txt a3>7f", "chan #11") and the hops in its path ("2h"). std::string row(const PacketSummary& p, const std::string& time); // Eight bytes a line, with the printable ones: "0000 ff ff ff ff 78 56 34 12 ....xV4.". std::vector hexDump(const uint8_t* data, size_t len); -// The Meshtastic header, one field a line; empty when the packet is too short to have one. -std::vector headerLines(const uint8_t* data, size_t len); +// What is in clear, one field a line: the Meshtastic header, or MeshCore's header, path and (for an +// advert) who the node says it is. Empty when the packet can't be read as that. +std::vector headerLines(const uint8_t* data, size_t len, Protocol protocol = Protocol::Meshtastic); } // namespace roro::lora diff --git a/lib/lora/src/scan_presets.cpp b/lib/lora/src/scan_presets.cpp new file mode 100644 index 0000000..f8a9080 --- /dev/null +++ b/lib/lora/src/scan_presets.cpp @@ -0,0 +1,34 @@ +#include "scan_presets.h" + +#include + +#include "meshcore_packet.h" +#include "meshtastic_presets.h" + +namespace roro::lora { + +size_t scanPresetCount() { return meshtastic::kEu868PresetCount + 1; } + +ScanPreset scanPreset(size_t i) { + if (i < meshtastic::kEu868PresetCount) { + const meshtastic::Preset& p = meshtastic::kEu868Presets[i]; + return {p.name, meshtastic::eu868FrequencyHz(p), p.bwKHz, p.sf, p.cr, meshtastic::kSyncWord, meshtastic::kPreambleLength, Protocol::Meshtastic}; + } + return {meshcore::kPresetName, meshcore::kEuNarrowHz, meshcore::kEuNarrowBwKHz, meshcore::kEuNarrowSf, meshcore::kEuNarrowCr, + meshcore::kSyncWord, meshcore::kPreambleLength, Protocol::MeshCore}; +} + +bool findScanPreset(const char* name, ScanPreset& out) { + for (size_t i = 0; i < scanPresetCount(); ++i) { + ScanPreset p = scanPreset(i); + const char *a = p.name, *b = name; + while (*a && *b && std::tolower(static_cast(*a)) == std::tolower(static_cast(*b))) ++a, ++b; + if (!*a && !*b) { + out = p; + return true; + } + } + return false; +} + +} // namespace roro::lora diff --git a/lib/lora/src/scan_presets.h b/lib/lora/src/scan_presets.h new file mode 100644 index 0000000..931b756 --- /dev/null +++ b/lib/lora/src/scan_presets.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +#include "packet_view.h" + +namespace roro::lora { + +// What the LoRa Scanner can be told to listen to by name: the Meshtastic presets allowed in +// EU_868, LongFast (the default) first, then MeshCore's EU/UK (Narrow). The order is kept: the +// chosen one is stored by its number. +struct ScanPreset { + const char* name; + uint32_t frequencyHz; + float bwKHz; + uint8_t sf, cr, syncWord; + uint16_t preamble; + Protocol protocol; +}; + +size_t scanPresetCount(); +ScanPreset scanPreset(size_t i); // i < scanPresetCount() +// By name, whatever its case. False when there is none. +bool findScanPreset(const char* name, ScanPreset& out); + +} // namespace roro::lora diff --git a/lib/meshcore/src/meshcore_packet.cpp b/lib/meshcore/src/meshcore_packet.cpp new file mode 100644 index 0000000..f38422c --- /dev/null +++ b/lib/meshcore/src/meshcore_packet.cpp @@ -0,0 +1,128 @@ +#include "meshcore_packet.h" + +#include + +namespace roro::meshcore { + +namespace { +int32_t le32(const uint8_t* p) { return static_cast(p[0] | p[1] << 8 | p[2] << 16 | static_cast(p[3]) << 24); } +constexpr size_t kMaxPath = 64, kMaxPayload = 184; +} // namespace + +bool parsePacket(const uint8_t* data, size_t len, Packet& out) { + if (!data || len < 2) return false; + uint8_t header = data[0]; + if (header >> 6) return false; // versions 1 to 3 are reserved + uint8_t type = (header >> 2) & 0x0F; + if (type >= 0xC && type <= 0xE) return false; // reserved + Packet p; + p.route = static_cast(header & 0x03); + p.type = static_cast(type); + size_t at = 1; + p.hasTransport = p.route == Route::TransportFlood || p.route == Route::TransportDirect; + if (p.hasTransport) { + if (len < at + 5) return false; + p.transport[0] = static_cast(data[at] | data[at + 1] << 8); + p.transport[1] = static_cast(data[at + 2] | data[at + 3] << 8); + at += 4; + } + uint8_t pathLen = data[at++]; + p.hops = pathLen & 0x3F; + p.hashSize = static_cast((pathLen >> 6) + 1); + size_t pathBytes = static_cast(p.hops) * p.hashSize; + if (p.hashSize > 3 || pathBytes > kMaxPath || at + pathBytes > len) return false; + p.path = data + at; + at += pathBytes; + p.payload = data + at; + p.payloadLen = len - at; + if (p.payloadLen > kMaxPayload) return false; + out = p; + return true; +} + +bool parseAdvert(const Packet& p, Advert& out) { + constexpr size_t kFixed = 32 + 4 + 64; // key, timestamp, signature + if (p.type != Payload::Advert || p.payloadLen < kFixed + 1) return false; + Advert a; + std::memcpy(a.key, p.payload, 32); + a.timestamp = static_cast(le32(p.payload + 32)); + const uint8_t* app = p.payload + kFixed; + size_t left = p.payloadLen - kFixed; + uint8_t flags = app[0]; + size_t at = 1; + a.kind = flags & 0x0F; + if (flags & 0x10) { + if (left < at + 8) return false; + a.hasLocation = true; + a.latE6 = le32(app + at); + a.lonE6 = le32(app + at + 4); + at += 8; + } + if (flags & 0x20) at += 2; // two reserved fields + if (flags & 0x40) at += 2; + if (at > left) return false; + if (flags & 0x80) + for (; at < left && app[at]; ++at) a.name += app[at] >= 0x20 && app[at] < 0x7F ? static_cast(app[at]) : '?'; + out = a; + return true; +} + +const char* routeName(Route r) { + switch (r) { + case Route::TransportFlood: return "flood in a region"; + case Route::Flood: return "flood"; + case Route::Direct: return "direct"; + case Route::TransportDirect: return "direct in a region"; + } + return "?"; +} + +const char* payloadName(Payload t) { + switch (t) { + case Payload::Request: return "request"; + case Payload::Response: return "response"; + case Payload::Text: return "text message"; + case Payload::Ack: return "ack"; + case Payload::Advert: return "advert"; + case Payload::GroupText: return "channel message"; + case Payload::GroupData: return "channel data"; + case Payload::AnonRequest: return "anonymous request"; + case Payload::Path: return "returned path"; + case Payload::Trace: return "trace"; + case Payload::Multipart: return "multipart"; + case Payload::Control: return "control"; + case Payload::RawCustom: return "custom"; + } + return "?"; +} + +const char* payloadShort(Payload t) { + switch (t) { + case Payload::Request: return "req"; + case Payload::Response: return "rsp"; + case Payload::Text: return "txt"; + case Payload::Ack: return "ack"; + case Payload::Advert: return "adv"; + case Payload::GroupText: return "chan"; + case Payload::GroupData: return "cdat"; + case Payload::AnonRequest: return "anon"; + case Payload::Path: return "path"; + case Payload::Trace: return "trace"; + case Payload::Multipart: return "multi"; + case Payload::Control: return "ctl"; + case Payload::RawCustom: return "raw"; + } + return "?"; +} + +const char* kindName(uint8_t kind) { + switch (kind) { + case 1: return "Chat node"; + case 2: return "Repeater"; + case 3: return "Room server"; + case 4: return "Sensor"; + } + return "Node"; +} + +} // namespace roro::meshcore diff --git a/lib/meshcore/src/meshcore_packet.h b/lib/meshcore/src/meshcore_packet.h new file mode 100644 index 0000000..b3f1e63 --- /dev/null +++ b/lib/meshcore/src/meshcore_packet.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include + +// MeshCore's packets as they are on air (its docs/packet_format.md and docs/payloads.md). Only +// what is sent in clear is read: the header, the path, and a node's advertisement. +namespace roro::meshcore { + +// Radio settings: RadioLib's "private" sync word, and the preset its EU/UK networks use. +constexpr uint8_t kSyncWord = 0x12; +constexpr uint16_t kPreambleLength = 16; +constexpr const char* kPresetName = "MeshCore"; // "EU/UK (Narrow)" in its apps +constexpr uint32_t kEuNarrowHz = 869618000; +constexpr float kEuNarrowBwKHz = 62.5f; +constexpr uint8_t kEuNarrowSf = 8, kEuNarrowCr = 8; + +// The first byte of the SHA-256 of the public channel's key (izOH6cXN6mrJ5e26oRXNcg==): what a +// group message on the channel every node has starts with. +constexpr uint8_t kPublicChannelHash = 0x11; + +enum class Route : uint8_t { TransportFlood = 0, Flood = 1, Direct = 2, TransportDirect = 3 }; +enum class Payload : uint8_t { + Request = 0x0, Response = 0x1, Text = 0x2, Ack = 0x3, Advert = 0x4, GroupText = 0x5, GroupData = 0x6, + AnonRequest = 0x7, Path = 0x8, Trace = 0x9, Multipart = 0xA, Control = 0xB, RawCustom = 0xF, +}; + +struct Packet { + Route route = Route::Flood; + Payload type = Payload::Request; + bool hasTransport = false; + uint16_t transport[2] = {0, 0}; // the first is the region's; the second is reserved + uint8_t hops = 0; // entries in the path + uint8_t hashSize = 1; // bytes each + const uint8_t* path = nullptr; // hops * hashSize bytes + const uint8_t* payload = nullptr; + size_t payloadLen = 0; + + bool flood() const { return route == Route::Flood || route == Route::TransportFlood; } + // Sent to one node from one node, each named by the first byte of its key. + bool addressed() const { return type == Payload::Request || type == Payload::Response || type == Payload::Text || type == Payload::Path; } + bool group() const { return type == Payload::GroupText || type == Payload::GroupData; } +}; + +// False when the bytes can't be a MeshCore packet: a version other than the first, a reserved +// type, or a path longer than what follows. +bool parsePacket(const uint8_t* data, size_t len, Packet& out); + +// A node saying who it is, signed: never encrypted. +struct Advert { + uint8_t key[32]; + uint32_t timestamp = 0; // the node's clock, seconds since 1970 + uint8_t kind = 0; // 1 chat, 2 repeater, 3 room server, 4 sensor + bool hasLocation = false; + int32_t latE6 = 0, lonE6 = 0; + std::string name; // printable ASCII kept, the rest as '?' +}; +bool parseAdvert(const Packet& p, Advert& out); + +const char* routeName(Route r); // "flood", "direct", ... +const char* payloadName(Payload t); // "advert", "text", ... +const char* payloadShort(Payload t); // for a list row: "adv", "txt", ... +const char* kindName(uint8_t kind); // "Chat node", "Repeater", ... + +} // namespace roro::meshcore diff --git a/site/content/dev/debug/commands.md b/site/content/dev/debug/commands.md index 029c046..62de628 100644 --- a/site/content/dev/debug/commands.md +++ b/site/content/dev/debug/commands.md @@ -21,7 +21,7 @@ boot other restart into the other app slot (manual Rollback) log level <0-5> ESP-IDF log level (0 none ... 5 verbose) ls [folder] | du | mkdir | rm [-r] [-f] | cp [-f] | mv [-f] | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask; * and ? in a name: /notes/*.txt) screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause -lora probe | lora status | lora rx on|off | lora preset the LoRa radio, receive only +lora probe | lora status | lora rx on|off | lora preset the LoRa radio, receive only; a Meshtastic preset (LongFast...) or MeshCore lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap) lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100) lora custom [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN) @@ -102,7 +102,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r | `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor | | `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack | | `lora rx on` / `lora rx off` | Listens and prints each packet on the console | -| `lora preset ` / `lora custom [preamble]` | Receive settings: a Meshtastic preset, or anything else (`lora custom 868.1 125 7 5 34 8` for LoRaWAN) | +| `lora preset ` / `lora custom [preamble]` | Receive settings: a Meshtastic preset or `MeshCore`, or anything else (`lora custom 868.1 125 7 5 34 8` for LoRaWAN) | | `lora capture start` / `lora capture stop` | A LoRa Capture, as `c` in the App | | `lora sweep on [from MHz] [to MHz] [step kHz]` / `lora sweep off` / `lora sweep dump` | Sweep a band (863 870 100 by default), with a summary every 2 s (floor, strongest, peaks), or print the latest pass | | `gnss quiet on` / `gnss quiet off` | The "Pause GNSS for LoRa" setting | diff --git a/site/content/faq.md b/site/content/faq.md index 781d4b1..d0869d2 100644 --- a/site/content/faq.md +++ b/site/content/faq.md @@ -12,7 +12,7 @@ Press **Fn + h**, on any screen: it lists the keys that wo ## Can I send messages over the mesh? -**Not yet.** The LoRa Scanner **listens** to Meshtastic traffic and shows what it hears, but nothing is transmitted. A mesh messenger is the goal and is planned in two milestones, one for receiving and one for transmitting; it waits for a second node to test against. +**Not yet.** The LoRa Scanner **listens** to Meshtastic or MeshCore traffic and shows what it hears, but nothing is transmitted. A mesh messenger is the goal and is planned in two milestones, one for receiving and one for transmitting; it waits for a second node to test against. ## Is this Meshtastic? diff --git a/site/content/guide/lora-scanner.md b/site/content/guide/lora-scanner.md index f79831e..4ab2df4 100644 --- a/site/content/guide/lora-scanner.md +++ b/site/content/guide/lora-scanner.md @@ -13,14 +13,38 @@ The Scanner uses the **Cap LoRa-1262**. It **never transmits**: it listens and s The first view lists what the radio hears, newest first: the time, the RSSI (signal strength, in dBm) and the SNR (signal over noise, in dB). For a **Meshtastic** packet it also shows the sender and the receiver (the last four hex digits of their numbers) and the number of hops. +For a **MeshCore** packet it shows what the packet is, who it concerns and how many repeaters are in its path: + +| Row | Is | +|---|---| +| `adv Brussels-R1 2h` | A node saying who it is, by its name, heard through 2 repeaters | +| `txt a3>7f` | A private message from the node whose key starts with `a3` to the one starting with `7f` (`req`, `rsp` and `path` look the same) | +| `chan #11 1h` | A message on a channel; `#11` is the channel's byte, and `11` is the public channel's | +| `ack`, `anon`, `trace`, `ctl` | An acknowledgement, a login, a trace, a discovery | + | Key | Does | |---|---| -| Enter | The details of a packet: the Meshtastic header, which is never encrypted, and a hex dump | -| p | Picks one of the 7 Meshtastic presets allowed in EU868 (LongFast by default) | +| Enter | The details of a packet: what is never encrypted (below), and a hex dump | +| p | Picks what to listen to: one of the 7 Meshtastic presets allowed in EU868 (LongFast by default), or **MeshCore** | | c | Starts or stops a **capture** | | Tab | Switches to the Sweep | -The Scanner shows the header and the bytes; it does not decrypt the message itself, which Meshtastic encrypts with the channel's key. +The Scanner shows the header and the bytes; it does not decrypt the message itself, which Meshtastic and MeshCore both encrypt. + +**One network at a time.** The radio listens on one frequency with one setting, and the two networks share neither: on a Meshtastic preset no MeshCore packet is heard, and the other way round. + +### MeshCore + +The **MeshCore** preset is the setting its networks use in Europe, "EU/UK (Narrow)": 869.618 MHz, 62.5 kHz wide, SF8, coding rate 4/8. A network set up differently needs `lora custom` in the [Shell](/guide/shell/). + +Enter on a MeshCore packet shows: + +- what it is and how it travels: **flood** (every repeater passes it on, and adds itself to the path) or **direct** (it carries the route to follow); +- the path, one repeater a hop, each named by the first byte of its key; +- for an **advert**, which is signed and never encrypted: the node's name, its kind (chat node, repeater, room server, sensor), the start of its key, and its position if it gives one; +- for a message: the first byte of the sender's and the receiver's keys, or the channel's byte, and how many bytes are encrypted. + +A byte is all a packet says about who it is from: two nodes can share it. The name comes only with a node's advert, which a node sends now and then, so a quiet network can take a while to show its names. ## Capture diff --git a/site/content/guide/storage.md b/site/content/guide/storage.md index 9b8dcdb..7222e85 100644 --- a/site/content/guide/storage.md +++ b/site/content/guide/storage.md @@ -36,7 +36,7 @@ The App says why when it refuses. - **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only a screenful is read from the card, so a file of any size opens at once, and logs open at the end. Up and down move a line, left and right a page, t and b go to the top and the end, and e edits it, whatever its size (as in [Notes](/guide/notes/)). - **Pictures** (`.png`, `.jpg`, `.bmp`, `.gif`): see [Pictures](#pictures) below. -- **Captures** (`.pcap`): the packets as the [LoRa Scanner](/guide/lora-scanner/) lists them; Enter shows one with its Meshtastic header and bytes. +- **Captures** (`.pcap`): the packets as the [LoRa Scanner](/guide/lora-scanner/) lists them; Enter shows one with its Meshtastic or MeshCore header and bytes. - **Tracks** (`.gpx`): the number of points, the start, the duration and the distance. - **Update files** (`.ota`): the version, and whether the file would install: it is checked as an install checks it, signature and contents, without writing anything. Enter then installs it (see [Updates](/guide/updates/)). - **Anything else:** a hex dump. diff --git a/site/content/howto/lora-capture.md b/site/content/howto/lora-capture.md index 8789e58..202fc68 100644 --- a/site/content/howto/lora-capture.md +++ b/site/content/howto/lora-capture.md @@ -9,7 +9,7 @@ tag = "LoRa Scanner" The radio only **listens**: nothing is transmitted. You need the **Cap LoRa-1262** and an **SD card**. 1. **Open the LoRa Scanner.** The Status Bar shows `L` while the radio listens. -2. **Pick a preset with p.** The Scanner offers the 7 Meshtastic presets allowed in EU868; LongFast is the default. +2. **Pick a preset with p.** The Scanner offers the 7 Meshtastic presets allowed in EU868 (LongFast is the default) and MeshCore. 3. **Wait for packets.** Each line is a packet: the time, RSSI and SNR, and for a Meshtastic packet the sender, the receiver and the hops. Enter shows a packet's header and its bytes. 4. **Press c to start a capture.** The Status Bar shows `CAP`. It keeps recording with the App closed. 5. **Press c again to stop.** diff --git a/site/data/apps.toml b/site/data/apps.toml index 9400ddd..f18b1e9 100644 --- a/site/data/apps.toml +++ b/site/data/apps.toml @@ -5,7 +5,7 @@ num = "01" tag = "LoRa Scanner" title = "See what is on the air" -text = "Lists every packet it hears with time, RSSI, SNR, sender and hops, reading the Meshtastic header. A Sweep shows signal strength from 863 to 870 MHz. Capture to a pcap for Wireshark." +text = "Lists every packet it hears with time, RSSI, SNR, sender and hops, reading the Meshtastic or MeshCore header. A Sweep shows signal strength from 863 to 870 MHz. Capture to a pcap for Wireshark." fact = "Only listens, never transmits" icon = 1 diff --git a/site/data/keys.toml b/site/data/keys.toml index 1fad3aa..97549d8 100644 --- a/site/data/keys.toml +++ b/site/data/keys.toml @@ -206,7 +206,7 @@ title = "LoRa Scanner, the packets" rows = [ ["; .", "up, down"], ["Enter", "the packet's details"], - ["p", "pick a Meshtastic preset"], + ["p", "pick a preset"], ["c", "start a Capture, or stop it"], ["Tab", "the Sweep"], ] diff --git a/src/apps/file_viewer.cpp b/src/apps/file_viewer.cpp index 4e38482..c000687 100644 --- a/src/apps/file_viewer.cpp +++ b/src/apps/file_viewer.cpp @@ -264,10 +264,9 @@ void FileViewer::openPacket(int index) { if (tap) { std::snprintf(line, sizeof line, "%.0f dBm, SNR %.1f dB, noise %.0f", rx.rssi, rx.snr, rx.noiseFloor); details_.push_back(line); - std::snprintf(line, sizeof line, "%.4f MHz, SF%u, %.0f kHz", rx.frequencyHz / 1e6, rx.spreadingFactor, rx.bandwidthKHz); + std::snprintf(line, sizeof line, "%.4f MHz, SF%u, %g kHz", rx.frequencyHz / 1e6, rx.spreadingFactor, rx.bandwidthKHz); details_.push_back(line); - if (rx.syncWord == meshtastic::kSyncWord) - for (auto& l : lora::headerLines(body + skip, have - skip)) details_.push_back(l); + for (auto& l : lora::headerLines(body + skip, have - skip, lora::protocolOf(rx.syncWord))) details_.push_back(l); } for (auto& l : lora::hexDump(body + skip, have - skip)) details_.push_back(l); if (have < r.length) details_.push_back("(the first bytes only)"); @@ -391,7 +390,7 @@ void FileViewer::refresh() { size_t have = std::min(r.length, n - files::kPcapRecordSize); if (tap && files::parseLoraTap(body, have, rx)) { shown_.push_back(lora::row({body + lora::kLoraTapSize, have - lora::kLoraTapSize, rx.rssi, rx.snr, true, - rx.syncWord == meshtastic::kSyncWord}, + lora::protocolOf(rx.syncWord)}, clockTime(r.seconds))); } else { shown_.push_back(clockTime(r.seconds) + " " + std::to_string(r.length) + " bytes"); diff --git a/src/apps/lora_scanner_app.cpp b/src/apps/lora_scanner_app.cpp index 1d25fc8..a622e63 100644 --- a/src/apps/lora_scanner_app.cpp +++ b/src/apps/lora_scanner_app.cpp @@ -7,7 +7,7 @@ #include #include -#include "meshtastic_presets.h" +#include "scan_presets.h" #include "packet_view.h" #include "sweep_view.h" #include "ui/canvas.h" @@ -21,8 +21,8 @@ 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]; + int i = std::clamp(settings_.getInt(Setting::LoraPreset), 0, lora::scanPresetCount() - 1); + lora::ScanPreset p = lora::scanPreset(i); if (std::string(radio_.config().name) != p.name) radio_.setPreset(p); radio_.listen(RadioService::App, true); view_ = View::Packets; @@ -48,7 +48,7 @@ void LoraScannerApp::showSweep(bool on) { requestRedraw(); } -bool LoraScannerApp::meshtasticSettings() const { return radio_.config().syncWord == meshtastic::kSyncWord; } +lora::Protocol LoraScannerApp::protocol() const { return lora::protocolOf(radio_.config().syncWord); } void LoraScannerApp::say(const std::string& text) { message_ = text; @@ -83,8 +83,8 @@ bool LoraScannerApp::onKey(const KeyEvent& e) { 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); + radio_.setPreset(lora::scanPreset(i)); + say(std::string("Listening on ") + lora::scanPreset(i).name); view_ = View::Packets; } else if (e.key == Key::Back) view_ = View::Packets; else return true; @@ -104,7 +104,7 @@ bool LoraScannerApp::onKey(const KeyEvent& e) { return true; } if (e.key == Key::Char && (e.ch == 'p' || e.ch == 'P')) { - presets_.setCount(meshtastic::kEu868PresetCount); + presets_.setCount(lora::scanPresetCount()); presets_.select(settings_.getInt(Setting::LoraPreset)); view_ = View::Presets; requestRedraw(); @@ -133,8 +133,8 @@ void LoraScannerApp::openDetails() { 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); + if (p.crcOk) + for (auto& l : lora::headerLines(p.data, p.len, lora::protocolOf(p.rx.syncWord))) details_.push_back(l); for (auto& l : lora::hexDump(p.data, p.len)) details_.push_back(l); detailsTop_ = 0; view_ = View::Details; @@ -238,10 +238,12 @@ void LoraScannerApp::drawPackets(Canvas& c) { 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, + lora::Protocol heard = protocol(); + c.drawString(heard == lora::Protocol::Meshtastic ? "Meshtastic nodes in range show here." + : heard == lora::Protocol::MeshCore ? "MeshCore nodes in range show here." : "Custom settings.", 4, list.y + 4 + theme::kLineHeight); } else { - bool mesh = meshtasticSettings(); + lora::Protocol mesh = protocol(); widgets::list(c, packets_, list, [&](int i) { RadioPacket p; if (!radio_.packet(newest - i, p)) return std::string("(gone)"); @@ -265,14 +267,14 @@ 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); + c.drawString("Meshtastic in EU868, and MeshCore", 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) { return std::string(lora::scanPreset(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); + lora::ScanPreset p = lora::scanPreset(i); + std::snprintf(v, sizeof v, "SF%u %gk %.3f", p.sf, p.bwKHz, p.frequencyHz / 1e6); return std::string(v); }); } diff --git a/src/apps/lora_scanner_app.h b/src/apps/lora_scanner_app.h index 59eca27..c42e3c3 100644 --- a/src/apps/lora_scanner_app.h +++ b/src/apps/lora_scanner_app.h @@ -14,7 +14,7 @@ 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 +// with the Meshtastic or MeshCore 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). Tab: Sweep, the band's // RSSI as bars with peak hold and a waterfall (Q98); it pauses the Sniffer while shown (Q99). class LoraScannerApp : public App { @@ -40,7 +40,7 @@ class LoraScannerApp : public App { void showSweep(bool on); void openDetails(); std::string timeOf(const RadioPacket& p) const; - bool meshtasticSettings() const; + lora::Protocol protocol() const; // whose packets the radio is set for void say(const std::string& text); // a line in the footer for a few seconds RadioService& radio_; diff --git a/src/main.cpp b/src/main.cpp index aa16c12..f08fd80 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -676,7 +676,7 @@ static const char* const kHelp = "log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n" "ls [folder] | du | mkdir | rm [-r] [-f] | cp [-f] | mv [-f] | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask; * and ? in a name: /notes/*.txt)\n" "screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause\n" - "lora probe | lora status | lora rx on|off | lora preset the LoRa radio, receive only\n" + "lora probe | lora status | lora rx on|off | lora preset the LoRa radio, receive only; a Meshtastic preset (LongFast...) or MeshCore\n" "lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)\n" "lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)\n" "lora custom [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)\n" @@ -1133,9 +1133,10 @@ static void runCommand(String line, bool fromSerial = false) { } } if (line.startsWith("lora preset ") && radioService) { - const meshtastic::Preset* p = meshtastic::findPreset(line.substring(12).c_str()); - if (p) radioService->setPreset(*p); - console.printf("lora preset: %s\n", p ? p->name : "unknown (LongFast LongSlow MediumSlow MediumFast ShortSlow ShortFast LongMod)"); + lora::ScanPreset p; + bool found = lora::findScanPreset(line.substring(12).c_str(), p); + if (found) radioService->setPreset(p); + console.printf("lora preset: %s\n", found ? p.name : "unknown (LongFast LongSlow MediumSlow MediumFast ShortSlow ShortFast LongMod MeshCore)"); } if (line == "gnss quiet on" || line == "gnss quiet off") { // Settings > Pause GNSS for LoRa (issue #20) settings.setBool(Setting::GnssQuietForLora, line.endsWith("on")); diff --git a/src/services/radio_service.cpp b/src/services/radio_service.cpp index cb9c9d8..79ae2ad 100644 --- a/src/services/radio_service.cpp +++ b/src/services/radio_service.cpp @@ -39,9 +39,8 @@ constexpr float kDeafBelow = -108.0f; RadioService* RadioService::instance_ = nullptr; RadioService::RadioService() : lock_(xSemaphoreCreateMutex()) { - const meshtastic::Preset& p = meshtastic::kEu868Presets[0]; // LongFast (Q95) - config_ = {meshtastic::eu868FrequencyHz(p), p.bwKHz, p.sf, p.cr, meshtastic::kSyncWord, meshtastic::kPreambleLength, - p.name}; + lora::ScanPreset p = lora::scanPreset(0); // LongFast (Q95) + config_ = {p.frequencyHz, p.bwKHz, p.sf, p.cr, p.syncWord, p.preamble, p.name}; } void RadioService::start() { @@ -334,10 +333,9 @@ RadioService::Config RadioService::config() const { return c; } -void RadioService::setPreset(const meshtastic::Preset& p) { +void RadioService::setPreset(const lora::ScanPreset& p) { xSemaphoreTake(lock_, portMAX_DELAY); - config_ = {meshtastic::eu868FrequencyHz(p), p.bwKHz, p.sf, p.cr, meshtastic::kSyncWord, meshtastic::kPreambleLength, - p.name}; + config_ = {p.frequencyHz, p.bwKHz, p.sf, p.cr, p.syncWord, p.preamble, p.name}; xSemaphoreGive(lock_); configChanged_ = true; wake(kRequest); @@ -374,7 +372,14 @@ void RadioService::tick(uint32_t nowMs) { console.printf("lora: #%lu %u B %.1f dBm SNR %.1f dB%s", (unsigned long)p.seq, p.len, p.rx.rssi, p.rx.snr, p.crcOk ? "" : " (bad CRC)"); meshtastic::PacketHeader h; - if (meshtastic::parseHeader(p.data, p.len, h)) + lora::Protocol protocol = lora::protocolOf(p.rx.syncWord); + if (protocol == lora::Protocol::MeshCore && p.crcOk) { // what the Scanner's details show, on one line + const char* sep = " | "; + for (auto& l : lora::headerLines(p.data, p.len, protocol)) { + console.printf("%s%s", sep, l.c_str()); + sep = "; "; + } + } else if (protocol == lora::Protocol::Meshtastic && meshtastic::parseHeader(p.data, p.len, h)) console.printf(" | %s > %s, ch 0x%02X, hops %d/%u%s", meshtastic::nodeId(h.from).c_str(), meshtastic::nodeId(h.to).c_str(), h.channelHash, h.hopsAway(), h.hopStart(), h.viaMqtt() ? ", via MQTT" : ""); @@ -400,7 +405,7 @@ void RadioService::printStatus(Print& out) const { Counters n = counters(); out.printf("radio: %s, %s, antenna switch %s\n", present_ ? "SX1262" : "no radio", tcxo_ > 0 ? "TCXO 1.8 V" : "crystal", expander_ ? "on (expander 0x43 P0)" : "expander missing"); - out.printf("radio: %s, %s %.3f MHz, BW %.0f kHz, SF %u, CR 4/%u, sync 0x%02X, preamble %u\n", + out.printf("radio: %s, %s %.3f MHz, BW %g kHz, SF %u, CR 4/%u, sync 0x%02X, preamble %u\n", sweeping_ ? "sweeping" : listening_ ? "listening" : "asleep", c.name, c.frequencyHz / 1e6, c.bandwidthKHz, c.spreadingFactor, c.codingRate, c.syncWord, c.preamble); out.printf("radio: clients%s%s%s%s\n", clients_ ? "" : " none", clients_ & App ? " App" : "", diff --git a/src/services/radio_service.h b/src/services/radio_service.h index 8971d2a..e4c32a3 100644 --- a/src/services/radio_service.h +++ b/src/services/radio_service.h @@ -10,7 +10,7 @@ #include #include "loratap.h" -#include "meshtastic_presets.h" +#include "scan_presets.h" #include "service.h" class SX1262; @@ -74,7 +74,7 @@ class RadioService : public Service { bool listening() const { return listening_; } void listen(Client client, bool on); Config config() const; - void setPreset(const meshtastic::Preset& preset); + void setPreset(const lora::ScanPreset& preset); void setConfig(const Config& config); // custom settings (Q95), named "Custom" uint32_t received() const { return seq_; } // seq of the newest packet diff --git a/test/test_meshcore/test_meshcore.cpp b/test/test_meshcore/test_meshcore.cpp new file mode 100644 index 0000000..51e3567 --- /dev/null +++ b/test/test_meshcore/test_meshcore.cpp @@ -0,0 +1,181 @@ +#include + +#include +#include +#include + +#include "meshcore_packet.h" +#include "packet_view.h" +#include "scan_presets.h" + +using namespace roro; +using namespace roro::meshcore; + +void setUp() {} +void tearDown() {} + +// A flood advert relayed by two repeaters (1-byte hashes), from a repeater that says where it is. +static std::vector advert(const char* name, bool location = true, uint8_t pathByte = 2) { + std::vector v = {static_cast(0x04 << 2 | 0x01), pathByte}; + for (int i = 0; i < (pathByte & 0x3F) * ((pathByte >> 6) + 1); ++i) v.push_back(static_cast(0xa3 + i)); + for (int i = 0; i < 32; ++i) v.push_back(static_cast(0xc0 + i)); // key + v.insert(v.end(), {0x00, 0x5e, 0xd0, 0x68}); // timestamp + v.insert(v.end(), 64, 0x55); // signature + v.push_back(static_cast(0x80 | (location ? 0x10 : 0) | 0x02)); // named, repeater + if (location) { + int32_t lat = 50860320, lon = 4300480; + for (int i = 0; i < 4; ++i) v.push_back(static_cast(lat >> (8 * i))); + for (int i = 0; i < 4; ++i) v.push_back(static_cast(lon >> (8 * i))); + } + v.insert(v.end(), name, name + std::strlen(name)); + return v; +} + +void test_header_route_type_and_path() { + auto v = advert("Brussels-R1"); + Packet p; + TEST_ASSERT_TRUE(parsePacket(v.data(), v.size(), p)); + TEST_ASSERT_TRUE(p.route == Route::Flood); + TEST_ASSERT_TRUE(p.type == Payload::Advert); + TEST_ASSERT_FALSE(p.hasTransport); + TEST_ASSERT_EQUAL(2, p.hops); + TEST_ASSERT_EQUAL(1, p.hashSize); + TEST_ASSERT_EQUAL_HEX8(0xa3, p.path[0]); + TEST_ASSERT_EQUAL_HEX8(0xc0, p.payload[0]); +} + +void test_transport_codes_and_wider_hashes() { + // Direct in a region, a text message, two hops of 2 bytes each. + const uint8_t v[] = {0x02 << 2 | 0x03, 0x2b, 0x1a, 0x00, 0x00, 0x40 | 2, 0x11, 0x22, 0x33, 0x44, 0x7f, 0xa3, 0xde, 0xad, 1, 2, 3}; + Packet p; + TEST_ASSERT_TRUE(parsePacket(v, sizeof v, p)); + TEST_ASSERT_TRUE(p.route == Route::TransportDirect); + TEST_ASSERT_TRUE(p.hasTransport); + TEST_ASSERT_EQUAL_HEX16(0x1a2b, p.transport[0]); + TEST_ASSERT_EQUAL(2, p.hops); + TEST_ASSERT_EQUAL(2, p.hashSize); + TEST_ASSERT_EQUAL_size_t(7, p.payloadLen); + TEST_ASSERT_EQUAL_HEX8(0x7f, p.payload[0]); + TEST_ASSERT_TRUE(p.addressed()); +} + +void test_what_is_not_a_meshcore_packet() { + Packet p; + const uint8_t version[] = {0x40 | 0x05, 0x00, 1, 2}; // a version that doesn't exist yet + const uint8_t reserved[] = {0x0c << 2 | 0x01, 0x00, 1, 2}; // a reserved type + const uint8_t longPath[] = {0x05, 0x09, 1, 2, 3}; // nine hops, three bytes left + const uint8_t badHash[] = {0x05, 0xc1, 1, 2, 3, 4}; // 4-byte hashes aren't defined + TEST_ASSERT_FALSE(parsePacket(version, sizeof version, p)); + TEST_ASSERT_FALSE(parsePacket(reserved, sizeof reserved, p)); + TEST_ASSERT_FALSE(parsePacket(longPath, sizeof longPath, p)); + TEST_ASSERT_FALSE(parsePacket(badHash, sizeof badHash, p)); + TEST_ASSERT_FALSE(parsePacket(version, 1, p)); +} + +void test_advert() { + auto v = advert("Brussels-R1"); + Packet p; + Advert a; + TEST_ASSERT_TRUE(parsePacket(v.data(), v.size(), p)); + TEST_ASSERT_TRUE(parseAdvert(p, a)); + TEST_ASSERT_EQUAL_STRING("Brussels-R1", a.name.c_str()); + TEST_ASSERT_EQUAL(2, a.kind); + TEST_ASSERT_TRUE(a.hasLocation); + TEST_ASSERT_EQUAL_INT32(50860320, a.latE6); + TEST_ASSERT_EQUAL_INT32(4300480, a.lonE6); + TEST_ASSERT_EQUAL_HEX32(0x68d05e00, a.timestamp); + TEST_ASSERT_EQUAL_HEX8(0xc0, a.key[0]); + + auto plain = advert("n", false, 0); + TEST_ASSERT_TRUE(parsePacket(plain.data(), plain.size(), p)); + TEST_ASSERT_TRUE(parseAdvert(p, a)); + TEST_ASSERT_FALSE(a.hasLocation); + TEST_ASSERT_EQUAL_STRING("n", a.name.c_str()); + + v.resize(60); // cut short: still a packet, not an advert that can be read + TEST_ASSERT_TRUE(parsePacket(v.data(), v.size(), p)); + TEST_ASSERT_FALSE(parseAdvert(p, a)); +} + +void test_rows() { + auto v = advert("Brussels-R1"); + lora::PacketSummary s{v.data(), v.size(), -97.4f, 6.25f, true, lora::Protocol::MeshCore}; + TEST_ASSERT_EQUAL_STRING("21:45:07 -97 6.2 adv Brussels-R1 2h", lora::row(s, "21:45:07").c_str()); + auto longName = advert("A repeater with a very long name"); + lora::PacketSummary l{longName.data(), longName.size(), -117.0f, -12.25f, true, lora::Protocol::MeshCore}; + TEST_ASSERT_TRUE(lora::row(l, "21:45:07").size() <= 38); + + const uint8_t text[] = {0x02 << 2 | 0x02, 0x00, 0x7f, 0xa3, 0xde, 0xad, 1, 2, 3}; + lora::PacketSummary t{text, sizeof text, -80.0f, 9.0f, true, lora::Protocol::MeshCore}; + TEST_ASSERT_EQUAL_STRING("21:45:07 -80 9.0 txt a3>7f", lora::row(t, "21:45:07").c_str()); + + const uint8_t group[] = {0x05 << 2 | 0x01, 0x01, 0x42, 0x11, 0xbe, 0xef, 1, 2, 3, 4}; + lora::PacketSummary g{group, sizeof group, -80.0f, 9.0f, true, lora::Protocol::MeshCore}; + TEST_ASSERT_EQUAL_STRING("21:45:07 -80 9.0 chan #11 1h", lora::row(g, "21:45:07").c_str()); + + // Not MeshCore after all (sync word 0x12 is anyone's): the bytes are counted, no more. + const uint8_t other[] = {0xff, 0x00, 1, 2}; + lora::PacketSummary o{other, sizeof other, -80.0f, 9.0f, true, lora::Protocol::MeshCore}; + TEST_ASSERT_EQUAL_STRING("21:45:07 -80 9.0 4 B", lora::row(o, "21:45:07").c_str()); +} + +void test_detail_lines() { + auto v = advert("Brussels-R1"); + auto lines = lora::headerLines(v.data(), v.size(), lora::Protocol::MeshCore); + TEST_ASSERT_EQUAL_size_t(5, lines.size()); + TEST_ASSERT_EQUAL_STRING("MeshCore advert, flood", lines[0].c_str()); + TEST_ASSERT_EQUAL_STRING("Through a3 a4", lines[1].c_str()); + TEST_ASSERT_EQUAL_STRING("Repeater Brussels-R1", lines[2].c_str()); + TEST_ASSERT_EQUAL_STRING("Key c0c1c2c3c4c5c6c7...", lines[3].c_str()); + TEST_ASSERT_EQUAL_STRING("At 50.86032, 4.30048", lines[4].c_str()); + + const uint8_t group[] = {0x05 << 2 | 0x01, 0x00, 0x11, 0xbe, 0xef, 1, 2, 3, 4}; + lines = lora::headerLines(group, sizeof group, lora::Protocol::MeshCore); + TEST_ASSERT_EQUAL_size_t(3, lines.size()); + TEST_ASSERT_EQUAL_STRING("MeshCore channel message, flood", lines[0].c_str()); + TEST_ASSERT_EQUAL_STRING("Heard first hand: no repeater yet", lines[1].c_str()); + TEST_ASSERT_EQUAL_STRING("Channel 0x11 (public?), 4 B encrypted", lines[2].c_str()); + + const uint8_t text[] = {0x02 << 2 | 0x02, 0x01, 0x42, 0x7f, 0xa3, 0xde, 0xad, 1, 2, 3}; + lines = lora::headerLines(text, sizeof text, lora::Protocol::MeshCore); + TEST_ASSERT_EQUAL_STRING("MeshCore text message, direct", lines[0].c_str()); + TEST_ASSERT_EQUAL_STRING("Route 42", lines[1].c_str()); + TEST_ASSERT_EQUAL_STRING("From ..a3 to ..7f, 3 B encrypted", lines[2].c_str()); + + for (auto& l : lines) TEST_ASSERT_TRUE(l.size() <= 38); + TEST_ASSERT_EQUAL_size_t(0, lora::headerLines(text, sizeof text, lora::Protocol::None).size()); +} + +void test_scan_presets() { + TEST_ASSERT_EQUAL_size_t(8, lora::scanPresetCount()); + lora::ScanPreset p = lora::scanPreset(0); // the default stays first: it is stored by number + TEST_ASSERT_EQUAL_STRING("LongFast", p.name); + TEST_ASSERT_EQUAL_UINT32(869525000u, p.frequencyHz); + TEST_ASSERT_EQUAL_HEX8(0x2B, p.syncWord); + TEST_ASSERT_TRUE(lora::findScanPreset("meshcore", p)); + TEST_ASSERT_EQUAL_STRING("MeshCore", p.name); + TEST_ASSERT_EQUAL_UINT32(869618000u, p.frequencyHz); + TEST_ASSERT_EQUAL_FLOAT(62.5f, p.bwKHz); + TEST_ASSERT_EQUAL(8, p.sf); + TEST_ASSERT_EQUAL(8, p.cr); + TEST_ASSERT_EQUAL_HEX8(0x12, p.syncWord); + TEST_ASSERT_EQUAL(16, p.preamble); + TEST_ASSERT_TRUE(p.protocol == lora::Protocol::MeshCore); + TEST_ASSERT_TRUE(lora::protocolOf(0x12) == lora::Protocol::MeshCore); + TEST_ASSERT_TRUE(lora::protocolOf(0x2B) == lora::Protocol::Meshtastic); + TEST_ASSERT_TRUE(lora::protocolOf(0x34) == lora::Protocol::None); + TEST_ASSERT_FALSE(lora::findScanPreset("ShortTurbo", p)); + TEST_ASSERT_FALSE(lora::findScanPreset("MeshCor", p)); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_header_route_type_and_path); + RUN_TEST(test_transport_codes_and_wider_hashes); + RUN_TEST(test_what_is_not_a_meshcore_packet); + RUN_TEST(test_advert); + RUN_TEST(test_rows); + RUN_TEST(test_detail_lines); + RUN_TEST(test_scan_presets); + return UNITY_END(); +} diff --git a/test/test_packet_view/test_packet_view.cpp b/test/test_packet_view/test_packet_view.cpp index 0f84527..c9e6de5 100644 --- a/test/test_packet_view/test_packet_view.cpp +++ b/test/test_packet_view/test_packet_view.cpp @@ -17,30 +17,30 @@ static const uint8_t kMeshtastic[] = { }; void test_row_for_a_meshtastic_packet() { - PacketSummary p{kMeshtastic, sizeof kMeshtastic, -97.4f, 6.25f, true, true}; + PacketSummary p{kMeshtastic, sizeof kMeshtastic, -97.4f, 6.25f, true, Protocol::Meshtastic}; // Time, RSSI, SNR, then who sent it to whom (by default short name: the last 4 hex digits, // as Meshtastic names a Node) and how far it came. Fits 40 columns with two node numbers. TEST_ASSERT_EQUAL_STRING("21:45:07 -97 6.2 5678>all 1/3", row(p, "21:45:07").c_str()); const uint8_t direct[] = {0x0a, 0x0b, 0x0c, 0x0d, 0xc4, 0xd3, 0xb2, 0xa1, 1, 2, 3, 4, 0x6a, 8, 0, 0xaa}; - PacketSummary d{direct, sizeof direct, -117.0f, -9.25f, true, true}; + PacketSummary d{direct, sizeof direct, -117.0f, -9.25f, true, Protocol::Meshtastic}; TEST_ASSERT_EQUAL_STRING("21:45:07 -117 -9.2 d3c4>0b0a 1/3", row(d, "21:45:07").c_str()); TEST_ASSERT_TRUE(row(d, "21:45:07").size() <= 38); } void test_row_for_something_else() { const uint8_t data[] = {0x40, 0x01, 0x02}; - PacketSummary p{data, sizeof data, -120.6f, -7.5f, true, false}; + PacketSummary p{data, sizeof data, -120.6f, -7.5f, true, Protocol::None}; TEST_ASSERT_EQUAL_STRING("21:45:07 -121 -7.5 3 B", row(p, "21:45:07").c_str()); } // Not on Meshtastic settings (LoRaWAN, say): the same bytes aren't read as a Meshtastic header. void test_row_not_meshtastic() { - PacketSummary p{kMeshtastic, sizeof kMeshtastic, -97.0f, 6.0f, true, false}; + PacketSummary p{kMeshtastic, sizeof kMeshtastic, -97.0f, 6.0f, true, Protocol::None}; TEST_ASSERT_EQUAL_STRING("21:45:07 -97 6.0 19 B", row(p, "21:45:07").c_str()); } void test_row_with_a_bad_crc() { - PacketSummary p{kMeshtastic, sizeof kMeshtastic, -97.0f, 6.0f, false, true}; + PacketSummary p{kMeshtastic, sizeof kMeshtastic, -97.0f, 6.0f, false, Protocol::Meshtastic}; TEST_ASSERT_EQUAL_STRING("21:45:07 -97 6.0 bad CRC, 19 B", row(p, "21:45:07").c_str()); }