diff --git a/README.md b/README.md index cb42945..e950790 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 message on the public channel read, with the name its sender gives; any other 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 public channel message's sender, time and text, decrypted with the channel's published key) and a hex dump. `p` picks **MeshCore** (the default; its EU/UK Narrow setting: 869.618 MHz, 62.5 kHz, SF8, CR 4/8) or one of the 7 Meshtastic presets allowed in EU868, `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/docs/milestones/M3.md b/docs/milestones/M3.md index b4522ce..3677e6c 100644 --- a/docs/milestones/M3.md +++ b/docs/milestones/M3.md @@ -36,7 +36,7 @@ | Q92 | A **Radio Service** owns the SX1262: driver, bus lock, IRQ task. The LoRa Scanner uses it in M3; the Mesh Service sits on top of it in M4. | | Q93 | Every radio transfer takes the shared bus lock (`SPI.beginTransaction`, as the card does). DIO1's interrupt only wakes the task; no SPI in the ISR. **Done when** a Gemini page streams to the card while the Sniffer receives, with no lost packets and no card errors (`lora status` counters). | | Q94 | **Receive only:** the Radio Service has no transmit function in M3. It doesn't exist, rather than being unused. | -| Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. | +| Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. *Later, the default became is the MeshCore preset (869.618 MHz, BW 62.5 kHz, SF 8, CR 4/8, sync word 0x12): it is what is heard here.* | | Q96 | The Sniffer lists packets (time, RSSI, SNR, frequency error, length; hex dump on Enter) **and decodes the Meshtastic header**: the first 16 bytes are never encrypted (destination, sender, packet ID, hop limit and hop start, channel hash, next hop, relay node). Host-tested. Payload decryption is M4. | | Q97 | A Sniffer **Capture** is pcap with **LoRaTap** headers (link type 270), for Wireshark. Started by hand, Status Bar mark, its own Clean-up category, the 90% rule. | | Q98 | **Sweep** steps across the Region's band (863–870 MHz) in 100 kHz steps by default, reading instant RSSI: bars with peak hold, and a waterfall, Wi-Fi Tools style. Optionally CAD on the preset's frequency to tell LoRa traffic from noise. | 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..0298d90 100644 --- a/lib/lora/src/packet_view.cpp +++ b/lib/lora/src/packet_view.cpp @@ -2,7 +2,10 @@ #include #include +#include +#include "meshcore_channel.h" +#include "meshcore_packet.h" #include "meshtastic_header.h" #include "meshtastic_presets.h" @@ -10,13 +13,137 @@ 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; +} + +// Onto lines of `cols` characters at most, broken at spaces where there are some. +void wrapInto(std::vector& lines, std::string text, size_t cols) { + while (text.size() > cols) { + size_t cut = text.rfind(' ', cols); + if (cut == std::string::npos || cut == 0) cut = cols; + lines.push_back(text.substr(0, cut)); + text.erase(0, text[cut] == ' ' ? cut + 1 : cut); + } + if (!text.empty()) lines.push_back(text); +} + +// `room` characters at most: a node's name or a message 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; + meshcore::Discover d; + meshcore::ChannelText t; + if (meshcore::parseDiscover(m, d)) { + out = d.response ? "here " + hex(d.id, 2) : "who's there?"; + } else if (meshcore::readChannelText(m, t)) { + out = (t.sender.empty() ? "" : t.sender + ": ") + t.text; + out = out.substr(0, room > hops.size() ? room - hops.size() : 0); + while (!out.empty() && out.back() == ' ') out.pop_back(); + return out + hops; + } else 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; + meshcore::Discover d; + if (meshcore::parseDiscover(m, d) && d.response) { + lines.push_back(std::string(meshcore::kindName(d.kind)) + " answering a search"); + lines.push_back("Key " + hex(d.id, 8) + "..."); + std::snprintf(s, sizeof s, "It heard the search at %.1f dB SNR", d.snr); + lines.push_back(s); + } else if (meshcore::parseDiscover(m, d)) { + std::string who; + for (int kind = 1; kind <= 4; ++kind) + if (d.kinds & (1 << kind)) who += std::string(who.empty() ? "" : ", ") + meshcore::kindName(static_cast(kind)); + lines.push_back("A search for nodes nearby"); + lines.push_back("Wanted: " + (who.empty() ? std::string("any") : who)); + } else 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 (meshcore::ChannelText t; meshcore::readChannelText(m, t)) { + lines.push_back(std::string("On the ") + t.channel + " channel" + (t.sender.empty() ? "" : ", from " + t.sender)); + std::time_t sent = t.timestamp; + std::tm tm{}; + gmtime_r(&sent, &tm); + std::strftime(s, sizeof s, "Sent %Y-%m-%d %H:%M:%S UTC", &tm); + lines.push_back(s); + wrapInto(lines, t.text, 38); + } 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 +175,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..95addad 100644 --- a/lib/lora/src/packet_view.h +++ b/lib/lora/src/packet_view.h @@ -7,23 +7,30 @@ 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", "who's there?", "here 18a6") and the hops in its path ("2h"); +// a message on the public channel is read: "Alice: hello fro 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, and a message on its public channel. 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..eedadbe --- /dev/null +++ b/lib/lora/src/scan_presets.h @@ -0,0 +1,28 @@ +#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 first, then MeshCore's EU/UK (Narrow), the default. 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; +}; + +constexpr size_t kDefaultScanPreset = 7; // MeshCore: the Settings' default says the same +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/aes128.cpp b/lib/meshcore/src/aes128.cpp new file mode 100644 index 0000000..771b8ee --- /dev/null +++ b/lib/meshcore/src/aes128.cpp @@ -0,0 +1,87 @@ +#include "aes128.h" + +#include + +namespace roro::meshcore { + +namespace { +// The S-box and its inverse, worked out once rather than typed in. +struct Tables { + uint8_t s[256], inv[256]; + Tables() { + auto rotl = [](uint8_t x, int n) { return static_cast(x << n | x >> (8 - n)); }; + uint8_t p = 1, q = 1; + do { + p = static_cast(p ^ (p << 1) ^ (p & 0x80 ? 0x1B : 0)); // p times 3 + q ^= static_cast(q << 1); // q divided by 3 + q ^= static_cast(q << 2); + q ^= static_cast(q << 4); + if (q & 0x80) q ^= 0x09; + uint8_t x = static_cast(q ^ rotl(q, 1) ^ rotl(q, 2) ^ rotl(q, 3) ^ rotl(q, 4) ^ 0x63); + s[p] = x; + inv[x] = p; + } while (p != 1); + s[0] = 0x63; + inv[0x63] = 0; + } +}; +const Tables& tables() { + static const Tables t; + return t; +} + +uint8_t mul(uint8_t a, uint8_t b) { // in GF(2^8) + uint8_t r = 0; + for (; b; b >>= 1) { + if (b & 1) r ^= a; + a = static_cast(a << 1 ^ (a & 0x80 ? 0x1B : 0)); + } + return r; +} +} // namespace + +Aes128::Aes128(const uint8_t key[16]) { + const Tables& t = tables(); + std::memcpy(roundKeys_, key, 16); + uint8_t rcon = 1; + for (int i = 16; i < 176; i += 4) { + uint8_t w[4]; + std::memcpy(w, roundKeys_ + i - 4, 4); + if (i % 16 == 0) { + uint8_t first = w[0]; + w[0] = static_cast(t.s[w[1]] ^ rcon); + w[1] = t.s[w[2]]; + w[2] = t.s[w[3]]; + w[3] = t.s[first]; + rcon = static_cast(rcon << 1 ^ (rcon & 0x80 ? 0x1B : 0)); + } + for (int j = 0; j < 4; ++j) roundKeys_[i + j] = roundKeys_[i - 16 + j] ^ w[j]; + } +} + +void Aes128::decryptBlock(const uint8_t in[16], uint8_t out[16]) const { + const Tables& t = tables(); + uint8_t s[16]; + for (int i = 0; i < 16; ++i) s[i] = in[i] ^ roundKeys_[160 + i]; + for (int round = 9; round >= 0; --round) { + // Rows shifted back and bytes substituted back, in one go: column c, row r came from column c - r. + uint8_t u[16]; + for (int c = 0; c < 4; ++c) + for (int r = 0; r < 4; ++r) u[4 * c + r] = t.inv[s[4 * ((c - r + 4) % 4) + r]]; + for (int i = 0; i < 16; ++i) u[i] ^= roundKeys_[16 * round + i]; + if (round == 0) { + std::memcpy(s, u, 16); + break; + } + for (int c = 0; c < 4; ++c) { + const uint8_t* a = u + 4 * c; + s[4 * c + 0] = mul(a[0], 14) ^ mul(a[1], 11) ^ mul(a[2], 13) ^ mul(a[3], 9); + s[4 * c + 1] = mul(a[0], 9) ^ mul(a[1], 14) ^ mul(a[2], 11) ^ mul(a[3], 13); + s[4 * c + 2] = mul(a[0], 13) ^ mul(a[1], 9) ^ mul(a[2], 14) ^ mul(a[3], 11); + s[4 * c + 3] = mul(a[0], 11) ^ mul(a[1], 13) ^ mul(a[2], 9) ^ mul(a[3], 14); + } + } + std::memcpy(out, s, 16); +} + +} // namespace roro::meshcore diff --git a/lib/meshcore/src/aes128.h b/lib/meshcore/src/aes128.h new file mode 100644 index 0000000..2d0d29a --- /dev/null +++ b/lib/meshcore/src/aes128.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace roro::meshcore { + +// AES-128 (FIPS 197), one block at a time, decryption only: what reading a MeshCore message +// takes. Small and dependency-free, so the same code runs in the PC tests and on the device. +class Aes128 { + public: + explicit Aes128(const uint8_t key[16]); + void decryptBlock(const uint8_t in[16], uint8_t out[16]) const; + + private: + uint8_t roundKeys_[176]; +}; + +} // namespace roro::meshcore diff --git a/lib/meshcore/src/meshcore_channel.cpp b/lib/meshcore/src/meshcore_channel.cpp new file mode 100644 index 0000000..ba8424a --- /dev/null +++ b/lib/meshcore/src/meshcore_channel.cpp @@ -0,0 +1,87 @@ +#include "meshcore_channel.h" + +#include + +#include "aes128.h" +#include "sha256.h" + +namespace roro::meshcore { + +namespace { +// izOH6cXN6mrJ5e26oRXNcg== in base64, as the apps show it. +const Channel kChannels[] = { + {"Public", {0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72}}, +}; + +void hmacSha256(const uint8_t key[16], const uint8_t* data, size_t len, uint8_t out[32]) { + uint8_t pad[64], inner[32]; + std::memset(pad, 0x36, sizeof pad); + for (int i = 0; i < 16; ++i) pad[i] ^= key[i]; + Sha256 in; + in.update(pad, sizeof pad); + in.update(data, len); + in.finish(inner); + std::memset(pad, 0x5C, sizeof pad); + for (int i = 0; i < 16; ++i) pad[i] ^= key[i]; + Sha256 o; + o.update(pad, sizeof pad); + o.update(inner, sizeof inner); + o.finish(out); +} + +// Bytes that aren't printable ASCII become '?': one for a whole UTF-8 character. +std::string printable(const uint8_t* p, size_t n) { + std::string out; + for (size_t i = 0; i < n; ++i) { + if (p[i] >= 0x20 && p[i] < 0x7F) out += static_cast(p[i]); + else if ((p[i] & 0xC0) != 0x80) out += '?'; + } + return out; +} +} // namespace + +size_t channelCount() { return sizeof kChannels / sizeof kChannels[0]; } +const Channel& channel(size_t i) { return kChannels[i]; } + +uint8_t channelHash(const Channel& c) { + uint8_t h[32]; + Sha256::hash(c.key, sizeof c.key, h); + return h[0]; +} + +bool readChannelText(const Packet& p, ChannelText& out) { + // The channel's hash, 2 bytes of check, then whole AES blocks. + if (p.type != Payload::GroupText || p.payloadLen < 3 + 16 || (p.payloadLen - 3) % 16) return false; + const uint8_t* cipher = p.payload + 3; + size_t len = p.payloadLen - 3; + for (size_t i = 0; i < channelCount(); ++i) { + const Channel& c = channel(i); + if (channelHash(c) != p.payload[0]) continue; + uint8_t mac[32]; + hmacSha256(c.key, cipher, len, mac); + if (mac[0] != p.payload[1] || mac[1] != p.payload[2]) continue; + uint8_t plain[192]; + Aes128 aes(c.key); + for (size_t at = 0; at < len; at += 16) aes.decryptBlock(cipher + at, plain + at); + // Timestamp, then a byte whose top 6 bits say what follows: 0 is plain text, "name: words". + if (plain[4] >> 2) return false; + ChannelText t; + t.channel = c.name; + t.timestamp = static_cast(plain[0] | plain[1] << 8 | plain[2] << 16 | static_cast(plain[3]) << 24); + size_t end = 5; + while (end < len && plain[end]) ++end; // padded with zeros + std::string all = printable(plain + 5, end - 5); + size_t colon = all.find(": "); + if (colon == std::string::npos) { + t.text = all; + } else { + t.sender = all.substr(0, colon); + t.text = all.substr(colon + 2); + } + out = t; + return true; + } + return false; +} + +} // namespace roro::meshcore diff --git a/lib/meshcore/src/meshcore_channel.h b/lib/meshcore/src/meshcore_channel.h new file mode 100644 index 0000000..072c628 --- /dev/null +++ b/lib/meshcore/src/meshcore_channel.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +#include "meshcore_packet.h" + +// MeshCore's channel messages: encrypted with a key everyone on the channel has. The public +// channel's key is published, so what is said there can be read by anyone listening. +namespace roro::meshcore { + +struct Channel { + const char* name; + uint8_t key[16]; +}; + +// The channels whose key is known here: the public one, for now. +size_t channelCount(); +const Channel& channel(size_t i); +// What a message on the channel starts with: the first byte of the SHA-256 of its key. +uint8_t channelHash(const Channel& c); + +struct ChannelText { + const char* channel = ""; + uint32_t timestamp = 0; // the sender's clock, seconds since 1970 + std::string sender; // what the sender calls itself: not signed, anyone can claim a name + std::string text; // printable ASCII kept, the rest as '?' +}; + +// True when the packet is a text on a known channel and its check (2 bytes of HMAC-SHA256) holds. +bool readChannelText(const Packet& p, ChannelText& out); + +} // namespace roro::meshcore diff --git a/lib/meshcore/src/meshcore_packet.cpp b/lib/meshcore/src/meshcore_packet.cpp new file mode 100644 index 0000000..5a5e996 --- /dev/null +++ b/lib/meshcore/src/meshcore_packet.cpp @@ -0,0 +1,149 @@ +#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; +} + +bool parseDiscover(const Packet& p, Discover& out) { + if (p.type != Payload::Control || p.payloadLen < 6) return false; + uint8_t sub = p.payload[0] >> 4; + Discover d; + d.tag = static_cast(le32(p.payload + 2)); + if (sub == 0x8) { + d.kinds = p.payload[1]; + } else if (sub == 0x9) { + d.response = true; + d.kind = p.payload[0] & 0x0F; + d.snr = static_cast(p.payload[1]) / 4.0f; + d.id = p.payload + 6; + d.idLen = p.payloadLen - 6; + if (d.idLen != 8 && d.idLen != 32) return false; + } else { + return false; + } + out = d; + 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..4ba6080 --- /dev/null +++ b/lib/meshcore/src/meshcore_packet.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include + +// MeshCore's packets as they are on air (its docs/packet_format.md and docs/payloads.md): what is +// sent in clear, which is the header, the path, and a node's advertisement. meshcore_channel.h +// reads the public channel's messages. +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); + +// Control packets that look for nodes nearby, and the answers: sent in clear, to neighbours only. +struct Discover { + bool response = false; + uint8_t kinds = 0; // a request: one bit per kind of node wanted (bit 2: repeaters) + uint8_t kind = 0; // a response: what answers, as in an advert + float snr = 0; // a response: how well it heard the request, dB + uint32_t tag = 0; // random in the request, repeated in its responses + const uint8_t* id = nullptr; // a response: the node's key, or its first 8 bytes + size_t idLen = 0; +}; +bool parseDiscover(const Packet& p, Discover& 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/lib/services/src/settings.cpp b/lib/services/src/settings.cpp index a08598b..018c51e 100644 --- a/lib/services/src/settings.cpp +++ b/lib/services/src/settings.cpp @@ -35,7 +35,7 @@ const Definition kDefinitions[] = { {"wifi_on", Kind::Bool, 1, nullptr, 0, 1}, {"gnss_on", Kind::Bool, 1, nullptr, 0, 1}, {"coord_dms", Kind::Bool, 0, nullptr, 0, 1}, - {"lora_preset", Kind::Int, 0, nullptr, 0, 6}, // LongFast first + {"lora_preset", Kind::Int, 7, nullptr, 0, 7}, // MeshCore, after the 7 Meshtastic ones {"dns1", Kind::String, 0, "9.9.9.9", 7, 15}, // Quad9 {"dns2", Kind::String, 0, "1.1.1.1", 0, 15}, // Cloudflare {"dns_always", Kind::Bool, 0, nullptr, 0, 1}, diff --git a/lib/services/src/settings.h b/lib/services/src/settings.h index 3a30f55..60ab16e 100644 --- a/lib/services/src/settings.h +++ b/lib/services/src/settings.h @@ -23,7 +23,7 @@ enum class Setting : uint8_t { WifiEnabled, // bool: the Wi-Fi Service stays Connected when a Saved Network is in range GnssEnabled, // bool: the GNSS Service reads the receiver (M2, Q58) CoordinatesDms, // bool: show degrees, minutes and seconds instead of decimal degrees (Q64) - LoraPreset, // int: the LoRa Scanner's Meshtastic preset, an index into the EU868 list (M3, Q95) + LoraPreset, // int: the LoRa Scanner's preset, an index into lora::scanPreset (M3, Q95): MeshCore by default Dns1, // string: the first DNS server, an IPv4 address (S1, Q108, Q109) Dns2, // string: the second, or empty DnsAlways, // bool: use them on Automatic (DHCP) networks too, instead of DHCP's 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/dev/milestones/m3.md b/site/content/dev/milestones/m3.md index e42c8f2..2a4b06f 100644 --- a/site/content/dev/milestones/m3.md +++ b/site/content/dev/milestones/m3.md @@ -44,7 +44,7 @@ tag = "M3" | Q92 | A **Radio Service** owns the SX1262: driver, bus lock, IRQ task. The LoRa Scanner uses it in M3; the Mesh Service sits on top of it in M4. | | Q93 | Every radio transfer takes the shared bus lock (`SPI.beginTransaction`, as the card does). DIO1's interrupt only wakes the task; no SPI in the ISR. **Done when** a Gemini page streams to the card while the Sniffer receives, with no lost packets and no card errors (`lora status` counters). | | Q94 | **Receive only:** the Radio Service has no transmit function in M3. It doesn't exist, rather than being unused. | -| Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. | +| Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. *Later, the default became is the MeshCore preset (869.618 MHz, BW 62.5 kHz, SF 8, CR 4/8, sync word 0x12): it is what is heard here.* | | Q96 | The Sniffer lists packets (time, RSSI, SNR, frequency error, length; hex dump on Enter) **and decodes the Meshtastic header**: the first 16 bytes are never encrypted (destination, sender, packet ID, hop limit and hop start, channel hash, next hop, relay node). Host-tested. Payload decryption is M4. | | Q97 | A Sniffer **Capture** is pcap with **LoRaTap** headers (link type 270), for Wireshark. Started by hand, Status Bar mark, its own Clean-up category, the 90% rule. | | Q98 | **Sweep** steps across the Region's band (863–870 MHz) in 100 kHz steps by default, reading instant RSSI: bars with peak hold, and a waterfall, Wi-Fi Tools style. Optionally CAD on the preset's frequency to tell LoRa traffic from noise. | 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..9f3b443 100644 --- a/site/content/guide/lora-scanner.md +++ b/site/content/guide/lora-scanner.md @@ -13,14 +13,45 @@ 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) | +| `Alice: hello 1h` | A message on the **public channel**, read: who says they sent it, and the start of what it says | +| `chan #5c 1h` | A message on another channel, still encrypted; `#5c` is the channel's byte | +| `who's there?` | Someone nearby searching for nodes (repeaters, usually) | +| `here 18a6` | A node answering that search; `18a6` starts its key | +| `ack`, `anon`, `trace`, `ctl` | An acknowledgement, a login, a trace, other control packets | + | 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: **MeshCore** (the default), or one of the 7 Meshtastic presets allowed in EU868 | | 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. Meshtastic and MeshCore both encrypt the message itself, and the Scanner reads only one kind: what is said on MeshCore's public channel, whose key is published. + +**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 **search** and its **answers**, also in clear: what kind of node is wanted; and in an answer the node's kind, the start of its key, and how well it heard the search; +- for a message on the **public channel**: the name the sender gives, when the sender's clock says it was sent (UTC), and the text; +- for any other message: the first byte of the sender's and the receiver's keys, or the channel's byte, and how many bytes are encrypted. + +The public channel is the one every MeshCore node starts with, and its key is the same everywhere, so anyone listening can read it. Private messages and other channels stay encrypted. The sender's name is part of the message and is not signed: anyone can write any name. Letters outside plain ASCII (accents, emoji) show as `?`. + +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..83e403c 100644 --- a/site/content/howto/lora-capture.md +++ b/site/content/howto/lora-capture.md @@ -9,12 +9,12 @@ 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 MeshCore (the default) and the 7 Meshtastic presets allowed in EU868. 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.** 6. **Get the file.** It is in `/captures/lora/`, a `.pcap` with LoRaTap headers. In the [Storage App](/guide/storage/) you can already look at its packets; on a computer, open it in Wireshark. -**What you will and will not see.** Meshtastic's header is never encrypted, so who sent a packet and how far it hopped is visible. The message itself is encrypted with the channel's key, and the Scanner does not decrypt it. +**What you will and will not see.** Meshtastic's header is never encrypted, so who sent a packet and how far it hopped is visible. The message itself is encrypted with the channel's key, and the Scanner does not decrypt it. MeshCore's public channel is the exception: its key is published, and the Scanner [reads what is said there](/guide/lora-scanner/#meshcore). **Hearing nothing is normal** if no node is in range, or if the preset does not match what the nodes nearby use. The [Sweep](/guide/lora-scanner/) (Tab) shows whether anything is on the air at all across 863 to 870 MHz. 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..42ad1bc 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(lora::kDefaultScanPreset); + 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..2fea788 --- /dev/null +++ b/test/test_meshcore/test_meshcore.cpp @@ -0,0 +1,286 @@ +#include + +#include +#include +#include + +#include "aes128.h" +#include "meshcore_channel.h" +#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()); +} + +// As received near Brussels on 2026-10-09: a search for repeaters, and one answering. +void test_discover_as_heard() { + const uint8_t ask[] = {0x2e, 0x00, 0x80, 0x04, 0xbb, 0xdd, 0x5f, 0x5a, 0x00, 0x00, 0x00, 0x00}; + const uint8_t answer[] = {0x2e, 0x00, 0x92, 0x2d, 0xbb, 0xdd, 0x5f, 0x5a, 0x18, 0xa6, 0x3f, 0xae, 0xf4, 0x01, 0x9b, 0xee, + 0xd0, 0x4f, 0xfd, 0xd3, 0xd1, 0x72, 0x65, 0xba, 0x4b, 0x9e, 0xeb, 0xd0, 0xdf, 0xb0, 0xb2, 0xc4, + 0x94, 0xec, 0x50, 0x5b, 0x03, 0x20, 0x59, 0x9d}; + Packet p; + Discover d; + TEST_ASSERT_TRUE(parsePacket(ask, sizeof ask, p)); + TEST_ASSERT_TRUE(p.route == Route::Direct); + TEST_ASSERT_TRUE(p.type == Payload::Control); + TEST_ASSERT_TRUE(parseDiscover(p, d)); + TEST_ASSERT_FALSE(d.response); + TEST_ASSERT_EQUAL_HEX8(0x04, d.kinds); + TEST_ASSERT_EQUAL_HEX32(0x5a5fddbb, d.tag); + TEST_ASSERT_TRUE(parsePacket(answer, sizeof answer, p)); + TEST_ASSERT_TRUE(parseDiscover(p, d)); + TEST_ASSERT_TRUE(d.response); + TEST_ASSERT_EQUAL(2, d.kind); + TEST_ASSERT_EQUAL_FLOAT(11.25f, d.snr); + TEST_ASSERT_EQUAL_HEX32(0x5a5fddbb, d.tag); + TEST_ASSERT_EQUAL_size_t(32, d.idLen); + + lora::PacketSummary a{ask, sizeof ask, -107.0f, -7.0f, true, lora::Protocol::MeshCore}; + TEST_ASSERT_EQUAL_STRING("20:44:01 -107 -7.0 who's there?", lora::row(a, "20:44:01").c_str()); + lora::PacketSummary r{answer, sizeof answer, -96.0f, 3.0f, true, lora::Protocol::MeshCore}; + TEST_ASSERT_EQUAL_STRING("20:44:02 -96 3.0 here 18a6", lora::row(r, "20:44:02").c_str()); + + auto lines = lora::headerLines(ask, sizeof ask, lora::Protocol::MeshCore); + TEST_ASSERT_EQUAL_size_t(4, lines.size()); + TEST_ASSERT_EQUAL_STRING("MeshCore control, direct", lines[0].c_str()); + TEST_ASSERT_EQUAL_STRING("No route in it: to a neighbour", lines[1].c_str()); + TEST_ASSERT_EQUAL_STRING("A search for nodes nearby", lines[2].c_str()); + TEST_ASSERT_EQUAL_STRING("Wanted: Repeater", lines[3].c_str()); + lines = lora::headerLines(answer, sizeof answer, lora::Protocol::MeshCore); + TEST_ASSERT_EQUAL_size_t(5, lines.size()); + TEST_ASSERT_EQUAL_STRING("Repeater answering a search", lines[2].c_str()); + TEST_ASSERT_EQUAL_STRING("Key 18a63faef4019bee...", lines[3].c_str()); + TEST_ASSERT_EQUAL_STRING("It heard the search at 11.2 dB SNR", lines[4].c_str()); +} + +void test_aes128_decrypts_the_standard_block() { // FIPS 197, appendix C.1 + uint8_t key[16], in[16] = {0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, 0xc5, 0x5a}, out[16]; + for (int i = 0; i < 16; ++i) key[i] = static_cast(i); + Aes128(key).decryptBlock(in, out); + for (int i = 0; i < 16; ++i) TEST_ASSERT_EQUAL_HEX8(i * 0x11, out[i]); +} + +// Messages on the public channel, relayed twice, encrypted and signed with openssl: +// "Alice: hello from Brussels" and "Zo\xc3\xa9: \xc3\xa7a va ?", both sent at 1791575000. +static const uint8_t kHello[] = {0x15, 0x02, 0xa3, 0x7f, 0x11, 0x20, 0x14, 0x90, 0xb7, 0xe1, 0x1d, 0x46, 0x5f, + 0x3f, 0xac, 0x4f, 0x98, 0xa8, 0xc6, 0x07, 0x79, 0x4c, 0x96, 0x19, 0x5e, 0x54, + 0x60, 0x67, 0x2b, 0x2e, 0xec, 0x0e, 0xe4, 0x6a, 0xbc, 0x8b, 0x51, 0x38, 0xfd}; +static const uint8_t kAccents[] = {0x15, 0x02, 0xa3, 0x7f, 0x11, 0x19, 0xdd, 0x85, 0x58, 0xc5, 0x73, 0x2d, 0x55, + 0x4d, 0xf4, 0xfd, 0x02, 0x47, 0xdc, 0xfa, 0xa2, 0xce, 0x3b, 0xcb, 0xb1, 0x8e, + 0x8f, 0x66, 0xc7, 0xd6, 0x61, 0x30, 0x3c, 0xbe, 0x29, 0xd7, 0xb8, 0x08, 0x26}; + +void test_public_channel_message() { + TEST_ASSERT_EQUAL_HEX8(kPublicChannelHash, channelHash(channel(0))); + Packet p; + ChannelText t; + TEST_ASSERT_TRUE(parsePacket(kHello, sizeof kHello, p)); + TEST_ASSERT_TRUE(readChannelText(p, t)); + TEST_ASSERT_EQUAL_STRING("Public", t.channel); + TEST_ASSERT_EQUAL_UINT32(1791575000, t.timestamp); + TEST_ASSERT_EQUAL_STRING("Alice", t.sender.c_str()); + TEST_ASSERT_EQUAL_STRING("hello from Brussels", t.text.c_str()); + + TEST_ASSERT_TRUE(parsePacket(kAccents, sizeof kAccents, p)); + TEST_ASSERT_TRUE(readChannelText(p, t)); + TEST_ASSERT_EQUAL_STRING("Zo?", t.sender.c_str()); // one '?' for a character outside ASCII + TEST_ASSERT_EQUAL_STRING("?a va ?", t.text.c_str()); + + // One bit wrong and the check fails: nothing is made up from it. + std::vector bad(kHello, kHello + sizeof kHello); + bad[20] ^= 1; + TEST_ASSERT_TRUE(parsePacket(bad.data(), bad.size(), p)); + TEST_ASSERT_FALSE(readChannelText(p, t)); + // Another channel with the same first byte, or not whole blocks: left encrypted. + bad.assign(kHello, kHello + sizeof kHello - 1); + TEST_ASSERT_TRUE(parsePacket(bad.data(), bad.size(), p)); + TEST_ASSERT_FALSE(readChannelText(p, t)); +} + +void test_public_channel_message_shown() { + lora::PacketSummary s{kHello, sizeof kHello, -97, 6.2f, true, lora::Protocol::MeshCore}; + std::string row = lora::row(s, "21:45:07"); + TEST_ASSERT_EQUAL_STRING("21:45:07 -97 6.2 Alice: hello from 2h", row.c_str()); + TEST_ASSERT_LESS_OR_EQUAL(38, row.size()); + auto lines = lora::headerLines(kHello, sizeof kHello, lora::Protocol::MeshCore); + TEST_ASSERT_EQUAL_size_t(5, lines.size()); + TEST_ASSERT_EQUAL_STRING("MeshCore channel message, flood", lines[0].c_str()); + TEST_ASSERT_EQUAL_STRING("Through a3 7f", lines[1].c_str()); + TEST_ASSERT_EQUAL_STRING("On the Public channel, from Alice", lines[2].c_str()); + TEST_ASSERT_EQUAL_STRING("Sent 2026-10-09 19:43:20 UTC", lines[3].c_str()); + TEST_ASSERT_EQUAL_STRING("hello from Brussels", lines[4].c_str()); +} + +void test_scan_presets() { + TEST_ASSERT_EQUAL_size_t(8, lora::scanPresetCount()); + TEST_ASSERT_EQUAL_STRING("MeshCore", lora::scanPreset(lora::kDefaultScanPreset).name); + lora::ScanPreset p = lora::scanPreset(0); // the order stays: a preset 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_discover_as_heard); + RUN_TEST(test_aes128_decrypts_the_standard_block); + RUN_TEST(test_public_channel_message); + RUN_TEST(test_public_channel_message_shown); + 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()); } diff --git a/test/test_settings/test_settings.cpp b/test/test_settings/test_settings.cpp index 32d773e..9a34fc2 100644 --- a/test/test_settings/test_settings.cpp +++ b/test/test_settings/test_settings.cpp @@ -28,8 +28,8 @@ void test_defaults_when_store_is_empty() { TEST_ASSERT_TRUE(s.getBool(Setting::WifiEnabled)); TEST_ASSERT_TRUE(s.getBool(Setting::GnssEnabled)); // Q58: on by default TEST_ASSERT_FALSE(s.getBool(Setting::CoordinatesDms)); // Q64: decimal degrees - TEST_ASSERT_EQUAL_INT32(0, s.getInt(Setting::LoraPreset)); // Q95: LongFast - TEST_ASSERT_FALSE(s.setInt(Setting::LoraPreset, 7)); // seven EU868 presets + TEST_ASSERT_EQUAL_INT32(7, s.getInt(Setting::LoraPreset)); // MeshCore, after the seven EU868 presets + TEST_ASSERT_FALSE(s.setInt(Setting::LoraPreset, 8)); TEST_ASSERT_EQUAL_STRING("EU868", s.getString(Setting::Region).c_str()); TEST_ASSERT_EQUAL_STRING("CET-1CEST,M3.5.0,M10.5.0/3", s.getString(Setting::Timezone).c_str()); }