feat(S2-b): connect durable MQTT sync to capture workflow

This commit is contained in:
Brent Perteet
2026-08-20 15:22:29 -05:00
parent a788cebea3
commit 71fcb9b63f
23 changed files with 1680 additions and 22 deletions

View File

@@ -76,6 +76,23 @@ public sealed class AppDatabase
.ToListAsync();
}
public async Task<List<LoggedPoint>> GetUnsyncedPointsAsync()
{
var db = await Db;
return await db.Table<LoggedPoint>()
.Where(point => !point.Synced)
.OrderBy(point => point.TimestampUtc)
.ToListAsync();
}
public async Task<LoggedPoint?> GetPointBySyncIdAsync(string syncPointId)
{
var db = await Db;
return await db.Table<LoggedPoint>()
.Where(point => point.SyncPointId == syncPointId)
.FirstOrDefaultAsync();
}
public async Task<int> GetPointCountAsync(int jobId)
{
var db = await Db;
@@ -87,4 +104,27 @@ public sealed class AppDatabase
var db = await Db;
await db.DeleteAsync<LoggedPoint>(pointId);
}
public async Task UpdatePointAsync(LoggedPoint point)
{
var db = await Db;
await db.UpdateAsync(point);
}
public async Task MarkPointSyncedAsync(string syncPointId)
{
var db = await Db;
await db.ExecuteAsync(
"UPDATE points SET Synced = 1, SyncError = NULL WHERE SyncPointId = ?",
syncPointId);
}
public async Task MarkPointSyncErrorAsync(string syncPointId, string reasonCode)
{
var db = await Db;
await db.ExecuteAsync(
"UPDATE points SET Synced = 0, SyncError = ? WHERE SyncPointId = ?",
reasonCode,
syncPointId);
}
}

View File

@@ -1,5 +1,6 @@
using FieldLogger.Models;
using FieldLogger.Services.Data;
using FieldLogger.Services.Sync;
using Microsoft.Extensions.Logging;
namespace FieldLogger.Services;
@@ -14,6 +15,7 @@ public sealed class PointLogger
private readonly MaglinkService _gps;
private readonly AppDatabase _database;
private readonly SettingsService _settings;
private readonly IMqttSyncService _sync;
private readonly ILogger<PointLogger> _logger;
/// <summary>Raised (on the UI thread) after a point is saved.</summary>
@@ -23,12 +25,13 @@ public sealed class PointLogger
public event EventHandler? PacketIgnoredNoJob;
public PointLogger(UmReceiverService locator, MaglinkService gps, AppDatabase database,
SettingsService settings, ILogger<PointLogger> logger)
SettingsService settings, IMqttSyncService sync, ILogger<PointLogger> logger)
{
_locator = locator;
_gps = gps;
_database = database;
_settings = settings;
_sync = sync;
_logger = logger;
_locator.PacketReceived += OnPacketReceived;
@@ -53,10 +56,13 @@ public sealed class PointLogger
var fix = _gps.FreshFix();
var point = LoggedPoint.From(jobId.Value, packet, _locator.DeviceInfo, fix, _gps.DeviceInfo);
point.SyncPointId = Guid.CreateVersion7().ToString();
await _database.AddPointAsync(point);
await _sync.PublishPointAsync(point);
_logger.LogInformation("Point {Id} saved to job {JobId} (gps valid: {GpsValid})",
point.Id, jobId, point.GpsValid);
_logger.LogInformation(
"Point {Id}/{SyncPointId} saved to job {JobId} (gps valid: {GpsValid}, sync error: {SyncError})",
point.Id, point.SyncPointId, jobId, point.GpsValid, point.SyncError);
MainThread.BeginInvokeOnMainThread(() => PointSaved?.Invoke(this, point));
}
catch (Exception ex)

View File

