diff --git a/src/dapps/dapps.core.uitests/SettingsTests.cs b/src/dapps/dapps.core.uitests/SettingsTests.cs index f17b496..5821131 100644 --- a/src/dapps/dapps.core.uitests/SettingsTests.cs +++ b/src/dapps/dapps.core.uitests/SettingsTests.cs @@ -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"); diff --git a/src/dapps/dapps.core/Controllers/MeshCoreController.cs b/src/dapps/dapps.core/Controllers/MeshCoreController.cs new file mode 100644 index 0000000..c535809 --- /dev/null +++ b/src/dapps/dapps.core/Controllers/MeshCoreController.cs @@ -0,0 +1,22 @@ +using dapps.core.Services; +using Microsoft.AspNetCore.Mvc; + +namespace dapps.core.Controllers; + +/// +/// 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 /Config into SystemOptions). +/// +[ApiController] +[Route("[controller]")] +public class MeshCoreController(MeshCoreBearer bearer) : ControllerBase +{ + [HttpGet("status")] + public MeshCoreStatus Status() => bearer.GetStatus(); + + /// Force the radio through a hard reset + reconfigure. + [HttpPost("reset")] + public async Task Reset(CancellationToken ct) + => await bearer.ResetRadioAsync(ct) ? Ok() : StatusCode(503, "meshcore bearer not running"); +} diff --git a/src/dapps/dapps.core/Pages/Settings.cshtml b/src/dapps/dapps.core/Pages/Settings.cshtml index a630115..35e4815 100644 --- a/src/dapps/dapps.core/Pages/Settings.cshtml +++ b/src/dapps/dapps.core/Pages/Settings.cshtml @@ -85,6 +85,39 @@ +
+

MeshCore bearer

+

LoRa radio bearer over a MeshCore private channel. Enable, port and region changes need a daemon restart; the rest hot-reload.

+
+ +
e.g. /dev/ttyUSB0
+
+ +
+
capped by the region's max
+
+
0 = public; 1+ = private
+
+
32-char hex (16 bytes) or a passphrase
+
good-citizen duty cap
+
back off at occupancy ≥ this (0 = off)
+
+ + +
+
+ Status: + +
+
+

Discovery & airtime

Total trailing-hour airtime cap shared by every discovery transmission (beacons, solicits, probes). Per-channel caps live in Topology.

@@ -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'); @@ -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); +})(); diff --git a/src/dapps/dapps.core/Services/MeshCoreBearer.cs b/src/dapps/dapps.core/Services/MeshCoreBearer.cs index 3b7be96..148b64d 100644 --- a/src/dapps/dapps.core/Services/MeshCoreBearer.cs +++ b/src/dapps/dapps.core/Services/MeshCoreBearer.cs @@ -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; + /// A read-only snapshot of the bearer for the dashboard / API. + 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); + } + + /// Operator device-control: force the radio through a hard reset + + /// reconfigure. Returns false if the bearer isn't running. + public Task ResetRadioAsync(CancellationToken ct) => + _link is { } link ? link.RecoverAsync(ct) : Task.FromResult(false); + public MeshCoreBearer( IOptionsMonitor sysOpts, IBackhaulInbox inbox, @@ -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()); try @@ -144,3 +170,8 @@ public async ValueTask DisposeAsync() if (_link is not null) await _link.DisposeAsync(); } } + +/// Read-only MeshCore bearer status for the dashboard / API. +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);