feat(S2-b): connect durable MQTT sync to capture workflow
This commit is contained in:
@@ -1,15 +1,18 @@
|
||||
using FieldLogger.Services;
|
||||
using FieldLogger.Services.Sync;
|
||||
|
||||
namespace FieldLogger;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private readonly DeviceConnectionManager _connectionManager;
|
||||
private readonly IMqttSyncService _syncService;
|
||||
|
||||
public App(DeviceConnectionManager connectionManager)
|
||||
public App(DeviceConnectionManager connectionManager, IMqttSyncService syncService)
|
||||
{
|
||||
InitializeComponent();
|
||||
_connectionManager = connectionManager;
|
||||
_syncService = syncService;
|
||||
|
||||
Console.WriteLine("===== FIELD LOGGER APP STARTING =====");
|
||||
Console.WriteLine($"Console output is working! Time: {DateTime.Now:HH:mm:ss}");
|
||||
@@ -24,6 +27,7 @@ public partial class App : Application
|
||||
{
|
||||
Console.WriteLine("App: Window closing, disconnecting devices...");
|
||||
await DisconnectAllDevicesAsync();
|
||||
await _syncService.StopAsync();
|
||||
};
|
||||
|
||||
return window;
|
||||
@@ -54,4 +58,4 @@ public partial class App : Application
|
||||
Console.WriteLine($"App: Error disconnecting devices: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,10 @@
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\src\FieldLogger.Sync\FieldLogger.Sync.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Native map control (Google Maps on Android, Apple Maps on iOS/macOS). Not available on Windows,
|
||||
where MapPage falls back to a Google Maps JavaScript WebView instead. -->
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
|
||||
|
||||
@@ -36,7 +36,7 @@ public static class MauiProgram
|
||||
builder.Services.AddSingleton<MaglinkService>();
|
||||
builder.Services.AddSingleton<DeviceConnectionManager>();
|
||||
builder.Services.AddSingleton<PointLogger>();
|
||||
builder.Services.AddSingleton<IMqttSyncService, NullMqttSyncService>();
|
||||
builder.Services.AddSingleton<IMqttSyncService, MqttSyncService>();
|
||||
|
||||
// View models
|
||||
builder.Services.AddSingleton<HomeViewModel>();
|
||||
@@ -60,6 +60,7 @@ public static class MauiProgram
|
||||
|
||||
// Instantiate the point logger so it listens for receiver packets from startup.
|
||||
_ = app.Services.GetRequiredService<PointLogger>();
|
||||
_ = app.Services.GetRequiredService<IMqttSyncService>().StartAsync();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,13 @@ public sealed class LoggedPoint
|
||||
public string GpsRawSentence { get; set; } = "";
|
||||
public string GpsSerialNumber { get; set; } = "";
|
||||
|
||||
/// <summary>Reserved for MQTT sync.</summary>
|
||||
/// <summary>Stable UUIDv7 used for MQTT retries and cloud idempotency.</summary>
|
||||
[Indexed(Unique = true)]
|
||||
public string? SyncPointId { get; set; }
|
||||
|
||||
/// <summary>Machine-readable terminal/configuration error; null while pending or after success.</summary>
|
||||
public string? SyncError { get; set; }
|
||||
|
||||
public bool Synced { get; set; }
|
||||
|
||||
[Ignore]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
395
FieldLogger/Services/Sync/MqttSyncService.cs
Normal file
395
FieldLogger/Services/Sync/MqttSyncService.cs
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services;
|
||||
using FieldLogger.Services.Data;
|
||||
using FieldLogger.Services.Sync;
|
||||
|
||||
namespace FieldLogger.ViewModels;
|
||||
|
||||
@@ -14,6 +15,7 @@ public sealed partial class HomeViewModel : ObservableObject
|
||||
private readonly PointLogger _pointLogger;
|
||||
private readonly AppDatabase _database;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly IMqttSyncService _sync;
|
||||
|
||||
public DeviceConnectionManager Manager => _manager;
|
||||
|
||||
@@ -35,20 +37,27 @@ public sealed partial class HomeViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private string _fixAccuracy = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _syncStatus = "";
|
||||
|
||||
public ObservableCollection<LoggedPoint> RecentPoints { get; } = new();
|
||||
|
||||
public HomeViewModel(DeviceConnectionManager manager, MaglinkService gps,
|
||||
PointLogger pointLogger, AppDatabase database, SettingsService settings)
|
||||
PointLogger pointLogger, AppDatabase database, SettingsService settings, IMqttSyncService sync)
|
||||
{
|
||||
_manager = manager;
|
||||
_gps = gps;
|
||||
_pointLogger = pointLogger;
|
||||
_database = database;
|
||||
_settings = settings;
|
||||
_sync = sync;
|
||||
SyncStatus = sync.Status;
|
||||
|
||||
_gps.FixReceived += OnFixReceived;
|
||||
_pointLogger.PointSaved += OnPointSaved;
|
||||
_pointLogger.PacketIgnoredNoJob += OnPacketIgnoredNoJob;
|
||||
_sync.StatusChanged += OnSyncStatusChanged;
|
||||
_sync.PointRejected += OnPointRejected;
|
||||
}
|
||||
|
||||
/// <summary>Called from the page's OnAppearing.</summary>
|
||||
@@ -146,4 +155,14 @@ public sealed partial class HomeViewModel : ObservableObject
|
||||
"A point was logged on the receiver, but no job is active so it was not saved. Create or select a job first.",
|
||||
"OK");
|
||||
}
|
||||
|
||||
private void OnSyncStatusChanged(object? sender, string status) =>
|
||||
MainThread.BeginInvokeOnMainThread(() => SyncStatus = status);
|
||||
|
||||
private void OnPointRejected(object? sender, PointSyncFailureEventArgs e) =>
|
||||
MainThread.BeginInvokeOnMainThread(async () =>
|
||||
await Shell.Current.DisplayAlert(
|
||||
"Point Saved Locally — Sync Needs Attention",
|
||||
$"Point #{e.Point.Id} remains on this device and was not uploaded. Reason: {e.ReasonCode}.",
|
||||
"OK"));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services;
|
||||
using FieldLogger.Services.Sync;
|
||||
|
||||
namespace FieldLogger.ViewModels;
|
||||
|
||||
@@ -9,23 +10,104 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
{
|
||||
private readonly DeviceConnectionManager _manager;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly IMqttSyncService _sync;
|
||||
|
||||
public DeviceConnectionManager Manager => _manager;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _mapsApiKey = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _mqttEnabled;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _mqttHost = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _mqttPort = "8884";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _mqttOrgId = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _mqttPassword = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _syncStatus = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isSavingSync;
|
||||
|
||||
public string MqttClientId => _settings.MqttClientId;
|
||||
|
||||
public string AppVersion => AppInfo.Current.VersionString;
|
||||
|
||||
public SettingsViewModel(DeviceConnectionManager manager, SettingsService settings)
|
||||
public SettingsViewModel(DeviceConnectionManager manager, SettingsService settings, IMqttSyncService sync)
|
||||
{
|
||||
_manager = manager;
|
||||
_settings = settings;
|
||||
_sync = sync;
|
||||
MapsApiKey = settings.GoogleMapsApiKey;
|
||||
MqttEnabled = settings.MqttEnabled;
|
||||
MqttHost = settings.MqttHost;
|
||||
MqttPort = settings.MqttPort.ToString();
|
||||
MqttOrgId = settings.MqttOrgId;
|
||||
SyncStatus = sync.Status;
|
||||
_sync.StatusChanged += OnSyncStatusChanged;
|
||||
}
|
||||
|
||||
partial void OnMapsApiKeyChanged(string value) => _settings.GoogleMapsApiKey = value.Trim();
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveSyncAsync()
|
||||
{
|
||||
if (!int.TryParse(MqttPort, out var port) || port is < 1 or > 65535)
|
||||
{
|
||||
await Shell.Current.DisplayAlert("Invalid MQTT Port", "Enter a port between 1 and 65535.", "OK");
|
||||
return;
|
||||
}
|
||||
if (MqttEnabled && (string.IsNullOrWhiteSpace(MqttHost) || string.IsNullOrWhiteSpace(MqttOrgId)))
|
||||
{
|
||||
await Shell.Current.DisplayAlert("Incomplete Sync Settings", "Host and organization id are required.", "OK");
|
||||
return;
|
||||
}
|
||||
|
||||
IsSavingSync = true;
|
||||
try
|
||||
{
|
||||
_settings.MqttHost = MqttHost;
|
||||
_settings.MqttPort = port;
|
||||
_settings.MqttOrgId = MqttOrgId;
|
||||
_settings.MqttEnabled = MqttEnabled;
|
||||
if (!string.IsNullOrWhiteSpace(MqttPassword))
|
||||
{
|
||||
await _settings.SetMqttPasswordAsync(MqttPassword);
|
||||
MqttPassword = "";
|
||||
}
|
||||
|
||||
if (MqttEnabled)
|
||||
{
|
||||
await _sync.ConnectAsync();
|
||||
await Shell.Current.DisplayAlert("Sync Connected", "The durable MQTT queue is connected.", "OK");
|
||||
}
|
||||
else
|
||||
{
|
||||
await _sync.DisconnectAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Shell.Current.DisplayAlert("Sync Connection Failed", ex.Message, "OK");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSavingSync = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSyncStatusChanged(object? sender, string status) =>
|
||||
MainThread.BeginInvokeOnMainThread(() => SyncStatus = status);
|
||||
|
||||
[RelayCommand]
|
||||
private Task ChangeLocatorAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.Locator}");
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
</Grid>
|
||||
<Label Text="Press the log button on the receiver to capture a point."
|
||||
FontSize="12" TextColor="Gray" IsVisible="{Binding HasActiveJob}" />
|
||||
<Label Text="{Binding SyncStatus}" FontSize="11" TextColor="DodgerBlue" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -52,8 +52,19 @@
|
||||
|
||||
<Label Text="Sync" FontAttributes="Bold" FontSize="16" Margin="0,12,0,0" />
|
||||
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
|
||||
<Label Text="MQTT job sync is planned. Data is currently stored locally on this device."
|
||||
FontSize="12" TextColor="Gray" />
|
||||
<VerticalStackLayout Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Label Text="Durable cloud sync" VerticalOptions="Center" />
|
||||
<Switch Grid.Column="1" IsToggled="{Binding MqttEnabled}" />
|
||||
</Grid>
|
||||
<Entry Text="{Binding MqttHost}" Placeholder="dev.hub.umagul.net" />
|
||||
<Entry Text="{Binding MqttPort}" Placeholder="8884" Keyboard="Numeric" />
|
||||
<Entry Text="{Binding MqttOrgId}" Placeholder="Organization ID / MQTT username" />
|
||||
<Entry Text="{Binding MqttPassword}" Placeholder="New MQTT password (stored securely)" IsPassword="True" />
|
||||
<Label Text="{Binding MqttClientId, StringFormat='Client: {0}'}" FontSize="10" TextColor="Gray" />
|
||||
<Label Text="{Binding SyncStatus}" FontSize="12" TextColor="DodgerBlue" />
|
||||
<Button Text="Save & Connect" Command="{Binding SaveSyncCommand}" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
<Label Text="{Binding AppVersion, StringFormat='Field Logger v{0}'}"
|
||||
|
||||
Reference in New Issue
Block a user