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 @@
+
diff --git a/FieldLogger/Views/SettingsPage.xaml b/FieldLogger/Views/SettingsPage.xaml
index 28c26f7..6b2ff2e 100644
--- a/FieldLogger/Views/SettingsPage.xaml
+++ b/FieldLogger/Views/SettingsPage.xaml
@@ -52,8 +52,19 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+