@@ -11,6 +11,12 @@ public sealed class SettingsService
private const string RtkNameKey = "device.rtk.name";
private const string ActiveJobKey = "job.active.id";
private const string MapsApiKeyKey = "maps.apikey";
private const string MqttEnabledKey = "mqtt.enabled";
private const string MqttHostKey = "mqtt.host";
private const string MqttPortKey = "mqtt.port";
private const string MqttOrgIdKey = "mqtt.org.id";
private const string MqttClientIdKey = "mqtt.client.id";
private const string MqttPasswordKey = "mqtt.password";
public Guid? GetSavedDeviceId(DeviceKind kind)
{
@@ -53,6 +59,52 @@ public sealed class SettingsService
set => Preferences.Default.Set(MapsApiKeyKey, value);
}
public bool MqttEnabled
{
get => Preferences.Default.Get(MqttEnabledKey, false);
set => Preferences.Default.Set(MqttEnabledKey, value);
}
public string MqttHost
{
get => Preferences.Default.Get(MqttHostKey, "dev.hub.umagul.net");
set => Preferences.Default.Set(MqttHostKey, value.Trim());
}
public int MqttPort
{
get => Preferences.Default.Get(MqttPortKey, 8884);
set => Preferences.Default.Set(MqttPortKey, value);
}
/// <summary>The interim MQTT username is the organization id.</summary>
public string MqttOrgId
{
get => Preferences.Default.Get(MqttOrgIdKey, string.Empty);
set => Preferences.Default.Set(MqttOrgIdKey, value.Trim());
}
public string MqttClientId
{
get
{
var existing = Preferences.Default.Get(MqttClientIdKey, string.Empty);
if (!string.IsNullOrWhiteSpace(existing)) return existing;
var created = $"fieldlogger-{Guid.NewGuid():N}";
Preferences.Default.Set(MqttClientIdKey, created);
return created;
}
}
public Task<string?> GetMqttPasswordAsync() => SecureStorage.Default.GetAsync(MqttPasswordKey);
public Task SetMqttPasswordAsync(string password) =>
string.IsNullOrWhiteSpace(password)
? throw new ArgumentException("MQTT password cannot be empty.", nameof(password))
: SecureStorage.Default.SetAsync(MqttPasswordKey, password);
public void ClearMqttPassword() => SecureStorage.Default.Remove(MqttPasswordKey);
private static string IdKey(DeviceKind kind) => kind == DeviceKind.Locator ? LocatorIdKey : RtkIdKey;
private static string NameKey(DeviceKind kind) => kind == DeviceKind.Locator ? LocatorNameKey : RtkNameKey;
}

View File

@@ -3,25 +3,30 @@ using FieldLogger.Models;
namespace FieldLogger.Services.Sync;
/// <summary>
/// Placeholder for the future MQTT synchronization layer:
/// - subscribe to receive jobs configured on the server
/// - publish logged points as they are captured
/// - reconcile the Synced flags on <see cref="Job"/> and <see cref="LoggedPoint"/>
/// Planned implementation: MQTTnet client against the configured broker.
/// Durable app MQTT synchronization. Captures are persisted locally before this service queues
/// them, and queue rows are released only by an application-level ACCEPTED/DUPLICATE ack.
/// </summary>
public interface IMqttSyncService
{
bool IsConnected { get; }
string Status { get; }
event EventHandler<string>? StatusChanged;
event EventHandler<PointSyncFailureEventArgs>? PointRejected;
Task StartAsync(CancellationToken cancellationToken = default);
Task StopAsync();
Task ConnectAsync(CancellationToken cancellationToken = default);
Task DisconnectAsync();
Task PublishPointAsync(LoggedPoint point, CancellationToken cancellationToken = default);
}
/// <summary>No-op stand-in until the MQTT backend exists.</summary>
public sealed class NullMqttSyncService : IMqttSyncService
public sealed class PointSyncFailureEventArgs : EventArgs
{
public bool IsConnected => false;
public Task ConnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task DisconnectAsync() => Task.CompletedTask;
public Task PublishPointAsync(LoggedPoint point, CancellationToken cancellationToken = default) => Task.CompletedTask;
public LoggedPoint Point { get; }
public string ReasonCode { get; }
public PointSyncFailureEventArgs(LoggedPoint point, string reasonCode)
{
Point = point;
ReasonCode = reasonCode;
}
}

