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
8 changes: 6 additions & 2 deletions src/dapps/dapps.core.tests/MeshCoreConfigTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ public async Task MeshCoreOptions_RoundTripThroughStore()
var opts = store.CurrentValue;
opts.MeshCoreEnabled = true;
opts.MeshCorePort = "/dev/ttyUSB9";
opts.MeshCoreRegion = "uk-narrow";
opts.MeshCoreRegion = "custom";
opts.MeshCoreCustomPreset = "freq=867.1;bw=125;sf=9;cr=6;pwr=20";
opts.MeshCoreFloodScopeKey = "dapps-uk";
opts.MeshCoreChannelIndex = 3;
opts.MeshCoreChannelName = "testch";
opts.MeshCoreChannelPsk = "3135135fd198029d689b64f45df2aae9";
Expand All @@ -59,7 +61,9 @@ public async Task MeshCoreOptions_RoundTripThroughStore()
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.MeshCoreRegion.Should().Be("custom");
reloaded.MeshCoreCustomPreset.Should().Be("freq=867.1;bw=125;sf=9;cr=6;pwr=20");
reloaded.MeshCoreFloodScopeKey.Should().Be("dapps-uk");
reloaded.MeshCoreChannelIndex.Should().Be(3);
reloaded.MeshCoreChannelName.Should().Be("testch");
reloaded.MeshCoreChannelPsk.Should().Be("3135135fd198029d689b64f45df2aae9");
Expand Down
118 changes: 118 additions & 0 deletions src/dapps/dapps.core.tests/MeshCoreDeploymentModelTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using System.Security.Cryptography;
using System.Text;
using AwesomeAssertions;
using dapps.meshcore;
using Xunit;

namespace dapps.core.tests;

