Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/dapps/dapps.core.tests/MeshCoreBearerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using System.Text;
using AwesomeAssertions;
using dapps.client.Backhaul;
using dapps.core.Models;
using dapps.core.Routing;
using dapps.meshcore;
using Xunit;

Expand Down Expand Up @@ -129,6 +131,54 @@ public void SelfInfo_ParsesRadioParams()
self.Name.Should().Be("DAPPS-R1");
}

[Fact]
public void ChannelMonitor_OccupancyRisesWithTrafficThenPrunes()
{
var region = Regions.Find("uk-test")!;
var m = new ChannelMonitor(region, TimeSpan.FromSeconds(10));
var t0 = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);

m.OccupancyFraction(t0).Should().Be(0);
m.SinceLastHeard(t0).Should().Be(TimeSpan.MaxValue);

for (var i = 0; i < 5; i++) m.RecordHeard(150, t0.AddMilliseconds(i * 10));
m.HeardCount.Should().Be(5);
m.OccupancyFraction(t0.AddSeconds(1)).Should().BeGreaterThan(0);
m.SinceLastHeard(t0.AddSeconds(1)).Should().BeLessThan(TimeSpan.FromSeconds(2));

// Once the heard packets age past the window, occupancy returns to zero.
m.OccupancyFraction(t0.AddSeconds(30)).Should().Be(0);
}

[Fact]
public void TxBudget_Refund_ReturnsTheLastReservation()
{
var now = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
var b = new TxBudget(secondsPerHour: 1.0); // 1000 ms/hr

b.TryReserve(700, now, out _).Should().BeTrue();
b.TryReserve(700, now, out _).Should().BeFalse("1400ms > 1000ms budget");
b.Refund(); // give the 700ms back
b.UsedSeconds(now).Should().BeApproximately(0, 0.001);
b.TryReserve(900, now, out _).Should().BeTrue("budget was refunded");
}

[Fact]
public void ChannelData_ParseRecv_ShortFrame_ThrowsInvalidData()
{
var act = () => ChannelData.ParseRecv([0x1B, 0, 0]);
act.Should().Throw<InvalidDataException>();
}

[Fact]
public void RouteBuilder_CopiesMeshCoreChannelHint()
{
var route = RouteBuilder.FromNeighbour(
new DbNeighbour { Callsign = "GB7XYZ-1", MeshCoreChannel = "dapps" }, defaultBearerPort: null);
route.Callsign.Should().Be("GB7XYZ-1");
route.MeshCoreChannel.Should().Be("dapps");
}

