diff --git a/FieldLogger/App.xaml.cs b/FieldLogger/App.xaml.cs index 691ecbc..d8a56b4 100644 --- a/FieldLogger/App.xaml.cs +++ b/FieldLogger/App.xaml.cs @@ -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}"); } } -} \ No newline at end of file +} diff --git a/FieldLogger/FieldLogger.csproj b/FieldLogger/FieldLogger.csproj index 6b5a0bd..35c2479 100644 --- a/FieldLogger/FieldLogger.csproj +++ b/FieldLogger/FieldLogger.csproj @@ -85,6 +85,10 @@ + + + + diff --git a/FieldLogger/MauiProgram.cs b/FieldLogger/MauiProgram.cs index 4545bf3..f65cf29 100644 --- a/FieldLogger/MauiProgram.cs +++ b/FieldLogger/MauiProgram.cs @@ -36,7 +36,7 @@ public static class MauiProgram builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // View models builder.Services.AddSingleton(); @@ -60,6 +60,7 @@ public static class MauiProgram // Instantiate the point logger so it listens for receiver packets from startup. _ = app.Services.GetRequiredService(); + _ = app.Services.GetRequiredService().StartAsync(); return app; } diff --git a/FieldLogger/Models/LoggedPoint.cs b/FieldLogger/Models/LoggedPoint.cs index ca83cb9..64497f1 100644 --- a/FieldLogger/Models/LoggedPoint.cs +++ b/FieldLogger/Models/LoggedPoint.cs @@ -69,7 +69,13 @@ public sealed class LoggedPoint public string GpsRawSentence { get; set; } = ""; public string GpsSerialNumber { get; set; } = ""; - /// Reserved for MQTT sync. + /// Stable UUIDv7 used for MQTT retries and cloud idempotency. + [Indexed(Unique = true)] + public string? SyncPointId { get; set; } + + /// Machine-readable terminal/configuration error; null while pending or after success. + public string? SyncError { get; set; } + public bool Synced { get; set; } [Ignore] diff --git a/FieldLogger/Services/Data/AppDatabase.cs b/FieldLogger/Services/Data/AppDatabase.cs index ec4150b..7dba3a8 100644 --- a/FieldLogger/Services/Data/AppDatabase.cs +++ b/FieldLogger/Services/Data/AppDatabase.cs @@ -76,6 +76,23 @@ public sealed class AppDatabase .ToListAsync(); } + public async Task> GetUnsyncedPointsAsync() + { + var db = await Db; + return await db.Table() + .Where(point => !point.Synced) + .OrderBy(point => point.TimestampUtc) + .ToListAsync(); + } + + public async Task GetPointBySyncIdAsync(string syncPointId) + { + var db = await Db; + return await db.Table() + .Where(point => point.SyncPointId == syncPointId) + .FirstOrDefaultAsync(); + } + public async Task GetPointCountAsync(int jobId) { var db = await Db; @@ -87,4 +104,27 @@ public sealed class AppDatabase var db = await Db; await db.DeleteAsync(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); + } } diff --git a/FieldLogger/Services/PointLogger.cs b/FieldLogger/Services/PointLogger.cs index 2a3c433..d5af272 100644 --- a/FieldLogger/Services/PointLogger.cs +++ b/FieldLogger/Services/PointLogger.cs @@ -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 _logger; /// Raised (on the UI thread) after a point is saved. @@ -23,12 +25,13 @@ public sealed class PointLogger public event EventHandler? PacketIgnoredNoJob; public PointLogger(UmReceiverService locator, MaglinkService gps, AppDatabase database, - SettingsService settings, ILogger logger) + SettingsService settings, IMqttSyncService sync, ILogger 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) diff --git a/FieldLogger/Services/SettingsService.cs b/FieldLogger/Services/SettingsService.cs index ee30e34..0710eb7 100644 --- a/FieldLogger/Services/SettingsService.cs +++ b/FieldLogger/Services/SettingsService.cs @@ -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); + } + + /// The interim MQTT username is the organization id. + 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 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; } diff --git a/FieldLogger/Services/Sync/IMqttSyncService.cs b/FieldLogger/Services/Sync/IMqttSyncService.cs index ba6ff63..27048b1 100644 --- a/FieldLogger/Services/Sync/IMqttSyncService.cs +++ b/FieldLogger/Services/Sync/IMqttSyncService.cs @@ -3,25 +3,30 @@ using FieldLogger.Models; namespace FieldLogger.Services.Sync; /// -/// 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 and -/// 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. /// public interface IMqttSyncService { bool IsConnected { get; } + string Status { get; } + event EventHandler? StatusChanged; + event EventHandler? PointRejected; + Task StartAsync(CancellationToken cancellationToken = default); + Task StopAsync(); Task ConnectAsync(CancellationToken cancellationToken = default); Task DisconnectAsync(); Task PublishPointAsync(LoggedPoint point, CancellationToken cancellationToken = default); } -/// No-op stand-in until the MQTT backend exists. -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; + } } diff --git a/FieldLogger/Services/Sync/MqttSyncService.cs b/FieldLogger/Services/Sync/MqttSyncService.cs new file mode 100644 index 0000000..4044d47 --- /dev/null +++ b/FieldLogger/Services/Sync/MqttSyncService.cs @@ -0,0 +1,395 @@ +using FieldLogger.Models; +using FieldLogger.Services.Data; +using Microsoft.Extensions.Logging; +using SyncCore = FieldLogger.Sync; + +namespace FieldLogger.Services.Sync; + +/// +/// 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. +/// +public sealed class MqttSyncService : IMqttSyncService +{ + private readonly SettingsService _settings; + private readonly AppDatabase _database; + private readonly ILogger _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? StatusChanged; + public event EventHandler? PointRejected; + + public MqttSyncService(SettingsService settings, AppDatabase database, ILogger 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", + }; +} diff --git a/FieldLogger/ViewModels/HomeViewModel.cs b/FieldLogger/ViewModels/HomeViewModel.cs index 54a98cf..0598b96 100644 --- a/FieldLogger/ViewModels/HomeViewModel.cs +++ b/FieldLogger/ViewModels/HomeViewModel.cs @@ -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 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; } /// Called from the page's OnAppearing. @@ -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")); } diff --git a/FieldLogger/ViewModels/SettingsViewModel.cs b/FieldLogger/ViewModels/SettingsViewModel.cs index 5ac61e6..2868b24 100644 --- a/FieldLogger/ViewModels/SettingsViewModel.cs +++ b/FieldLogger/ViewModels/SettingsViewModel.cs @@ -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}"); diff --git a/FieldLogger/Views/HomePage.xaml b/FieldLogger/Views/HomePage.xaml index 1664815..76ae658 100644 --- a/FieldLogger/Views/HomePage.xaml +++ b/FieldLogger/Views/HomePage.xaml @@ -63,6 +63,7 @@