View File

@@ -0,0 +1,395 @@
using FieldLogger.Models;
using FieldLogger.Services.Data;
using Microsoft.Extensions.Logging;
using SyncCore = FieldLogger.Sync;
namespace FieldLogger.Services.Sync;
/// <summary>
/// Bridges the MAUI capture database to the workload-free durable MQTT engine. Network loss never
/// deletes a capture: the local point and outbound queue are separate durable records, and an
/// application acknowledgement is the only path that marks the local point synced.
/// </summary>
public sealed class MqttSyncService : IMqttSyncService
{
private readonly SettingsService _settings;
private readonly AppDatabase _database;
private readonly ILogger<MqttSyncService> _logger;
private readonly SyncCore.SqliteOutboundStore _store;
private readonly SemaphoreSlim _connectionGate = new(1, 1);
private readonly SemaphoreSlim _pumpGate = new(1, 1);
private CancellationTokenSource? _lifetime;
private Task? _backgroundLoop;
private SyncCore.MqttnetTransport? _transport;
private SyncCore.MqttSyncEngine? _engine;
private bool _started;
public bool IsConnected => _transport?.IsConnected == true;
public string Status { get; private set; } = "Not started";
public event EventHandler<string>? StatusChanged;
public event EventHandler<PointSyncFailureEventArgs>? PointRejected;
public MqttSyncService(SettingsService settings, AppDatabase database, ILogger<MqttSyncService> logger)
{
_settings = settings;
_database = database;
_logger = logger;
_store = new SyncCore.SqliteOutboundStore(
Path.Combine(FileSystem.AppDataDirectory, "fieldlogger-sync.db3"));
}
public async Task StartAsync(CancellationToken cancellationToken = default)
{
if (_started) return;
_started = true;
await _store.InitAsync();
_lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_backgroundLoop = RunAsync(_lifetime.Token);
if (_settings.MqttEnabled)
{
try
{
await ConnectAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Initial MQTT connection failed; durable retry loop remains active");
SetStatus($"Waiting to reconnect: {ex.Message}");
}
}
else
{
SetStatus("Sync disabled");
}
}
public async Task StopAsync()
{
_lifetime?.Cancel();
if (_backgroundLoop is not null)
{
try { await _backgroundLoop; }
catch (OperationCanceledException) { }
}
await DisconnectTransportAsync();
SetStatus("Stopped");
}
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
await ConnectCoreAsync(forceReconnect: true, cancellationToken);
await QueueUnsyncedAsync(cancellationToken);
await DrainAsync(cancellationToken);
}
public async Task DisconnectAsync()
{
await DisconnectTransportAsync();
SetStatus("Disconnected");
}
public async Task PublishPointAsync(LoggedPoint point, CancellationToken cancellationToken = default)
{
await _pumpGate.WaitAsync(cancellationToken);
try
{
await QueuePointAsync(point, cancellationToken);
if (IsConnected && _engine is not null)
await _engine.DrainOnceAsync(cancellationToken);
}
finally
{
_pumpGate.Release();
}
}
private async Task QueuePointAsync(LoggedPoint point, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(point.SyncPointId))
{
point.SyncPointId = Guid.CreateVersion7().ToString();
point.Synced = false;
await _database.UpdatePointAsync(point);
}
if (string.IsNullOrWhiteSpace(_settings.MqttOrgId))
{
await RecordFailureAsync(point, "SYNC_NOT_CONFIGURED");
return;
}
var job = await _database.GetJobAsync(point.JobId);
if (job is null)
{
await RecordFailureAsync(point, "LOCAL_JOB_NOT_FOUND");
return;
}
try
{
var record = ToSyncPoint(point);
var topic = $"ul/{_settings.MqttOrgId}/app/{_settings.MqttClientId}/log/points";
string? remoteJobId = string.IsNullOrWhiteSpace(job.RemoteId) ? null : job.RemoteId;
string? ticket = remoteJobId is null ? TicketFor(job) : null;
var outbound = SyncCore.OutboundMessage.FromPoint(
record,
topic,
DateTimeOffset.UtcNow,
jobId: remoteJobId,
ticket: ticket);
await _store.EnqueueAsync(outbound);
point.SyncError = null;
await _database.UpdatePointAsync(point);
}
catch (SyncCore.PointNotPublishableException ex)
{
await RecordFailureAsync(point, ex.ReasonCode);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to enqueue point {PointId}", point.SyncPointId);
await RecordFailureAsync(point, "QUEUE_ERROR");
}
cancellationToken.ThrowIfCancellationRequested();
}
private async Task QueueUnsyncedAsync(CancellationToken cancellationToken)
{
foreach (var point in await _database.GetUnsyncedPointsAsync())
{
cancellationToken.ThrowIfCancellationRequested();
// Permanent capture/server rejections require operator action, not an automatic loop.
if (point.SyncError is not null && point.SyncError is not "SYNC_NOT_CONFIGURED" and not "QUEUE_ERROR")
continue;
await QueuePointAsync(point, cancellationToken);
}
}
private async Task ConnectCoreAsync(bool forceReconnect, CancellationToken cancellationToken)
{
if (!_settings.MqttEnabled)
throw new InvalidOperationException("MQTT sync is disabled in Settings.");
if (string.IsNullOrWhiteSpace(_settings.MqttHost) || string.IsNullOrWhiteSpace(_settings.MqttOrgId))
throw new InvalidOperationException("MQTT host and organization id are required.");
string password = await _settings.GetMqttPasswordAsync()
?? throw new InvalidOperationException("MQTT password is required.");
await _connectionGate.WaitAsync(cancellationToken);
try
{
if (IsConnected && !forceReconnect) return;
await DisconnectTransportAsync();
_transport = new SyncCore.MqttnetTransport(new SyncCore.MqttBrokerConfig
{
Host = _settings.MqttHost,
Port = _settings.MqttPort,
ClientId = _settings.MqttClientId,
Username = _settings.MqttOrgId,
Password = password,
UseTls = true,
});
_engine = new SyncCore.MqttSyncEngine(
_transport,
_store,
new SyncCore.SyncOptions
{
OrgId = _settings.MqttOrgId,
ClientId = _settings.MqttClientId,
});
_engine.PointAccepted += HandleAcceptedAsync;
_engine.PointRejected += HandleRejectedAsync;
await _engine.ConnectAsync(cancellationToken);
SetStatus("Connected");
}
finally
{
_connectionGate.Release();
}
}
private async Task RunAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
if (!_settings.MqttEnabled)
{
SetStatus("Sync disabled");
continue;
}
try
{
if (!IsConnected)
{
SetStatus("Connecting…");
await ConnectCoreAsync(forceReconnect: false, cancellationToken);
await QueueUnsyncedAsync(cancellationToken);
}
await DrainAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "MQTT sync iteration failed; queued data retained");
SetStatus($"Offline — retrying: {ex.Message}");
}
}
}
private async Task DrainAsync(CancellationToken cancellationToken)
{
if (_engine is null || !IsConnected) return;
await _pumpGate.WaitAsync(cancellationToken);
try
{
int published = await _engine.DrainOnceAsync(cancellationToken);
if (published > 0) SetStatus($"Connected — {published} point(s) awaiting ack");
}
finally
{
_pumpGate.Release();
}
}
private async Task HandleAcceptedAsync(string pointId)
{
await _database.MarkPointSyncedAsync(pointId);
SetStatus("Connected — synced");
}
private async Task HandleRejectedAsync(SyncCore.OutboundMessage message)
{
string reason = message.LastReason ?? "REJECTED";
await _database.MarkPointSyncErrorAsync(message.PointId, reason);
var point = await _database.GetPointBySyncIdAsync(message.PointId);
if (point is not null) PointRejected?.Invoke(this, new PointSyncFailureEventArgs(point, reason));
SetStatus($"Point rejected: {reason}");
}
private async Task RecordFailureAsync(LoggedPoint point, string reasonCode)
{
point.SyncError = reasonCode;
point.Synced = false;
await _database.UpdatePointAsync(point);
PointRejected?.Invoke(this, new PointSyncFailureEventArgs(point, reasonCode));
SetStatus($"Point not queued: {reasonCode}");
}
private async Task DisconnectTransportAsync()
{
if (_transport is null) return;
try { await _transport.DisposeAsync(); }
finally
{
_transport = null;
_engine = null;
}
}
private void SetStatus(string value)
{
if (Status == value) return;
Status = value;
StatusChanged?.Invoke(this, value);
}
private static string TicketFor(Job job)
{
string value = $"FL-{job.Id}-{job.Name}".Trim();
return value.Length <= 64 ? value : value[..64];
}
private static SyncCore.PointRecord ToSyncPoint(LoggedPoint point)
{
var timestamp = new DateTimeOffset(DateTime.SpecifyKind(point.TimestampUtc, DateTimeKind.Utc));
DateTimeOffset? positionEpoch = point.GpsUnixTimestamp > 0
? DateTimeOffset.FromUnixTimeSeconds(point.GpsUnixTimestamp)
: null;
return new SyncCore.PointRecord
{
PointId = point.SyncPointId!,
Origin = SyncCore.PointOrigin.APP,
UploadPath = SyncCore.UploadPath.APP_MQTT,
CaptureTrigger = SyncCore.CaptureTrigger.LOCATOR_BUTTON,
CreatedAt = timestamp,
Position = point.GpsValid
? new SyncCore.PositionGroup
{
Lat = point.Latitude,
Lon = point.Longitude,
EllipsoidalHeight = point.Altitude,
OrthometricHeight = point.AltitudeCorrected,
PositionEpoch = positionEpoch,
}
: null,
Gnss = new SyncCore.GnssGroup
{
FixType = FixType(point.FixStatusEnum),
SatsUsed = point.SatellitesUsed,
Hdop = point.Hdop,
Hrms = point.Hrms,
Vrms = point.Vrms,
CorrectionAge = point.CorrectionAgeSeconds,
ReceiverSerial = point.GpsSerialNumber,
TiltAngle = point.TiltAngle,
Source = string.IsNullOrWhiteSpace(point.GpsSerialNumber) ? "PHONE" : "MAGLINK",
},
Locate = new SyncCore.LocateGroup
{
Depth = point.DepthMeters,
DepthUnits = point.DepthMeters is null ? null : "m",
SignalCurrent = point.CurrentMilliamps,
SignalStrength = point.Signal,
Frequency = point.Frequency,
Gain = point.GainDb,
LocateMode = LocateMode(point),
PhaseDegrees = point.LdPhase,
CompassDegrees = point.CompassAngle,
LocatorModel = point.LocatorModel,
LocatorSerial = point.LocatorSerialNumber,
TelemetryEpoch = timestamp,
},
Attributes = new SyncCore.AttributesGroup { UtilityType = UtilityType(point.UtilityEnum) },
Quality = new SyncCore.QualityGroup
{
QualityFlag = point.GpsValid && point.Hrms > 0 && point.Hrms <= 0.10
? "IN_SPEC"
: "OUT_OF_SPEC",
},
};
}
private static string FixType(GnssFixStatus status) => status switch
{
GnssFixStatus.Single => "AUTONOMOUS",
GnssFixStatus.Dgps => "DGPS",
GnssFixStatus.RtkFloat => "FLOAT",
GnssFixStatus.RtkFixed => "FIXED",
_ => "NO_FIX",
};
private static string UtilityType(UmUtility utility) => utility switch
{
UmUtility.Gas => "GAS",
UmUtility.Power => "ELECTRIC",
UmUtility.Communications => "TELECOM",
UmUtility.Water => "WATER",
UmUtility.Sewer => "SEWER",
UmUtility.Fiber => "FIBER",
_ => "UNKNOWN",
};
private static string LocateMode(LoggedPoint point) => point.FreqType == (int)UmFreqType.Sonde
? "SONDE"
: (UmMode)point.Mode switch
{
UmMode.Null => "NULL",
UmMode.Omni or UmMode.TwinOmni => "BROAD_PEAK",
_ => "PEAK",
};
}