diff --git a/poc/MeshCorePoc/CHARACTERISATION.md b/poc/MeshCorePoc/CHARACTERISATION.md new file mode 100644 index 0000000..18912b5 --- /dev/null +++ b/poc/MeshCorePoc/CHARACTERISATION.md @@ -0,0 +1,86 @@ +# MeshCore bearer characterisation — transport & compression + +Goal: choose the transport (text vs binary) and compression for the DAPPS-over-MeshCore bearer, +and quantify the cost in the currency that matters — **LoRa airtime** on a slow, shared, flooded +channel. Numbers below are from the no-radio harness (`meshcore-poc characterise`) plus on-air runs +between two Heltec V3s on the UK-narrow preset (869.618 MHz / BW 62.5 kHz / SF8 / CR4-8). + +At this PHY a full ~165-byte packet is **≈1.6 s on air** (≈1 kbit/s) — so packet count and byte +count translate almost linearly into airtime, and airtime is the scarce, shared resource. + +## 1. Transport: text (0x03) vs binary (0x3E) + +| Path | base64 | name prefix | DAPPS bytes / packet | vs text | +|---|---|---|---:|---:| +| text `SEND_CHANNEL_TXT_MSG` | yes (+33%) | yes (`": "`) | 95 | 1.00× | +| **binary `SEND_CHANNEL_DATA`** | no | **none** | **151** | **1.59×** | + +**On-air confirmation** of the binary path's two wins: +- *No prefix*: sent 21 raw bytes `DAPPS-NO-PREFIX-12345`, received **byte-identical** (`raw: 4441…3435`, SNR 11.8 dB). The `": "` is purely the chat/text API. +- *Verbatim binary*: a compressed (non-UTF-8) DAPPS frame round-tripped intact. + +Decision: **binary**. (Gotcha handled: the binary path has no on-air timestamp, so identical frames +share a packet hash and are dropped by the mesh `hasSeen` dedup — each frame carries a 1-byte +rolling nonce so retransmits stay distinct.) + +## 2. Compression of the encoded BackhaulMessage (30-message test corpus) + +| Scheme | mean ratio | ≤1 packet (binary) | mean airtime / msg (binary) | +|---|---:|---:|---:| +| None | 1.00 | 93% | 1257 ms | +| Deflate (raw) | 0.85 | 93% | 1109 ms | +| Brotli (q11) | 0.97 | 97% | 1163 ms | +| Zstd (l19, no dict) | 1.00 | 93% | 1275 ms | +| **Zstd + shared dictionary** | **0.32** | **100%** | **645 ms** | + +The headline: **generic compressors barely dent a ~100-byte message** (brotli/zstd even expand some). +A **shared dictionary trained on representative DAPPS traffic** collapses messages ~3× and puts +**100% of the corpus into a single packet**. DAPPS frames are highly regular (codec framing, +callsigns, common payloads), so a dictionary is the right lever. + +Whole-corpus airtime: text+uncompressed 57.7 s → binary+uncompressed 37.7 s (0.65×) → +**binary + zstd-dict 18.0 s (0.31×)** — i.e. **≈3.2× the goodput** of the text path. + +**On-air confirmation** (122 B payload, 168 B encoded): +| | frames | bytes on air | ~airtime | +|---|---:|---:|---:| +| uncompressed | 2 (161 B + 35 B) | 196 | ≈2.1 s | +| **zstd-dict** | **1 (49 B)** | **49** | **≈0.64 s** | + +Both decoded byte-perfect on the second radio; compression took a 2-packet message to **1 packet** +over real RF (≈3.3× airtime saving for that message). + +> Honesty: the dictionary was trained on synthetic-but-representative traffic, so absolute ratios are +> optimistic; real gains track how well a shipped dictionary matches real traffic. Direction is robust. +> A production bearer needs a **versioned dictionary** negotiated by id so both ends agree. + +## 3. Airtime is a network-wide shared budget (not per-link) + +Two multipliers make airtime even scarcer than the per-packet numbers suggest: +- **Flooding**: one channel transmission is re-flooded by every relaying repeater in scope — one send + becomes many on-air transmissions across the network's RF cells. +- **Shared medium**: the 869.618 sub-band is **10 % duty / 500 mW**, shared by *every* originator on + the preset. The per-packet ~1.6 s is drawn from one common budget. + +So compression and parsimony don't just speed one node up — they extend the whole network's capacity. +And see the README "containment" section: on the public preset, unscoped DAPPS floods burn the whole +same-preset network's airtime — the strongest argument for both heavy compression and flood-scoping / +a dedicated preset. + +## 4. Recommendation + +- **Transport: binary channel-data (`0x3E`/`0x1B`)**, `data_type = 0xFFFF` (DEV), 1-byte nonce/flags + header, Packetiser fragments ≤160 B (payload ≤165 B/packet). +- **Compression: zstd with a versioned shared dictionary**, applied to the encoded BackhaulMessage + before fragmentation. Skip it only if it expands a given message (flag per-message — the header bit + is already there). Generic (no-dict) compression isn't worth the CPU here. +- **Budget airtime** per channel via `AirtimeAccountant` on the *data* path (today discovery-only), + conservatively (shared, network-wide). +- **Reliability**: end-to-end (no MeshCore ACK for channel msgs), idempotent on `dapps-id`, TTL-aware + resends with **long backoff** (a resend is another network-wide flood). + +## 5. Operational note — firmware hangs + +During testing a radio's Companion firmware became unresponsive to `APP_START` until a hard reset +(esptool `--after hard_reset`, i.e. a DTR/RTS toggle). The bearer should **watchdog the serial link** +and reset the radio (DTR/RTS, no buttons) when it goes mute — which ties into the device-control API. diff --git a/poc/MeshCorePoc/Characterise.cs b/poc/MeshCorePoc/Characterise.cs new file mode 100644 index 0000000..23877cb --- /dev/null +++ b/poc/MeshCorePoc/Characterise.cs @@ -0,0 +1,289 @@ +using System.IO.Compression; +using System.Text; +using dapps.client.Backhaul; +using dapps.client.Backhaul.Datagram; + +namespace MeshCorePoc; + +/// +/// No-radio characterisation harness: how much DAPPS payload fits per MeshCore +/// LoRa packet on each transport (text+base64 vs binary channel-data), how heavy +/// compression changes that, and the resulting packet count + LoRa airtime. +/// +/// Two numbers matter on this bearer: +/// - DAPPS bytes carried per packet (transport efficiency), and +/// - LoRa airtime per packet (the duty-cycle currency). +/// At SF8/BW62.5/CR4-8 a full packet is ~1 s on air, so every byte saved and +/// every packet avoided is real airtime back. +/// +public static class Characterise +{ + // ---- LoRa PHY (UK narrow) ---- + public const int Sf = 8; + public const double BwHz = 62_500; + public const int CrDenom = 8; // 4/8 + public const int Preamble = 8; + + // MeshCore packet overhead on air (header + path + cipher MAC), beyond the + // channel plaintext. Calibrated from a hardware LOG_RX_DATA measurement; + // overridable so the report states its assumption. + public const int MeshCoreOnAirOverhead = 16; + + // Channel plaintext caps (firmware): one LoRa packet holds at most this many + // plaintext bytes for a group/channel message. + public const int MaxGroupPlaintext = 165; // MAX_GROUP_DATA_LENGTH + + // Per-packet plaintext consumed by framing on each path, before our bytes: + // text: 4-byte timestamp + 1 flags + ": " prefix (assume 10) + // binary: NO on-air timestamp; just our own 1-byte nonce/flags header + // (needed because identical binary frames are de-duped by the mesh). + // data_type(2)+data_len(1) are already excluded from the 165 cap. + public const int TextFramingOverhead = 4 + 1 + 10; + public const int BinaryFramingOverhead = 1; + + public const string Marker = "D1:"; // text-path DAPPS marker + public const int PacketiserHeader = Packetiser.HeaderLength; // 13 + + /// LoRa time-on-air in ms for a PHY payload of . + public static double AirtimeMs(int payloadBytes) + { + double tSym = Math.Pow(2, Sf) / BwHz * 1000.0; + double tPreamble = (Preamble + 4.25) * tSym; + const int de = 0, ih = 0, crcOn = 1; // explicit header, CRC on, no low-rate-opt + int cr = CrDenom - 4; + double num = 8 * payloadBytes - 4 * Sf + 28 + 16 * crcOn - 20 * ih; + double den = 4 * (Sf - 2 * de); + int symb = 8 + (int)Math.Max(Math.Ceiling(num / den) * (cr + 4), 0); + return tPreamble + symb * tSym; + } + + /// Max DAPPS-payload bytes carried per packet on each path + /// (after Packetiser header), and the on-air packet size for a full packet. + public static (int textPerPkt, int binPerPkt, int textOnAir, int binOnAir) PerPacketCapacity() + { + // text: base64 inflates 3 raw -> 4 chars. Available text chars = + // MaxGroupPlaintext - TextFramingOverhead - marker. raw = chars/4*3. + int textChars = MaxGroupPlaintext - TextFramingOverhead - Marker.Length; + int textRaw = textChars / 4 * 3; // pre-base64 fragment bytes + int textPerPkt = Math.Max(0, textRaw - PacketiserHeader); + int textOnAir = MaxGroupPlaintext + MeshCoreOnAirOverhead; + + int binRaw = MaxGroupPlaintext - BinaryFramingOverhead; // fragment bytes + int binPerPkt = Math.Max(0, binRaw - PacketiserHeader); + int binOnAir = MaxGroupPlaintext + MeshCoreOnAirOverhead; + + return (textPerPkt, binPerPkt, textOnAir, binOnAir); + } + + // ---------- compression ---------- + + public enum Scheme { None, Deflate, Brotli, Zstd, ZstdDict } + + public static byte[] Compress(Scheme s, byte[] data, ZstdSharp.Compressor? zstd, ZstdSharp.Compressor? zstdDict) => s switch + { + Scheme.None => data, + Scheme.Deflate => DeflateBytes(data), + Scheme.Brotli => BrotliBytes(data), + Scheme.Zstd => zstd!.Wrap(data).ToArray(), + Scheme.ZstdDict => zstdDict!.Wrap(data).ToArray(), + _ => data, + }; + + private static byte[] DeflateBytes(byte[] d) + { + using var ms = new MemoryStream(); + using (var z = new DeflateStream(ms, CompressionLevel.SmallestSize, true)) z.Write(d, 0, d.Length); + return ms.ToArray(); + } + + private static byte[] BrotliBytes(byte[] d) + { + using var ms = new MemoryStream(); + using (var z = new BrotliStream(ms, CompressionLevel.SmallestSize, true)) z.Write(d, 0, d.Length); + return ms.ToArray(); + } + + /// Packets needed to carry on a path + /// whose per-packet capacity is (Packetiser always on). + public static int Packets(int payloadBytes, int perPkt) => + Math.Max(1, (payloadBytes + perPkt - 1) / perPkt); + + // ---------- corpus ---------- + + public static IReadOnlyList<(string label, BackhaulMessage msg)> Corpus(int seed) + { + var rng = new Random(seed); + string[] calls = ["M0LTE-7", "GB7RDG-1", "EI5IYB-1", "G4BFG-9", "2E0XYZ", "MM0ABC-2", "GB7XYZ-1", "M7DEF-5"]; + string[] apps = ["chat", "mail", "pos", "sensor", "ack", "telem"]; + string[] chats = + [ + "73", "QSL 73 GL", "GM all de M0LTE", "ack", "ok rx 5/9", + "GM all de M0LTE, nice signal into Reading this morning, 599 here", + "Anyone around for a sked on the DAPPS net at 1900 local? 73", + "Rig is FT-991A into a 40m dipole at 8m, running 25W on this one", + ]; + string[] positions = ["!5152.34N/00007.12W>DAPPS node QRV", "!5340.10N/00220.55W>portable /P on hilltop"]; + string[] sensors = ["{\"t\":21.4,\"h\":62,\"p\":1013}", "{\"t\":-3.1,\"h\":88,\"p\":998,\"w\":12.4}", "{\"batt\":3.92,\"sol\":0.41}"]; + string[] acks = ["ACK 4f2", "ACK a91 ok", "NAK 0c3 retry"]; + string mail = "Hello from the DAPPS mailbox. This is a longer store-and-forward message " + + "that a user might send over the mesh. It contains a few sentences of ordinary " + + "English prose so that the compressor has something realistic to chew on, and so " + + "that we can see how a multi-packet payload behaves over a slow LoRa channel. 73."; + + var list = new List<(string, BackhaulMessage)>(); + int n = 0; + void Add(string app, string text) + { + string from = calls[rng.Next(calls.Length)]; + string to = calls[rng.Next(calls.Length)]; + var m = new BackhaulMessage( + Id: n.ToString("x7"), Destination: $"{to}", Salt: rng.Next(), + Ttl: 3600, Payload: Encoding.UTF8.GetBytes(text), + Originator: from, LinkSourceCallsign: from, + Headers: new Dictionary { ["app"] = app }); + list.Add(($"{app}:{text.Length}B", m)); + n++; + } + + foreach (var c in chats) Add("chat", c); + foreach (var p in positions) Add("pos", p); + foreach (var s in sensors) Add("sensor", s); + foreach (var a in acks) Add("ack", a); + Add("mail", mail); + Add("mail", mail[..120]); + // a bit more chat variety + for (int i = 0; i < 12; i++) Add("chat", chats[rng.Next(chats.Length)] + (i % 3 == 0 ? " ##" + i : "")); + return list; + } + + // ---------- report ---------- + + private static int Base64Len(int n) => (n + 2) / 3 * 4; + + /// Total LoRa airtime (ms) to carry + /// of (already-compressed) encoded payload over the given path. + public static double AirtimeForPayload(int dappsBytes, bool binary) + { + var (textPerPkt, binPerPkt, _, _) = PerPacketCapacity(); + int perPkt = binary ? binPerPkt : textPerPkt; + int packets = Packets(dappsBytes, perPkt); + double total = 0; + int remaining = dappsBytes; + for (int i = 0; i < packets; i++) + { + int chunk = Math.Min(perPkt, Math.Max(remaining, 0)); + remaining -= chunk; + int fragBytes = chunk + PacketiserHeader; + int plaintext = binary + ? BinaryFramingOverhead + fragBytes + : TextFramingOverhead + Marker.Length + Base64Len(fragBytes); + total += AirtimeMs(plaintext + MeshCoreOnAirOverhead); + } + return total; + } + + private static byte[] BuildDict(List samples, int cap) + { + using var ms = new MemoryStream(); + foreach (var s in samples) + { + if (ms.Length + s.Length > cap) break; + ms.Write(s, 0, s.Length); + } + return ms.ToArray(); + } + + private static double Median(IEnumerable xs) + { + var a = xs.OrderBy(x => x).ToArray(); + return a.Length == 0 ? 0 : a.Length % 2 == 1 ? a[a.Length / 2] : (a[a.Length / 2 - 1] + a[a.Length / 2]) / 2.0; + } + + public static string Run(int trainSeed = 1, int testSeed = 2) + { + var sb = new StringBuilder(); + var (textPerPkt, binPerPkt, textOnAir, binOnAir) = PerPacketCapacity(); + double fullAir = AirtimeMs(MaxGroupPlaintext + MeshCoreOnAirOverhead); + + // Dictionary trained on a DISJOINT corpus so the test isn't self-fitted. + var trainEnc = Corpus(trainSeed).Select(x => BackhaulMessageCodec.Encode(x.msg)).ToList(); + var dictBlob = BuildDict(trainEnc, 8 * 1024); + using var zstd = new ZstdSharp.Compressor(19); + using var zstdDict = new ZstdSharp.Compressor(19); + zstdDict.LoadDictionary(dictBlob); + + var test = Corpus(testSeed); + var encoded = test.Select(x => (x.label, enc: BackhaulMessageCodec.Encode(x.msg))).ToList(); + + sb.AppendLine("# MeshCore bearer characterisation (DAPPS payload over a private channel)"); + sb.AppendLine(); + sb.AppendLine($"- LoRa: SF{Sf} / BW {BwHz / 1000:0.#} kHz / CR 4/{CrDenom} / preamble {Preamble} → **full packet ≈ {fullAir:0} ms on air**"); + sb.AppendLine($"- Channel plaintext cap {MaxGroupPlaintext} B; assumed MeshCore on-air overhead {MeshCoreOnAirOverhead} B (calibrate from hardware)"); + sb.AppendLine($"- Dictionary: zstd content dict ({dictBlob.Length} B) trained on a disjoint corpus; test corpus = {encoded.Count} messages"); + sb.AppendLine(); + + // Table 1 - per-packet transport capacity. + sb.AppendLine("## 1. Transport efficiency (DAPPS bytes carried per packet)"); + sb.AppendLine(); + sb.AppendLine("| Path | base64? | DAPPS bytes/packet | vs text |"); + sb.AppendLine("|---|---|---:|---:|"); + sb.AppendLine($"| text `0x03` + base64 | yes | {textPerPkt} | 1.00× |"); + sb.AppendLine($"| binary `0x3E` | no | {binPerPkt} | {(double)binPerPkt / textPerPkt:0.00}× |"); + sb.AppendLine(); + + // Table 2 - compression over the test corpus. + sb.AppendLine("## 2. Compression of the encoded BackhaulMessage (test corpus)"); + sb.AppendLine(); + sb.AppendLine("| Scheme | mean ratio | median | mean bytes | ≤1 pkt (text) | ≤1 pkt (bin) | mean airtime (bin) |"); + sb.AppendLine("|---|---:|---:|---:|---:|---:|---:|"); + foreach (Scheme s in Enum.GetValues()) + { + var comp = encoded.Select(e => Compress(s, e.enc, zstd, zstdDict).Length).ToList(); + var ratios = encoded.Zip(comp, (e, c) => (double)c / e.enc.Length).ToList(); + int onePktText = encoded.Zip(comp, (e, c) => c <= textPerPkt ? 1 : 0).Sum(); + int onePktBin = encoded.Zip(comp, (e, c) => c <= binPerPkt ? 1 : 0).Sum(); + double airBin = encoded.Zip(comp, (e, c) => AirtimeForPayload(c, true)).Average(); + sb.AppendLine($"| {s} | {ratios.Average():0.00} | {Median(ratios):0.00} | {comp.Average():0.0} | " + + $"{100.0 * onePktText / comp.Count:0}% | {100.0 * onePktBin / comp.Count:0}% | {airBin:0} ms |"); + } + sb.AppendLine(); + sb.AppendLine("_ratio = compressed ÷ encoded (lower is better); <1 means smaller. Ratios >1 on tiny messages = compressor overhead._"); + sb.AppendLine(); + + // Table 3 - representative messages: raw vs best compression, both paths. + sb.AppendLine("## 3. Representative messages (packets & airtime: raw → zstd+dict)"); + sb.AppendLine(); + sb.AppendLine("| message | encoded B | zstd+dict B | pkts text raw→cmp | pkts bin raw→cmp | airtime bin raw→cmp |"); + sb.AppendLine("|---|---:|---:|---:|---:|---:|"); + string[] want = ["ack:", "chat:2B", "chat:15B", "pos:", "sensor:", "mail:"]; + foreach (var w in want) + { + var hit = encoded.FirstOrDefault(e => e.label.StartsWith(w, StringComparison.Ordinal)); + if (hit.enc is null) continue; + int raw = hit.enc.Length; + int cmp = Compress(Scheme.ZstdDict, hit.enc, zstd, zstdDict).Length; + sb.AppendLine($"| {hit.label} | {raw} | {cmp} | " + + $"{Packets(raw, textPerPkt)}→{Packets(cmp, textPerPkt)} | " + + $"{Packets(raw, binPerPkt)}→{Packets(cmp, binPerPkt)} | " + + $"{AirtimeForPayload(raw, true):0}→{AirtimeForPayload(cmp, true):0} ms |"); + } + sb.AppendLine(); + + // Aggregate headline. + var encLens = encoded.Select(e => e.enc.Length).ToList(); + var bestLens = encoded.Select(e => Compress(Scheme.ZstdDict, e.enc, zstd, zstdDict).Length).ToList(); + double airRawTextTotal = encLens.Sum(l => AirtimeForPayload(l, false)); + double airRawBinTotal = encLens.Sum(l => AirtimeForPayload(l, true)); + double airCmpBinTotal = bestLens.Sum(l => AirtimeForPayload(l, true)); + sb.AppendLine("## 4. Headline (total airtime to send the whole test corpus)"); + sb.AppendLine(); + sb.AppendLine($"| | total airtime | vs text-raw |"); + sb.AppendLine($"|---|---:|---:|"); + sb.AppendLine($"| text + base64, no compression | {airRawTextTotal / 1000:0.0} s | 1.00× |"); + sb.AppendLine($"| binary, no compression | {airRawBinTotal / 1000:0.0} s | {airRawBinTotal / airRawTextTotal:0.00}× |"); + sb.AppendLine($"| binary + zstd-dict | {airCmpBinTotal / 1000:0.0} s | {airCmpBinTotal / airRawTextTotal:0.00}× |"); + sb.AppendLine(); + sb.AppendLine($"**Net: binary + heavy dictionary compression ≈ {airRawTextTotal / airCmpBinTotal:0.0}× the goodput of the text path** on this corpus."); + return sb.ToString(); + } +} diff --git a/poc/MeshCorePoc/Compression.cs b/poc/MeshCorePoc/Compression.cs new file mode 100644 index 0000000..32f2ff0 --- /dev/null +++ b/poc/MeshCorePoc/Compression.cs @@ -0,0 +1,49 @@ +using dapps.client.Backhaul.Datagram; + +namespace MeshCorePoc; + +/// +/// Optional payload compression for the DAPPS-over-MeshCore bearer. The big win +/// on this slow, shared, flooded channel is a SHARED DICTIONARY trained on +/// representative DAPPS traffic - generic compressors barely dent a ~100-byte +/// message, but a dictionary collapses most messages into a single LoRa packet. +/// +/// The dictionary here is built deterministically from the characterisation +/// corpus, so every node running this binary derives byte-identical dictionary +/// bytes (a real deployment would ship a versioned dictionary blob, negotiated +/// by id so both ends agree). +/// +public static class DappsCompression +{ + public enum Mode { None, ZstdDict } + + private static readonly byte[] Dict = BuildDict(); + + private static byte[] BuildDict() + { + using var ms = new MemoryStream(); + foreach (var (_, msg) in Characterise.Corpus(1)) + { + var enc = BackhaulMessageCodec.Encode(msg); + if (ms.Length + enc.Length > 8 * 1024) break; + ms.Write(enc, 0, enc.Length); + } + return ms.ToArray(); + } + + public static byte[] Compress(Mode mode, byte[] data) + { + if (mode == Mode.None) return data; + using var c = new ZstdSharp.Compressor(19); + c.LoadDictionary(Dict); + return c.Wrap(data).ToArray(); + } + + public static byte[] Decompress(Mode mode, byte[] data) + { + if (mode == Mode.None) return data; + using var d = new ZstdSharp.Decompressor(); + d.LoadDictionary(Dict); + return d.Unwrap(data).ToArray(); + } +} diff --git a/poc/MeshCorePoc/Frames.cs b/poc/MeshCorePoc/Frames.cs new file mode 100644 index 0000000..53ef500 --- /dev/null +++ b/poc/MeshCorePoc/Frames.cs @@ -0,0 +1,91 @@ +using System.Buffers.Binary; +using System.Text; + +namespace MeshCorePoc; + +/// Parsed SELF_INFO (0x05) reply to APP_START. +public sealed record SelfInfo( + byte AdvType, byte TxPower, byte MaxTxPower, byte[] PublicKey, + double FreqMhz, double BwKhz, byte Sf, byte Cr, string Name) +{ + public string PublicKeyHex => Convert.ToHexString(PublicKey).ToLowerInvariant(); + + public static SelfInfo Parse(byte[] p) + { + // [0]=0x05 [1]=adv_type [2]=tx_power [3]=max_tx [4..36]=pubkey(32) + // [48..52]=freq*1000 [52..56]=bw*1000 [56]=sf [57]=cr [58..]=name + var pub = p[4..36]; + double freq = BinaryPrimitives.ReadUInt32LittleEndian(p.AsSpan(48, 4)) / 1000.0; + double bw = BinaryPrimitives.ReadUInt32LittleEndian(p.AsSpan(52, 4)) / 1000.0; + byte sf = p[56], cr = p[57]; + string name = p.Length > 58 + ? Encoding.UTF8.GetString(p, 58, p.Length - 58).TrimEnd('\0') + : ""; + return new SelfInfo(p[1], p[2], p[3], pub, freq, bw, sf, cr, name); + } +} + +/// Parsed CHANNEL_INFO (0x12) reply to GET_CHANNEL. +public sealed record ChannelInfo(byte Index, string Name, byte[] Secret) +{ + public string SecretHex => Convert.ToHexString(Secret).ToLowerInvariant(); + + public static ChannelInfo Parse(byte[] p) + { + // [0]=0x12 [1]=index [2..34]=name(32) [34..50]=secret(16) + byte idx = p[1]; + string name = Encoding.UTF8.GetString(p, 2, 32).TrimEnd('\0'); + var secret = p[34..50]; + return new ChannelInfo(idx, name, secret); + } +} + +/// Parsed CHANNEL_MSG_RECV_V3 (0x11) inbound channel message. +public sealed record ChannelMessage( + sbyte Snr, byte ChannelIndex, byte PathLen, byte TxtType, uint Timestamp, string Text) +{ + /// SNR in dB (firmware sends snr*4). + public double SnrDb => Snr / 4.0; + /// 0xFF path-len means the packet was received direct (no flood hops). + public bool ReceivedDirect => PathLen == 0xFF; + + public static ChannelMessage ParseV3(byte[] p) + { + // [0]=0x11 [1]=snr(int8) [2..3]=reserved [4]=channel_idx [5]=path_len + // [6]=txt_type [7..10]=timestamp u32 LE [11..]=text + sbyte snr = unchecked((sbyte)p[1]); + byte ch = p[4], pathLen = p[5], txtType = p[6]; + uint ts = BinaryPrimitives.ReadUInt32LittleEndian(p.AsSpan(7, 4)); + string text = p.Length > 11 ? Encoding.UTF8.GetString(p, 11, p.Length - 11) : ""; + return new ChannelMessage(snr, ch, pathLen, txtType, ts, text); + } + + public static ChannelMessage ParseLegacy(byte[] p) + { + // [0]=0x08 [1]=channel_idx [2]=path_len [3]=txt_type [4..7]=timestamp + // u32 LE [8..]=text. No SNR field (SnrDb reports 0 = unknown). + byte ch = p[1], pathLen = p[2], txtType = p[3]; + uint ts = BinaryPrimitives.ReadUInt32LittleEndian(p.AsSpan(4, 4)); + string text = p.Length > 8 ? Encoding.UTF8.GetString(p, 8, p.Length - 8) : ""; + return new ChannelMessage(0, ch, pathLen, txtType, ts, text); + } +} + +/// Parsed CHANNEL_DATA_RECV (0x1B) inbound binary channel datagram. +public sealed record ChannelData(sbyte Snr, byte ChannelIndex, byte PathLen, ushort DataType, byte[] Payload) +{ + public double SnrDb => Snr / 4.0; + public bool ReceivedDirect => PathLen == 0xFF; + + public static ChannelData ParseRecv(byte[] p) + { + // [0]=0x1B [1]=snr(int8 x4) [2..3]=reserved [4]=channel_idx [5]=path_len + // [6..7]=data_type u16 LE [8]=data_len [9..]=payload + sbyte snr = unchecked((sbyte)p[1]); + byte ch = p[4], pathLen = p[5]; + ushort dataType = BinaryPrimitives.ReadUInt16LittleEndian(p.AsSpan(6, 2)); + byte dataLen = p[8]; + var payload = p.Length >= 9 + dataLen ? p[9..(9 + dataLen)] : p[9..]; + return new ChannelData(snr, ch, pathLen, dataType, payload); + } +} diff --git a/poc/MeshCorePoc/MeshCoreClient.cs b/poc/MeshCorePoc/MeshCoreClient.cs new file mode 100644 index 0000000..0408d3a --- /dev/null +++ b/poc/MeshCorePoc/MeshCoreClient.cs @@ -0,0 +1,354 @@ +using System.Buffers.Binary; +using System.IO.Ports; +using System.Text; +using System.Threading.Channels; + +namespace MeshCorePoc; + +/// +/// Minimal client for the MeshCore "Companion" USB-serial protocol +/// (firmware v1.16.0). Independent of DAPPS. Framing: +/// host -> device: [0x3C][len_lo][len_hi][payload...] +/// device -> host: [0x3E][len_lo][len_hi][payload...] +/// len is a little-endian uint16 counting payload bytes only. The first +/// payload byte is the opcode. Device-to-host frames are either +/// synchronous responses (code < 0x80) or asynchronous pushes (code +/// >= 0x80). Inbound over-the-air messages are NOT pushed inline: the +/// device emits a 1-byte MSG_WAITING (0x83) tickle and the host pulls +/// each queued message with SYNC_NEXT_MESSAGE (0x0A). +/// 8N1 @ 115200, no flow control. DTR/RTS held low so opening the port +/// does not reset the board. +/// +public sealed class MeshCoreClient : IAsyncDisposable +{ + // ---- command codes (host -> device) ---- + public const byte CMD_APP_START = 0x01; + public const byte CMD_SEND_CHANNEL_TXT_MSG = 0x03; + public const byte CMD_SET_ADVERT_NAME = 0x08; + public const byte CMD_SYNC_NEXT_MESSAGE = 0x0A; + public const byte CMD_SET_RADIO_PARAMS = 0x0B; + public const byte CMD_SET_RADIO_TX_POWER = 0x0C; + public const byte CMD_GET_CHANNEL = 0x1F; + public const byte CMD_SET_CHANNEL = 0x20; + public const byte CMD_SEND_CHANNEL_DATA = 0x3E; // binary group datagram + public const ushort DATA_TYPE_DEV = 0xFFFF; // developer namespace + + // ---- response codes (device -> host, synchronous) ---- + public const byte RSP_OK = 0x00; + public const byte RSP_ERR = 0x01; + public const byte RSP_SELF_INFO = 0x05; + public const byte RSP_NO_MORE_MESSAGES = 0x0A; + public const byte RSP_CONTACT_MSG_RECV = 0x07; // legacy (no SNR) + public const byte RSP_CHANNEL_MSG_RECV = 0x08; // legacy (no SNR) - what v1.16.0 actually sends for channels + public const byte RSP_CONTACT_MSG_RECV_V3 = 0x10; + public const byte RSP_CHANNEL_MSG_RECV_V3 = 0x11; + public const byte RSP_CHANNEL_INFO = 0x12; + public const byte RSP_CHANNEL_DATA_RECV = 0x1B; // binary group datagram + + // ---- push codes (device -> host, async, high bit set) ---- + public const byte PUSH_SEND_CONFIRMED = 0x82; + public const byte PUSH_MSG_WAITING = 0x83; + + private const byte FrameToRadio = 0x3C; // '<' + private const byte FrameFromRadio = 0x3E; // '>' + + /// Set MESHCORE_TRACE=1 to log every frame in/out to stderr. + public static readonly bool Trace = Environment.GetEnvironmentVariable("MESHCORE_TRACE") == "1"; + + private readonly SerialPort _port; + // Synchronous response frames only (code < 0x80). Pushes are handled + // inline in the read loop and never land here. + private readonly Channel _responses = + Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = false, SingleWriter = true }); + // One command/response exchange at a time. + private readonly SemaphoreSlim _exchange = new(1, 1); + private readonly CancellationTokenSource _cts = new(); + private Task? _readLoop; + + /// Raised when the device signals queued inbound messages (0x83). + public event Action? MessageWaiting; + + public MeshCoreClient(string portName, int baud = 115200) + { + _port = new SerialPort(portName, baud, Parity.None, 8, StopBits.One) + { + Handshake = Handshake.None, + DtrEnable = false, + RtsEnable = false, + ReadTimeout = 200, + WriteTimeout = 2000, + }; + } + + public void Open() + { + _port.Open(); + // Hold the auto-reset lines low; some adapters assert on open. + try { _port.DtrEnable = false; _port.RtsEnable = false; } catch { /* best effort */ } + _port.DiscardInBuffer(); + _readLoop = Task.Run(() => ReadLoopAsync(_cts.Token)); + } + + private async Task ReadLoopAsync(CancellationToken ct) + { + var stream = _port.BaseStream; + while (!ct.IsCancellationRequested) + { + try + { + // Resync: scan for a frame marker. + int marker = await ReadByteAsync(stream, ct); + if (marker < 0) continue; + if (marker != FrameFromRadio && marker != FrameToRadio) continue; + + int lo = await ReadByteAsync(stream, ct); + int hi = await ReadByteAsync(stream, ct); + if (lo < 0 || hi < 0) continue; + int len = lo | (hi << 8); + if (len is < 0 or > 4096) continue; // resync guard + + var payload = await ReadExactAsync(stream, len, ct); + if (payload is null || payload.Length == 0) continue; + + byte code = payload[0]; + if (Trace) + Console.Error.WriteLine($"<< {payload.Length}B code=0x{code:X2} {Convert.ToHexString(payload.AsSpan(0, Math.Min(payload.Length, 48)))}"); + if (code >= 0x80) + { + HandlePush(code, payload); + } + else + { + _responses.Writer.TryWrite(payload); + } + } + catch (OperationCanceledException) { break; } + catch (Exception) { /* keep the loop alive; transient serial hiccup */ } + } + } + + private void HandlePush(byte code, byte[] payload) + { + switch (code) + { + case PUSH_MSG_WAITING: + MessageWaiting?.Invoke(); + break; + // PUSH_SEND_CONFIRMED and others are ignored for the PoC. + } + } + + /// + /// Send a command frame and await the next synchronous response whose + /// opcode is in . Frames with other + /// (non-push) opcodes are skipped, so a stray boot frame can't be + /// mistaken for our reply. + /// + public async Task ExchangeAsync(byte[] payload, byte[] expectedCodes, TimeSpan timeout, CancellationToken ct) + { + await _exchange.WaitAsync(ct); + try + { + // Clear any stale buffered responses before issuing the command. + while (_responses.Reader.TryRead(out _)) { } + WriteFrame(payload); + + using var to = CancellationTokenSource.CreateLinkedTokenSource(ct); + to.CancelAfter(timeout); + while (true) + { + byte[] resp; + try { resp = await _responses.Reader.ReadAsync(to.Token); } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException($"no response (codes {Hex(expectedCodes)}) within {timeout.TotalSeconds:0.#}s"); + } + if (Array.IndexOf(expectedCodes, resp[0]) >= 0) return resp; + if (resp[0] == RSP_ERR) + throw new MeshCoreException($"device returned ERR code {(resp.Length > 1 ? resp[1] : 0)}"); + // else: unexpected non-push frame; skip and keep waiting. + } + } + finally { _exchange.Release(); } + } + + public void WriteFrame(ReadOnlySpan payload) + { + var buf = new byte[3 + payload.Length]; + buf[0] = FrameToRadio; + buf[1] = (byte)(payload.Length & 0xFF); + buf[2] = (byte)((payload.Length >> 8) & 0xFF); + payload.CopyTo(buf.AsSpan(3)); + if (Trace) + Console.Error.WriteLine($">> {payload.Length}B code=0x{payload[0]:X2} {Convert.ToHexString(buf.AsSpan(0, Math.Min(buf.Length, 48)))}"); + _port.BaseStream.Write(buf, 0, buf.Length); + _port.BaseStream.Flush(); + } + + // ---------- high-level operations ---------- + + public async Task AppStartAsync(string appName, CancellationToken ct) + { + // [0]=0x01 [1]=proto ver(3) [2..7]=6 reserved zeros [8..]=app name + var p = new List { CMD_APP_START, 0x03, 0, 0, 0, 0, 0, 0 }; + p.AddRange(Encoding.ASCII.GetBytes(appName)); + var resp = await ExchangeAsync(p.ToArray(), [RSP_SELF_INFO], TimeSpan.FromSeconds(4), ct); + return SelfInfo.Parse(resp); + } + + public async Task SetNameAsync(string name, CancellationToken ct) + { + var p = new byte[1 + Encoding.UTF8.GetByteCount(name)]; + p[0] = CMD_SET_ADVERT_NAME; + Encoding.UTF8.GetBytes(name).CopyTo(p, 1); + await ExchangeAsync(p, [RSP_OK], TimeSpan.FromSeconds(3), ct); + } + + public async Task SetRadioParamsAsync(double freqMhz, double bwKhz, byte sf, byte cr, CancellationToken ct) + { + var p = new byte[11]; + p[0] = CMD_SET_RADIO_PARAMS; + BinaryPrimitives.WriteUInt32LittleEndian(p.AsSpan(1, 4), (uint)Math.Round(freqMhz * 1000)); + BinaryPrimitives.WriteUInt32LittleEndian(p.AsSpan(5, 4), (uint)Math.Round(bwKhz * 1000)); + p[9] = sf; + p[10] = cr; + await ExchangeAsync(p, [RSP_OK], TimeSpan.FromSeconds(3), ct); + } + + public async Task SetTxPowerAsync(byte dbm, CancellationToken ct) + { + await ExchangeAsync([CMD_SET_RADIO_TX_POWER, dbm], [RSP_OK], TimeSpan.FromSeconds(3), ct); + } + + public async Task SetChannelAsync(byte index, string name, byte[] secret16, CancellationToken ct) + { + if (secret16.Length != 16) throw new ArgumentException("secret must be 16 bytes", nameof(secret16)); + var p = new byte[1 + 1 + 32 + 16]; + p[0] = CMD_SET_CHANNEL; + p[1] = index; + var nameBytes = Encoding.UTF8.GetBytes(name); + Array.Copy(nameBytes, 0, p, 2, Math.Min(nameBytes.Length, 32)); + Array.Copy(secret16, 0, p, 34, 16); + await ExchangeAsync(p, [RSP_OK], TimeSpan.FromSeconds(3), ct); + } + + public async Task GetChannelAsync(byte index, CancellationToken ct) + { + var resp = await ExchangeAsync([CMD_GET_CHANNEL, index], [RSP_CHANNEL_INFO], TimeSpan.FromSeconds(3), ct); + return ChannelInfo.Parse(resp); + } + + /// Send a UTF-8 text message to a channel slot. Returns when + /// the device acks (OK) that it queued the frame for transmission. + public async Task SendChannelTextAsync(byte channelIndex, string text, uint unixTime, CancellationToken ct) + { + var textBytes = Encoding.UTF8.GetBytes(text); + var p = new byte[7 + textBytes.Length]; + p[0] = CMD_SEND_CHANNEL_TXT_MSG; + p[1] = 0; // txt_type = PLAIN + p[2] = channelIndex; + BinaryPrimitives.WriteUInt32LittleEndian(p.AsSpan(3, 4), unixTime); + textBytes.CopyTo(p, 7); + await ExchangeAsync(p, [RSP_OK], TimeSpan.FromSeconds(5), ct); + } + + /// Send a binary datagram to a channel (SEND_CHANNEL_DATA 0x3E). + /// Flood (path_len=0xFF). data_type defaults to the DEV namespace. The bytes + /// arrive byte-identical at the peer (no name prefix). Payload <= 165. + public async Task SendChannelDataAsync(byte channelIndex, byte[] payload, ushort dataType, CancellationToken ct) + { + if (payload.Length > 165) throw new ArgumentException("channel-data payload must be <= 165 bytes", nameof(payload)); + var p = new byte[5 + payload.Length]; + p[0] = CMD_SEND_CHANNEL_DATA; + p[1] = channelIndex; + p[2] = 0xFF; // path_len: flood + p[3] = (byte)(dataType & 0xFF); + p[4] = (byte)(dataType >> 8); + payload.CopyTo(p, 5); + await ExchangeAsync(p, [RSP_OK], TimeSpan.FromSeconds(5), ct); + } + + /// All inbound items drained from one pull cycle. + public sealed record InboundBatch(List Texts, List Data); + + /// Drain all queued inbound messages (text + binary). Pulls with + /// SYNC_NEXT_MESSAGE until NO_MORE_MESSAGES. + public async Task DrainAsync(CancellationToken ct) + { + var texts = new List(); + var data = new List(); + while (true) + { + byte[] resp; + try + { + resp = await ExchangeAsync( + [CMD_SYNC_NEXT_MESSAGE], + [RSP_CHANNEL_MSG_RECV, RSP_CHANNEL_MSG_RECV_V3, RSP_CHANNEL_DATA_RECV, + RSP_CONTACT_MSG_RECV, RSP_CONTACT_MSG_RECV_V3, RSP_NO_MORE_MESSAGES], + TimeSpan.FromMilliseconds(1500), ct); + } + catch (TimeoutException) + { + // Some firmware stays silent on an empty queue rather than + // replying NO_MORE_MESSAGES; treat that as "nothing waiting". + break; + } + switch (resp[0]) + { + case RSP_NO_MORE_MESSAGES: return new InboundBatch(texts, data); + case RSP_CHANNEL_MSG_RECV: texts.Add(ChannelMessage.ParseLegacy(resp)); break; + case RSP_CHANNEL_MSG_RECV_V3: texts.Add(ChannelMessage.ParseV3(resp)); break; + case RSP_CHANNEL_DATA_RECV: data.Add(ChannelData.ParseRecv(resp)); break; + // CONTACT_MSG_RECV* (direct messages) ignored for the PoC. + } + } + return new InboundBatch(texts, data); + } + + private static async Task ReadByteAsync(Stream s, CancellationToken ct) + { + var b = new byte[1]; + try + { + int n = await s.ReadAsync(b.AsMemory(0, 1), ct); + return n == 1 ? b[0] : -1; + } + catch (TimeoutException) { return -1; } + catch (IOException) { return -1; } + } + + private static async Task ReadExactAsync(Stream s, int count, CancellationToken ct) + { + var buf = new byte[count]; + int got = 0; + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); + while (got < count) + { + if (DateTime.UtcNow > deadline) return null; + int n; + try { n = await s.ReadAsync(buf.AsMemory(got, count - got), ct); } + catch (TimeoutException) { continue; } + catch (IOException) { return null; } + if (n == 0) continue; + got += n; + } + return buf; + } + + private static string Hex(byte[] b) => string.Join(",", b.Select(x => "0x" + x.ToString("X2"))); + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + if (_readLoop is not null) + { + try { await _readLoop; } catch { /* ignore */ } + } + try { if (_port.IsOpen) _port.Close(); } catch { /* ignore */ } + _port.Dispose(); + _cts.Dispose(); + } +} + +public sealed class MeshCoreException(string message) : Exception(message); diff --git a/poc/MeshCorePoc/MeshCorePoc.csproj b/poc/MeshCorePoc/MeshCorePoc.csproj new file mode 100644 index 0000000..6dd2a61 --- /dev/null +++ b/poc/MeshCorePoc/MeshCorePoc.csproj @@ -0,0 +1,26 @@ + + + + + Exe + net8.0 + enable + enable + MeshCorePoc + meshcore-poc + true + + + + + + + + diff --git a/poc/MeshCorePoc/Presets.cs b/poc/MeshCorePoc/Presets.cs new file mode 100644 index 0000000..81c3a9b --- /dev/null +++ b/poc/MeshCorePoc/Presets.cs @@ -0,0 +1,32 @@ +namespace MeshCorePoc; + +/// +/// A localisation/region preset = the regulatory + network radio settings for a +/// locale. In the real DAPPS bearer this is part of the device-control API the +/// node exposes (alongside TX power and channel management): an operator picks a +/// region and DAPPS pushes the matching SET_RADIO_PARAMS / SET_RADIO_TX_POWER. +/// +/// NOTE: only the two EU/UK presets here are hardware-confirmed. A production +/// build should source the full, current preset table from MeshCore upstream +/// (e.g. the api.meshcore.nz preset API) rather than hard-coding regulatory +/// values, and must enforce MaxPowerDbm per region. +/// +public sealed record RegionPreset( + string Name, double FreqMhz, double BwKhz, byte Sf, byte Cr, byte MaxPowerDbm, string Notes); + +public static class Regions +{ + public static readonly IReadOnlyList All = + [ + new("uk-narrow", 869.618, 62.5, 8, 8, 27, + "Current UK MeshCore net. 869.4-869.65 sub-band: 10% duty cycle, up to 500mW (27dBm) ERP."), + new("eu-legacy", 869.525, 250.0, 11, 5, 14, + "Deprecated EU/UK 'wide long range' (pre-2025). 0.1% sub-band, 25mW (14dBm)."), + new("uk-test", 868.400, 62.5, 8, 8, 14, + "Bench/prototype ISOLATION. 868.0-868.6 sub-band: 1% duty, 25mW (14dBm). Off the UK-narrow " + + "repeater frequency so test floods aren't relayed across the public mesh. Verify UK legality before use."), + ]; + + public static RegionPreset? Find(string name) => + All.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); +} diff --git a/poc/MeshCorePoc/PrivateChannelTransport.cs b/poc/MeshCorePoc/PrivateChannelTransport.cs new file mode 100644 index 0000000..9ed429b --- /dev/null +++ b/poc/MeshCorePoc/PrivateChannelTransport.cs @@ -0,0 +1,83 @@ +using dapps.client.Backhaul; +using dapps.client.Backhaul.Datagram; + +namespace MeshCorePoc; + +/// +/// Carries a DAPPS over a MeshCore private channel +/// using the BINARY channel-data path (`SEND_CHANNEL_DATA` 0x3E / `CHANNEL_DATA_RECV` +/// 0x1B). Binary is the chosen carriage: ~1.5× the payload/packet of text, no base64, +/// and no firmware "<name>: " prefix (the bytes arrive verbatim). +/// +/// send: Encode → (optional) compress → Packetiser.Split → per fragment prepend a +/// 1-byte header and emit as one channel-data datagram. +/// recv: strip header → Reassembler → (decompress) → Decode. +/// +/// Frame header byte: bit0 = compressed, bits1-7 = rolling nonce. The nonce is +/// essential: the binary path has NO on-air timestamp, so two byte-identical +/// datagrams share a packet hash and the mesh `hasSeen` table would silently drop +/// the second. Varying the header guarantees each datagram is distinct on air. +/// +public sealed class PrivateChannelTransport +{ + /// Fragment size incl. the 13-byte Packetiser header. The channel-data + /// payload = 1 (our header) + fragment, capped at the firmware's 165-byte plaintext + /// limit; 160 leaves margin. + public const int Mtu = 160; + + private readonly Reassembler _reassembler = new(); + private readonly Dictionary _compressed = new(); + + /// Encode a BackhaulMessage into one-or-more channel-data payloads. + public static IReadOnlyList ToFrames(BackhaulMessage message, DappsCompression.Mode compress, ref byte nonce) + { + var encoded = BackhaulMessageCodec.Encode(message); + var body = DappsCompression.Compress(compress, encoded); + var fragments = Packetiser.Split(message.Id, body, Mtu); + bool comp = compress != DappsCompression.Mode.None; + var frames = new List(fragments.Count); + foreach (var f in fragments) + { + byte hdr = (byte)((nonce << 1) | (comp ? 1 : 0)); + nonce = (byte)((nonce + 1) & 0x7F); + var frame = new byte[1 + f.Length]; + frame[0] = hdr; + f.CopyTo(frame, 1); + frames.Add(frame); + } + return frames; + } + + public enum Kind { FragmentPartial, BackhaulComplete, Bad } + + public readonly record struct Result(Kind Kind, BackhaulMessage? Message, FragmentHeader? Header); + + /// Feed one received channel-data payload. Returns whether it was a + /// partial fragment or completed (and decoded) a BackhaulMessage. + public Result Ingest(byte[] dataPayload, DateTime now) + { + if (dataPayload.Length < 1 + Packetiser.HeaderLength) return new Result(Kind.Bad, null, null); + bool comp = (dataPayload[0] & 1) != 0; + var fragment = dataPayload[1..]; + + FragmentHeader header; + try { header = Packetiser.ParseHeader(fragment); } + catch (InvalidDataException) { return new Result(Kind.Bad, null, null); } + + _compressed[header.Id] = comp; + var assembled = _reassembler.Accept(fragment, now); + if (assembled is null) return new Result(Kind.FragmentPartial, null, header); + + var compressed = _compressed.TryGetValue(header.Id, out var c) && c; + _compressed.Remove(header.Id); + try + { + var body = compressed ? DappsCompression.Decompress(DappsCompression.Mode.ZstdDict, assembled) : assembled; + return new Result(Kind.BackhaulComplete, BackhaulMessageCodec.Decode(body), header); + } + catch (Exception) + { + return new Result(Kind.Bad, null, header); + } + } +} diff --git a/poc/MeshCorePoc/Program.cs b/poc/MeshCorePoc/Program.cs new file mode 100644 index 0000000..8973c0d --- /dev/null +++ b/poc/MeshCorePoc/Program.cs @@ -0,0 +1,426 @@ +using System.Security.Cryptography; +using System.Text; +using dapps.client.Backhaul; +using MeshCorePoc; + +// --------------------------------------------------------------------------- +// DAPPS <-> MeshCore private-channel PoC (issue #137 / Phase H1). +// Independent of DAPPS core. Proves a real BackhaulMessage round-trips over a +// MeshCore private channel between two Heltec V3 radios. +// +// info read identity + radio params +// provision [opts] set UK-narrow radio params, name, channel +// get-channel [opts] dump a channel slot (name + PSK) +// send-text "msg" [opts] send a plain channel text (smoke test) +// send-backhaul [opts] encode+fragment+send a BackhaulMessage +// listen [opts] receive; reassemble+decode DAPPS frames +// --------------------------------------------------------------------------- + +var (cmd, port, opts) = ParseArgs(args); +if (cmd is null) +{ + PrintUsage(); + return 1; +} + +// PoC defaults. +const string AppName = "dapps-poc"; +double freq = GetD(opts, "freq", 869.618); // UK 868 "narrow" +double bw = GetD(opts, "bw", 62.5); +byte sf = (byte)GetI(opts, "sf", 8); +byte cr = (byte)GetI(opts, "cr", 8); // UK narrow uses CR 8 (firmware default is 5) +byte channelIndex = (byte)GetI(opts, "channel-index", 1); +string channelName = GetS(opts, "channel-name", "dapps-poc"); +byte[] psk = DerivePsk(GetS(opts, "psk-phrase", "dapps-poc-channel-v1")); + +// Localisation preset overrides the individual radio params when given. +if (opts.TryGetValue("region", out var regionName) && regionName is not null) +{ + var preset = Regions.Find(regionName) + ?? throw new ArgumentException($"unknown region '{regionName}'; known: {string.Join(", ", Regions.All.Select(r => r.Name))}"); + freq = preset.FreqMhz; bw = preset.BwKhz; sf = preset.Sf; cr = preset.Cr; +} + +using var cts = new CancellationTokenSource(); +Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + +// stdout is block-buffered when piped over ssh (non-tty); flush each line +// so a backgrounded listener's output appears in real time. +Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + +try +{ + switch (cmd) + { + case "info": await CmdInfo(); break; + case "provision": await CmdProvision(); break; + case "get-channel": await CmdGetChannel(); break; + case "send-text": await CmdSendText(); break; + case "send-backhaul": await CmdSendBackhaul(); break; + case "send-data": await CmdSendData(); break; + case "listen": await CmdListen(); break; + case "selftest": return CmdSelfTest(); + case "budget-test": return CmdBudgetTest(); + case "characterise": CmdCharacterise(); break; + case "regions": CmdRegions(); break; + default: PrintUsage(); return 1; + } +} +catch (Exception ex) +{ + Console.Error.WriteLine($"ERROR: {ex.Message}"); + return 2; +} +return 0; + +// ---------------- commands ---------------- + +async Task<(MeshCoreClient, SelfInfo)> OpenAndStart() +{ + var c = new MeshCoreClient(port!); + c.Open(); + var self = await c.AppStartAsync(AppName, cts.Token); + return (c, self); +} + +async Task CmdInfo() +{ + var (c, self) = await OpenAndStart(); + await using (c) PrintSelf(self); +} + +async Task CmdProvision() +{ + await using var c = new MeshCoreClient(port!); + c.Open(); + var self = await c.AppStartAsync(AppName, cts.Token); + Console.WriteLine($"node {self.PublicKeyHex[..12]} name='{self.Name}'"); + + if (!opts.ContainsKey("no-radio")) + { + Console.WriteLine($"set radio: {freq:0.000} MHz / {bw:0.0} kHz / SF{sf} / CR{cr}"); + await c.SetRadioParamsAsync(freq, bw, sf, cr, cts.Token); + } + + if (opts.TryGetValue("name", out var nm) && nm is not null) + { + Console.WriteLine($"set name: {nm}"); + await c.SetNameAsync(nm, cts.Token); + } + + if (opts.ContainsKey("tx-power")) + { + byte dbm = (byte)GetI(opts, "tx-power", 22); + Console.WriteLine($"set tx power: {dbm} dBm"); + await c.SetTxPowerAsync(dbm, cts.Token); + } + + Console.WriteLine($"set channel[{channelIndex}] name='{channelName}' psk={Convert.ToHexString(psk).ToLowerInvariant()}"); + await c.SetChannelAsync(channelIndex, channelName, psk, cts.Token); + + var ch = await c.GetChannelAsync(channelIndex, cts.Token); + Console.WriteLine($"verify channel[{ch.Index}]: name='{ch.Name}' psk={ch.SecretHex}"); + + // Re-read identity to confirm radio params applied. + var after = await c.AppStartAsync(AppName, cts.Token); + Console.WriteLine($"after: {after.FreqMhz:0.000} MHz / {after.BwKhz:0.0} kHz / SF{after.Sf} / CR{after.Cr}"); + Console.WriteLine("provisioned OK"); +} + +async Task CmdGetChannel() +{ + await using var c = new MeshCoreClient(port!); + c.Open(); + await c.AppStartAsync(AppName, cts.Token); + var ch = await c.GetChannelAsync(channelIndex, cts.Token); + Console.WriteLine($"channel[{ch.Index}]: name='{ch.Name}' psk={ch.SecretHex}"); +} + +async Task CmdSendText() +{ + var text = opts.TryGetValue("_text", out var t) ? t! : "ping from dapps-poc"; + await using var c = new MeshCoreClient(port!); + c.Open(); + await c.AppStartAsync(AppName, cts.Token); + var ts = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + await c.SendChannelTextAsync(channelIndex, text, ts, cts.Token); + Console.WriteLine($"sent channel[{channelIndex}] text ({Encoding.UTF8.GetByteCount(text)} bytes): {text}"); +} + +async Task CmdSendBackhaul() +{ + string from = GetS(opts, "from", "POC-1"); + string dest = GetS(opts, "dest", "POC-2"); + string text = GetS(opts, "text", "hello from DAPPS over a MeshCore private channel"); + string id = GetS(opts, "id", Guid.NewGuid().ToString("N")[..7]); + int gapMs = GetI(opts, "gap-ms", 1500); + + var compress = GetS(opts, "compress", "none").Equals("zstd", StringComparison.OrdinalIgnoreCase) + ? DappsCompression.Mode.ZstdDict : DappsCompression.Mode.None; + + var msg = new BackhaulMessage( + Id: id, + Destination: dest, + Salt: null, + Ttl: 3600, + Payload: Encoding.UTF8.GetBytes(text), + Originator: from, + LinkSourceCallsign: from); + + int encodedLen = dapps.client.Backhaul.Datagram.BackhaulMessageCodec.Encode(msg).Length; + byte nonce = (byte)Random.Shared.Next(128); + var frames = PrivateChannelTransport.ToFrames(msg, compress, ref nonce); + Console.WriteLine($"backhaul id={id} {from} -> {dest} payload={msg.Payload.Length}B encoded={encodedLen}B compress={compress}"); + Console.WriteLine($" -> {frames.Count} binary frame(s) (max {frames.Max(f => f.Length)}B) @ mtu={PrivateChannelTransport.Mtu}, gap={gapMs}ms"); + + // Good-citizen control: estimate airtime and gate on a self-enforced budget. + // On Model A every send floods the whole same-preset network, so this is a hard gate. + var budget = new TxBudget(GetD(opts, "tx-budget-sec-per-hour", TxBudget.DefaultSecondsPerHour)); + double totalAirMs = frames.Sum(f => Characterise.AirtimeMs(f.Length + Characterise.MeshCoreOnAirOverhead)); + Console.WriteLine($" est. airtime {totalAirMs / 1000:0.00}s (budget {budget.BudgetSeconds:0}s/hr); NB each send floods the whole same-preset network"); + if (frames.Count > 4) + Console.WriteLine($" WARN: {frames.Count} packets is heavy for the public preset (Model A) — consider Model B/C for bulk"); + + await using var c = new MeshCoreClient(port!); + c.Open(); + await c.AppStartAsync(AppName, cts.Token); + + var swAll = System.Diagnostics.Stopwatch.StartNew(); + for (int i = 0; i < frames.Count; i++) + { + double airMs = Characterise.AirtimeMs(frames[i].Length + Characterise.MeshCoreOnAirOverhead); + if (!budget.TryReserve(airMs, DateTime.UtcNow, out var reason)) + { + Console.WriteLine($" THROTTLED at frame {i + 1}/{frames.Count}: {reason}"); + break; + } + var sw = System.Diagnostics.Stopwatch.StartNew(); + await c.SendChannelDataAsync(channelIndex, frames[i], MeshCoreClient.DATA_TYPE_DEV, cts.Token); + sw.Stop(); + Console.WriteLine($" frame {i + 1}/{frames.Count} sent ({frames[i].Length}B, ~{airMs / 1000:0.00}s air) in {sw.ElapsedMilliseconds}ms"); + if (i < frames.Count - 1) await Task.Delay(gapMs, cts.Token); + } + swAll.Stop(); + Console.WriteLine($"all frames queued in {swAll.ElapsedMilliseconds}ms; airtime used this session {budget.UsedSeconds(DateTime.UtcNow):0.00}s ({budget.DutyPercent(DateTime.UtcNow):0.000}% duty)"); +} + +async Task CmdSendData() +{ + var textArg = opts.TryGetValue("_text", out var t) && t is not null ? t : "RAWBYTES"; + var bytes = Encoding.UTF8.GetBytes(textArg); + await using var c = new MeshCoreClient(port!); + c.Open(); + await c.AppStartAsync(AppName, cts.Token); + await c.SendChannelDataAsync(channelIndex, bytes, MeshCoreClient.DATA_TYPE_DEV, cts.Token); + Console.WriteLine($"sent raw channel-data ({bytes.Length}B) on channel[{channelIndex}]: {Convert.ToHexString(bytes)} \"{textArg}\""); +} + +async Task CmdListen() +{ + int seconds = GetI(opts, "seconds", 0); // 0 = until Ctrl-C + bool raw = opts.ContainsKey("raw"); + var transport = new PrivateChannelTransport(); + await using var c = new MeshCoreClient(port!); + + var wake = new SemaphoreSlim(0); + c.MessageWaiting += () => { try { wake.Release(); } catch { } }; + + c.Open(); + var self = await c.AppStartAsync(AppName, cts.Token); + var ch = await c.GetChannelAsync(channelIndex, cts.Token); + Console.WriteLine($"listening on {port} as {self.PublicKeyHex[..12]} channel[{ch.Index}]='{ch.Name}' " + + $"({self.FreqMhz:0.000}MHz/SF{self.Sf}/CR{self.Cr}){(raw ? " [raw]" : "")}. Ctrl-C to stop."); + + var deadline = seconds > 0 ? DateTime.UtcNow.AddSeconds(seconds) : DateTime.MaxValue; + while (!cts.IsCancellationRequested && DateTime.UtcNow < deadline) + { + // Wake on MSG_WAITING push, or poll every 800ms as a safety net. + await Task.WhenAny(wake.WaitAsync(cts.Token), Task.Delay(800, cts.Token)); + MeshCoreClient.InboundBatch batch; + try { batch = await c.DrainAsync(cts.Token); } + catch (OperationCanceledException) { break; } + catch (Exception ex) { Console.Error.WriteLine($"drain error: {ex.Message}"); continue; } + + // Text messages are human chatter on the channel - just surface them. + foreach (var m in batch.Texts) + Console.WriteLine($"[ch{m.ChannelIndex} text] {m.Text}"); + + // Binary channel-data is the DAPPS carriage. + foreach (var d in batch.Data) + { + var rx = $"[ch{d.ChannelIndex} snr={d.SnrDb:0.0}dB {(d.ReceivedDirect ? "direct" : $"flood/{d.PathLen}h")} {d.Payload.Length}B]"; + if (raw) + { + Console.WriteLine($"{rx} raw: {Convert.ToHexString(d.Payload)} \"{Printable(d.Payload)}\""); + continue; + } + var r = transport.Ingest(d.Payload, DateTime.UtcNow); + switch (r.Kind) + { + case PrivateChannelTransport.Kind.FragmentPartial: + Console.WriteLine($"{rx} frag {r.Header!.Value.Seq + 1}/{r.Header!.Value.Count} of {r.Header!.Value.Id} (waiting for more)"); + break; + case PrivateChannelTransport.Kind.BackhaulComplete: + var bm = r.Message!; + Console.WriteLine($"{rx} >>> BACKHAUL id={bm.Id} {bm.Originator} -> {bm.Destination} " + + $"ttl={bm.Ttl} linksrc={bm.LinkSourceCallsign} payload=\"{Encoding.UTF8.GetString(bm.Payload)}\""); + break; + case PrivateChannelTransport.Kind.Bad: + Console.WriteLine($"{rx} non-DAPPS / undecodable: \"{Printable(d.Payload)}\""); + break; + } + } + } + Console.WriteLine("listener stopped."); +} + +static string Printable(byte[] b) => + new string(b.Select(x => x >= 0x20 && x < 0x7F ? (char)x : '.').ToArray()); + +void CmdRegions() +{ + Console.WriteLine("region presets (DAPPS-controllable localisation):"); + foreach (var r in Regions.All) + Console.WriteLine($" {r.Name,-12} {r.FreqMhz,8:0.000} MHz BW {r.BwKhz,5:0.#} SF{r.Sf} CR4/{r.Cr} ≤{r.MaxPowerDbm}dBm — {r.Notes}"); +} + +void CmdCharacterise() +{ + var report = Characterise.Run(); + Console.WriteLine(report); + var path = GetS(opts, "out", "characterisation.md"); + File.WriteAllText(path, report); + Console.Error.WriteLine($"(written to {path})"); +} + +int CmdBudgetTest() +{ + // Software demo of the good-citizen airtime governor - NO transmission + // (deliberately: we're on the live public preset; soak-testing the air would + // be exactly the bad behaviour the governor exists to prevent). + double budgetSec = GetD(opts, "tx-budget-sec-per-hour", TxBudget.DefaultSecondsPerHour); + var msgs = Characterise.Corpus(2).Select(x => + { + byte n = 0; + var frames = PrivateChannelTransport.ToFrames(x.msg, DappsCompression.Mode.ZstdDict, ref n); + double air = frames.Sum(f => Characterise.AirtimeMs(f.Length + Characterise.MeshCoreOnAirOverhead)); + return (frames.Count, air); + }).ToList(); + double meanAir = msgs.Average(m => m.air); + double meanPkts = msgs.Average(m => m.Item1); + + Console.WriteLine($"budget-test: {budgetSec:0}s/hr governor; compressed corpus mean {meanAir / 1000:0.00}s airtime/msg ({meanPkts:0.0} pkts/msg)"); + Console.WriteLine($" sustainable rate ≈ {budgetSec / (meanAir / 1000):0} msgs/hr (~{3600 / (budgetSec / (meanAir / 1000)):0}s between msgs); duty {budgetSec / 36:0.00}%"); + + // Burst: offer 500 messages with no spacing and watch the hard gate cap them. + var budget = new TxBudget(budgetSec); + var t0 = DateTime.UnixEpoch; + int sent = 0, refused = 0; + for (int i = 0; i < 500; i++) + if (budget.TryReserve(msgs[i % msgs.Count].air, t0, out _)) sent++; else refused++; + Console.WriteLine($" burst (no spacing): {sent} admitted then {refused} REFUSED — governor capped the burst at {budget.UsedSeconds(t0):0.0}s ({budget.DutyPercent(t0):0.00}% duty)"); + Console.WriteLine(" (the real long-running bearer accumulates over a trailing hour; this PoC governor is per-process)"); + return 0; +} + +int CmdSelfTest() +{ + // In-process loopback: exercises the real vendored codec + packetiser + // + base64 channel framing with no radio. Tests in-order AND out-of-order + // fragment delivery (LoRa floods can reorder). + string payload = string.Concat(Enumerable.Repeat("DAPPS-over-MeshCore PoC payload. ", 9)); // ~290 bytes + var original = new BackhaulMessage( + Id: "ab12cd3", Destination: "GB7ABC-1", Salt: 42, Ttl: 1800, + Payload: Encoding.UTF8.GetBytes(payload), + Originator: "M0LTE-7", LinkSourceCallsign: "M0LTE-7"); + + bool ok = true; + foreach (var mode in new[] { DappsCompression.Mode.None, DappsCompression.Mode.ZstdDict }) + { + byte nonce = 0; + var frames = PrivateChannelTransport.ToFrames(original, mode, ref nonce); + int maxLen = frames.Max(f => f.Length); + Console.WriteLine($"selftest [{mode}]: {original.Payload.Length}B payload -> {frames.Count} frame(s), max {maxLen}B channel-data"); + if (maxLen > 165) { Console.WriteLine($" FAIL: frame {maxLen}B exceeds 165B cap"); ok = false; } + + foreach (var order in new[] { "in-order", "reversed" }) + { + var t = new PrivateChannelTransport(); + var seq = order == "reversed" ? frames.Reverse().ToList() : frames.ToList(); + BackhaulMessage? got = null; + foreach (var f in seq) + { + var r = t.Ingest(f, DateTime.UtcNow); + if (r.Kind == PrivateChannelTransport.Kind.BackhaulComplete) got = r.Message; + } + bool pass = got is not null + && got.Id == original.Id && got.Destination == original.Destination + && got.Originator == original.Originator && got.Ttl == original.Ttl + && got.Salt == original.Salt && got.LinkSourceCallsign == original.LinkSourceCallsign + && got.Payload.AsSpan().SequenceEqual(original.Payload); + Console.WriteLine($" {order}: {(pass ? "PASS" : "FAIL")}"); + ok &= pass; + } + } + Console.WriteLine(ok ? "selftest PASS" : "selftest FAIL"); + return ok ? 0 : 3; +} + +// ---------------- helpers ---------------- + +void PrintSelf(SelfInfo s) => Console.WriteLine( + $"name='{s.Name}' pubkey={s.PublicKeyHex[..12]}... adv_type={s.AdvType} txp={s.TxPower}/{s.MaxTxPower} " + + $"{s.FreqMhz:0.000}MHz BW{s.BwKhz:0.0} SF{s.Sf} CR{s.Cr}"); + +static byte[] DerivePsk(string phrase) => SHA256.HashData(Encoding.UTF8.GetBytes(phrase))[..16]; + +static (string?, string?, Dictionary) ParseArgs(string[] a) +{ + if (a.Length == 0) return (null, null, new()); + string cmd = a[0]; + string port = "/dev/ttyUSB0"; + var opts = new Dictionary(); + var positionals = new List(); + for (int i = 1; i < a.Length; i++) + { + if (a[i].StartsWith("--")) + { + string key = a[i][2..]; + string? val = (i + 1 < a.Length && !a[i + 1].StartsWith("--")) ? a[++i] : null; + opts[key] = val; + } + else positionals.Add(a[i]); + } + if (positionals.Count > 0) port = positionals[0]; + if (positionals.Count > 1) opts["_text"] = positionals[1]; + return (cmd, port, opts); +} + +static double GetD(Dictionary o, string k, double dflt) => + o.TryGetValue(k, out var v) && double.TryParse(v, out var d) ? d : dflt; +static int GetI(Dictionary o, string k, int dflt) => + o.TryGetValue(k, out var v) && int.TryParse(v, out var i) ? i : dflt; +static string GetS(Dictionary o, string k, string dflt) => + o.TryGetValue(k, out var v) && v is not null ? v : dflt; + +void PrintUsage() +{ + Console.WriteLine(""" + meshcore-poc [] [options] + + info + regions + provision [--region uk-narrow] [--name N] [--tx-power 8] + [--channel-index 1] [--channel-name dapps-poc] [--no-radio] + get-channel [--channel-index 1] + send-text "message" [--channel-index 1] (diagnostic; text path) + send-data "bytes" [--channel-index 1] (raw binary; proves no prefix) + send-backhaul [--from POC-1] [--dest POC-2] [--text "..."] [--id 7hex] + [--compress none|zstd] [--tx-budget-sec-per-hour 30] + [--channel-index 1] [--gap-ms 1500] + listen [--channel-index 1] [--seconds N] [--raw] + selftest (no radio) + budget-test [--tx-budget-sec-per-hour 30] (no radio) + characterise [--out characterisation.md] (no radio) + """); +} diff --git a/poc/MeshCorePoc/README.md b/poc/MeshCorePoc/README.md new file mode 100644 index 0000000..f9b92d5 --- /dev/null +++ b/poc/MeshCorePoc/README.md @@ -0,0 +1,253 @@ +# DAPPS ⇄ MeshCore private-channel PoC + +Independent proof-of-concept for **issue #137 / Phase H1** (MeshCore Companion-over-USB +as a DAPPS bearer). Standalone .NET 8 console app — **not** wired into DAPPS — that drives +two Heltec WiFi LoRa 32 V3 radios over the MeshCore **Companion** serial protocol and +round-trips a real DAPPS `BackhaulMessage` over a **private (PSK) channel**. + +## What it proves (verified on hardware, 2026-06-30) + +Two Heltec V3s on Raspberry Pis (`radio1`, `radio2`), same private channel: + +``` +radio1 send-backhaul → LoRa 869.618 MHz / 62.5 kHz / SF8 / CR8 → radio2 listen + +[ch1] >>> BACKHAUL id=aaa0001 DAPPS-R1 -> DAPPS-R2 ttl=3600 linksrc=DAPPS-R1 + payload="single fragment backhaul payload" +[ch1] >>> BACKHAUL id=bbb0002 DAPPS-R1 -> DAPPS-R2 ttl=3600 linksrc=DAPPS-R1 + payload="This is a deliberately long DAPPS BackhaulMessage payload designed to + span several MeshCore LoRa packets ... reassembly over the air." (194 B, 4 fragments) +``` + +The bytes on the wire are the **real DAPPS encoding**: `BackhaulMessageCodec` (v7) + +`Packetiser` (13-byte fragment header), copied verbatim into `vendored/` (see +`vendored/VENDORED.md`). Encode → fragment → base64 → MeshCore channel text → OTA LoRa → +reassemble → decode, with every field (`id`, `dest`, `originator`, `linkSource`, `ttl`, +`payload`) intact, including out-of-order delivery (`selftest`). + +## Hardware / firmware / radio setup + +| | | +|---|---| +| Board | Heltec WiFi LoRa 32 V3 (ESP32-S3, 8 MB, CP2102 → `/dev/ttyUSB0`) | +| Firmware | MeshCore **Companion (USB)** `v1.16.0` — `Heltec_v3_companion_radio_usb-…-merged.bin`, flashed at `0x0` with esptool | +| Region | **UK 868 "narrow"**: 869.618 MHz / BW 62.5 kHz / SF 8 / **CR 8** (firmware default is CR 5; the live UK net uses CR 8) | +| Channel | slot **1**, name `dapps-poc`, 16-byte PSK = `SHA256("dapps-poc-channel-v1")[:16]` (`3135135f…aae9`) | +| TX power | **8 dBm** on the bench — see "near-field overload" below | + +`869.618 MHz` sits in the UK **869.4–869.65 MHz** sub-band: **10 % duty cycle, up to 500 mW ERP** +(more generous than the 1 % / 25 mW 868.0–868.6 band). + +## Build / deploy / run + +```bash +# build + self-test (no radio needed) — roll-forward if only a newer runtime is installed +dotnet build -c Release +DOTNET_ROLL_FORWARD=LatestMajor dotnet bin/Release/net8.0/meshcore-poc.dll selftest + +# publish a self-contained arm64 single-file binary for the Pi +dotnet publish -c Release -r linux-arm64 --self-contained true \ + -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true + +# on each Pi (CP2102 = /dev/ttyUSB0) +./meshcore-poc info /dev/ttyUSB0 +./meshcore-poc provision /dev/ttyUSB0 --name DAPPS-R1 --channel-index 1 --channel-name dapps-poc --tx-power 8 +./meshcore-poc listen /dev/ttyUSB0 --channel-index 1 # radio2 +./meshcore-poc send-backhaul /dev/ttyUSB0 --from DAPPS-R1 --dest DAPPS-R2 --text "hi" # radio1 +``` + +`MESHCORE_TRACE=1` logs every serial frame (hex) to stderr. + +## Code shape (how it maps onto DAPPS later) + +- `MeshCoreClient.cs` — the genuinely new code: Companion serial framing + (`[0x3C|0x3E][len-LE16][payload]`), opcode set, async request/response, push handling, + and the `MSG_WAITING → SYNC_NEXT_MESSAGE` inbound drain. Zero DAPPS dependency — this is + the reusable "MeshCore link" library. +- `PrivateChannelTransport.cs` — carries a `BackhaulMessage` over a channel: + `Encode → Packetiser.Split → base64 → "D1:"-marked channel text`, and the reverse with a + `Reassembler`. This is the thin shim that a real `IDappsBackhaul` would own. +- `vendored/` — the real DAPPS wire format, copied so the PoC stays standalone. + +## Wire-shape findings (the questions H1 existed to answer) + +1. **Companion framing is dead simple** — `0x3C` (host→radio) / `0x3E` (radio→host), then a + little-endian uint16 length, then `[opcode][payload]`. No SLIP, no CRC. 115200 8N1, hold + DTR/RTS low so opening the port doesn't reset the board. +2. **Inbound is pull, not push.** The radio emits a 1-byte `MSG_WAITING` (0x83) tickle; you + then loop `SYNC_NEXT_MESSAGE` (0x0A) until `NO_MORE_MESSAGES`. A bearer's receive loop must + model this (we also poll as a safety net). +3. **v1.16.0 sends the *legacy* channel-message frame (0x08), not V3 (0x11)** — even when you + negotiate protocol version 3 in `APP_START`. V3 only seems to apply to contact/direct + messages here. **A bearer must handle 0x08** (no SNR field). This cost us the first run. +4. **The firmware auto-prepends `": "` to channel text.** We send `"hi"`, it + transmits `"DAPPS-R1: hi"`. Consequences for a bearer: (a) it eats the byte budget, and + (b) your framing marker is **not at offset 0** on receive — find it as a substring. +5. **Channel text is UTF-8; `BackhaulMessage` is binary** → base64 (≈ 33 % inflation) *or* the + binary `SEND_CHANNEL_DATA` (0x3E) / `CHANNEL_DATA_RECV` (0x1B) path. We used base64-text + (robust, most-tested). Effective payload at `mtu=90`: **77 DAPPS-encoded bytes per LoRa + packet** (90 raw → 120 b64 + `"D1:"` + `": "` ≈ 133 chars on air). The binary path + would recover the base64 overhead (~165 usable bytes/packet) and is the likely production + choice. +6. **MeshCore does NOT fragment a message** — one text = one packet, hard ceiling ~150–160 + bytes of text (OTA group plaintext cap 165). So DAPPS's `Packetiser` is doing the real work; + reuse it unchanged. +7. **Near-field overload is real.** At the firmware default **22 dBm** with the two radios + ~tens of cm apart, **nothing decoded** — the RX front-end saturates. Dropping to **8 dBm** + fixed it instantly. Worth a note in operator docs for bench setups. + +## Slotting into DAPPS (recommended next step) + +The seam is ready (see the fresh-look review). To land H1: + +1. **Outbound** — a `MeshCoreCompanionBackhaul : IDappsBackhaul`. `CanHandle(route)` matches a + MeshCore-channel route hint; `SendAsync` = `MeshCoreClient` + `PrivateChannelTransport` + (reuse `BackhaulMessageCodec`/`Packetiser` directly — drop `vendored/`). Stamp + `LinkSourceCallsign` on TX exactly like `UdpDatagramBackhaul`. +2. **Inbound** — a `HostedService` owning the `MeshCoreClient` read loop; on each reassembled + `BackhaulMessage` call `IBackhaulInbox.DeliverAsync(msg, sourceCallsign)`, deriving + `sourceCallsign` from `LinkSourceCallsign` (sentinel `"MESHCORE"` fallback) — the channel + is anonymous. +3. **Config** — `DAPPS_MESHCORE_ENABLED`, `DAPPS_MESHCORE_PORT`, `DAPPS_MESHCORE_CHANNEL_INDEX`, + `DAPPS_MESHCORE_PSK`, radio params. `SystemOptions` keys get `DAPPS_*` binding for free. + +Three seam issues to resolve *as part of* this (from the fresh-look review), in priority order: +- **Broadcast fan-out** — a private channel is one shared medium; the OMM's one-`SendAsync`-per- + neighbour flood would transmit N times where one suffices. Coalesce identical `(msgId, + channel)` sends, or model a first-class broadcast route. Most important on a duty-cycled band. +- **Positive bearer selection** — replace AGW's `route.UdpEndpoint is null` catch-all with an + explicit `route.Bearer` discriminator so adding MeshCore doesn't require editing AGW. +- **Airtime budget on the data path** — wire `AirtimeAccountant.TryReserve` (today discovery-only) + into the MeshCore `SendAsync`, keyed on the channel. The 869.618 sub-band is 10 % duty cycle — + finite, must be enforced (back-pressure, not drop). + +## The MeshCore network is ONE shared broadcast segment (key design constraint) + +DAPPS nodes will be sparse relative to MeshCore nodes — long chains of pure-MeshCore relays +between MeshCore-equipped DAPPS nodes — and all DAPPS nodes likely share **one** private channel +across the whole MeshCore network. + +**Verified against firmware source** (`Mesh.cpp`, `BaseChatMesh.cpp`, `docs/faq.md`): a group/channel +message (`PAYLOAD_TYPE_GRP_TXT`/`GRP_DATA`) is an **unaddressed, PSK-encrypted FLOOD broadcast** — a +1-byte channel hash, no node addresses, no path, **no ACK and no retransmit** at the MeshCore layer. +Specifics that matter: + +- **Relay is by infrastructure, not leaves.** Stock **Companion** nodes do **not** relay + (`allowPacketForward` → `client_repeat == 0` by default); only **Repeater / Room-Server / Sensor** + roles re-flood. So two DAPPS Companion nodes out of direct range interwork **only via intervening + MeshCore repeaters** — and crucially **repeaters forward channel packets without holding the PSK**, + so the public MeshCore repeater network carries our private DAPPS traffic for free. (A DAPPS node + *can* opt to relay via `client_repeat` to extend coverage.) +- **Bounded flood:** hard 64-hop cap (`MAX_PATH_SIZE`) plus each repeater's `flood.max` (default 64). +- **Loop suppression:** 8-byte SHA256 packet-hash dedup in a **160-entry cyclic ring with no time + expiry** — which is exactly why the binary path needs the per-frame nonce (identical bytes = same + hash = dropped until 160 newer packets evict it). +- **The MeshCore mesh IS the routing layer.** To DAPPS the whole MeshCore network is a single + broadcast segment — every DAPPS node is effectively a one-hop neighbour, however many MeshCore + hops away it physically is. All DAPPS nodes tend to hear all DAPPS traffic (subject to flood + loss, which is *very* significant — there is no link-layer reliability). + +Commitments to avoid painting into a corner: + +1. **One broadcast per message, never per-neighbour.** A message for a specific DAPPS node is a + single channel broadcast; the addressee self-selects (`IsLocal`), others ignore or relay across + *other* bearers. (The fresh-look review's issue #1, now central — not a nice-to-have.) +2. **DAPPS must NOT re-flood within the segment.** MeshCore already flooded it to all channel + members; re-broadcasting on the same channel doubles airtime and starts a DAPPS-layer flood + storm. DAPPS-level forwarding applies only at **gateways crossing to another bearer** (MeshCore + → AX.25, …). The bearer should advertise "shared broadcast segment; intra-segment delivery is + mine, not yours." +3. **Airtime is a single network-wide shared budget.** One broadcast floods through many nodes, + burning airtime in each one's RF cell; the 10 % duty / ~1.6 s-per-packet ceiling is shared by + *every* originator. So heavy compression (≈3× here) directly multiplies network capacity, and + per-channel airtime budgets must be conservative. +4. **Discovery = passive learning, not flooded beacons.** You learn a DAPPS node exists just by + hearing its broadcasts; flooding beacons across the whole network is far too costly. +5. **Reliability over an unreliable flood.** No per-hop acks (fire-and-forget), high loss, + asymmetric paths. Favour end-to-end idempotency + TTL-aware resends with **long backoff** (a + resend is another network-wide flood); rate-limit hard to avoid flood storms. Both the + per-frame nonce (mesh dedup) and DAPPS `(Id, source)` dedup matter. +6. **Keep partitioning open.** Assume one channel today but don't hard-code it — multiple private + channels (regional sub-nets) are the escape valve if one channel saturates. + +### Containment: private ≠ contained (verified from source) + +A private channel gives **privacy** (PSK) but **not containment**: channel messages flood **unscoped +by default** (`companion_radio/MyMesh.cpp:486–520`; `DEFAULT_FLOOD_SCOPE_NAME` unset in all builds), +and repeaters relay floods **without the PSK** — so on a shared preset our private traffic is +re-flooded network-wide, burning others' airtime invisibly. MeshCore's **flood scope** +(`ROUTE_TYPE_TRANSPORT_FLOOD` + a 2-byte keyed-hash transport code) does contain it — a stock public +repeater with no matching region **drops** a scoped flood (`simple_repeater/MyMesh.cpp:436–439`) — but +(a) the scope is **not secret** (hashtag-derived public key; the `$`-private keystore is stubbed, +`TransportKeyStore.cpp:52–91`), (b) it's **global per device, off by default, not per-channel** +(`MyMesh.cpp:510` TODO), and (c) **carrying** traffic between non-adjacent DAPPS nodes still needs +**repeaters configured with the scope** — i.e. our own. There is no sender hop-TTL, only binary +zero-hop (local-only) vs full flood, and channel sends can't be zero-hopped via the companion API. + +**Three deployment models — the bearer must support all three as operator config (preset + scope):** + +| Model | Public-repeater carriage | Burdens public net | Needs own repeaters | +|---|---|---|---| +| A. public preset, **unscoped** | free | **yes (antisocial)** | no — only OK for featherweight traffic | +| B. public preset, **scoped** | none (dropped) | no | **yes** (scoped Repeater-firmware nodes) | +| C. **dedicated preset** (freq/SF) | n/a | no | **yes** | + +Sustainable DAPPS-over-MeshCore at volume ⇒ **deploy DAPPS-aware repeaters** (scoped-on-public, or +dedicated preset). The free public ride (A) suits only trivially light traffic — another argument for +heavy compression. (A Companion `client_repeat` relays **unscoped** — no region filter — so a proper +scoped backbone needs Repeater firmware with the region configured, not Companion leaves.) + +The PoC already embodies the right primitive (send = one channel broadcast; receive = promiscuous ++ filter by destination), so we are **not** cornered — these commitments are mostly about what +DAPPS *core* must not do when the bearer lands. + +## Good-citizen controls for Model A (chosen first — ride the public preset) + +Model A (public preset, unscoped) gets free public-repeater carriage but floods the whole same-preset +network, so it is only acceptable with **strong, self-enforced** controls. The contract: + +| Control | Status | Detail | +|---|---|---| +| **Airtime governor** | **implemented** (`TxBudget`) | Hard trailing-hour budget, **back-pressure not drop**, per node. Default **30 s/hr ≈ 0.83% duty** (12× under the 10% regulatory cap) — a policy knob (`--tx-budget-sec-per-hour`). Wired into the send path; `budget-test` shows it admit 50 compressed msgs/hr then refuse the rest. | +| **Heavy compression** | **implemented** | zstd + shared dictionary ≈3× fewer packets → directly ≈3× fewer floods. The governor + compression compound: 1 pkt/msg is what makes 50 msgs/hr fit in 30 s. | +| **No flooded beacons** | design | Discovery = passive learning off real traffic only; never periodically flood the network for discovery. | +| **Bounded retransmits** | design | End-to-end idempotency on `dapps-id`; **long exponential backoff**, capped attempts — a resend is another network-wide flood. No blind per-hop resend. | +| **Size discipline** | partial (warns >4 pkts) | Cap message size on the public preset; route bulk/large transfers to Model B/C. | +| **No app-layer re-flood** | inherent | Companion leaves don't relay at the MeshCore layer; DAPPS must also never re-broadcast a received message onto the channel (forward only across *other* bearers). | +| **Observability + kill switch** | partial | Surface airtime-used / duty (the send path prints it); the existing master TX kill-switch enforced at the bearer chokepoint. | + +The budget number is the key policy dial: on a mesh shared with N public users, DAPPS should take a +small, bounded slice. 30 s/hr is a conservative starting point — tune per deployment. **B and C remain +the path for higher volume / guaranteed isolation** (see the deployment-models table above). + +## Device-control & firmware API (DAPPS-driven) + +Radio settings must be controllable from DAPPS, not just hand-provisioned. The PoC client +already exposes the primitives; the bearer should surface a small control interface: + +- `GetSelfInfo()` — identity (pubkey/name) + current radio params (read-back). +- `SetRegion(preset)` — **localisation**: push freq/bw/sf/cr for a named preset and enforce the + region's max power. `Presets.cs` + the `regions` command are the seed; a production build + should pull the live preset table from MeshCore upstream rather than hard-code regulatory values. +- `SetTxPower(dBm)` — capped by the active region. +- channel management — `SetChannel` / `GetChannel` / `ListChannels` (name + 16-byte PSK). +- `SetName(name)`. + +These map 1:1 to companion opcodes already implemented (`SET_RADIO_PARAMS` 0x0B, +`SET_RADIO_TX_POWER` 0x0C, `SET_CHANNEL` 0x20, `GET_CHANNEL` 0x1F, `SET_ADVERT_NAME` 0x08, +`APP_START`→`SELF_INFO`). DAPPS persists these in `SystemOptions` and applies on startup / on +operator change, exactly like AGW port config today. + +**Firmware flashing / upgrade** is heavier but in scope. The working flow is `esptool` driving the +ESP32-S3 over the same USB serial (download mode via CP2102 DTR/RTS — no buttons), flashing the +per-board MeshCore `*-merged.bin` at `0x0` (see `../flash-meshcore.sh`). A DAPPS-managed updater +would: detect the board, fetch the right release asset (verify sha256), **release the serial port**, +flash, verify, restart the bearer. Caveats: the port is exclusive (bearer must let go), the radio is +offline ~30–60 s, and it needs the esptool toolchain present — so this is a deliberate operator +action, not silent auto-update. + +## Open questions for iteration + +- Binary `SEND_CHANNEL_DATA` vs base64-text — measure goodput/airtime delta. +- Does the `": "` prefix survive across firmware versions? Pin the bearer to a release tag. +- Discovery: lean on B5 flood-then-learn over this bearer, or add a `MeshCoreDiscoveryBearer`? diff --git a/poc/MeshCorePoc/TxBudget.cs b/poc/MeshCorePoc/TxBudget.cs new file mode 100644 index 0000000..b372b0f --- /dev/null +++ b/poc/MeshCorePoc/TxBudget.cs @@ -0,0 +1,55 @@ +namespace MeshCorePoc; + +/// +/// Self-enforced airtime governor — the centerpiece "good citizen" control for +/// Model A (riding the public preset, where every channel send floods the whole +/// same-preset network). It is a HARD gate, not advisory: a send that would push +/// our trailing-hour airtime over budget is refused (back-pressure), so DAPPS can +/// never burst the shared channel however much traffic the app offers. +/// +/// Budget is expressed as airtime seconds per trailing hour. The regulatory limit +/// on the UK 869.4-869.65 sub-band is 10% duty (360 s/hr); a polite DAPPS +/// self-limit on a shared public mesh should be a small fraction of that. The +/// default here is deliberately conservative and is a policy knob. +/// +public sealed class TxBudget +{ + private readonly double _budgetMs; + private readonly Queue<(DateTime when, double ms)> _window = new(); + private double _sumMs; + + /// Conservative default: 30 s/hr ≈ 0.83% duty (12× under the 10% cap). + public const double DefaultSecondsPerHour = 30; + + public TxBudget(double secondsPerHour) => _budgetMs = secondsPerHour * 1000.0; + + public double BudgetSeconds => _budgetMs / 1000.0; + public double UsedSeconds(DateTime now) { Prune(now); return _sumMs / 1000.0; } + /// Trailing-hour duty cycle as a percentage. + public double DutyPercent(DateTime now) { Prune(now); return _sumMs / 36_000.0; } + + /// Reserve airtime for one transmission. Returns false (and changes + /// nothing) if it would exceed the trailing-hour budget. + public bool TryReserve(double airtimeMs, DateTime now, out string reason) + { + Prune(now); + if (_sumMs + airtimeMs > _budgetMs) + { + reason = $"airtime budget exceeded: used {_sumMs / 1000:0.0}s + {airtimeMs / 1000:0.00}s " + + $"> {_budgetMs / 1000:0.0}s/hr"; + return false; + } + _window.Enqueue((now, airtimeMs)); + _sumMs += airtimeMs; + reason = ""; + return true; + } + + private void Prune(DateTime now) + { + var cutoff = now.AddHours(-1); + while (_window.Count > 0 && _window.Peek().when < cutoff) + _sumMs -= _window.Dequeue().ms; + if (_sumMs < 0) _sumMs = 0; + } +} diff --git a/poc/MeshCorePoc/vendored/BackhaulMessage.cs b/poc/MeshCorePoc/vendored/BackhaulMessage.cs new file mode 100644 index 0000000..1a02b4c --- /dev/null +++ b/poc/MeshCorePoc/vendored/BackhaulMessage.cs @@ -0,0 +1,87 @@ +namespace dapps.client.Backhaul; + +/// +/// A unit of DAPPS traffic - bearer-neutral. Outbound: +/// implementations translate this shape into +/// bearer-specific frames (DAPPSv1 stream exchange for AGW today; +/// companion datagrams for MeshCore later). Inbound: bearer-specific +/// receive code constructs one of these from a fully-received-and- +/// validated message and hands it to . +/// +/// carries any non-reserved KVs from the on-air +/// `ihave` line (post-A0 the outbound submission path doesn't populate +/// it, but inbound preserves what the peer sent). +/// +public sealed record BackhaulMessage( + string Id, + string Destination, + long? Salt, + int? Ttl, + byte[] Payload, + IReadOnlyDictionary? Headers = null, + string? Originator = null, + string? LinkSourceCallsign = null, + byte? FloodHopsRemaining = null, + IReadOnlyList? SourceRoute = null, + IReadOnlyList? TraversedHops = null, + string? MasterId = null, + int? FragmentIndex = null, + int? FragmentTotal = null, + string? StreamId = null, + uint? StreamSeq = null, + uint? StreamGapTimeoutSeconds = null); + +// LinkSourceCallsign: the *immediate sender's* callsign, distinct from +// Originator (the F1 end-to-end source). Carried on bearers that don't +// natively identify the sender - UDP being the prime example, since +// the source port is ephemeral and there's no session-level handshake +// that establishes peer identity. Stamped by the bearer's send path +// with the local callsign; consumed by the receive path so the inbox +// (and downstream passive-learning algorithms) can see who handed +// each hop the message. +// +// AGW already identifies the link source from the C-frame's CallFrom +// field, so AGW-bearer SendAsync may leave this null; the inbound +// path uses the AGW-supplied identity directly. +// +// FloodHopsRemaining: when set, this message is a B5 cold-start +// flood. Each forwarding hop decrements before re-flooding; the +// flood stops when the value reaches zero. null means "this is a +// regular routed message, not a flood." The bounded-flood fallback +// (FloodFallbackAlgorithm) is the only thing that originates floods; +// other algorithms / inbox handlers just propagate them. +// +// SourceRoute (MeshCore-flavoured): when set, the message must be +// delivered along this exact ordered list of intermediate hops. Each +// forwarder takes the first entry as its next hop, strips it before +// re-encoding, and forwards. When the list is empty the recipient +// uses the destination's callsign as next hop (or delivers locally). +// null means "no embedded path; let the algorithm pick a hop." +// +// TraversedHops (MeshCore-flavoured discovery): the ordered list of +// intermediate node callsigns the message has visited so far, +// excluding the originator and the local node. Each forwarder +// appends its own callsign before re-encoding. Carried on +// flood-discovery messages so the destination (and every transiting +// node) can derive the reverse path back to the originator - +// MeshCoreLikeRoutingAlgorithm uses this to populate its discovered- +// paths table without explicit RREP frames. +// +// MasterId / FragmentIndex / FragmentTotal (F2 multi-part): when +// MasterId is set, this BackhaulMessage is one fragment of a larger +// logical payload that the originator chunked. Intermediate hops +// forward fragments as opaque messages; only the final destination's +// inbox reassembles. Set together (all-or-none on the wire as +// `mid=…` + `frag=N/M` headers); the receiver's IHaveValidator +// rejects any mismatched-presence combination. FragmentTotal ≥ 2; +// single-fragment messages just omit all three fields. +// +// StreamId / StreamSeq / StreamGapTimeoutSeconds (opt-in ordering): +// when StreamSeq is set the message is part of a per-sender ordered +// stream identified by StreamId. The receiver delivers messages on +// each (sender-callsign, StreamId) cursor in monotonically-increasing +// StreamSeq order; gaps stall until the missing seq arrives or +// StreamGapTimeoutSeconds elapses (gt=0 = strict, never skip). +// Wire form: `sid=`, `sn=`, `gt=` keys on the ihave line; codec flag +// bit 9 on datagram bearers. All three are required together when +// any one is set; intermediate forwarders preserve them verbatim. diff --git a/poc/MeshCorePoc/vendored/BackhaulMessageCodec.cs b/poc/MeshCorePoc/vendored/BackhaulMessageCodec.cs new file mode 100644 index 0000000..2b8fcd5 --- /dev/null +++ b/poc/MeshCorePoc/vendored/BackhaulMessageCodec.cs @@ -0,0 +1,461 @@ +using System.Buffers.Binary; +using System.Text; + +namespace dapps.client.Backhaul.Datagram; + +/// +/// Self-describing binary codec for . Used +/// by datagram-shaped bearers (UDP today, MeshCore Companion / KISS +/// later) where the streamed DAPPSv1 ihave/data exchange +/// doesn't fit. Stays in dapps.client so any bearer impl can +/// reuse it without taking a dependency on the AGW path. +/// +/// Wire format (all integers little-endian). The version byte is the +/// first thing on the wire; receivers reject anything other than the +/// current . We're pre-shipping, so there's no +/// in-flight traffic that would break - the version mechanism is +/// preserved (so a future format change still hard-fails cleanly +/// rather than silently misinterpreting bytes), but we don't carry +/// historical decoder paths. +/// +/// [1] version = current Version +/// [2] flags (UInt16) bit0=salt, bit1=ttl, bit2=headers, +/// bit3=originator, bit4=link-source, +/// bit5=flood-hops-remaining, +/// bit6=source-route, bit7=traversed-hops, +/// bit8=fragment (F2 multi-part), +/// bit9=stream (opt-in ordering) +/// [7] id (UTF-8 ASCII, 7-char hex from DappsMessage.ComputeHash) +/// [8] salt (only when flags bit0) +/// [4] ttl seconds (only when flags bit1) +/// [2] destination len +/// [N] destination (UTF-8) +/// [2] originator len (only when flags bit3) +/// [O] originator (UTF-8) +/// [2] link-source len (only when flags bit4) +/// [L] link-source (UTF-8) +/// [1] flood-hops (only when flags bit5) +/// [1] source-route count (only when flags bit6) +/// per source-route hop: +/// [1] hop len, [N] hop (UTF-8) +/// [1] traversed count (only when flags bit7) +/// per traversed hop: +/// [1] hop len, [N] hop (UTF-8) +/// [7] master id (only when flags bit8; ASCII) +/// [2] fragment index (only when flags bit8; UInt16, 1-based) +/// [2] fragment total (only when flags bit8; UInt16) +/// [1] stream id len (only when flags bit9; max 255 bytes) +/// [S] stream id (only when flags bit9; UTF-8) +/// [4] stream seq (only when flags bit9; UInt32 LE) +/// [4] stream gap timeout (only when flags bit9; UInt32 LE seconds, 0=strict) +/// [2] headers count (only when flags bit2) +/// per header: +/// [2] key len, [K] key (UTF-8), [2] value len, [V] value (UTF-8) +/// [4] payload len +/// [P] payload bytes +/// +/// Length-prefixed throughout - no escapes, binary-safe. +/// +public static class BackhaulMessageCodec +{ + /// Version this encoder writes AND the only version the + /// decoder accepts. Bump on any wire-format change so a mismatched + /// peer fails fast instead of silently misreading flag bits. + public const byte Version = 7; + public const int IdLength = 7; + + [Flags] + private enum Flags : ushort + { + None = 0, + HasSalt = 1 << 0, + HasTtl = 1 << 1, + HasHeaders = 1 << 2, + HasOriginator = 1 << 3, + HasLinkSource = 1 << 4, + HasFloodHops = 1 << 5, + HasSourceRoute = 1 << 6, + HasTraversedHops = 1 << 7, + HasFragment = 1 << 8, + HasStream = 1 << 9, + } + + public static byte[] Encode(BackhaulMessage message) + { + if (message.Id.Length != IdLength) + { + throw new ArgumentException($"id must be exactly {IdLength} characters; got '{message.Id}'", nameof(message)); + } + + // F2 fragment headers always travel together; presence of MasterId + // is the gate. Validate the trio so an in-transit relay can't half- + // strip them (which would leave the receiver unable to reassemble + // and unable to tell that's what happened). + var hasFragment = message.MasterId is not null; + if (hasFragment) + { + if (message.MasterId!.Length != IdLength) + { + throw new ArgumentException($"master id must be exactly {IdLength} characters; got '{message.MasterId}'", nameof(message)); + } + if (!message.FragmentIndex.HasValue || !message.FragmentTotal.HasValue) + { + throw new ArgumentException("fragment index/total must accompany master id", nameof(message)); + } + } + else if (message.FragmentIndex.HasValue || message.FragmentTotal.HasValue) + { + throw new ArgumentException("fragment index/total without master id", nameof(message)); + } + + // Opt-in ordering trio: present together or absent together. The + // receiver enforces this in IHaveValidator too, but a relay that + // forwards a partial set would silently drop ordering for the + // downstream hop, which is worse than failing fast here. + var hasStream = !string.IsNullOrEmpty(message.StreamId) + || message.StreamSeq.HasValue + || message.StreamGapTimeoutSeconds.HasValue; + if (hasStream + && (string.IsNullOrEmpty(message.StreamId) + || !message.StreamSeq.HasValue + || !message.StreamGapTimeoutSeconds.HasValue)) + { + throw new ArgumentException( + "stream id/seq/gap-timeout must all be set together (opt-in ordering) or all be absent", + nameof(message)); + } + var streamIdBytes = hasStream ? Encoding.UTF8.GetBytes(message.StreamId!) : []; + if (hasStream && streamIdBytes.Length > byte.MaxValue) + { + throw new ArgumentException("stream id exceeds 255 bytes", nameof(message)); + } + + var idBytes = Encoding.ASCII.GetBytes(message.Id); + var dstBytes = Encoding.UTF8.GetBytes(message.Destination); + var origBytes = string.IsNullOrEmpty(message.Originator) + ? [] + : Encoding.UTF8.GetBytes(message.Originator!); + var linkBytes = string.IsNullOrEmpty(message.LinkSourceCallsign) + ? [] + : Encoding.UTF8.GetBytes(message.LinkSourceCallsign!); + var headerBytes = message.Headers is { Count: > 0 } + ? EncodeHeaders(message.Headers) + : []; + var sourceRouteBytes = message.SourceRoute is { Count: > 0 } + ? EncodeCallsignList(message.SourceRoute) + : []; + var traversedBytes = message.TraversedHops is { Count: > 0 } + ? EncodeCallsignList(message.TraversedHops) + : []; + var masterIdBytes = hasFragment + ? Encoding.ASCII.GetBytes(message.MasterId!) + : []; + + var flags = Flags.None; + if (message.Salt.HasValue) flags |= Flags.HasSalt; + if (message.Ttl.HasValue) flags |= Flags.HasTtl; + if (headerBytes.Length > 0) flags |= Flags.HasHeaders; + if (origBytes.Length > 0) flags |= Flags.HasOriginator; + if (linkBytes.Length > 0) flags |= Flags.HasLinkSource; + if (message.FloodHopsRemaining.HasValue) flags |= Flags.HasFloodHops; + if (sourceRouteBytes.Length > 0) flags |= Flags.HasSourceRoute; + if (traversedBytes.Length > 0) flags |= Flags.HasTraversedHops; + if (hasFragment) flags |= Flags.HasFragment; + if (hasStream) flags |= Flags.HasStream; + + var size = 1 + 2 + IdLength + + (message.Salt.HasValue ? 8 : 0) + + (message.Ttl.HasValue ? 4 : 0) + + 2 + dstBytes.Length + + (origBytes.Length > 0 ? 2 + origBytes.Length : 0) + + (linkBytes.Length > 0 ? 2 + linkBytes.Length : 0) + + (message.FloodHopsRemaining.HasValue ? 1 : 0) + + sourceRouteBytes.Length + + traversedBytes.Length + + (hasFragment ? IdLength + 2 + 2 : 0) + + (hasStream ? 1 + streamIdBytes.Length + 4 + 4 : 0) + + headerBytes.Length + + 4 + message.Payload.Length; + + var buffer = new byte[size]; + var offset = 0; + + buffer[offset++] = Version; + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(offset, 2), (ushort)flags); + offset += 2; + + idBytes.CopyTo(buffer.AsSpan(offset)); + offset += IdLength; + + if (message.Salt.HasValue) + { + BinaryPrimitives.WriteInt64LittleEndian(buffer.AsSpan(offset, 8), message.Salt.Value); + offset += 8; + } + if (message.Ttl.HasValue) + { + BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(offset, 4), message.Ttl.Value); + offset += 4; + } + + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(offset, 2), (ushort)dstBytes.Length); + offset += 2; + dstBytes.CopyTo(buffer.AsSpan(offset)); + offset += dstBytes.Length; + + if (origBytes.Length > 0) + { + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(offset, 2), (ushort)origBytes.Length); + offset += 2; + origBytes.CopyTo(buffer.AsSpan(offset)); + offset += origBytes.Length; + } + + if (linkBytes.Length > 0) + { + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(offset, 2), (ushort)linkBytes.Length); + offset += 2; + linkBytes.CopyTo(buffer.AsSpan(offset)); + offset += linkBytes.Length; + } + + if (message.FloodHopsRemaining.HasValue) + { + buffer[offset++] = message.FloodHopsRemaining.Value; + } + + if (sourceRouteBytes.Length > 0) + { + sourceRouteBytes.CopyTo(buffer.AsSpan(offset)); + offset += sourceRouteBytes.Length; + } + + if (traversedBytes.Length > 0) + { + traversedBytes.CopyTo(buffer.AsSpan(offset)); + offset += traversedBytes.Length; + } + + if (hasFragment) + { + masterIdBytes.CopyTo(buffer.AsSpan(offset)); + offset += IdLength; + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(offset, 2), (ushort)message.FragmentIndex!.Value); + offset += 2; + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(offset, 2), (ushort)message.FragmentTotal!.Value); + offset += 2; + } + + if (hasStream) + { + buffer[offset++] = (byte)streamIdBytes.Length; + streamIdBytes.CopyTo(buffer.AsSpan(offset)); + offset += streamIdBytes.Length; + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset, 4), message.StreamSeq!.Value); + offset += 4; + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset, 4), message.StreamGapTimeoutSeconds!.Value); + offset += 4; + } + + headerBytes.CopyTo(buffer.AsSpan(offset)); + offset += headerBytes.Length; + + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset, 4), (uint)message.Payload.Length); + offset += 4; + message.Payload.CopyTo(buffer.AsSpan(offset)); + + return buffer; + } + + public static BackhaulMessage Decode(ReadOnlySpan buffer) + { + if (buffer.Length < 1 + 2 + IdLength + 2 + 4) + { + throw new InvalidDataException("buffer too short for any valid backhaul message"); + } + + var offset = 0; + var version = buffer[offset++]; + if (version != Version) + { + throw new InvalidDataException($"unsupported codec version {version}; expected {Version}"); + } + var flags = (Flags)BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(offset, 2)); + offset += 2; + + var id = Encoding.ASCII.GetString(buffer.Slice(offset, IdLength)); + offset += IdLength; + + long? salt = null; + if ((flags & Flags.HasSalt) != 0) + { + salt = BinaryPrimitives.ReadInt64LittleEndian(buffer.Slice(offset, 8)); + offset += 8; + } + + int? ttl = null; + if ((flags & Flags.HasTtl) != 0) + { + ttl = BinaryPrimitives.ReadInt32LittleEndian(buffer.Slice(offset, 4)); + offset += 4; + } + + var dstLen = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(offset, 2)); + offset += 2; + var destination = Encoding.UTF8.GetString(buffer.Slice(offset, dstLen)); + offset += dstLen; + + string? originator = null; + if ((flags & Flags.HasOriginator) != 0) + { + var origLen = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(offset, 2)); + offset += 2; + originator = Encoding.UTF8.GetString(buffer.Slice(offset, origLen)); + offset += origLen; + } + + string? linkSource = null; + if ((flags & Flags.HasLinkSource) != 0) + { + var linkLen = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(offset, 2)); + offset += 2; + linkSource = Encoding.UTF8.GetString(buffer.Slice(offset, linkLen)); + offset += linkLen; + } + + byte? floodHops = null; + if ((flags & Flags.HasFloodHops) != 0) + { + floodHops = buffer[offset++]; + } + + IReadOnlyList? sourceRoute = null; + if ((flags & Flags.HasSourceRoute) != 0) + { + sourceRoute = DecodeCallsignList(buffer, ref offset); + } + + IReadOnlyList? traversedHops = null; + if ((flags & Flags.HasTraversedHops) != 0) + { + traversedHops = DecodeCallsignList(buffer, ref offset); + } + + string? masterId = null; + int? fragmentIndex = null; + int? fragmentTotal = null; + if ((flags & Flags.HasFragment) != 0) + { + masterId = Encoding.ASCII.GetString(buffer.Slice(offset, IdLength)); + offset += IdLength; + fragmentIndex = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(offset, 2)); + offset += 2; + fragmentTotal = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(offset, 2)); + offset += 2; + } + + string? streamId = null; + uint? streamSeq = null; + uint? streamGapTimeout = null; + if ((flags & Flags.HasStream) != 0) + { + var sidLen = buffer[offset++]; + streamId = Encoding.UTF8.GetString(buffer.Slice(offset, sidLen)); + offset += sidLen; + streamSeq = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(offset, 4)); + offset += 4; + streamGapTimeout = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(offset, 4)); + offset += 4; + } + + IReadOnlyDictionary? headers = null; + if ((flags & Flags.HasHeaders) != 0) + { + var count = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(offset, 2)); + offset += 2; + var dict = new Dictionary(count); + for (var i = 0; i < count; i++) + { + var keyLen = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(offset, 2)); + offset += 2; + var key = Encoding.UTF8.GetString(buffer.Slice(offset, keyLen)); + offset += keyLen; + var valLen = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(offset, 2)); + offset += 2; + var val = Encoding.UTF8.GetString(buffer.Slice(offset, valLen)); + offset += valLen; + dict[key] = val; + } + headers = dict; + } + + var payloadLen = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(offset, 4)); + offset += 4; + var payload = buffer.Slice(offset, (int)payloadLen).ToArray(); + + return new BackhaulMessage(id, destination, salt, ttl, payload, headers, originator, linkSource, floodHops, sourceRoute, traversedHops, masterId, fragmentIndex, fragmentTotal, streamId, streamSeq, streamGapTimeout); + } + + private static byte[] EncodeHeaders(IReadOnlyDictionary headers) + { + var pairs = headers.Select(kv => (Key: Encoding.UTF8.GetBytes(kv.Key), Val: Encoding.UTF8.GetBytes(kv.Value))).ToArray(); + var size = 2 + pairs.Sum(p => 2 + p.Key.Length + 2 + p.Val.Length); + var buf = new byte[size]; + var off = 0; + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(off, 2), (ushort)pairs.Length); + off += 2; + foreach (var (key, val) in pairs) + { + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(off, 2), (ushort)key.Length); + off += 2; + key.CopyTo(buf.AsSpan(off)); + off += key.Length; + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(off, 2), (ushort)val.Length); + off += 2; + val.CopyTo(buf.AsSpan(off)); + off += val.Length; + } + return buf; + } + + /// Length-prefixed list of UTF-8 callsigns. Count is one + /// byte (max 255 hops - far beyond anything realistic; AODV-style + /// algorithms cap at ~10) and each hop's length is one byte (max + /// 255 chars; a callsign is ~9 incl SSID). + private static byte[] EncodeCallsignList(IReadOnlyList hops) + { + if (hops.Count > byte.MaxValue) + { + throw new ArgumentException($"callsign list exceeds {byte.MaxValue} entries", nameof(hops)); + } + var encoded = hops.Select(h => Encoding.UTF8.GetBytes(h)).ToArray(); + var size = 1 + encoded.Sum(b => 1 + b.Length); + var buf = new byte[size]; + var off = 0; + buf[off++] = (byte)hops.Count; + foreach (var bytes in encoded) + { + if (bytes.Length > byte.MaxValue) + { + throw new ArgumentException("callsign exceeds 255 bytes", nameof(hops)); + } + buf[off++] = (byte)bytes.Length; + bytes.CopyTo(buf.AsSpan(off)); + off += bytes.Length; + } + return buf; + } + + private static IReadOnlyList DecodeCallsignList(ReadOnlySpan buffer, ref int offset) + { + var count = buffer[offset++]; + var hops = new List(count); + for (var i = 0; i < count; i++) + { + var len = buffer[offset++]; + hops.Add(Encoding.UTF8.GetString(buffer.Slice(offset, len))); + offset += len; + } + return hops; + } +} diff --git a/poc/MeshCorePoc/vendored/Packetiser.cs b/poc/MeshCorePoc/vendored/Packetiser.cs new file mode 100644 index 0000000..2767bbe --- /dev/null +++ b/poc/MeshCorePoc/vendored/Packetiser.cs @@ -0,0 +1,205 @@ +using System.Buffers.Binary; +using System.Text; + +namespace dapps.client.Backhaul.Datagram; + +/// +/// Splits an opaque byte buffer into fragments of size ≤ MTU and +/// reassembles them on the receiving side. Bearer-agnostic - the UDP +/// backhaul uses it today; a MeshCore Companion / KISS adapter with a +/// similarly small MTU will reuse it directly. Plan A0.3: fragmentation +/// is DAPPS-owned, not bearer-owned. +/// +/// Fragment format: +/// +/// [7] message id (UTF-8 ASCII; matches DappsMessage 7-hex id) +/// [2] seq (uint16 LE - 0-based fragment index) +/// [2] count (uint16 LE - total fragments for this message) +/// [2] chunk len (uint16 LE) +/// [N] chunk +/// +/// 13-byte header. With MTU=200 a fragment carries up to 187 chunk +/// bytes; tests can dial MTU lower (e.g. 64) to force fragmentation +/// of short messages. +/// +public static class Packetiser +{ + public const int IdLength = 7; + public const int HeaderLength = IdLength + 2 + 2 + 2; + + /// Smallest MTU the packetiser tolerates: header + at least + /// one chunk byte. Anything below this would mean a fragment with + /// no payload, which would never reassemble. + public const int MinMtu = HeaderLength + 1; + + /// + /// Split into fragments tagged with + /// . The returned datagrams are each + /// ≤ bytes including the fragment header. + /// + public static IReadOnlyList Split(string messageId, byte[] buffer, int mtu) + { + if (messageId.Length != IdLength) + { + throw new ArgumentException($"messageId must be exactly {IdLength} chars", nameof(messageId)); + } + if (mtu < MinMtu) + { + throw new ArgumentOutOfRangeException(nameof(mtu), + $"mtu must be at least {MinMtu} (header + 1 chunk byte)"); + } + + var maxChunk = mtu - HeaderLength; + var idBytes = Encoding.ASCII.GetBytes(messageId); + + // Empty buffers still produce a single fragment with chunk len 0 + // - the receiver needs to know the message exists at all to + // deliver it via the inbox. + var fragmentCount = buffer.Length == 0 + ? 1 + : (buffer.Length + maxChunk - 1) / maxChunk; + if (fragmentCount > ushort.MaxValue) + { + throw new ArgumentException( + $"buffer fragments to {fragmentCount} pieces at mtu={mtu}; exceeds 65535 cap"); + } + + var output = new List(fragmentCount); + for (var seq = 0; seq < fragmentCount; seq++) + { + var offset = seq * maxChunk; + var chunkLen = Math.Min(maxChunk, buffer.Length - offset); + if (chunkLen < 0) chunkLen = 0; + + var fragment = new byte[HeaderLength + chunkLen]; + idBytes.CopyTo(fragment.AsSpan(0)); + BinaryPrimitives.WriteUInt16LittleEndian(fragment.AsSpan(IdLength, 2), (ushort)seq); + BinaryPrimitives.WriteUInt16LittleEndian(fragment.AsSpan(IdLength + 2, 2), (ushort)fragmentCount); + BinaryPrimitives.WriteUInt16LittleEndian(fragment.AsSpan(IdLength + 4, 2), (ushort)chunkLen); + if (chunkLen > 0) + { + buffer.AsSpan(offset, chunkLen).CopyTo(fragment.AsSpan(HeaderLength)); + } + output.Add(fragment); + } + return output; + } + + /// + /// Parse a single fragment without copying the chunk. + /// owns the lifetime of the chunk bytes. + /// + public static FragmentHeader ParseHeader(ReadOnlySpan fragment) + { + if (fragment.Length < HeaderLength) + { + throw new InvalidDataException( + $"fragment is shorter than {HeaderLength}-byte header (got {fragment.Length})"); + } + var id = Encoding.ASCII.GetString(fragment[..IdLength]); + var seq = BinaryPrimitives.ReadUInt16LittleEndian(fragment.Slice(IdLength, 2)); + var count = BinaryPrimitives.ReadUInt16LittleEndian(fragment.Slice(IdLength + 2, 2)); + var chunkLen = BinaryPrimitives.ReadUInt16LittleEndian(fragment.Slice(IdLength + 4, 2)); + if (HeaderLength + chunkLen > fragment.Length) + { + throw new InvalidDataException( + $"fragment claims chunk length {chunkLen} but only {fragment.Length - HeaderLength} chunk bytes available"); + } + return new FragmentHeader(id, seq, count, chunkLen); + } +} + +public readonly record struct FragmentHeader(string Id, ushort Seq, ushort Count, ushort ChunkLength); + +/// +/// Collects fragments and reports when a message is complete. Not +/// thread-safe; the UDP listener holds one of these per receive loop +/// and processes fragments serially, so external locking isn't +/// required. +/// +/// Stale-reassembly cleanup is the listener's job: call +/// periodically with a deadline so a +/// message that lost a fragment doesn't pin memory forever. +/// +public sealed class Reassembler +{ + private sealed class Pending + { + public ushort Count; + public byte[]?[] Chunks = []; + public int Received; + public DateTime FirstSeen; + } + + private readonly Dictionary _byId = new(StringComparer.Ordinal); + + /// + /// Accept a fragment. If this completes a message, returns the + /// reassembled buffer; otherwise returns null and the fragment is + /// retained until the rest arrive. + /// + public byte[]? Accept(byte[] fragment, DateTime now) + { + var header = Packetiser.ParseHeader(fragment); + if (header.Count == 0) return null; + if (header.Seq >= header.Count) return null; + + if (!_byId.TryGetValue(header.Id, out var pending)) + { + pending = new Pending + { + Count = header.Count, + Chunks = new byte[]?[header.Count], + FirstSeen = now, + }; + _byId[header.Id] = pending; + } + else if (pending.Count != header.Count) + { + // Conflicting fragment count for the same id - likely a sender + // restart with the same id mid-stream. Drop the old state. + pending = new Pending + { + Count = header.Count, + Chunks = new byte[]?[header.Count], + FirstSeen = now, + }; + _byId[header.Id] = pending; + } + + if (pending.Chunks[header.Seq] != null) return null; // duplicate + + var chunk = new byte[header.ChunkLength]; + if (header.ChunkLength > 0) + { + fragment.AsSpan(Packetiser.HeaderLength, header.ChunkLength).CopyTo(chunk); + } + pending.Chunks[header.Seq] = chunk; + pending.Received++; + + if (pending.Received < pending.Count) return null; + + // Complete - concatenate. + _byId.Remove(header.Id); + var totalLen = pending.Chunks.Sum(c => c?.Length ?? 0); + var assembled = new byte[totalLen]; + var off = 0; + foreach (var c in pending.Chunks) + { + if (c is null || c.Length == 0) continue; + c.CopyTo(assembled.AsSpan(off)); + off += c.Length; + } + return assembled; + } + + /// Drop reassembly state for any message whose first + /// fragment is older than . Returns the + /// number of incomplete reassemblies discarded. + public int DropOlderThan(DateTime cutoff) + { + var stale = _byId.Where(kv => kv.Value.FirstSeen < cutoff).Select(kv => kv.Key).ToList(); + foreach (var id in stale) _byId.Remove(id); + return stale.Count; + } +} diff --git a/poc/MeshCorePoc/vendored/VENDORED.md b/poc/MeshCorePoc/vendored/VENDORED.md new file mode 100644 index 0000000..c0fa888 --- /dev/null +++ b/poc/MeshCorePoc/vendored/VENDORED.md @@ -0,0 +1,14 @@ +# Vendored from dapps.client + +These three files are **copied verbatim** from `src/dapps/dapps.client/Backhaul/` +at repo commit `58a5db3` so the PoC can prove the *actual* DAPPS wire format +round-trips over a real MeshCore private channel, without taking a project +reference on `dapps.client` (which would pull in central package management, +RhpV2.Client, logging, etc. and stop this being a standalone PoC). + +- `BackhaulMessage.cs` ← dapps.client/Backhaul/BackhaulMessage.cs +- `BackhaulMessageCodec.cs` ← dapps.client/Backhaul/Datagram/BackhaulMessageCodec.cs (codec v7) +- `Packetiser.cs` ← dapps.client/Backhaul/Datagram/Packetiser.cs (13-byte fragment header) + +**When this graduates into DAPPS proper**, delete this folder and reference +`dapps.client` directly — the MeshCore bearer is meant to reuse these, not fork them. diff --git a/src/dapps/Directory.Packages.props b/src/dapps/Directory.Packages.props index 262aa2e..a7409dd 100644 --- a/src/dapps/Directory.Packages.props +++ b/src/dapps/Directory.Packages.props @@ -59,6 +59,13 @@ net10.0; we pull the net8.0 build. --> + + + + + + Exe + net8.0 + enable + enable + dapps-meshcore-soak + true + + + + + + + + + + + diff --git a/src/dapps/dapps.meshcore/DappsCompression.cs b/src/dapps/dapps.meshcore/DappsCompression.cs new file mode 100644 index 0000000..356961e --- /dev/null +++ b/src/dapps/dapps.meshcore/DappsCompression.cs @@ -0,0 +1,83 @@ +using System.Text; +using dapps.client.Backhaul; +using dapps.client.Backhaul.Datagram; + +namespace dapps.meshcore; + +/// +/// Optional payload compression for the MeshCore bearer. The win on this slow, +/// shared, flooded channel is a SHARED DICTIONARY trained on representative DAPPS +/// traffic - generic compressors barely dent a ~100-byte message, a dictionary +/// collapses most messages into a single LoRa packet. +/// +/// The dictionary is built deterministically from a fixed sample corpus so every +/// node running the same build derives byte-identical dictionary bytes. Version 1. +/// A production build should ship a versioned dictionary blob negotiated by id +/// (#154 / dictionary-versioning) rather than rebuilding from code. +/// +public static class DappsCompression +{ + public enum Mode { None, ZstdDict } + + /// Dictionary version - both ends must agree. Carried implicitly by + /// the build today; negotiate explicitly later. + public const byte DictionaryVersion = 1; + + private static readonly byte[] Dict = BuildDict(); + + public static byte[] Compress(Mode mode, byte[] data) + { + if (mode == Mode.None) return data; + using var c = new ZstdSharp.Compressor(19); + c.LoadDictionary(Dict); + return c.Wrap(data).ToArray(); + } + + public static byte[] Decompress(Mode mode, byte[] data) + { + if (mode == Mode.None) return data; + using var d = new ZstdSharp.Decompressor(); + d.LoadDictionary(Dict); + return d.Unwrap(data).ToArray(); + } + + private static byte[] BuildDict() + { + using var ms = new MemoryStream(); + foreach (var m in SampleCorpus()) + { + var enc = BackhaulMessageCodec.Encode(m); + if (ms.Length + enc.Length > 16 * 1024) break; + ms.Write(enc, 0, enc.Length); + } + return ms.ToArray(); + } + + /// Fixed, representative DAPPS messages used to seed the zstd content + /// dictionary. Deterministic across builds. + private static IEnumerable SampleCorpus() + { + string[] calls = ["M0LTE-7", "GB7RDG-1", "EI5IYB-1", "G4BFG-9", "2E0XYZ", "MM0ABC-2", "GB7XYZ-1", "M7DEF-5"]; + string[] texts = + [ + "73", "QSL 73 GL", "GM all de M0LTE", "ack", "ok rx 5/9", "ACK 4f2", "NAK 0c3 retry", + "GM all de M0LTE, nice signal into Reading this morning, 599 here", + "Anyone around for a sked on the DAPPS net at 1900 local? 73", + "Rig is FT-991A into a 40m dipole at 8m, running 25W on this one", + "!5152.34N/00007.12W>DAPPS node QRV", "!5340.10N/00220.55W>portable /P on hilltop", + "{\"t\":21.4,\"h\":62,\"p\":1013}", "{\"t\":-3.1,\"h\":88,\"p\":998,\"w\":12.4}", "{\"batt\":3.92,\"sol\":0.41}", + "Hello from the DAPPS mailbox. This is a longer store-and-forward message that a user might send over the mesh. 73.", + ]; + var i = 0; + foreach (var t in texts) + { + var from = calls[i % calls.Length]; + var to = calls[(i + 3) % calls.Length]; + yield return new BackhaulMessage( + Id: i.ToString("x7"), Destination: to, Salt: 1000 + i, Ttl: 3600, + Payload: Encoding.UTF8.GetBytes(t), Originator: from, LinkSourceCallsign: from, + Headers: new Dictionary { ["app"] = "chat" }); + i++; + } + } +} diff --git a/src/dapps/dapps.meshcore/LoRaAirtime.cs b/src/dapps/dapps.meshcore/LoRaAirtime.cs new file mode 100644 index 0000000..0cab97b --- /dev/null +++ b/src/dapps/dapps.meshcore/LoRaAirtime.cs @@ -0,0 +1,28 @@ +namespace dapps.meshcore; + +/// +/// LoRa time-on-air estimate (Semtech formula). Used by the airtime governor +/// and stats. On-air bytes = the channel-data payload + MeshCore packet overhead. +/// +public static class LoRaAirtime +{ + /// MeshCore packet header + 1-byte channel hash + cipher block/MAC, + /// on top of our channel-data payload. Approximate; refine from LOG_RX_DATA. + public const int MeshCoreOnAirOverhead = 16; + + public static double Ms(int onAirPayloadBytes, int sf = 8, double bwHz = 62_500, int crDenom = 8, int preamble = 8) + { + double tSym = Math.Pow(2, sf) / bwHz * 1000.0; + double tPreamble = (preamble + 4.25) * tSym; + const int de = 0, ih = 0, crcOn = 1; // explicit header, CRC on, no low-rate-opt + int cr = crDenom - 4; + double num = 8 * onAirPayloadBytes - 4 * sf + 28 + 16 * crcOn - 20 * ih; + double den = 4 * (sf - 2 * de); + int symb = 8 + (int)Math.Max(Math.Ceiling(num / den) * (cr + 4), 0); + return tPreamble + symb * tSym; + } + + /// Airtime for a single channel-data frame of . + public static double FrameMs(int payloadBytes, RegionPreset region) => + Ms(payloadBytes + MeshCoreOnAirOverhead, region.Sf, region.BwKhz * 1000, region.Cr); +} diff --git a/src/dapps/dapps.meshcore/MeshCoreBearerOptions.cs b/src/dapps/dapps.meshcore/MeshCoreBearerOptions.cs new file mode 100644 index 0000000..5fd6b22 --- /dev/null +++ b/src/dapps/dapps.meshcore/MeshCoreBearerOptions.cs @@ -0,0 +1,35 @@ +using System.Security.Cryptography; +using System.Text; + +namespace dapps.meshcore; + +/// Configuration for the MeshCore bearer. In the dapps host these are +/// populated from SystemOptions / DAPPS_MESHCORE_* env vars. +public sealed class MeshCoreBearerOptions +{ + public bool Enabled { get; set; } + public string SerialPort { get; set; } = "/dev/ttyUSB0"; + public string Region { get; set; } = "uk-test"; + public byte TxPowerDbm { get; set; } = 8; + public byte ChannelIndex { get; set; } = 1; + public string ChannelName { get; set; } = "dapps"; + /// 16-byte channel PSK as hex (32 chars), or a passphrase to derive one. + public string ChannelPsk { get; set; } = "dapps-default-channel"; + public string NodeName { get; set; } = "DAPPS"; + public double AirtimeBudgetSecPerHour { get; set; } = TxBudget.DefaultSecondsPerHour; + public bool Compress { get; set; } = true; + public string AppName { get; set; } = "dapps"; + + public RegionPreset ResolveRegion() => + Regions.Find(Region) ?? throw new ArgumentException($"unknown MeshCore region '{Region}'"); + + /// The 16-byte channel secret: a 32-char hex string is used verbatim, + /// otherwise the value is treated as a passphrase and hashed to 16 bytes. + public byte[] ResolvePsk() + { + var v = ChannelPsk?.Trim() ?? ""; + if (v.Length == 32 && v.All(Uri.IsHexDigit)) + return Convert.FromHexString(v); + return SHA256.HashData(Encoding.UTF8.GetBytes(v))[..16]; + } +} diff --git a/src/dapps/dapps.meshcore/MeshCoreChannelTransport.cs b/src/dapps/dapps.meshcore/MeshCoreChannelTransport.cs new file mode 100644 index 0000000..3e5201f --- /dev/null +++ b/src/dapps/dapps.meshcore/MeshCoreChannelTransport.cs @@ -0,0 +1,83 @@ +using dapps.client.Backhaul; +using dapps.client.Backhaul.Datagram; + +namespace dapps.meshcore; + +/// +/// Carries a DAPPS over a MeshCore private channel +/// using the binary channel-data path. Reuses the real DAPPS codec + packetiser. +/// +/// send: Encode → (optional) compress → Packetiser.Split → per fragment prepend a +/// 1-byte header and emit as one channel-data datagram. +/// recv: strip header → Reassembler → (decompress) → Decode. +/// +/// Frame header byte: bit0 = compressed, bits1-7 = rolling nonce. The nonce is +/// essential - the binary path has no on-air timestamp, so byte-identical frames +/// share a packet hash and the mesh hasSeen table would drop the duplicate. +/// +public sealed class MeshCoreChannelTransport +{ + /// Fragment size incl. the 13-byte Packetiser header. The channel-data + /// payload = 1 (our header) + fragment, capped at the firmware's 165 B limit. + public const int Mtu = 160; + + private readonly Reassembler _reassembler = new(); + private readonly Dictionary _compressed = new(); + private byte _nonce; + + /// Encode a BackhaulMessage into one-or-more channel-data payloads. + public IReadOnlyList ToFrames(BackhaulMessage message, DappsCompression.Mode compress) + { + var encoded = BackhaulMessageCodec.Encode(message); + var body = DappsCompression.Compress(compress, encoded); + var fragments = Packetiser.Split(message.Id, body, Mtu); + bool comp = compress != DappsCompression.Mode.None; + var frames = new List(fragments.Count); + foreach (var f in fragments) + { + byte hdr = (byte)((_nonce << 1) | (comp ? 1 : 0)); + _nonce = (byte)((_nonce + 1) & 0x7F); + var frame = new byte[1 + f.Length]; + frame[0] = hdr; + f.CopyTo(frame, 1); + frames.Add(frame); + } + return frames; + } + + public enum Kind { FragmentPartial, BackhaulComplete, Bad } + + public readonly record struct Result(Kind Kind, BackhaulMessage? Message, FragmentHeader? Header); + + /// Feed one received channel-data payload. + public Result Ingest(byte[] dataPayload, DateTime now) + { + if (dataPayload.Length < 1 + Packetiser.HeaderLength) return new Result(Kind.Bad, null, null); + bool comp = (dataPayload[0] & 1) != 0; + var fragment = dataPayload[1..]; + + FragmentHeader header; + try { header = Packetiser.ParseHeader(fragment); } + catch (InvalidDataException) { return new Result(Kind.Bad, null, null); } + + _compressed[header.Id] = comp; + var assembled = _reassembler.Accept(fragment, now); + if (assembled is null) return new Result(Kind.FragmentPartial, null, header); + + var compressed = _compressed.TryGetValue(header.Id, out var c) && c; + _compressed.Remove(header.Id); + try + { + var body = compressed ? DappsCompression.Decompress(DappsCompression.Mode.ZstdDict, assembled) : assembled; + return new Result(Kind.BackhaulComplete, BackhaulMessageCodec.Decode(body), header); + } + catch (Exception) + { + return new Result(Kind.Bad, null, header); + } + } + + /// Drop reassembly state for messages whose first fragment is older + /// than . + public int DropStale(DateTime cutoff) => _reassembler.DropOlderThan(cutoff); +} diff --git a/src/dapps/dapps.meshcore/MeshCoreClient.cs b/src/dapps/dapps.meshcore/MeshCoreClient.cs new file mode 100644 index 0000000..05daa6a --- /dev/null +++ b/src/dapps/dapps.meshcore/MeshCoreClient.cs @@ -0,0 +1,308 @@ +using System.Buffers.Binary; +using System.IO.Ports; +using System.Text; +using System.Threading.Channels; + +namespace dapps.meshcore; + +/// +/// Client for the MeshCore "Companion" USB-serial protocol (firmware v1.16.x). +/// Framing: host→device [0x3C][len_lo][len_hi][payload], +/// device→host [0x3E][len_lo][len_hi][payload] (len = LE uint16, payload +/// only; first payload byte is the opcode). Device→host frames are synchronous +/// responses (code < 0x80) or async pushes (≥0x80). Inbound over-the-air +/// messages are queued: the device emits a 1-byte MSG_WAITING (0x83) tickle and +/// the host pulls each with SYNC_NEXT_MESSAGE (0x0A). 8N1 @ 115200; DTR/RTS held +/// low so opening the port does not reset the board. +/// +public sealed class MeshCoreClient : IAsyncDisposable +{ + // command codes (host → device) + public const byte CMD_APP_START = 0x01; + public const byte CMD_SEND_CHANNEL_TXT_MSG = 0x03; + public const byte CMD_SET_ADVERT_NAME = 0x08; + public const byte CMD_SYNC_NEXT_MESSAGE = 0x0A; + public const byte CMD_SET_RADIO_PARAMS = 0x0B; + public const byte CMD_SET_RADIO_TX_POWER = 0x0C; + public const byte CMD_GET_CHANNEL = 0x1F; + public const byte CMD_SET_CHANNEL = 0x20; + public const byte CMD_SEND_CHANNEL_DATA = 0x3E; + public const ushort DATA_TYPE_DEV = 0xFFFF; + + // response codes (device → host, synchronous) + public const byte RSP_OK = 0x00; + public const byte RSP_ERR = 0x01; + public const byte RSP_SELF_INFO = 0x05; + public const byte RSP_NO_MORE_MESSAGES = 0x0A; + public const byte RSP_CONTACT_MSG_RECV = 0x07; // legacy (no SNR) + public const byte RSP_CHANNEL_MSG_RECV = 0x08; // legacy (no SNR) - what v1.16 sends for channel text + public const byte RSP_CONTACT_MSG_RECV_V3 = 0x10; + public const byte RSP_CHANNEL_MSG_RECV_V3 = 0x11; + public const byte RSP_CHANNEL_INFO = 0x12; + public const byte RSP_CHANNEL_DATA_RECV = 0x1B; // binary channel datagram + + // push codes (device → host, async) + public const byte PUSH_SEND_CONFIRMED = 0x82; + public const byte PUSH_MSG_WAITING = 0x83; + + private const byte FrameToRadio = 0x3C; + private const byte FrameFromRadio = 0x3E; + + public static readonly bool Trace = Environment.GetEnvironmentVariable("MESHCORE_TRACE") == "1"; + + private readonly SerialPort _port; + private readonly Channel _responses = + Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = false, SingleWriter = true }); + private readonly SemaphoreSlim _exchange = new(1, 1); + private readonly CancellationTokenSource _cts = new(); + private Task? _readLoop; + + /// UTC time of the last successful request/response exchange. The + /// watchdog uses this to spot a hung radio. + public DateTime LastOkUtc { get; private set; } = DateTime.UtcNow; + + /// Raised when the device signals queued inbound messages (0x83). + public event Action? MessageWaiting; + + public MeshCoreClient(string portName, int baud = 115200) + { + _port = new SerialPort(portName, baud, Parity.None, 8, StopBits.One) + { + Handshake = Handshake.None, + DtrEnable = false, + RtsEnable = false, + ReadTimeout = 200, + WriteTimeout = 2000, + }; + } + + public void Open() + { + _port.Open(); + try { _port.DtrEnable = false; _port.RtsEnable = false; } catch { /* best effort */ } + _port.DiscardInBuffer(); + _readLoop = Task.Run(() => ReadLoopAsync(_cts.Token)); + } + + private async Task ReadLoopAsync(CancellationToken ct) + { + var stream = _port.BaseStream; + while (!ct.IsCancellationRequested) + { + try + { + int marker = await ReadByteAsync(stream, ct); + if (marker is not (FrameFromRadio or FrameToRadio)) continue; + int lo = await ReadByteAsync(stream, ct); + int hi = await ReadByteAsync(stream, ct); + if (lo < 0 || hi < 0) continue; + int len = lo | (hi << 8); + if (len is < 0 or > 4096) continue; + var payload = await ReadExactAsync(stream, len, ct); + if (payload is null || payload.Length == 0) continue; + + byte code = payload[0]; + if (Trace) + Console.Error.WriteLine($"<< {payload.Length}B code=0x{code:X2} {Convert.ToHexString(payload.AsSpan(0, Math.Min(payload.Length, 48)))}"); + if (code >= 0x80) HandlePush(code, payload); + else _responses.Writer.TryWrite(payload); + } + catch (OperationCanceledException) { break; } + catch (Exception) { /* keep the loop alive across transient serial hiccups */ } + } + } + + private void HandlePush(byte code, byte[] payload) + { + if (code == PUSH_MSG_WAITING) MessageWaiting?.Invoke(); + } + + /// Send a command frame and await the next synchronous response whose + /// opcode is in (skipping unrelated frames). + public async Task ExchangeAsync(byte[] payload, byte[] expectedCodes, TimeSpan timeout, CancellationToken ct) + { + await _exchange.WaitAsync(ct); + try + { + while (_responses.Reader.TryRead(out _)) { } + WriteFrame(payload); + + using var to = CancellationTokenSource.CreateLinkedTokenSource(ct); + to.CancelAfter(timeout); + while (true) + { + byte[] resp; + try { resp = await _responses.Reader.ReadAsync(to.Token); } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException($"no response (codes {Hex(expectedCodes)}) within {timeout.TotalSeconds:0.#}s"); + } + if (Array.IndexOf(expectedCodes, resp[0]) >= 0) + { + LastOkUtc = DateTime.UtcNow; + return resp; + } + if (resp[0] == RSP_ERR) + throw new MeshCoreException($"device returned ERR code {(resp.Length > 1 ? resp[1] : 0)}"); + } + } + finally { _exchange.Release(); } + } + + public void WriteFrame(ReadOnlySpan payload) + { + var buf = new byte[3 + payload.Length]; + buf[0] = FrameToRadio; + buf[1] = (byte)(payload.Length & 0xFF); + buf[2] = (byte)((payload.Length >> 8) & 0xFF); + payload.CopyTo(buf.AsSpan(3)); + if (Trace) + Console.Error.WriteLine($">> {payload.Length}B code=0x{payload[0]:X2} {Convert.ToHexString(buf.AsSpan(0, Math.Min(buf.Length, 48)))}"); + _port.BaseStream.Write(buf, 0, buf.Length); + _port.BaseStream.Flush(); + } + + // ---------- device control ---------- + + public async Task AppStartAsync(string appName, CancellationToken ct) + { + var p = new List { CMD_APP_START, 0x03, 0, 0, 0, 0, 0, 0 }; + p.AddRange(Encoding.ASCII.GetBytes(appName)); + // Retry: opening the port can reset the board and race the first APP_START. + Exception? last = null; + for (var attempt = 0; attempt < 3; attempt++) + { + try + { + var resp = await ExchangeAsync(p.ToArray(), [RSP_SELF_INFO], TimeSpan.FromSeconds(3), ct); + return SelfInfo.Parse(resp); + } + catch (TimeoutException ex) { last = ex; await Task.Delay(800, ct); } + } + throw last ?? new TimeoutException("APP_START failed"); + } + + public async Task SetNameAsync(string name, CancellationToken ct) + { + var p = new byte[1 + Encoding.UTF8.GetByteCount(name)]; + p[0] = CMD_SET_ADVERT_NAME; + Encoding.UTF8.GetBytes(name).CopyTo(p, 1); + await ExchangeAsync(p, [RSP_OK], TimeSpan.FromSeconds(3), ct); + } + + public async Task SetRadioParamsAsync(double freqMhz, double bwKhz, byte sf, byte cr, CancellationToken ct) + { + var p = new byte[11]; + p[0] = CMD_SET_RADIO_PARAMS; + BinaryPrimitives.WriteUInt32LittleEndian(p.AsSpan(1, 4), (uint)Math.Round(freqMhz * 1000)); + BinaryPrimitives.WriteUInt32LittleEndian(p.AsSpan(5, 4), (uint)Math.Round(bwKhz * 1000)); + p[9] = sf; + p[10] = cr; + await ExchangeAsync(p, [RSP_OK], TimeSpan.FromSeconds(3), ct); + } + + public async Task SetTxPowerAsync(byte dbm, CancellationToken ct) => + await ExchangeAsync([CMD_SET_RADIO_TX_POWER, dbm], [RSP_OK], TimeSpan.FromSeconds(3), ct); + + public async Task SetChannelAsync(byte index, string name, byte[] secret16, CancellationToken ct) + { + if (secret16.Length != 16) throw new ArgumentException("secret must be 16 bytes", nameof(secret16)); + var p = new byte[1 + 1 + 32 + 16]; + p[0] = CMD_SET_CHANNEL; + p[1] = index; + var nameBytes = Encoding.UTF8.GetBytes(name); + Array.Copy(nameBytes, 0, p, 2, Math.Min(nameBytes.Length, 32)); + Array.Copy(secret16, 0, p, 34, 16); + await ExchangeAsync(p, [RSP_OK], TimeSpan.FromSeconds(3), ct); + } + + public async Task GetChannelAsync(byte index, CancellationToken ct) + { + var resp = await ExchangeAsync([CMD_GET_CHANNEL, index], [RSP_CHANNEL_INFO], TimeSpan.FromSeconds(3), ct); + return ChannelInfo.Parse(resp); + } + + /// Send a binary datagram to a channel (flood). Bytes arrive verbatim + /// at the peer (no name prefix). Payload ≤ 165. + public async Task SendChannelDataAsync(byte channelIndex, byte[] payload, ushort dataType, CancellationToken ct) + { + if (payload.Length > 165) throw new ArgumentException("channel-data payload must be <= 165 bytes", nameof(payload)); + var p = new byte[5 + payload.Length]; + p[0] = CMD_SEND_CHANNEL_DATA; + p[1] = channelIndex; + p[2] = 0xFF; // path_len: flood + p[3] = (byte)(dataType & 0xFF); + p[4] = (byte)(dataType >> 8); + payload.CopyTo(p, 5); + await ExchangeAsync(p, [RSP_OK], TimeSpan.FromSeconds(5), ct); + } + + public sealed record InboundBatch(List Texts, List Data); + + /// Drain all queued inbound messages (text + binary). + public async Task DrainAsync(CancellationToken ct) + { + var texts = new List(); + var data = new List(); + while (true) + { + byte[] resp; + try + { + resp = await ExchangeAsync( + [CMD_SYNC_NEXT_MESSAGE], + [RSP_CHANNEL_MSG_RECV, RSP_CHANNEL_MSG_RECV_V3, RSP_CHANNEL_DATA_RECV, + RSP_CONTACT_MSG_RECV, RSP_CONTACT_MSG_RECV_V3, RSP_NO_MORE_MESSAGES], + TimeSpan.FromMilliseconds(1500), ct); + } + catch (TimeoutException) { break; } + switch (resp[0]) + { + case RSP_NO_MORE_MESSAGES: return new InboundBatch(texts, data); + case RSP_CHANNEL_MSG_RECV: texts.Add(ChannelMessage.ParseLegacy(resp)); break; + case RSP_CHANNEL_MSG_RECV_V3: texts.Add(ChannelMessage.ParseV3(resp)); break; + case RSP_CHANNEL_DATA_RECV: data.Add(ChannelData.ParseRecv(resp)); break; + } + } + return new InboundBatch(texts, data); + } + + private static async Task ReadByteAsync(Stream s, CancellationToken ct) + { + var b = new byte[1]; + try { return await s.ReadAsync(b.AsMemory(0, 1), ct) == 1 ? b[0] : -1; } + catch (TimeoutException) { return -1; } + catch (IOException) { return -1; } + } + + private static async Task ReadExactAsync(Stream s, int count, CancellationToken ct) + { + var buf = new byte[count]; + var got = 0; + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); + while (got < count) + { + if (DateTime.UtcNow > deadline) return null; + int n; + try { n = await s.ReadAsync(buf.AsMemory(got, count - got), ct); } + catch (TimeoutException) { continue; } + catch (IOException) { return null; } + if (n == 0) continue; + got += n; + } + return buf; + } + + private static string Hex(byte[] b) => string.Join(",", b.Select(x => "0x" + x.ToString("X2"))); + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + if (_readLoop is not null) { try { await _readLoop; } catch { } } + try { if (_port.IsOpen) _port.Close(); } catch { } + _port.Dispose(); + _cts.Dispose(); + } +} + +public sealed class MeshCoreException(string message) : Exception(message); diff --git a/src/dapps/dapps.meshcore/MeshCoreCompanionBackhaul.cs b/src/dapps/dapps.meshcore/MeshCoreCompanionBackhaul.cs new file mode 100644 index 0000000..42d8d67 --- /dev/null +++ b/src/dapps/dapps.meshcore/MeshCoreCompanionBackhaul.cs @@ -0,0 +1,104 @@ +using dapps.client.Backhaul; +using dapps.client.Tx; +using Microsoft.Extensions.Logging; + +namespace dapps.meshcore; + +/// +/// over a MeshCore private channel (Phase H1, #154). +/// Carries a as binary channel-data datagrams: +/// stamps the link source, compresses, fragments, and broadcasts — gated by the +/// TX kill-switch and the airtime governor. +/// +/// A private channel is one shared broadcast medium, so a message addressed to a +/// specific node is sent ONCE to the channel and the addressee self-selects (the +/// inbox's IsLocal gate). To stay a good citizen when the router offers the same +/// message for several MeshCore neighbours, identical message ids are coalesced +/// within a short window so we don't re-broadcast (#155). +/// +public sealed class MeshCoreCompanionBackhaul : IDappsBackhaul +{ + private static readonly TimeSpan CoalesceWindow = TimeSpan.FromSeconds(30); + private static readonly TimeSpan FramePace = TimeSpan.FromMilliseconds(1000); + + private readonly MeshCoreLink _link; + private readonly MeshCoreBearerOptions _opts; + private readonly TxBudget _budget; + private readonly IDappsTxGate _txGate; + private readonly RegionPreset _region; + private readonly ILogger _log; + private readonly MeshCoreChannelTransport _tx = new(); + private readonly Dictionary _recent = new(); + private readonly object _recentLock = new(); + + public MeshCoreCompanionBackhaul( + MeshCoreLink link, MeshCoreBearerOptions opts, TxBudget budget, ILogger log, IDappsTxGate? txGate = null) + { + _link = link; + _opts = opts; + _budget = budget; + _log = log; + _txGate = txGate ?? AlwaysOpenTxGate.Instance; + _region = opts.ResolveRegion(); + } + + /// Handle routes that carry a MeshCore channel hint (positive + /// bearer selection — no reliance on AGW's "no UDP endpoint" catch-all). + public bool CanHandle(BackhaulRoute route) => !string.IsNullOrEmpty(route.MeshCoreChannel); + + public async Task SendAsync( + BackhaulMessage message, BackhaulRoute route, string localCallsign, CancellationToken ct) + { + if (!_txGate.TxAllowed) + return BackhaulSendResult.Fail($"tx-stopped: {_txGate.BlockReason ?? "(no reason)"}"); + + // Broadcast coalescing: a channel send reaches every member, so the same + // message offered for multiple neighbours need only go on air once. + if (AlreadyBroadcast(message.Id)) + return BackhaulSendResult.Ok(); + + var stamped = message with { LinkSourceCallsign = localCallsign }; + var mode = _opts.Compress ? DappsCompression.Mode.ZstdDict : DappsCompression.Mode.None; + var frames = _tx.ToFrames(stamped, mode); + + for (var i = 0; i < frames.Count; i++) + { + var airMs = LoRaAirtime.FrameMs(frames[i].Length, _region); + if (!_budget.TryReserve(airMs, DateTime.UtcNow, out var reason)) + return BackhaulSendResult.Fail(reason); + + bool sent; + try { sent = await _link.SendDataAsync(frames[i], ct); } + catch (Exception ex) { return BackhaulSendResult.Fail($"meshcore send failed: {ex.Message}"); } + if (!sent) return BackhaulSendResult.Fail($"meshcore link not ready ({_link.State})"); + + if (i < frames.Count - 1) { try { await Task.Delay(FramePace, ct); } catch { } } + } + + MarkBroadcast(message.Id); + _log.LogInformation("MeshCore: broadcast {0} ({1} frame(s)) dst={2} from={3} duty={4:0.00}%", + message.Id, frames.Count, message.Destination, localCallsign, _budget.DutyPercent(DateTime.UtcNow)); + return BackhaulSendResult.Ok(); + } + + private bool AlreadyBroadcast(string id) + { + lock (_recentLock) + { + Prune(); + return _recent.ContainsKey(id); + } + } + + private void MarkBroadcast(string id) + { + lock (_recentLock) { _recent[id] = DateTime.UtcNow; } + } + + private void Prune() + { + var cutoff = DateTime.UtcNow - CoalesceWindow; + foreach (var k in _recent.Where(kv => kv.Value < cutoff).Select(kv => kv.Key).ToList()) + _recent.Remove(k); + } +} diff --git a/src/dapps/dapps.meshcore/MeshCoreFrames.cs b/src/dapps/dapps.meshcore/MeshCoreFrames.cs new file mode 100644 index 0000000..3247a7c --- /dev/null +++ b/src/dapps/dapps.meshcore/MeshCoreFrames.cs @@ -0,0 +1,81 @@ +using System.Buffers.Binary; +using System.Text; + +namespace dapps.meshcore; + +/// Parsed SELF_INFO (0x05) reply to APP_START. +public sealed record SelfInfo( + byte AdvType, byte TxPower, byte MaxTxPower, byte[] PublicKey, + double FreqMhz, double BwKhz, byte Sf, byte Cr, string Name) +{ + public string PublicKeyHex => Convert.ToHexString(PublicKey).ToLowerInvariant(); + + public static SelfInfo Parse(byte[] p) + { + var pub = p[4..36]; + double freq = BinaryPrimitives.ReadUInt32LittleEndian(p.AsSpan(48, 4)) / 1000.0; + double bw = BinaryPrimitives.ReadUInt32LittleEndian(p.AsSpan(52, 4)) / 1000.0; + byte sf = p[56], cr = p[57]; + string name = p.Length > 58 ? Encoding.UTF8.GetString(p, 58, p.Length - 58).TrimEnd('\0') : ""; + return new SelfInfo(p[1], p[2], p[3], pub, freq, bw, sf, cr, name); + } +} + +/// Parsed CHANNEL_INFO (0x12) reply to GET_CHANNEL. +public sealed record ChannelInfo(byte Index, string Name, byte[] Secret) +{ + public string SecretHex => Convert.ToHexString(Secret).ToLowerInvariant(); + + public static ChannelInfo Parse(byte[] p) + { + byte idx = p[1]; + // The firmware returns a null-terminated name in a 32-byte field whose + // tail is uninitialised; trim at the first null. + var nameField = p.AsSpan(2, 32); + var nul = nameField.IndexOf((byte)0); + string name = Encoding.UTF8.GetString(nul >= 0 ? nameField[..nul] : nameField); + return new ChannelInfo(idx, name, p[34..50]); + } +} + +/// Parsed inbound channel TEXT message (legacy 0x08 or V3 0x11). +public sealed record ChannelMessage( + sbyte Snr, byte ChannelIndex, byte PathLen, byte TxtType, uint Timestamp, string Text) +{ + public double SnrDb => Snr / 4.0; + public bool ReceivedDirect => PathLen == 0xFF; + + public static ChannelMessage ParseV3(byte[] p) + { + sbyte snr = unchecked((sbyte)p[1]); + byte ch = p[4], pathLen = p[5], txtType = p[6]; + uint ts = BinaryPrimitives.ReadUInt32LittleEndian(p.AsSpan(7, 4)); + string text = p.Length > 11 ? Encoding.UTF8.GetString(p, 11, p.Length - 11) : ""; + return new ChannelMessage(snr, ch, pathLen, txtType, ts, text); + } + + public static ChannelMessage ParseLegacy(byte[] p) + { + byte ch = p[1], pathLen = p[2], txtType = p[3]; + uint ts = BinaryPrimitives.ReadUInt32LittleEndian(p.AsSpan(4, 4)); + string text = p.Length > 8 ? Encoding.UTF8.GetString(p, 8, p.Length - 8) : ""; + return new ChannelMessage(0, ch, pathLen, txtType, ts, text); + } +} + +/// Parsed CHANNEL_DATA_RECV (0x1B) inbound binary channel datagram. +public sealed record ChannelData(sbyte Snr, byte ChannelIndex, byte PathLen, ushort DataType, byte[] Payload) +{ + public double SnrDb => Snr / 4.0; + public bool ReceivedDirect => PathLen == 0xFF; + + public static ChannelData ParseRecv(byte[] p) + { + sbyte snr = unchecked((sbyte)p[1]); + byte ch = p[4], pathLen = p[5]; + ushort dataType = BinaryPrimitives.ReadUInt16LittleEndian(p.AsSpan(6, 2)); + byte dataLen = p[8]; + var payload = p.Length >= 9 + dataLen ? p[9..(9 + dataLen)] : p[9..]; + return new ChannelData(snr, ch, pathLen, dataType, payload); + } +} diff --git a/src/dapps/dapps.meshcore/MeshCoreInbound.cs b/src/dapps/dapps.meshcore/MeshCoreInbound.cs new file mode 100644 index 0000000..833b718 --- /dev/null +++ b/src/dapps/dapps.meshcore/MeshCoreInbound.cs @@ -0,0 +1,80 @@ +using dapps.client.Backhaul; +using Microsoft.Extensions.Logging; + +namespace dapps.meshcore; + +/// +/// Inbound side of the MeshCore bearer: drains binary channel-data datagrams from +/// the , reassembles + decodes them into +/// s, and hands each to . +/// +/// The channel is anonymous, so the sender is taken from the in-band +/// LinkSourceCallsign (stamped by the sending bearer), falling back to a +/// sentinel — the same pattern as the UDP datagram listener. +/// +public sealed class MeshCoreInbound +{ + public const string UnknownSourceCallsign = "MESHCORE"; + private static readonly TimeSpan ReassemblyTimeout = TimeSpan.FromMinutes(2); + private static readonly TimeSpan SweepInterval = TimeSpan.FromMinutes(1); + + private readonly MeshCoreLink _link; + private readonly IBackhaulInbox _inbox; + private readonly ILogger _log; + private readonly MeshCoreChannelTransport _rx = new(); + private readonly SemaphoreSlim _wake = new(0); + + /// Count of fully-decoded BackhaulMessages delivered (observability). + public long Delivered { get; private set; } + + public MeshCoreInbound(MeshCoreLink link, IBackhaulInbox inbox, ILogger log) + { + _link = link; + _inbox = inbox; + _log = log; + _link.MessageWaiting += () => { try { _wake.Release(); } catch { } }; + } + + public async Task RunAsync(CancellationToken ct) + { + var nextSweep = DateTime.UtcNow + SweepInterval; + while (!ct.IsCancellationRequested) + { + // Wake on the MSG_WAITING push, or poll every 800ms as a safety net. + await Task.WhenAny(_wake.WaitAsync(ct), Task.Delay(800, ct)); + if (ct.IsCancellationRequested) break; + + var batch = await _link.DrainAsync(ct); + if (batch is not null) + { + foreach (var d in batch.Data) + { + var r = _rx.Ingest(d.Payload, DateTime.UtcNow); + if (r.Kind != MeshCoreChannelTransport.Kind.BackhaulComplete) continue; + + var msg = r.Message!; + var source = !string.IsNullOrEmpty(msg.LinkSourceCallsign) + ? msg.LinkSourceCallsign! + : UnknownSourceCallsign; + try + { + await _inbox.DeliverAsync(msg, source, ct); + Delivered++; + _log.LogInformation("MeshCore: delivered {0} from {1} (dst={2}, snr={3:0.0}dB)", + msg.Id, source, msg.Destination, d.SnrDb); + } + catch (Exception ex) + { + _log.LogError(ex, "MeshCore inbox delivery failed for {0}", msg.Id); + } + } + } + + if (DateTime.UtcNow >= nextSweep) + { + _rx.DropStale(DateTime.UtcNow - ReassemblyTimeout); + nextSweep = DateTime.UtcNow + SweepInterval; + } + } + } +} diff --git a/src/dapps/dapps.meshcore/MeshCoreLink.cs b/src/dapps/dapps.meshcore/MeshCoreLink.cs new file mode 100644 index 0000000..20f4dc6 --- /dev/null +++ b/src/dapps/dapps.meshcore/MeshCoreLink.cs @@ -0,0 +1,173 @@ +using System.IO.Ports; +using Microsoft.Extensions.Logging; + +namespace dapps.meshcore; + +/// +/// Owns the serial and keeps it alive: opens and +/// configures the radio (region params, TX power, name, channel), then runs a +/// watchdog that detects a hung/mute companion and recovers it by hard-resetting +/// the ESP32 over CP2102 DTR/RTS, re-opening, and re-applying config (#160). +/// +/// Outbound () and inbound () +/// callers see the link's current state; during a reset they get a soft failure +/// (send returns false, drain returns null) and retry once the link is back. +/// +public sealed class MeshCoreLink : IAsyncDisposable +{ + public enum LinkState { Down, Healthy, Resetting, Failed } + + private static readonly TimeSpan WatchdogInterval = TimeSpan.FromSeconds(10); + private static readonly TimeSpan IdleProbeAfter = TimeSpan.FromSeconds(25); + private static readonly TimeSpan FailedRetryBackoff = TimeSpan.FromSeconds(30); + + private readonly MeshCoreBearerOptions _opts; + private readonly RegionPreset _region; + private readonly byte[] _psk; + private readonly ILogger _log; + private readonly SemaphoreSlim _gate = new(1, 1); + + private MeshCoreClient? _client; + private Task? _watchdog; + private CancellationTokenSource? _cts; + private DateTime _nextFailedRetry = DateTime.MinValue; + + public LinkState State { get; private set; } = LinkState.Down; + public int ResetCount { get; private set; } + public SelfInfo? Self { get; private set; } + public event Action? MessageWaiting; + + public MeshCoreLink(MeshCoreBearerOptions opts, ILogger log) + { + _opts = opts; + _log = log; + _region = opts.ResolveRegion(); + _psk = opts.ResolvePsk(); + } + + public async Task StartAsync(CancellationToken ct) + { + _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + await OpenAndConfigureAsync(_cts.Token); + _watchdog = Task.Run(() => WatchdogLoopAsync(_cts.Token)); + } + + private async Task OpenAndConfigureAsync(CancellationToken ct) + { + var client = new MeshCoreClient(_opts.SerialPort); + client.MessageWaiting += () => MessageWaiting?.Invoke(); + client.Open(); + await client.AppStartAsync(_opts.AppName, ct); + await client.SetRadioParamsAsync(_region.FreqMhz, _region.BwKhz, _region.Sf, _region.Cr, ct); + await client.SetTxPowerAsync(Math.Min(_opts.TxPowerDbm, _region.MaxPowerDbm), ct); + await client.SetNameAsync(_opts.NodeName, ct); + await client.SetChannelAsync(_opts.ChannelIndex, _opts.ChannelName, _psk, ct); + var self = await client.AppStartAsync(_opts.AppName, ct); + + _client = client; + Self = self; + State = LinkState.Healthy; + _log.LogInformation( + "MeshCore link up: {0} {1:0.000}MHz/{2:0.#}kHz/SF{3}/CR{4} ch[{5}]='{6}' node='{7}' txp={8}dBm", + self.PublicKeyHex[..12], self.FreqMhz, self.BwKhz, self.Sf, self.Cr, + _opts.ChannelIndex, _opts.ChannelName, _opts.NodeName, self.TxPower); + } + + private async Task WatchdogLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try { await Task.Delay(WatchdogInterval, ct); } + catch (OperationCanceledException) { break; } + + if (State == LinkState.Healthy) + { + var client = _client; + if (client is null) continue; + if (DateTime.UtcNow - client.LastOkUtc < IdleProbeAfter) continue; // traffic flowing + try { await client.AppStartAsync(_opts.AppName, ct); } // idle liveness probe + catch (Exception ex) + { + _log.LogWarning("MeshCore liveness probe failed: {0}; recovering", ex.Message); + await RecoverAsync(ct); + } + } + else if (State is LinkState.Failed or LinkState.Down && DateTime.UtcNow >= _nextFailedRetry) + { + if (!await RecoverAsync(ct)) _nextFailedRetry = DateTime.UtcNow + FailedRetryBackoff; + } + } + } + + /// One reset + reconfigure attempt. Returns whether the link is back. + public async Task RecoverAsync(CancellationToken ct) + { + await _gate.WaitAsync(ct); + try + { + State = LinkState.Resetting; + var old = _client; + _client = null; + if (old is not null) { try { await old.DisposeAsync(); } catch { } } + try { await Task.Delay(300, ct); } catch { } // let the OS release the port + + try + { + HardReset(_opts.SerialPort, _log); + await Task.Delay(1500, ct); // boot + await OpenAndConfigureAsync(ct); + ResetCount++; + _log.LogInformation("MeshCore link recovered (reset #{0})", ResetCount); + return true; + } + catch (Exception ex) + { + _log.LogWarning("MeshCore recovery failed: {0}", ex.Message); + if (_client is not null) { try { await _client.DisposeAsync(); } catch { } _client = null; } + State = LinkState.Failed; + return false; + } + } + finally { _gate.Release(); } + } + + /// Hard-reset the ESP32 over CP2102 DTR/RTS (no buttons): pulse EN via + /// RTS while IO0 (DTR) stays high → boots firmware (not the ROM bootloader). + public static void HardReset(string portName, ILogger log) + { + using var p = new SerialPort(portName, 115200); + p.Open(); + p.DtrEnable = false; // IO0 high → run firmware + p.RtsEnable = true; // EN low → reset + Thread.Sleep(120); + p.RtsEnable = false; // EN high → boot + p.Close(); + log.LogInformation("MeshCore: hard-reset {0} via DTR/RTS", portName); + } + + /// Send one channel-data datagram. False if the link is mid-recovery. + public async Task SendDataAsync(byte[] payload, CancellationToken ct) + { + var client = _client; + if (client is null || State != LinkState.Healthy) return false; + await client.SendChannelDataAsync(_opts.ChannelIndex, payload, MeshCoreClient.DATA_TYPE_DEV, ct); + return true; + } + + /// Drain queued inbound messages, or null if the link is unavailable. + public async Task DrainAsync(CancellationToken ct) + { + var client = _client; + if (client is null) return null; + try { return await client.DrainAsync(ct); } + catch (Exception ex) { _log.LogDebug("MeshCore drain error: {0}", ex.Message); return null; } + } + + public async ValueTask DisposeAsync() + { + _cts?.Cancel(); + if (_watchdog is not null) { try { await _watchdog; } catch { } } + if (_client is not null) { try { await _client.DisposeAsync(); } catch { } } + _cts?.Dispose(); + } +} diff --git a/src/dapps/dapps.meshcore/README.md b/src/dapps/dapps.meshcore/README.md new file mode 100644 index 0000000..7c67c05 --- /dev/null +++ b/src/dapps/dapps.meshcore/README.md @@ -0,0 +1,65 @@ +# dapps.meshcore — MeshCore Companion bearer (Phase H1, #154) + +Carries DAPPS `BackhaulMessage`s over a MeshCore radio's **private channel**, integrated behind +the standard DAPPS bearer seam (`IDappsBackhaul` / `IBackhaulInbox`). Proven end-to-end on two +Heltec V3s; see the standalone PoC + characterisation under `poc/MeshCorePoc/` for the wire-shape +evidence this is built on. + +## What it does + +- **Binary channel-data transport** (`MeshCoreChannelTransport`) — encodes a `BackhaulMessage` + with the real `dapps.client` codec + packetiser, optionally compresses it, fragments to the + ~165 B LoRa payload, and sends each fragment as a `SEND_CHANNEL_DATA` (0x3E) datagram. Each + frame carries a 1-byte rolling nonce so the mesh's dedup table doesn't drop identical frames. +- **Compression** (`DappsCompression`) — zstd with a shared dictionary trained on representative + DAPPS traffic; collapses most messages to a single LoRa packet (~3× goodput). Versioned (`v1`); + per-message compressed flag on the wire. +- **Airtime governor** (`TxBudget`) — a hard, self-enforced trailing-hour airtime budget (default + 30 s/hr ≈ 0.83 % duty). Sends over budget are refused (back-pressure), so DAPPS can't burst the + shared channel. The good-citizen control for riding a shared preset. +- **Broadcast semantics** — a private channel is one shared medium, so a message is broadcast once + and the addressee self-selects (the inbox `IsLocal` gate). Identical message ids offered for + multiple neighbours are coalesced within a window so we don't re-broadcast. +- **Watchdog + recovery** (`MeshCoreLink`, #160) — opens and configures the radio, then detects a + hung/mute companion (idle liveness probe) and recovers it by hard-resetting the ESP32 over + CP2102 DTR/RTS, re-opening, and re-applying the radio/channel config. Bounded attempts + backoff; + link state surfaced (`Healthy`/`Resetting`/`Failed`). +- **Device control** — region presets (`Regions`: `uk-narrow`, `uk-test`, `eu-legacy`), TX power + (region-capped), channel name + PSK, node name. + +## Enabling it in a dapps node + +The bearer is registered in `Program.cs` (before the AGW catch-all) and driven by +`MeshCoreBearerService`. It is **inert (no serial port opened) unless `MeshCoreEnabled=true`**. +Configure via `DAPPS_MESHCORE_*` env vars (or the `systemoptions` table): + +| Env var | Default | Meaning | +|---|---|---| +| `DAPPS_MESHCORE_ENABLED` | `false` | turn the bearer on | +| `DAPPS_MESHCORE_PORT` | `/dev/ttyUSB0` | radio serial port | +| `DAPPS_MESHCORE_REGION` | `uk-test` | localisation preset (freq/BW/SF/CR + power cap) | +| `DAPPS_MESHCORE_TX_POWER_DBM` | `8` | TX power (capped by region) | +| `DAPPS_MESHCORE_CHANNEL_INDEX` | `1` | radio channel slot | +| `DAPPS_MESHCORE_CHANNEL_NAME` | `dapps` | channel label | +| `DAPPS_MESHCORE_CHANNEL_PSK` | `dapps-default-channel` | 32-char hex (16 B) or a passphrase | +| `DAPPS_MESHCORE_NODE_NAME` | `DAPPS` | radio advert name | +| `DAPPS_MESHCORE_AIRTIME_BUDGET_SECONDS_PER_HOUR` | `30` | governor budget | +| `DAPPS_MESHCORE_COMPRESS` | `true` | zstd-dict compression | + +Inbound is fully wired: received messages are decoded and delivered to `IBackhaulInbox` (DB + MQTT), +sender derived from the in-band `LinkSourceCallsign`. Outbound is selected for routes carrying a +MeshCore channel hint (`BackhaulRoute.MeshCoreChannel`); wiring that hint onto neighbour rows +(`DbNeighbour`/`RouteBuilder`) is the remaining "usable from a configured neighbour" step (#155). + +## Soak harness + +`dapps.meshcore.soak` drives the **real** bearer classes through the real seam between two radios +(continuous sequence-numbered traffic, loss/airtime stats, optional forced watchdog recovery): + +``` +dapps-meshcore-soak --self GB7AAA-1 --peer GB7BBB-1 --region uk-test \ + --channel-index 2 --channel dapps-soak --duration-sec 600 --interval-sec 25 \ + --budget 120 [--force-reset-at-sec 200] +``` + +Run it symmetrically on both radios (self/peer swapped). `MESHCORE_TRACE=1` logs every serial frame. diff --git a/src/dapps/dapps.meshcore/Regions.cs b/src/dapps/dapps.meshcore/Regions.cs new file mode 100644 index 0000000..d9a5980 --- /dev/null +++ b/src/dapps/dapps.meshcore/Regions.cs @@ -0,0 +1,27 @@ +namespace dapps.meshcore; + +/// +/// A localisation/region preset: regulatory + network radio settings for a +/// locale, pushed to the radio via SET_RADIO_PARAMS / SET_RADIO_TX_POWER. +/// Only the EU/UK presets are hardware-confirmed; a production build should +/// source the live table from MeshCore upstream and enforce MaxPowerDbm. +/// +public sealed record RegionPreset( + string Name, double FreqMhz, double BwKhz, byte Sf, byte Cr, byte MaxPowerDbm, string Notes); + +public static class Regions +{ + public static readonly IReadOnlyList All = + [ + new("uk-narrow", 869.618, 62.5, 8, 8, 27, + "Current UK MeshCore net. 869.4-869.65 sub-band: 10% duty, up to 500mW (27dBm) ERP."), + new("eu-legacy", 869.525, 250.0, 11, 5, 14, + "Deprecated EU/UK 'wide long range' (pre-2025). 0.1% sub-band, 25mW (14dBm)."), + new("uk-test", 868.400, 62.5, 8, 8, 14, + "Bench/prototype ISOLATION. 868.0-868.6 sub-band: 1% duty, 25mW (14dBm). Off the UK-narrow " + + "repeater frequency so test floods aren't relayed across the public mesh. Verify UK legality."), + ]; + + public static RegionPreset? Find(string name) => + All.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/dapps/dapps.meshcore/TxBudget.cs b/src/dapps/dapps.meshcore/TxBudget.cs new file mode 100644 index 0000000..2b88aa6 --- /dev/null +++ b/src/dapps/dapps.meshcore/TxBudget.cs @@ -0,0 +1,59 @@ +namespace dapps.meshcore; + +/// +/// Self-enforced airtime governor (the "good citizen" control for Model A, where +/// every channel send floods the whole same-preset network). HARD gate: a send +/// that would push trailing-hour airtime over budget is refused (back-pressure), +/// so DAPPS can never burst the shared channel. Budget is airtime seconds/hour; +/// the default is a small fraction of the 10% regulatory duty. Thread-safe. +/// +public sealed class TxBudget +{ + public const double DefaultSecondsPerHour = 30; // ≈0.83% duty + + private readonly double _budgetMs; + private readonly Queue<(DateTime when, double ms)> _window = new(); + private readonly object _lock = new(); + private double _sumMs; + + public TxBudget(double secondsPerHour) => _budgetMs = secondsPerHour * 1000.0; + + public double BudgetSeconds => _budgetMs / 1000.0; + + public double UsedSeconds(DateTime now) + { + lock (_lock) { Prune(now); return _sumMs / 1000.0; } + } + + public double DutyPercent(DateTime now) + { + lock (_lock) { Prune(now); return _sumMs / 36_000.0; } + } + + /// Reserve airtime for one transmission; returns false (changes + /// nothing) if it would exceed the trailing-hour budget. + public bool TryReserve(double airtimeMs, DateTime now, out string reason) + { + lock (_lock) + { + Prune(now); + if (_sumMs + airtimeMs > _budgetMs) + { + reason = $"airtime budget exceeded: used {_sumMs / 1000:0.0}s + {airtimeMs / 1000:0.00}s > {_budgetMs / 1000:0.0}s/hr"; + return false; + } + _window.Enqueue((now, airtimeMs)); + _sumMs += airtimeMs; + reason = ""; + return true; + } + } + + private void Prune(DateTime now) + { + var cutoff = now.AddHours(-1); + while (_window.Count > 0 && _window.Peek().when < cutoff) + _sumMs -= _window.Dequeue().ms; + if (_sumMs < 0) _sumMs = 0; + } +} diff --git a/src/dapps/dapps.meshcore/dapps.meshcore.csproj b/src/dapps/dapps.meshcore/dapps.meshcore.csproj new file mode 100644 index 0000000..7f3379b --- /dev/null +++ b/src/dapps/dapps.meshcore/dapps.meshcore.csproj @@ -0,0 +1,28 @@ + + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/src/dapps/dapps.sln b/src/dapps/dapps.sln index 8a93e2f..de22580 100644 --- a/src/dapps/dapps.sln +++ b/src/dapps/dapps.sln @@ -19,6 +19,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dapps.core.uitests", "dapps.core.uitests\dapps.core.uitests.csproj", "{6A58B264-4F45-4CDA-8C54-5875C1644768}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dapps.meshcore", "dapps.meshcore\dapps.meshcore.csproj", "{5663AA5D-88E1-4041-8033-59101887267F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dapps.meshcore.soak", "dapps.meshcore.soak\dapps.meshcore.soak.csproj", "{3332047B-7143-461B-9D9C-1FDCBAAD00AD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -89,6 +93,30 @@ Global {6A58B264-4F45-4CDA-8C54-5875C1644768}.Release|x64.Build.0 = Release|Any CPU {6A58B264-4F45-4CDA-8C54-5875C1644768}.Release|x86.ActiveCfg = Release|Any CPU {6A58B264-4F45-4CDA-8C54-5875C1644768}.Release|x86.Build.0 = Release|Any CPU + {5663AA5D-88E1-4041-8033-59101887267F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5663AA5D-88E1-4041-8033-59101887267F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5663AA5D-88E1-4041-8033-59101887267F}.Debug|x64.ActiveCfg = Debug|Any CPU + {5663AA5D-88E1-4041-8033-59101887267F}.Debug|x64.Build.0 = Debug|Any CPU + {5663AA5D-88E1-4041-8033-59101887267F}.Debug|x86.ActiveCfg = Debug|Any CPU + {5663AA5D-88E1-4041-8033-59101887267F}.Debug|x86.Build.0 = Debug|Any CPU + {5663AA5D-88E1-4041-8033-59101887267F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5663AA5D-88E1-4041-8033-59101887267F}.Release|Any CPU.Build.0 = Release|Any CPU + {5663AA5D-88E1-4041-8033-59101887267F}.Release|x64.ActiveCfg = Release|Any CPU + {5663AA5D-88E1-4041-8033-59101887267F}.Release|x64.Build.0 = Release|Any CPU + {5663AA5D-88E1-4041-8033-59101887267F}.Release|x86.ActiveCfg = Release|Any CPU + {5663AA5D-88E1-4041-8033-59101887267F}.Release|x86.Build.0 = Release|Any CPU + {3332047B-7143-461B-9D9C-1FDCBAAD00AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3332047B-7143-461B-9D9C-1FDCBAAD00AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3332047B-7143-461B-9D9C-1FDCBAAD00AD}.Debug|x64.ActiveCfg = Debug|Any CPU + {3332047B-7143-461B-9D9C-1FDCBAAD00AD}.Debug|x64.Build.0 = Debug|Any CPU + {3332047B-7143-461B-9D9C-1FDCBAAD00AD}.Debug|x86.ActiveCfg = Debug|Any CPU + {3332047B-7143-461B-9D9C-1FDCBAAD00AD}.Debug|x86.Build.0 = Debug|Any CPU + {3332047B-7143-461B-9D9C-1FDCBAAD00AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3332047B-7143-461B-9D9C-1FDCBAAD00AD}.Release|Any CPU.Build.0 = Release|Any CPU + {3332047B-7143-461B-9D9C-1FDCBAAD00AD}.Release|x64.ActiveCfg = Release|Any CPU + {3332047B-7143-461B-9D9C-1FDCBAAD00AD}.Release|x64.Build.0 = Release|Any CPU + {3332047B-7143-461B-9D9C-1FDCBAAD00AD}.Release|x86.ActiveCfg = Release|Any CPU + {3332047B-7143-461B-9D9C-1FDCBAAD00AD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE