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
2 changes: 1 addition & 1 deletion src/dapps/dapps.core.uitests/SettingsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public async Task Settings_Form_Renders_All_Panel_Groups()
"the settings form is broken into Identity / App interface / Discovery / Probing / Polling / Heartbeat / Updates groups");

// Spot-check key fields exist by name attribute.
foreach (var name in new[] { "Callsign", "NodeHost", "ProbeStrategy", "MqttPort", "HeartbeatEnabled" })
foreach (var name in new[] { "Callsign", "NodeHost", "ProbeStrategy", "MqttPort", "HeartbeatEnabled", "MeshCoreEnabled", "MeshCoreRegion" })
{
(await page.Locator($"[name='{name}']").CountAsync())
.Should().Be(1, $"Settings form should expose the {name} input");
Expand Down
22 changes: 22 additions & 0 deletions src/dapps/dapps.core/Controllers/MeshCoreController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using dapps.core.Services;
using Microsoft.AspNetCore.Mvc;

namespace dapps.core.Controllers;

/// <summary>
/// Dashboard / API surface for the MeshCore bearer (#159): a read-only status
/// snapshot and an operator device-control reset. Configuration itself is edited
/// through the Settings form (persisted via <c>/Config</c> into SystemOptions).
/// </summary>
[ApiController]
[Route("[controller]")]
public class MeshCoreController(MeshCoreBearer bearer) : ControllerBase
{
[HttpGet("status")]
public MeshCoreStatus Status() => bearer.GetStatus();

/// <summary>Force the radio through a hard reset + reconfigure.</summary>
[HttpPost("reset")]
public async Task<IActionResult> Reset(CancellationToken ct)
=> await bearer.ResetRadioAsync(ct) ? Ok() : StatusCode(503, "meshcore bearer not running");
}
73 changes: 73 additions & 0 deletions src/dapps/dapps.core/Pages/Settings.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,39 @@
</div>
</div>

<div class="panel">
<h3>MeshCore bearer</h3>
<p class="panel-sub">LoRa radio bearer over a MeshCore private channel. Enable, port and region changes need a daemon restart; the rest hot-reload.</p>
<div class="field-grid">
<label class="field-checkbox" style="grid-column: 1 / -1;">
<input name="MeshCoreEnabled" type="checkbox" @(Model.Options.MeshCoreEnabled ? "checked" : "") />
<span>Enable the MeshCore bearer<br/><span class="hint">opens the serial radio and carries DAPPS traffic over a private channel</span></span>
</label>
<div class="field"><label>Serial port</label><input name="MeshCorePort" type="text" value="@Model.Options.MeshCorePort" /><span class="hint">e.g. /dev/ttyUSB0</span></div>
<div class="field"><label>Region preset</label>
<select name="MeshCoreRegion">
<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>
</select>
</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>
<div class="field"><label>Channel name</label><input name="MeshCoreChannelName" type="text" value="@Model.Options.MeshCoreChannelName" /></div>
<div class="field"><label>Channel PSK</label><input name="MeshCoreChannelPsk" type="text" value="@Model.Options.MeshCoreChannelPsk" class="masked" /><span class="hint">32-char hex (16 bytes) or a passphrase</span></div>
<div class="field"><label>Airtime budget (s/h)</label><input name="MeshCoreAirtimeBudgetSecondsPerHour" type="number" min="0" max="3600" step="1" value="@Model.Options.MeshCoreAirtimeBudgetSecondsPerHour" /><span class="hint">good-citizen duty cap</span></div>
<div class="field"><label>Congestion backoff</label><input name="MeshCoreCongestionBackoffFraction" type="number" min="0" max="1" step="0.05" value="@Model.Options.MeshCoreCongestionBackoffFraction" /><span class="hint">back off at occupancy ≥ this (0 = off)</span></div>
<div class="field"><label>LBT guard (ms)</label><input name="MeshCoreLbtGuardMs" type="number" min="0" max="10000" value="@Model.Options.MeshCoreLbtGuardMs" /></div>
<label class="field-checkbox"><input name="MeshCoreCompress" type="checkbox" @(Model.Options.MeshCoreCompress ? "checked" : "") /><span>Compress payloads (zstd + dictionary)</span></label>
<label class="field-checkbox"><input name="MeshCoreReliableDelivery" type="checkbox" @(Model.Options.MeshCoreReliableDelivery ? "checked" : "") /><span>End-to-end reliability (ACK + resend)</span></label>
</div>
<div class="panel-sub" style="margin-top:.75rem; display:flex; align-items:center; gap:.5rem;">
<span>Status: <span id="mc-status">…</span></span>
<button type="button" id="mc-reset" class="btn-secondary" style="margin-left:auto;">Reset radio</button>
</div>
</div>

<div class="panel">
<h3>Discovery &amp; airtime</h3>
<p class="panel-sub">Total trailing-hour airtime cap shared by every discovery transmission (beacons, solicits, probes). Per-channel caps live in Topology.</p>
Expand Down Expand Up @@ -218,6 +251,19 @@ async function dappsSaveConfig(ev) {
HeartbeatIntervalSeconds: parseInt(f.HeartbeatIntervalSeconds.value, 10),
AutoDiscoverViaNodeCall: f.AutoDiscoverViaNodeCall.checked,
NodePromptApplicationCommand: f.NodePromptApplicationCommand.value,
MeshCoreEnabled: f.MeshCoreEnabled.checked,
MeshCorePort: f.MeshCorePort.value,
MeshCoreRegion: f.MeshCoreRegion.value,
MeshCoreTxPowerDbm: parseInt(f.MeshCoreTxPowerDbm.value, 10),
MeshCoreChannelIndex: parseInt(f.MeshCoreChannelIndex.value, 10),
MeshCoreChannelName: f.MeshCoreChannelName.value,
MeshCoreChannelPsk: f.MeshCoreChannelPsk.value,
MeshCoreNodeName: f.MeshCoreNodeName.value,
MeshCoreAirtimeBudgetSecondsPerHour: parseFloat(f.MeshCoreAirtimeBudgetSecondsPerHour.value),
MeshCoreCompress: f.MeshCoreCompress.checked,
MeshCoreCongestionBackoffFraction: parseFloat(f.MeshCoreCongestionBackoffFraction.value),
MeshCoreLbtGuardMs: parseInt(f.MeshCoreLbtGuardMs.value, 10),
MeshCoreReliableDelivery: f.MeshCoreReliableDelivery.checked,
};
if (await dappsPostJson('@Url.Content("~/Config")', body)) {
const head = document.querySelector('.page-head');
Expand Down Expand Up @@ -326,4 +372,31 @@ async function dappsRotatePassword(ev) {
}
});
})();