/// <summary>
/// Deployment models B/C (#24): the bearer exposes preset + flood-scope as first-class
/// config. Model A = unscoped public preset, B = scoped public preset (flood-scope key),
/// C = dedicated/custom preset (own freq/SF). These tests pin the config resolution -
/// the on-air behaviour of the scope key is validated by the soak.
/// </summary>
public sealed class MeshCoreDeploymentModelTests
{
// ── Model C: custom dedicated preset ───────────────────────────

[Fact]
public void ParseCustom_ValidSpec_ProducesPreset()
{
var p = Regions.ParseCustom("freq=868.4;bw=62.5;sf=8;cr=8;pwr=14");
p.Name.Should().Be(Regions.CustomName);
p.FreqMhz.Should().Be(868.4);
p.BwKhz.Should().Be(62.5);
p.Sf.Should().Be(8);
p.Cr.Should().Be(8);
p.MaxPowerDbm.Should().Be(14);
}

[Fact]
public void ParseCustom_ToleratesCommasAndWhitespace()
{
var p = Regions.ParseCustom(" freq=869.5 , bw=250 , sf=11 , cr=5 , pwr=27 ");
p.FreqMhz.Should().Be(869.5);
p.Sf.Should().Be(11);
p.MaxPowerDbm.Should().Be(27);
}

[Theory]
[InlineData("")] // empty
[InlineData("freq=868.4;bw=62.5;sf=8;cr=8")] // missing pwr
[InlineData("freq=868.4;bw=62.5;sf=99;cr=8;pwr=14")] // sf out of range
[InlineData("freq=868.4;bw=62.5;sf=8;cr=8;pwr=99")] // pwr out of range
[InlineData("freq=abc;bw=62.5;sf=8;cr=8;pwr=14")] // freq not a number
[InlineData("freq;bw=62.5;sf=8;cr=8;pwr=14")] // malformed field
public void ParseCustom_Rejects_BadSpecs(string spec)
{
var act = () => Regions.ParseCustom(spec);
act.Should().Throw<ArgumentException>();
}

[Fact]
public void ResolveRegion_Custom_UsesCustomPreset()
{
var opts = new MeshCoreBearerOptions { Region = "custom", CustomPreset = "freq=867.1;bw=125;sf=9;cr=6;pwr=20" };
var p = opts.ResolveRegion();
p.FreqMhz.Should().Be(867.1);
p.Sf.Should().Be(9);
}

[Fact]
public void ResolveRegion_BakedName_StillWorks()
{
new MeshCoreBearerOptions { Region = "uk-test" }.ResolveRegion().FreqMhz.Should().Be(868.4);
}

// ── Model B: flood-scope key derivation ────────────────────────

[Fact]
public void ResolveFloodScopeKey_Empty_IsUnscoped()
{
new MeshCoreBearerOptions { FloodScopeKey = "" }.ResolveFloodScopeKey().Should().BeNull();
new MeshCoreBearerOptions { FloodScopeKey = " " }.ResolveFloodScopeKey().Should().BeNull();
}

[Fact]
public void ResolveFloodScopeKey_RegionName_HashesLikeMeshCorePublicKey()
{
// MeshCore derives a public region key as SHA256("#"+name)[..16]; matching that
// lets repeaters configured with `region put <name>` carry our scoped floods.
var key = new MeshCoreBearerOptions { FloodScopeKey = "dapps-uk" }.ResolveFloodScopeKey();
var expected = SHA256.HashData(Encoding.UTF8.GetBytes("#dapps-uk"))[..16];
key.Should().Equal(expected);
key!.Length.Should().Be(16);
}

[Fact]
public void ResolveFloodScopeKey_HexString_IsVerbatim()
{
var hex = "00112233445566778899aabbccddeeff";
var key = new MeshCoreBearerOptions { FloodScopeKey = hex }.ResolveFloodScopeKey();
Convert.ToHexString(key!).ToLowerInvariant().Should().Be(hex);
}

[Fact]
public void ResolveFloodScopeKey_AllZeroHex_IsUnscoped()
{
// An all-zero key is what the radio treats as unscoped, so it must resolve to null
// (and classify as model A) - not read as a scoped key the label/logs would lie about.
var opts = new MeshCoreBearerOptions { FloodScopeKey = new string('0', 32) };
opts.ResolveFloodScopeKey().Should().BeNull();
opts.DeploymentModel().Should().StartWith("A");
}

// ── DeploymentModel classification ─────────────────────────────

[Theory]
[InlineData("uk-narrow", "", "A")] // unscoped public preset
[InlineData("uk-narrow", "dapps-uk", "B")] // scoped public preset
[InlineData("custom", "", "C")] // dedicated preset (scope irrelevant)
[InlineData("custom", "dapps-uk", "C")] // dedicated wins over scope in the label
public void DeploymentModel_Classifies(string region, string scope, string expectedPrefix)
{
var opts = new MeshCoreBearerOptions { Region = region, FloodScopeKey = scope };
opts.DeploymentModel().Should().StartWith(expectedPrefix);
}
}
13 changes: 12 additions & 1 deletion src/dapps/dapps.core/Models/SystemOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,20 @@ public class SystemOptions
public string MeshCorePort { get; set; } = "/dev/ttyUSB0";

/// <summary>Region preset (localisation): <c>uk-narrow</c>, <c>uk-test</c>,
/// <c>eu-legacy</c>. Sets frequency/BW/SF/CR and caps TX power.</summary>
/// <c>eu-legacy</c>, or <c>custom</c> (deployment model C - use
/// <see cref="MeshCoreCustomPreset"/>). Sets frequency/BW/SF/CR and caps TX power.</summary>
public string MeshCoreRegion { get; set; } = "uk-test";

/// <summary>Deployment model C: when <see cref="MeshCoreRegion"/> is <c>custom</c>, the
/// dedicated-preset spec for a DAPPS-only frequency/SF, e.g.
/// <c>freq=868.4;bw=62.5;sf=8;cr=8;pwr=14</c>. Ignored for baked regions.</summary>
public string MeshCoreCustomPreset { get; set; } = "";

/// <summary>Deployment model B: flood-scope key. Empty = unscoped (model A - floods
/// carried network-wide by any same-preset public repeater). Non-empty scopes our
/// floods so nodes/repeaters not sharing it drop them. See the MeshCore README.</summary>
public string MeshCoreFloodScopeKey { get; set; } = "";

/// <summary>TX power in dBm (capped by the region's regulatory max).</summary>
public int MeshCoreTxPowerDbm { get; set; } = 8;