private static void AssertEqual(BackhaulMessage? got, BackhaulMessage original)
{
got.Should().NotBeNull();
Expand Down
72 changes: 72 additions & 0 deletions src/dapps/dapps.core.tests/MeshCoreConfigTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using AwesomeAssertions;
using dapps.core.Models;
using dapps.core.Services;
using Microsoft.Extensions.Logging.Abstractions;
using SQLite;
using Xunit;

namespace dapps.core.tests;

/// <summary>
/// Guards the MeshCore config round-trip through <see cref="SystemOptionsStore"/>
/// (#154 review): every DAPPS_MESHCORE_* option must survive Save -> reload, so
/// the bearer can actually be enabled and configured via the persisted table
/// (regression guard for the earlier gap where Parse/SaveAsync ignored them).
/// </summary>
[Collection(SqliteOverridePathCollection.Name)]
public sealed class MeshCoreConfigTests : IAsyncLifetime
{
private string dbPath = null!;

public ValueTask InitializeAsync()
{
dbPath = Path.Combine(Path.GetTempPath(), $"dapps-mc-{Guid.NewGuid():N}.db");
DbInfo.OverridePath = dbPath;
using var c = new SQLiteConnection(dbPath);
c.CreateTable<DbSystemOption>();
return ValueTask.CompletedTask;
}

public ValueTask DisposeAsync()
{
DbInfo.OverridePath = null;
try { File.Delete(dbPath); } catch { /* ignore */ }
return ValueTask.CompletedTask;
}

[Fact]
public async Task MeshCoreOptions_RoundTripThroughStore()
{
var store = new SystemOptionsStore(NullLogger<SystemOptionsStore>.Instance);
var opts = store.CurrentValue;
opts.MeshCoreEnabled = true;
opts.MeshCorePort = "/dev/ttyUSB9";
opts.MeshCoreRegion = "uk-narrow";
opts.MeshCoreChannelIndex = 3;
opts.MeshCoreChannelName = "testch";
opts.MeshCoreChannelPsk = "3135135fd198029d689b64f45df2aae9";
opts.MeshCoreNodeName = "GB7TST-1";
opts.MeshCoreTxPowerDbm = 14;
opts.MeshCoreAirtimeBudgetSecondsPerHour = 45;
opts.MeshCoreCompress = false;
opts.MeshCoreCongestionBackoffFraction = 0.25;
opts.MeshCoreLbtGuardMs = 250;

await store.SaveAsync(opts);

// A fresh store reads the persisted rows via Parse.
var reloaded = new SystemOptionsStore(NullLogger<SystemOptionsStore>.Instance).CurrentValue;
reloaded.MeshCoreEnabled.Should().BeTrue();
reloaded.MeshCorePort.Should().Be("/dev/ttyUSB9");
reloaded.MeshCoreRegion.Should().Be("uk-narrow");
reloaded.MeshCoreChannelIndex.Should().Be(3);
reloaded.MeshCoreChannelName.Should().Be("testch");
reloaded.MeshCoreChannelPsk.Should().Be("3135135fd198029d689b64f45df2aae9");
reloaded.MeshCoreNodeName.Should().Be("GB7TST-1");
reloaded.MeshCoreTxPowerDbm.Should().Be(14);
reloaded.MeshCoreAirtimeBudgetSecondsPerHour.Should().Be(45);
reloaded.MeshCoreCompress.Should().BeFalse();
reloaded.MeshCoreCongestionBackoffFraction.Should().Be(0.25);
reloaded.MeshCoreLbtGuardMs.Should().Be(250);
}
}
8 changes: 8 additions & 0 deletions src/dapps/dapps.core/Models/DbRouteHint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,12 @@ public class DbNeighbour
/// direct connection (the usual case). See <see cref="dapps.client.ConnectScript"/>.
/// </summary>
public string? ConnectScriptJson { get; set; }

/// <summary>
/// Optional MeshCore channel name. When set, this neighbour is reachable over
/// the MeshCore bearer (#154): the backhaul broadcasts on the configured
/// private channel and this neighbour self-selects by destination callsign.
/// Null = not a MeshCore neighbour. (sqlite-net adds this column on upgrade.)
/// </summary>
public string? MeshCoreChannel { get; set; }
}
7 changes: 7 additions & 0 deletions src/dapps/dapps.core/Models/SystemOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ public class SystemOptions
/// <summary>Compress the backhaul payload (zstd + shared dictionary).</summary>
public bool MeshCoreCompress { get; set; } = true;

/// <summary>Adaptive congestion backoff (#157): refuse sends when channel
/// occupancy is at/above this fraction (0..1). 0 disables.</summary>
public double MeshCoreCongestionBackoffFraction { get; set; } = 0.5;

/// <summary>Listen-before-talk guard in ms (#157). 0 disables.</summary>
public int MeshCoreLbtGuardMs { get; set; } = 400;

/// <summary>
/// When true, app-interface clients (MQTT and REST) must present a
/// valid token; topic / endpoint scope is also enforced against the
Expand Down
3 changes: 2 additions & 1 deletion src/dapps/dapps.core/Routing/RouteBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ public static BackhaulRoute FromNeighbour(DbNeighbour neighbour, int? defaultBea
Callsign: neighbour.Callsign,
BearerPort: neighbour.BearerPort ?? defaultBearerPort,
UdpEndpoint: neighbour.UdpEndpoint,
ConnectScript: ConnectScript.FromJson(neighbour.ConnectScriptJson));
ConnectScript: ConnectScript.FromJson(neighbour.ConnectScriptJson),
MeshCoreChannel: neighbour.MeshCoreChannel);
}
2 changes: 2 additions & 0 deletions src/dapps/dapps.core/Services/DbStartup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ private static readonly (string Key, string Default)[] SeededOptions =
("MeshCoreNodeName", "DAPPS"),
("MeshCoreAirtimeBudgetSecondsPerHour", "30"),
("MeshCoreCompress", "true"),
("MeshCoreCongestionBackoffFraction", "0.5"),
("MeshCoreLbtGuardMs", "400"),
("AuthRequired", "false"),
("UpdateCheckEnabled", "true"),
("RoutingAlgorithm", "passive-flood"),
Expand Down
2 changes: 2 additions & 0 deletions src/dapps/dapps.core/Services/MeshCoreBearer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ public Task<BackhaulSendResult> SendAsync(
NodeName = s.MeshCoreNodeName,
AirtimeBudgetSecPerHour = s.MeshCoreAirtimeBudgetSecondsPerHour,
Compress = s.MeshCoreCompress,
CongestionBackoffFraction = s.MeshCoreCongestionBackoffFraction,
LbtGuardMs = s.MeshCoreLbtGuardMs,
AppName = "dapps",
};