// MeshCore bearer status + device-control reset (#159).
(function () {
const statusEl = document.getElementById('mc-status');
const resetBtn = document.getElementById('mc-reset');
if (!statusEl) return;
async function refresh() {
try {
const r = await fetch('@Url.Content("~/MeshCore/status")');
if (!r.ok) { statusEl.textContent = 'unavailable'; return; }
const s = await r.json();
if (!s.enabled) { statusEl.textContent = 'disabled'; if (resetBtn) resetBtn.disabled = true; return; }
if (resetBtn) resetBtn.disabled = false;
const f = s.freqMhz != null ? s.freqMhz.toFixed(3) + 'MHz' : '?';
const exp = s.reliableExpired ? ' / ' + s.reliableExpired + '✗' : '';
statusEl.textContent = `${s.linkState} · ${f} SF${s.sf ?? '?'} CR${s.cr ?? '?'} · occ ${(s.occupancy * 100).toFixed(0)}% · rx ${s.delivered} · reliable ${s.reliableConfirmed}✓/${s.reliablePending}⋯${exp} · resets ${s.resets}`;
} catch { statusEl.textContent = 'unavailable'; }
}
if (resetBtn) resetBtn.addEventListener('click', async () => {
resetBtn.disabled = true;
statusEl.textContent = 'resetting…';
try { await fetch('@Url.Content("~/MeshCore/reset")', { method: 'POST' }); } catch { }
setTimeout(refresh, 5000);
});
refresh();
setInterval(refresh, 5000);
})();
</script>
31 changes: 31 additions & 0 deletions src/dapps/dapps.core/Services/MeshCoreBearer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,36 @@ public sealed class MeshCoreBearer : IDappsBackhaul, IAsyncDisposable
private MeshCoreLink? _link;
private MeshCoreCompanionBackhaul? _backhaul;
private MeshCoreInbound? _inbound;
private MeshCoreReliability? _reliability;

public bool Enabled { get; private set; }
public MeshCoreLink? Link => _link;
public MeshCoreInbound? Inbound => _inbound;

/// <summary>A read-only snapshot of the bearer for the dashboard / API.</summary>
public MeshCoreStatus GetStatus()
{
var link = _link;
var self = link?.Self;
return new MeshCoreStatus(
Enabled: Enabled,
LinkState: link?.State.ToString() ?? "Down",
FreqMhz: self?.FreqMhz,
Sf: self?.Sf,
Cr: self?.Cr,
Resets: link?.ResetCount ?? 0,
Occupancy: _backhaul?.Occupancy ?? 0,
Delivered: _inbound?.Delivered ?? 0,
ReliablePending: _reliability?.PendingCount ?? 0,
ReliableConfirmed: _reliability?.Confirmed ?? 0,
ReliableExpired: _reliability?.Expired ?? 0);
}

/// <summary>Operator device-control: force the radio through a hard reset +
/// reconfigure. Returns false if the bearer isn't running.</summary>
public Task<bool> ResetRadioAsync(CancellationToken ct) =>
_link is { } link ? link.RecoverAsync(ct) : Task.FromResult(false);

public MeshCoreBearer(
IOptionsMonitor<SystemOptions> sysOpts,
IBackhaulInbox inbox,
Expand Down Expand Up @@ -59,6 +84,7 @@ public async Task RunAsync(CancellationToken ct)
var opts = BuildOptions(s);
var budget = new TxBudget(opts.AirtimeBudgetSecPerHour);
var reliability = opts.ReliableDelivery ? new MeshCoreReliability() : null;
_reliability = reliability;
_link = new MeshCoreLink(opts, _loggerFactory.CreateLogger<MeshCoreLink>());

try
Expand Down Expand Up @@ -144,3 +170,8 @@ public async ValueTask DisposeAsync()
if (_link is not null) await _link.DisposeAsync();
}
}

/// <summary>Read-only MeshCore bearer status for the dashboard / API.</summary>
public sealed record MeshCoreStatus(
bool Enabled, string LinkState, double? FreqMhz, byte? Sf, byte? Cr, int Resets,
double Occupancy, long Delivered, int ReliablePending, long ReliableConfirmed, long ReliableExpired);
Loading