Expand Down
5 changes: 5 additions & 0 deletions src/dapps/dapps.core/Pages/Settings.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,11 @@
<option value="uk-narrow" selected="@(Model.Options.MeshCoreRegion == "uk-narrow")">UK 868 narrow — 869.618 / SF8 / CR8</option>
<option value="uk-test" selected="@(Model.Options.MeshCoreRegion == "uk-test")">UK test — 868.4 (isolated bench)</option>
<option value="eu-legacy" selected="@(Model.Options.MeshCoreRegion == "eu-legacy")">EU legacy wide — 869.525 / SF11</option>
<option value="custom" selected="@(Model.Options.MeshCoreRegion == "custom")">Custom — dedicated preset (model C)</option>
</select>
</div>
<div class="field"><label>Custom preset</label><input name="MeshCoreCustomPreset" type="text" value="@Model.Options.MeshCoreCustomPreset" /><span class="hint">model C, when region = custom: freq=868.4;bw=62.5;sf=8;cr=8;pwr=14</span></div>
<div class="field"><label>Flood-scope key</label><input name="MeshCoreFloodScopeKey" type="text" value="@Model.Options.MeshCoreFloodScopeKey" class="masked" /><span class="hint">model B: blank = unscoped; set to contain floods to scoped nodes</span></div>
<div class="field"><label>TX power (dBm)</label><input name="MeshCoreTxPowerDbm" type="number" min="0" max="30" value="@Model.Options.MeshCoreTxPowerDbm" /><span class="hint">capped by the region's max</span></div>
<div class="field"><label>Node name</label><input name="MeshCoreNodeName" type="text" value="@Model.Options.MeshCoreNodeName" /></div>
<div class="field"><label>Channel slot</label><input name="MeshCoreChannelIndex" type="number" min="0" max="255" value="@Model.Options.MeshCoreChannelIndex" /><span class="hint">0 = public; 1+ = private</span></div>
Expand Down Expand Up @@ -254,6 +257,8 @@ async function dappsSaveConfig(ev) {
MeshCoreEnabled: f.MeshCoreEnabled.checked,
MeshCorePort: f.MeshCorePort.value,
MeshCoreRegion: f.MeshCoreRegion.value,
MeshCoreCustomPreset: f.MeshCoreCustomPreset.value,
MeshCoreFloodScopeKey: f.MeshCoreFloodScopeKey.value,
MeshCoreTxPowerDbm: parseInt(f.MeshCoreTxPowerDbm.value, 10),
MeshCoreChannelIndex: parseInt(f.MeshCoreChannelIndex.value, 10),
MeshCoreChannelName: f.MeshCoreChannelName.value,
Expand Down
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 @@ -94,6 +94,8 @@ private static readonly (string Key, string Default)[] SeededOptions =
("MeshCoreEnabled", "false"),
("MeshCorePort", "/dev/ttyUSB0"),
("MeshCoreRegion", "uk-test"),
("MeshCoreCustomPreset", ""),
("MeshCoreFloodScopeKey", ""),
("MeshCoreTxPowerDbm", "8"),
("MeshCoreChannelIndex", "1"),
("MeshCoreChannelName", "dapps"),
Expand Down
10 changes: 8 additions & 2 deletions src/dapps/dapps.core/Services/MeshCoreBearer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,19 @@ public async Task RunAsync(CancellationToken ct)
var budget = new TxBudget(opts.AirtimeBudgetSecPerHour);
var reliability = opts.ReliableDelivery ? new MeshCoreReliability() : null;
_reliability = reliability;
_link = new MeshCoreLink(opts, _loggerFactory.CreateLogger<MeshCoreLink>());

try
{
// Construct inside the try: the ctor resolves the region preset, and a bad
// custom preset (model C) throws from ParseCustom - a config error must
// disable the bearer gracefully, not fault the hosted service and crash the
// daemon (BackgroundServiceExceptionBehavior.StopHost).
_link = new MeshCoreLink(opts, _loggerFactory.CreateLogger<MeshCoreLink>());
await _link.StartAsync(ct);
}
catch (Exception ex)
{
_log.LogError(ex, "MeshCore bearer failed to start on {0}", opts.SerialPort);
_log.LogError(ex, "MeshCore bearer failed to start (port={0}, region={1})", opts.SerialPort, opts.Region);
return;
}

Expand Down Expand Up @@ -250,6 +254,8 @@ public Task<BackhaulSendResult> SendAsync(
Enabled = true,
SerialPort = s.MeshCorePort,
Region = s.MeshCoreRegion,
CustomPreset = s.MeshCoreCustomPreset,
FloodScopeKey = s.MeshCoreFloodScopeKey,
TxPowerDbm = (byte)Math.Clamp(s.MeshCoreTxPowerDbm, 0, 30),
ChannelIndex = (byte)Math.Clamp(s.MeshCoreChannelIndex, 0, 255),
ChannelName = s.MeshCoreChannelName,
Expand Down
4 changes: 4 additions & 0 deletions src/dapps/dapps.core/Services/SystemOptionsStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ public async Task SaveAsync(SystemOptions options)
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.MeshCoreCustomPreset), options.MeshCoreCustomPreset);
await Upsert(connection, existing, nameof(options.MeshCoreFloodScopeKey), options.MeshCoreFloodScopeKey);
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);
Expand Down Expand Up @@ -190,6 +192,8 @@ private static SystemOptions Parse(Dictionary<string, string> r)
MeshCoreEnabled = TryGetBool(r, nameof(SystemOptions.MeshCoreEnabled), false),
MeshCorePort = TryGet(r, nameof(SystemOptions.MeshCorePort), "/dev/ttyUSB0"),
MeshCoreRegion = TryGet(r, nameof(SystemOptions.MeshCoreRegion), "uk-test"),
MeshCoreCustomPreset = TryGet(r, nameof(SystemOptions.MeshCoreCustomPreset), ""),
MeshCoreFloodScopeKey = TryGet(r, nameof(SystemOptions.MeshCoreFloodScopeKey), ""),
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"),
Expand Down
2 changes: 2 additions & 0 deletions src/dapps/dapps.meshcore.soak/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
Enabled = true,
SerialPort = a.Get("port", "/dev/ttyUSB0"),
Region = a.Get("region", "uk-test"),
CustomPreset = a.Get("custom-preset", ""), // model C: with --region custom
FloodScopeKey = a.Get("flood-scope", ""), // model B: scope key/name
TxPowerDbm = (byte)a.GetInt("tx-power", 8),
ChannelIndex = (byte)a.GetInt("channel-index", 2),
ChannelName = a.Get("channel", "dapps-soak"),
Expand Down
45 changes: 44 additions & 1 deletion src/dapps/dapps.meshcore/MeshCoreBearerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ public sealed class MeshCoreBearerOptions
public bool Enabled { get; set; }
public string SerialPort { get; set; } = "/dev/ttyUSB0";
public string Region { get; set; } = "uk-test";