Expand Down
6 changes: 4 additions & 2 deletions src/dapps/dapps.core/Services/OutboundMessageManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ private async Task ForwardAndObserveAsync(
{
await ta.RecordAsync(
kind: "forward",
bearer: route.UdpEndpoint is not null ? "udp" : "agw",
bearer: route.MeshCoreChannel is not null ? "meshcore"
: route.UdpEndpoint is not null ? "udp" : "agw",
channelKey: route.BearerPort?.ToString() ?? "",
targetCallsign: route.Callsign,
messageId: message.Id,
Expand Down Expand Up @@ -243,7 +244,8 @@ private async Task FloodAndMarkAsync(
{
await ta.RecordAsync(
kind: "forward-flood",
bearer: route.UdpEndpoint is not null ? "udp" : "agw",
bearer: route.MeshCoreChannel is not null ? "meshcore"
: route.UdpEndpoint is not null ? "udp" : "agw",
channelKey: route.BearerPort?.ToString() ?? "",
targetCallsign: route.Callsign,
messageId: message.Id,
Expand Down
37 changes: 37 additions & 0 deletions src/dapps/dapps.core/Services/SystemOptionsStore.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using dapps.client;
using dapps.core.Models;
using Microsoft.Extensions.Options;
Expand Down Expand Up @@ -105,6 +106,18 @@ public async Task SaveAsync(SystemOptions options)
await Upsert(connection, existing, nameof(options.TransmissionAuditRetentionDays), options.TransmissionAuditRetentionDays.ToString());
await Upsert(connection, existing, nameof(options.TransmissionAuditMqttPublish), options.TransmissionAuditMqttPublish.ToString());
await Upsert(connection, existing, nameof(options.TxEnabled), options.TxEnabled.ToString());
await Upsert(connection, existing, nameof(options.MeshCoreEnabled), options.MeshCoreEnabled.ToString());
await Upsert(connection, existing, nameof(options.MeshCorePort), options.MeshCorePort);
await Upsert(connection, existing, nameof(options.MeshCoreRegion), options.MeshCoreRegion);
await Upsert(connection, existing, nameof(options.MeshCoreTxPowerDbm), options.MeshCoreTxPowerDbm.ToString());
await Upsert(connection, existing, nameof(options.MeshCoreChannelIndex), options.MeshCoreChannelIndex.ToString());
await Upsert(connection, existing, nameof(options.MeshCoreChannelName), options.MeshCoreChannelName);
await Upsert(connection, existing, nameof(options.MeshCoreChannelPsk), options.MeshCoreChannelPsk);
await Upsert(connection, existing, nameof(options.MeshCoreNodeName), options.MeshCoreNodeName);
await Upsert(connection, existing, nameof(options.MeshCoreAirtimeBudgetSecondsPerHour), options.MeshCoreAirtimeBudgetSecondsPerHour.ToString(CultureInfo.InvariantCulture));
await Upsert(connection, existing, nameof(options.MeshCoreCompress), options.MeshCoreCompress.ToString());
await Upsert(connection, existing, nameof(options.MeshCoreCongestionBackoffFraction), options.MeshCoreCongestionBackoffFraction.ToString(CultureInfo.InvariantCulture));
await Upsert(connection, existing, nameof(options.MeshCoreLbtGuardMs), options.MeshCoreLbtGuardMs.ToString());

Reload();
}
Expand Down Expand Up @@ -173,6 +186,18 @@ private static SystemOptions Parse(Dictionary<string, string> r)
TransmissionAuditRetentionDays = TryGetInt(r, nameof(SystemOptions.TransmissionAuditRetentionDays), 90, min: 0),
TransmissionAuditMqttPublish = TryGetBool(r, nameof(SystemOptions.TransmissionAuditMqttPublish), false),
TxEnabled = TryGetBool(r, nameof(SystemOptions.TxEnabled), true),
MeshCoreEnabled = TryGetBool(r, nameof(SystemOptions.MeshCoreEnabled), false),
MeshCorePort = TryGet(r, nameof(SystemOptions.MeshCorePort), "/dev/ttyUSB0"),
MeshCoreRegion = TryGet(r, nameof(SystemOptions.MeshCoreRegion), "uk-test"),
MeshCoreTxPowerDbm = TryGetInt(r, nameof(SystemOptions.MeshCoreTxPowerDbm), 8, min: 0, max: 30),
MeshCoreChannelIndex = TryGetInt(r, nameof(SystemOptions.MeshCoreChannelIndex), 1, min: 0, max: 255),
MeshCoreChannelName = TryGet(r, nameof(SystemOptions.MeshCoreChannelName), "dapps"),
MeshCoreChannelPsk = TryGet(r, nameof(SystemOptions.MeshCoreChannelPsk), "dapps-default-channel"),
MeshCoreNodeName = TryGet(r, nameof(SystemOptions.MeshCoreNodeName), "DAPPS"),
MeshCoreAirtimeBudgetSecondsPerHour = TryGetDouble(r, nameof(SystemOptions.MeshCoreAirtimeBudgetSecondsPerHour), 30, min: 0),
MeshCoreCompress = TryGetBool(r, nameof(SystemOptions.MeshCoreCompress), true),
MeshCoreCongestionBackoffFraction = TryGetDouble(r, nameof(SystemOptions.MeshCoreCongestionBackoffFraction), 0.5, min: 0, max: 1),
MeshCoreLbtGuardMs = TryGetInt(r, nameof(SystemOptions.MeshCoreLbtGuardMs), 400, min: 0),
};
}

Expand All @@ -193,6 +218,18 @@ private static int TryGetInt(Dictionary<string, string> r, string key, int fallb
private static bool TryGetBool(Dictionary<string, string> r, string key, bool fallback)
=> r.TryGetValue(key, out var s) && bool.TryParse(s, out var v) ? v : fallback;

private static double TryGetDouble(Dictionary<string, string> r, string key, double fallback, double? min = null, double? max = null)
{
if (r.TryGetValue(key, out var s)
&& double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var v))
{
if (min is { } lo && v < lo) return fallback;
if (max is { } hi && v > hi) return fallback;
return v;
}
return fallback;
}

private sealed class Subscription : IDisposable
{
private readonly SystemOptionsStore store;
Expand Down
8 changes: 6 additions & 2 deletions src/dapps/dapps.meshcore.soak/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
NodeName = self,
AirtimeBudgetSecPerHour = a.GetDouble("budget", 120),
Compress = !a.Has("no-compress"),
CongestionBackoffFraction = a.GetDouble("congestion", 0.5),
LbtGuardMs = a.GetInt("lbt", 400),
AppName = "dapps-soak",
};

Expand Down Expand Up @@ -59,7 +61,7 @@

var inboundTask = Task.Run(() => inbound.RunAsync(cts.Token));

long sent = 0, accepted = 0, throttled = 0, failed = 0;
long sent = 0, accepted = 0, throttled = 0, backedOff = 0, failed = 0;
string[] samples =
[
"73 de " + self, "QSL 73 GL", "GM all, nice signal this morning, 599 here",
Expand All @@ -83,6 +85,7 @@
Interlocked.Increment(ref sent);
if (r.Accepted) Interlocked.Increment(ref accepted);
else if (r.Error?.Contains("budget") == true) { Interlocked.Increment(ref throttled); log.LogWarning("TX seq={0} throttled: {1}", seq, r.Error); }
else if (r.Error?.Contains("congested") == true) { Interlocked.Increment(ref backedOff); log.LogWarning("TX seq={0} backoff: {1}", seq, r.Error); }
else { Interlocked.Increment(ref failed); log.LogWarning("TX seq={0} failed: {1}", seq, r.Error); }
}
catch (Exception ex) { Interlocked.Increment(ref failed); log.LogWarning("TX seq={0} exception: {1}", seq, ex.Message); }
Expand Down Expand Up @@ -110,8 +113,9 @@
var (recv, maxSeq, distinct) = inbox.Snapshot();
double lossPct = maxSeq >= 0 ? 100.0 * (1.0 - (double)distinct / (maxSeq + 1)) : 0;
log.LogInformation("================= SOAK SUMMARY ({0}) =================", self);
log.LogInformation("TX: offered={0} accepted={1} throttled={2} failed={3}", sent, accepted, throttled, failed);
log.LogInformation("TX: offered={0} accepted={1} throttled={2} backoff={3} failed={4}", sent, accepted, throttled, backedOff, failed);
log.LogInformation("RX: delivered={0} distinctSeq={1} maxSeqFromPeer={2} loss={3:0.0}%", recv, distinct, maxSeq, lossPct);
log.LogInformation("Channel occupancy (trailing 60s): {0:0.0}%", backhaul.Occupancy * 100);
log.LogInformation("Airtime used (trailing hr): {0:0.0}s ({1:0.000}% duty); link resets={2}; link state={3}",
budget.UsedSeconds(DateTime.UtcNow), budget.DutyPercent(DateTime.UtcNow), link.ResetCount, link.State);
return 0;
Expand Down
64 changes: 64 additions & 0 deletions src/dapps/dapps.meshcore/ChannelMonitor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
namespace dapps.meshcore;

/// <summary>
/// Estimates how busy the shared LoRa channel is from the radio's LOG_RX_DATA
/// (0x88) "packet heard" events (#157). Every packet the radio overhears — our
/// peers' floods, other same-preset traffic — is recorded with its estimated
/// airtime; occupancy is the busy fraction over a trailing window. The bearer
/// uses this to be a *dynamically* good citizen: listen-before-talk and back off
/// when the channel is congested, on top of the static airtime budget.
/// </summary>
public sealed class ChannelMonitor
{
private readonly RegionPreset _region;
private readonly TimeSpan _window;
private readonly Queue<(DateTime when, double airMs)> _heard = new();
private readonly object _lock = new();
private double _sumMs;

public DateTime LastHeardUtc { get; private set; } = DateTime.MinValue;
public long HeardCount { get; private set; }

public ChannelMonitor(RegionPreset region, TimeSpan? window = null)
{
_region = region;
_window = window ?? TimeSpan.FromSeconds(60);
}

/// <summary>Record a packet overheard on the channel. <paramref name="rawLen"/>
/// is the logged on-air length; its airtime is estimated for the active preset.</summary>
public void RecordHeard(int rawLen, DateTime now)
{
var airMs = LoRaAirtime.Ms(Math.Max(rawLen, 1), _region.Sf, _region.BwKhz * 1000, _region.Cr);
lock (_lock)
{
Prune(now);
_heard.Enqueue((now, airMs));
_sumMs += airMs;
LastHeardUtc = now;
HeardCount++;
}
}

/// <summary>Busy fraction (0..1) over the trailing window.</summary>
public double OccupancyFraction(DateTime now)
{
lock (_lock)
{
Prune(now);
return Math.Min(1.0, _sumMs / _window.TotalMilliseconds);
}
}

public TimeSpan SinceLastHeard(DateTime now) =>
LastHeardUtc == DateTime.MinValue ? TimeSpan.MaxValue : now - LastHeardUtc;

private void Prune(DateTime now)
{
var cutoff = now - _window;
while (_heard.Count > 0 && _heard.Peek().when < cutoff)
_sumMs -= _heard.Dequeue().airMs;
// Reset exactly when the window empties to avoid float residual drift.
if (_heard.Count == 0 || _sumMs < 0) _sumMs = 0;
}
}
9 changes: 9 additions & 0 deletions src/dapps/dapps.meshcore/MeshCoreBearerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ public sealed class MeshCoreBearerOptions
public bool Compress { get; set; } = true;
public string AppName { get; set; } = "dapps";

/// <summary>Adaptive congestion backoff (#157): refuse sends when the channel's
/// trailing-window occupancy is at or above this fraction (0..1). 0 disables.</summary>
public double CongestionBackoffFraction { get; set; } = 0.5;

/// <summary>Listen-before-talk guard (ms): if a packet was overheard more
/// recently than this, wait out the remainder (plus jitter) before transmitting,
/// to avoid colliding with an in-progress flood. 0 disables.</summary>
public int LbtGuardMs { get; set; } = 400;

public RegionPreset ResolveRegion() =>
Regions.Find(Region) ?? throw new ArgumentException($"unknown MeshCore region '{Region}'");

Expand Down
Loading
Loading