/// <summary>Deployment model C: when <see cref="Region"/> is "custom", the dedicated
/// preset spec (e.g. <c>freq=868.4;bw=62.5;sf=8;cr=8;pwr=14</c>) for a DAPPS-only
/// frequency/SF. Ignored for baked regions. See README "Containment".</summary>
public string CustomPreset { get; set; } = "";

/// <summary>Deployment model B: flood-scope key. Empty = unscoped (model A - floods are
/// carried network-wide by any same-preset public repeater). Non-empty tells the radio
/// to tag our floods with this scope so nodes/repeaters that don't share it drop them,
/// containing DAPPS traffic to our own scoped infra. See README "Containment" for the
/// firmware caveats.</summary>
public string FloodScopeKey { get; set; } = "";
public byte TxPowerDbm { get; set; } = 8;
public byte ChannelIndex { get; set; } = 1;
public string ChannelName { get; set; } = "dapps";
Expand Down Expand Up @@ -38,7 +50,18 @@ public sealed class MeshCoreBearerOptions
public string LocalCallsign { get; set; } = "";

public RegionPreset ResolveRegion() =>
Regions.Find(Region) ?? throw new ArgumentException($"unknown MeshCore region '{Region}'");
Region.Equals(Regions.CustomName, StringComparison.OrdinalIgnoreCase)
? Regions.ParseCustom(CustomPreset)
: Regions.Find(Region) ?? throw new ArgumentException($"unknown MeshCore region '{Region}'");

/// <summary>Which deployment model this config selects, for logging/observability.
/// A = unscoped public preset, B = scoped public preset, C = dedicated/custom preset.</summary>
public string DeploymentModel()
{
bool scoped = ResolveFloodScopeKey() is not null; // resolved, so an all-zero key reads as unscoped
bool dedicated = Region.Equals(Regions.CustomName, StringComparison.OrdinalIgnoreCase);
return dedicated ? "C (dedicated preset)" : scoped ? "B (scoped public preset)" : "A (unscoped public preset)";
}

/// <summary>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.</summary>
Expand All @@ -49,4 +72,24 @@ public byte[] ResolvePsk()
return Convert.FromHexString(v);
return SHA256.HashData(Encoding.UTF8.GetBytes(v))[..16];
}

/// <summary>The 16-byte flood-scope key (deployment model B), or null when unscoped
/// (empty <see cref="FloodScopeKey"/>). A 32-char hex string is used verbatim; any
/// other value is treated as a PUBLIC region NAME and hashed as SHA256("#"+name)[..16]
/// - the same derivation MeshCore uses for public region keys, so repeaters configured
/// with <c>region put &lt;name&gt;</c> (flood-allowed) carry our traffic and everyone
/// else drops it. A truly-secret key would be dropped network-wide: the firmware's
/// private ($-prefixed) keystore is stubbed in v1.16.x.</summary>
public byte[]? ResolveFloodScopeKey()
{
var v = FloodScopeKey?.Trim() ?? "";
if (v.Length == 0) return null;
var key = (v.Length == 32 && v.All(Uri.IsHexDigit))
? Convert.FromHexString(v)
: SHA256.HashData(Encoding.UTF8.GetBytes("#" + v))[..16];
// An all-zero key is what the radio treats as "unscoped" (SetFloodScopeAsync sends
// the clear frame for it), so report it as unscoped here too - otherwise
// DeploymentModel()/the link log would claim model B while the wire is unscoped.
return key.All(b => b == 0) ? null : key;
}
}
29 changes: 29 additions & 0 deletions src/dapps/dapps.meshcore/MeshCoreClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public sealed class MeshCoreClient : IAsyncDisposable
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_SET_FLOOD_SCOPE_KEY = 0x36; // v8+: transient (RAM) flood-scope override
public const byte CMD_SEND_CHANNEL_DATA = 0x3E;
public const ushort DATA_TYPE_DEV = 0xFFFF;

Expand Down Expand Up @@ -233,6 +234,34 @@ public async Task SetChannelAsync(byte index, string name, byte[] secret16, Canc
await ExchangeAsync(p, [RSP_OK], TimeSpan.FromSeconds(3), ct);
}

/// <summary>Set (or clear) the transient flood-scope key (Companion CMD 0x36, v8+),
/// deployment model B. The key is never transmitted - the radio HMACs it to a 2-byte
/// transport code and marks our floods scoped, so repeaters/room-servers without a
/// matching region silently drop them. <paramref name="key16"/> null/empty/all-zero
/// clears the scope (unscoped, model A). RAM-only: reset on radio reboot, so this is
/// re-applied on every (re)configure. Returns false (rather than throwing) if the
/// firmware rejects the command - i.e. it's too old to support scoping.</summary>
public async Task<bool> SetFloodScopeAsync(byte[]? key16, CancellationToken ct)
{
if (key16 is not null && key16.Length != 0 && key16.Length != 16)
throw new ArgumentException("flood-scope key must be 16 bytes", nameof(key16));
bool scoped = key16 is { Length: 16 } && key16.Any(b => b != 0);
byte[] p;
if (scoped)
{
p = new byte[18];
p[0] = CMD_SET_FLOOD_SCOPE_KEY;
p[1] = 0x00; // selector 0 = set/clear scope key
key16!.CopyTo(p, 2);
}
else
{
p = [CMD_SET_FLOOD_SCOPE_KEY, 0x00]; // len 2 = clear the override -> unscoped
}
var resp = await ExchangeAsync(p, [RSP_OK, RSP_ERR], TimeSpan.FromSeconds(3), ct);
return resp[0] == RSP_OK;
}

public async Task<ChannelInfo> GetChannelAsync(byte index, CancellationToken ct)
{
var resp = await ExchangeAsync([CMD_GET_CHANNEL, index], [RSP_CHANNEL_INFO], TimeSpan.FromSeconds(3), ct);
Expand Down
Loading
Loading