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;
|
||||||
|
using FieldLogger.Services.Sync;
|
||||||
|
|
||||||
namespace FieldLogger;
|
namespace FieldLogger;
|
||||||
|
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
private readonly DeviceConnectionManager _connectionManager;
|
private readonly DeviceConnectionManager _connectionManager;
|
||||||
|
private readonly IMqttSyncService _syncService;
|
||||||
|
|
||||||
public App(DeviceConnectionManager connectionManager)
|
public App(DeviceConnectionManager connectionManager, IMqttSyncService syncService)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_connectionManager = connectionManager;
|
_connectionManager = connectionManager;
|
||||||
|
_syncService = syncService;
|
||||||
|
|
||||||
Console.WriteLine("===== FIELD LOGGER APP STARTING =====");
|
Console.WriteLine("===== FIELD LOGGER APP STARTING =====");
|
||||||
Console.WriteLine($"Console output is working! Time: {DateTime.Now:HH:mm:ss}");
|
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...");
|
Console.WriteLine("App: Window closing, disconnecting devices...");
|
||||||
await DisconnectAllDevicesAsync();
|
await DisconnectAllDevicesAsync();
|
||||||
|
await _syncService.StopAsync();
|
||||||
};
|
};
|
||||||
|
|
||||||
return window;
|
return window;
|
||||||
@@ -54,4 +58,4 @@ public partial class App : Application
|
|||||||
Console.WriteLine($"App: Error disconnecting devices: {ex.Message}");
|
Console.WriteLine($"App: Error disconnecting devices: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,10 @@
|
|||||||
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.10" />
|
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.10" />
|
||||||
</ItemGroup>
|
</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,
|
<!-- 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. -->
|
where MapPage falls back to a Google Maps JavaScript WebView instead. -->
|
||||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
|
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public static class MauiProgram
|
|||||||
builder.Services.AddSingleton<MaglinkService>();
|
builder.Services.AddSingleton<MaglinkService>();
|
||||||
builder.Services.AddSingleton<DeviceConnectionManager>();
|
builder.Services.AddSingleton<DeviceConnectionManager>();
|
||||||
builder.Services.AddSingleton<PointLogger>();
|
builder.Services.AddSingleton<PointLogger>();
|
||||||
builder.Services.AddSingleton<IMqttSyncService, NullMqttSyncService>();
|
builder.Services.AddSingleton<IMqttSyncService, MqttSyncService>();
|
||||||
|
|
||||||
// View models
|
// View models
|
||||||
builder.Services.AddSingleton<HomeViewModel>();
|
builder.Services.AddSingleton<HomeViewModel>();
|
||||||
@@ -60,6 +60,7 @@ public static class MauiProgram
|
|||||||
|
|
||||||
// Instantiate the point logger so it listens for receiver packets from startup.
|
// Instantiate the point logger so it listens for receiver packets from startup.
|
||||||
_ = app.Services.GetRequiredService<PointLogger>();
|
_ = app.Services.GetRequiredService<PointLogger>();
|
||||||
|
_ = app.Services.GetRequiredService<IMqttSyncService>().StartAsync();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,13 @@ public sealed class LoggedPoint
|
|||||||
public string GpsRawSentence { get; set; } = "";
|
public string GpsRawSentence { get; set; } = "";
|
||||||
public string GpsSerialNumber { 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; }
|
public bool Synced { get; set; }
|
||||||
|
|
||||||
[Ignore]
|
[Ignore]
|
||||||
|
|||||||
@@ -76,6 +76,23 @@ public sealed class AppDatabase
|
|||||||
.ToListAsync();
|
.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)
|
public async Task<int> GetPointCountAsync(int jobId)
|
||||||
{
|
{
|
||||||
var db = await Db;
|
var db = await Db;
|
||||||
@@ -87,4 +104,27 @@ public sealed class AppDatabase
|
|||||||
var db = await Db;
|
var db = await Db;
|
||||||
await db.DeleteAsync<LoggedPoint>(pointId);
|
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.Models;
|
||||||
using FieldLogger.Services.Data;
|
using FieldLogger.Services.Data;
|
||||||
|
using FieldLogger.Services.Sync;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace FieldLogger.Services;
|
namespace FieldLogger.Services;
|
||||||
@@ -14,6 +15,7 @@ public sealed class PointLogger
|
|||||||
private readonly MaglinkService _gps;
|
private readonly MaglinkService _gps;
|
||||||
private readonly AppDatabase _database;
|
private readonly AppDatabase _database;
|
||||||
private readonly SettingsService _settings;
|
private readonly SettingsService _settings;
|
||||||
|
private readonly IMqttSyncService _sync;
|
||||||
private readonly ILogger<PointLogger> _logger;
|
private readonly ILogger<PointLogger> _logger;
|
||||||
|
|
||||||
/// <summary>Raised (on the UI thread) after a point is saved.</summary>
|
/// <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 event EventHandler? PacketIgnoredNoJob;
|
||||||
|
|
||||||
public PointLogger(UmReceiverService locator, MaglinkService gps, AppDatabase database,
|
public PointLogger(UmReceiverService locator, MaglinkService gps, AppDatabase database,
|
||||||
SettingsService settings, ILogger<PointLogger> logger)
|
SettingsService settings, IMqttSyncService sync, ILogger<PointLogger> logger)
|
||||||
{
|
{
|
||||||
_locator = locator;
|
_locator = locator;
|
||||||
_gps = gps;
|
_gps = gps;
|
||||||
_database = database;
|
_database = database;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
_sync = sync;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
_locator.PacketReceived += OnPacketReceived;
|
_locator.PacketReceived += OnPacketReceived;
|
||||||
@@ -53,10 +56,13 @@ public sealed class PointLogger
|
|||||||
|
|
||||||
var fix = _gps.FreshFix();
|
var fix = _gps.FreshFix();
|
||||||
var point = LoggedPoint.From(jobId.Value, packet, _locator.DeviceInfo, fix, _gps.DeviceInfo);
|
var point = LoggedPoint.From(jobId.Value, packet, _locator.DeviceInfo, fix, _gps.DeviceInfo);
|
||||||
|
point.SyncPointId = Guid.CreateVersion7().ToString();
|
||||||
await _database.AddPointAsync(point);
|
await _database.AddPointAsync(point);
|
||||||
|
await _sync.PublishPointAsync(point);
|
||||||
|
|
||||||
_logger.LogInformation("Point {Id} saved to job {JobId} (gps valid: {GpsValid})",
|
_logger.LogInformation(
|
||||||
point.Id, jobId, point.GpsValid);
|
"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));
|
MainThread.BeginInvokeOnMainThread(() => PointSaved?.Invoke(this, point));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ public sealed class SettingsService
|
|||||||
private const string RtkNameKey = "device.rtk.name";
|
private const string RtkNameKey = "device.rtk.name";
|
||||||
private const string ActiveJobKey = "job.active.id";
|
private const string ActiveJobKey = "job.active.id";
|
||||||
private const string MapsApiKeyKey = "maps.apikey";
|
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)
|
public Guid? GetSavedDeviceId(DeviceKind kind)
|
||||||
{
|
{
|
||||||
@@ -53,6 +59,52 @@ public sealed class SettingsService
|
|||||||
set => Preferences.Default.Set(MapsApiKeyKey, value);
|
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 IdKey(DeviceKind kind) => kind == DeviceKind.Locator ? LocatorIdKey : RtkIdKey;
|
||||||
private static string NameKey(DeviceKind kind) => kind == DeviceKind.Locator ? LocatorNameKey : RtkNameKey;
|
private static string NameKey(DeviceKind kind) => kind == DeviceKind.Locator ? LocatorNameKey : RtkNameKey;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,30 @@ using FieldLogger.Models;
|
|||||||
namespace FieldLogger.Services.Sync;
|
namespace FieldLogger.Services.Sync;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Placeholder for the future MQTT synchronization layer:
|
/// Durable app MQTT synchronization. Captures are persisted locally before this service queues
|
||||||
/// - subscribe to receive jobs configured on the server
|
/// them, and queue rows are released only by an application-level ACCEPTED/DUPLICATE ack.
|
||||||
/// - 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.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IMqttSyncService
|
public interface IMqttSyncService
|
||||||
{
|
{
|
||||||
bool IsConnected { get; }
|
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 ConnectAsync(CancellationToken cancellationToken = default);
|
||||||
Task DisconnectAsync();
|
Task DisconnectAsync();
|
||||||
Task PublishPointAsync(LoggedPoint point, CancellationToken cancellationToken = default);
|
Task PublishPointAsync(LoggedPoint point, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>No-op stand-in until the MQTT backend exists.</summary>
|
public sealed class PointSyncFailureEventArgs : EventArgs
|
||||||
public sealed class NullMqttSyncService : IMqttSyncService
|
|
||||||
{
|
{
|
||||||
public bool IsConnected => false;
|
public LoggedPoint Point { get; }
|
||||||
public Task ConnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
public string ReasonCode { get; }
|
||||||
public Task DisconnectAsync() => Task.CompletedTask;
|
|
||||||
public Task PublishPointAsync(LoggedPoint point, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
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.Models;
|
||||||
using FieldLogger.Services;
|
using FieldLogger.Services;
|
||||||
using FieldLogger.Services.Data;
|
using FieldLogger.Services.Data;
|
||||||
|
using FieldLogger.Services.Sync;
|
||||||
|
|
||||||
namespace FieldLogger.ViewModels;
|
namespace FieldLogger.ViewModels;
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ public sealed partial class HomeViewModel : ObservableObject
|
|||||||
private readonly PointLogger _pointLogger;
|
private readonly PointLogger _pointLogger;
|
||||||
private readonly AppDatabase _database;
|
private readonly AppDatabase _database;
|
||||||
private readonly SettingsService _settings;
|
private readonly SettingsService _settings;
|
||||||
|
private readonly IMqttSyncService _sync;
|
||||||
|
|
||||||
public DeviceConnectionManager Manager => _manager;
|
public DeviceConnectionManager Manager => _manager;
|
||||||
|
|
||||||
@@ -35,20 +37,27 @@ public sealed partial class HomeViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _fixAccuracy = "";
|
private string _fixAccuracy = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _syncStatus = "";
|
||||||
|
|
||||||
public ObservableCollection<LoggedPoint> RecentPoints { get; } = new();
|
public ObservableCollection<LoggedPoint> RecentPoints { get; } = new();
|
||||||
|
|
||||||
public HomeViewModel(DeviceConnectionManager manager, MaglinkService gps,
|
public HomeViewModel(DeviceConnectionManager manager, MaglinkService gps,
|
||||||
PointLogger pointLogger, AppDatabase database, SettingsService settings)
|
PointLogger pointLogger, AppDatabase database, SettingsService settings, IMqttSyncService sync)
|
||||||
{
|
{
|
||||||
_manager = manager;
|
_manager = manager;
|
||||||
_gps = gps;
|
_gps = gps;
|
||||||
_pointLogger = pointLogger;
|
_pointLogger = pointLogger;
|
||||||
_database = database;
|
_database = database;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
_sync = sync;
|
||||||
|
SyncStatus = sync.Status;
|
||||||
|
|
||||||
_gps.FixReceived += OnFixReceived;
|
_gps.FixReceived += OnFixReceived;
|
||||||
_pointLogger.PointSaved += OnPointSaved;
|
_pointLogger.PointSaved += OnPointSaved;
|
||||||
_pointLogger.PacketIgnoredNoJob += OnPacketIgnoredNoJob;
|
_pointLogger.PacketIgnoredNoJob += OnPacketIgnoredNoJob;
|
||||||
|
_sync.StatusChanged += OnSyncStatusChanged;
|
||||||
|
_sync.PointRejected += OnPointRejected;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Called from the page's OnAppearing.</summary>
|
/// <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.",
|
"A point was logged on the receiver, but no job is active so it was not saved. Create or select a job first.",
|
||||||
"OK");
|
"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 CommunityToolkit.Mvvm.Input;
|
||||||
using FieldLogger.Models;
|
using FieldLogger.Models;
|
||||||
using FieldLogger.Services;
|
using FieldLogger.Services;
|
||||||
|
using FieldLogger.Services.Sync;
|
||||||
|
|
||||||
namespace FieldLogger.ViewModels;
|
namespace FieldLogger.ViewModels;
|
||||||
|
|
||||||
@@ -9,23 +10,104 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
private readonly DeviceConnectionManager _manager;
|
private readonly DeviceConnectionManager _manager;
|
||||||
private readonly SettingsService _settings;
|
private readonly SettingsService _settings;
|
||||||
|
private readonly IMqttSyncService _sync;
|
||||||
|
|
||||||
public DeviceConnectionManager Manager => _manager;
|
public DeviceConnectionManager Manager => _manager;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _mapsApiKey = "";
|
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 string AppVersion => AppInfo.Current.VersionString;
|
||||||
|
|
||||||
public SettingsViewModel(DeviceConnectionManager manager, SettingsService settings)
|
public SettingsViewModel(DeviceConnectionManager manager, SettingsService settings, IMqttSyncService sync)
|
||||||
{
|
{
|
||||||
_manager = manager;
|
_manager = manager;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
_sync = sync;
|
||||||
MapsApiKey = settings.GoogleMapsApiKey;
|
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();
|
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]
|
[RelayCommand]
|
||||||
private Task ChangeLocatorAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.Locator}");
|
private Task ChangeLocatorAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.Locator}");
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Label Text="Press the log button on the receiver to capture a point."
|
<Label Text="Press the log button on the receiver to capture a point."
|
||||||
FontSize="12" TextColor="Gray" IsVisible="{Binding HasActiveJob}" />
|
FontSize="12" TextColor="Gray" IsVisible="{Binding HasActiveJob}" />
|
||||||
|
<Label Text="{Binding SyncStatus}" FontSize="11" TextColor="DodgerBlue" />
|
||||||
</VerticalStackLayout>
|
</VerticalStackLayout>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,19 @@
|
|||||||
|
|
||||||
<Label Text="Sync" FontAttributes="Bold" FontSize="16" Margin="0,12,0,0" />
|
<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">
|
<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."
|
<VerticalStackLayout Spacing="8">
|
||||||
FontSize="12" TextColor="Gray" />
|
<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>
|
</Border>
|
||||||
|
|
||||||
<Label Text="{Binding AppVersion, StringFormat='Field Logger v{0}'}"
|
<Label Text="{Binding AppVersion, StringFormat='Field Logger v{0}'}"
|
||||||
|
|||||||
25
src/FieldLogger.Sync/BackoffPolicy.cs
Normal file
25
src/FieldLogger.Sync/BackoffPolicy.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
namespace FieldLogger.Sync;
|
||||||
|
|
||||||
|
/// <summary>Exponential retry backoff with full jitter and a hard cap.</summary>
|
||||||
|
public sealed class BackoffPolicy
|
||||||
|
{
|
||||||
|
private readonly TimeSpan _base;
|
||||||
|
private readonly TimeSpan _cap;
|
||||||
|
private readonly Random _random;
|
||||||
|
|
||||||
|
public BackoffPolicy(TimeSpan baseDelay, TimeSpan cap, Random? random = null)
|
||||||
|
{
|
||||||
|
if (baseDelay <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(baseDelay));
|
||||||
|
if (cap < baseDelay) throw new ArgumentOutOfRangeException(nameof(cap));
|
||||||
|
_base = baseDelay;
|
||||||
|
_cap = cap;
|
||||||
|
_random = random ?? Random.Shared;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeSpan NextDelay(int attempt)
|
||||||
|
{
|
||||||
|
int exponent = Math.Min(Math.Max(1, attempt) - 1, 30);
|
||||||
|
double ceilingMs = Math.Min(_cap.TotalMilliseconds, _base.TotalMilliseconds * Math.Pow(2, exponent));
|
||||||
|
return TimeSpan.FromMilliseconds(_random.NextDouble() * ceilingMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/FieldLogger.Sync/FieldLogger.Sync.csproj
Normal file
27
src/FieldLogger.Sync/FieldLogger.Sync.csproj
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Cloud-sync core for Field Logger: the durable outbound queue, the Point wire mapping,
|
||||||
|
and the MQTTS publish/ack engine (SRS-SYN-2/5/7, §3.4.2, telemetry-schema.md).
|
||||||
|
Deliberately a plain net9.0 library (NO MAUI head) so the queue-durability, ack-release,
|
||||||
|
and exactly-once behaviour run headless in the QA gate without the Android/iOS workloads.
|
||||||
|
The MAUI app references this and supplies a DB path + broker config; tests supply a fake
|
||||||
|
transport and a temp-file store.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<RootNamespace>FieldLogger.Sync</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Real broker transport. Tests do not touch this (they use a fake IMqttTransport). -->
|
||||||
|
<PackageReference Include="MQTTnet" Version="4.3.7.1207" />
|
||||||
|
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
|
||||||
|
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.10" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
56
src/FieldLogger.Sync/IMqttTransport.cs
Normal file
56
src/FieldLogger.Sync/IMqttTransport.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace FieldLogger.Sync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal MQTTS transport the sync engine drives. Abstracted so the engine's
|
||||||
|
/// durability/ack/backoff behaviour is tested headless with a fake, while the app uses the
|
||||||
|
/// MQTTnet-backed implementation against the real broker (§3.4.2).
|
||||||
|
/// </summary>
|
||||||
|
public interface IMqttTransport
|
||||||
|
{
|
||||||
|
bool IsConnected { get; }
|
||||||
|
|
||||||
|
/// <summary>Connect (MQTTS/TLS) and subscribe to the ack topic. Throws on failure.</summary>
|
||||||
|
Task ConnectAsync(string ackTopic, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task DisconnectAsync();
|
||||||
|
|
||||||
|
/// <summary>Publish a payload at QoS 1 (durable). Completes on broker PUBACK; throws on failure.</summary>
|
||||||
|
Task PublishAsync(string topic, byte[] payload, string schemaVersion, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Raised when an application-level ack payload arrives on the ack topic.</summary>
|
||||||
|
event Func<AckBatch, Task>? AckReceived;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Application-level ack payload on ul/{orgId}/app/{clientId}/ack (SRS §3.4.2 "…/ack:
|
||||||
|
/// UUIDs accepted/rejected + reason"). Distinct from the broker PUBACK — the queue releases a
|
||||||
|
/// record only on the application ack referencing its pointId.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AckBatch
|
||||||
|
{
|
||||||
|
[JsonPropertyName("schemaVersion")] public string SchemaVersion { get; init; } = "1";
|
||||||
|
[JsonPropertyName("results")] public List<AckItem> Results { get; init; } = new();
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions Opts = new()
|
||||||
|
{
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static AckBatch FromJsonUtf8(ReadOnlySpan<byte> utf8) =>
|
||||||
|
JsonSerializer.Deserialize<AckBatch>(utf8, Opts) ?? throw new JsonException("null AckBatch");
|
||||||
|
public byte[] ToJsonUtf8() => JsonSerializer.SerializeToUtf8Bytes(this, Opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AckOutcome { ACCEPTED, DUPLICATE, REJECTED }
|
||||||
|
|
||||||
|
public sealed record AckItem
|
||||||
|
{
|
||||||
|
[JsonPropertyName("pointId")] public required string PointId { get; init; }
|
||||||
|
[JsonPropertyName("outcome")] public AckOutcome Outcome { get; init; }
|
||||||
|
/// <summary>Machine code when REJECTED (api.yaml Error catalog); null on ACCEPTED.</summary>
|
||||||
|
[JsonPropertyName("reasonCode")] public string? ReasonCode { get; init; }
|
||||||
|
}
|
||||||
134
src/FieldLogger.Sync/MqttSyncEngine.cs
Normal file
134
src/FieldLogger.Sync/MqttSyncEngine.cs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
namespace FieldLogger.Sync;
|
||||||
|
|
||||||
|
/// <summary>Tunables for the publish/retry/ack loop.</summary>
|
||||||
|
public sealed record SyncOptions
|
||||||
|
{
|
||||||
|
/// <summary>ul/{orgId}/app/{clientId} — the app's own namespace root. The engine only ever
|
||||||
|
/// publishes/subscribes under this prefix (namespace confinement; SRS §3.4.2 / §10.2).</summary>
|
||||||
|
public required string OrgId { get; init; }
|
||||||
|
public required string ClientId { get; init; }
|
||||||
|
|
||||||
|
public string PointsTopic => $"ul/{OrgId}/app/{ClientId}/log/points";
|
||||||
|
public string AckTopic => $"ul/{OrgId}/app/{ClientId}/ack";
|
||||||
|
|
||||||
|
/// <summary>How long to wait for the application-level ack before republishing (idempotent
|
||||||
|
/// via UUID; cloud dedups). Not a failure — release still requires the ack.</summary>
|
||||||
|
public TimeSpan AckWindow { get; init; } = TimeSpan.FromSeconds(10);
|
||||||
|
/// <summary>Backoff on broker/publish failure: base, doubled per attempt, capped.</summary>
|
||||||
|
public TimeSpan BackoffBase { get; init; } = TimeSpan.FromSeconds(1);
|
||||||
|
public TimeSpan BackoffCap { get; init; } = TimeSpan.FromSeconds(60);
|
||||||
|
public int DrainBatch { get; init; } = 50;
|
||||||
|
|
||||||
|
/// <summary>Reason codes that make a REJECTED ack terminal (drop from queue + surface for
|
||||||
|
/// LOG-7) rather than retryable. Everything else is retried with backoff. Default: schema /
|
||||||
|
/// validation / authorization failures — resending won't help.</summary>
|
||||||
|
public IReadOnlySet<string> TerminalRejectCodes { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"VALIDATION_ERROR", "POINT_ID_NOT_UUIDV7", "ENVELOPE_VALIDATION_ERROR",
|
||||||
|
"UNKNOWN_JOB_OR_WRONG_ORG", "SCHEMA_INVALID", "FORBIDDEN", "UNAUTHENTICATED",
|
||||||
|
"NOT_FOUND", "PAYLOAD_TOO_LARGE",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the durable outbound queue to the broker and releases records only on the cloud's
|
||||||
|
/// application-level ack (SRS-SYN-2/7, §3.4.2). Publish is idempotent via the UUIDv7 pointId,
|
||||||
|
/// so an unacked record is safely republished after the ack window and the cloud de-duplicates.
|
||||||
|
/// Broker loss never loses a capture — the record stays durably queued (SRS-SYN-1). Transport-
|
||||||
|
/// and store-agnostic so it runs headless in tests.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MqttSyncEngine
|
||||||
|
{
|
||||||
|
private readonly IMqttTransport _transport;
|
||||||
|
private readonly IOutboundStore _store;
|
||||||
|
private readonly SyncOptions _opts;
|
||||||
|
private readonly Func<DateTimeOffset> _now;
|
||||||
|
private readonly BackoffPolicy _backoff;
|
||||||
|
|
||||||
|
/// <summary>Raised when the cloud terminally rejects a record (LOG-7 surface: never silent).</summary>
|
||||||
|
public event Func<OutboundMessage, Task>? PointRejected;
|
||||||
|
/// <summary>Raised when a record is accepted and released from the queue.</summary>
|
||||||
|
public event Func<string, Task>? PointAccepted;
|
||||||
|
|
||||||
|
public MqttSyncEngine(IMqttTransport transport, IOutboundStore store, SyncOptions opts,
|
||||||
|
Func<DateTimeOffset>? now = null, BackoffPolicy? backoff = null)
|
||||||
|
{
|
||||||
|
_transport = transport;
|
||||||
|
_store = store;
|
||||||
|
_opts = opts;
|
||||||
|
_now = now ?? (() => DateTimeOffset.UtcNow);
|
||||||
|
_backoff = backoff ?? new BackoffPolicy(opts.BackoffBase, opts.BackoffCap);
|
||||||
|
_transport.AckReceived += ApplyAckAsync;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Connect + subscribe to the ack topic. Safe to call when a broker is reachable.</summary>
|
||||||
|
public Task ConnectAsync(CancellationToken ct = default) => _transport.ConnectAsync(_opts.AckTopic, ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publish every ready record once. Returns the number published this pass. On publish
|
||||||
|
/// failure (e.g. broker down) the record is rescheduled with backoff and kept — no capture
|
||||||
|
/// is lost. Records published successfully stay queued until their app-level ack arrives.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> DrainOnceAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!_transport.IsConnected)
|
||||||
|
return 0; // broker unreachable → leave everything durably queued (SRS-SYN-1)
|
||||||
|
|
||||||
|
var ready = await _store.DequeueReadyAsync(_now(), _opts.DrainBatch);
|
||||||
|
int published = 0;
|
||||||
|
foreach (var m in ready)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _transport.PublishAsync(m.Topic, System.Text.Encoding.UTF8.GetBytes(m.PayloadJson),
|
||||||
|
m.SchemaVersion, ct);
|
||||||
|
// Published (broker PUBACK). Do NOT release — wait for the application ack.
|
||||||
|
// Reschedule a republish after the ack window in case the ack is lost.
|
||||||
|
await _store.TouchAwaitingAckAsync(m.PointId, _now() + _opts.AckWindow);
|
||||||
|
published++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _store.RescheduleAsync(m.PointId, _now() + _backoff.NextDelay(m.AttemptCount + 1), ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return published;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyAckAsync(AckBatch batch)
|
||||||
|
{
|
||||||
|
foreach (var item in batch.Results)
|
||||||
|
{
|
||||||
|
if (item.Outcome is AckOutcome.ACCEPTED or AckOutcome.DUPLICATE)
|
||||||
|
{
|
||||||
|
await _store.ReleaseAckedAsync(item.PointId); // release only on ACCEPTED
|
||||||
|
if (PointAccepted is { } acceptedHandlers)
|
||||||
|
{
|
||||||
|
foreach (Func<string, Task> handler in acceptedHandlers.GetInvocationList())
|
||||||
|
await handler(item.PointId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else // REJECTED
|
||||||
|
{
|
||||||
|
var code = item.ReasonCode ?? "REJECTED";
|
||||||
|
if (_opts.TerminalRejectCodes.Contains(code))
|
||||||
|
{
|
||||||
|
await _store.MarkRejectedAsync(item.PointId, code); // LOG-7: surface, never silent
|
||||||
|
var rejected = (await _store.GetRejectedAsync()).FirstOrDefault(r => r.PointId == item.PointId);
|
||||||
|
if (rejected is not null && PointRejected is { } rejectedHandlers)
|
||||||
|
{
|
||||||
|
foreach (Func<OutboundMessage, Task> handler in rejectedHandlers.GetInvocationList())
|
||||||
|
await handler(rejected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Retryable rejection — reschedule immediately-ish with backoff.
|
||||||
|
await _store.RescheduleAsync(item.PointId, _now() + _opts.BackoffBase, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
112
src/FieldLogger.Sync/MqttnetTransport.cs
Normal file
112
src/FieldLogger.Sync/MqttnetTransport.cs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
using System.Text;
|
||||||
|
using MQTTnet;
|
||||||
|
using MQTTnet.Client;
|
||||||
|
using MQTTnet.Protocol;
|
||||||
|
|
||||||
|
namespace FieldLogger.Sync;
|
||||||
|
|
||||||
|
/// <summary>Interim app→broker connection config (SRS §3.4.2). MQTTS/TLS only; namespace-scoped
|
||||||
|
/// credential. OIDC-derived tokens (SRS-SYN-3) are deferred to the auth sprint — see decisions.md.
|
||||||
|
/// Reviewed by `security` (S2-sec).</summary>
|
||||||
|
public sealed record MqttBrokerConfig
|
||||||
|
{
|
||||||
|
public required string Host { get; init; }
|
||||||
|
public int Port { get; init; } = 8884;
|
||||||
|
public bool UseTls { get; init; } = true;
|
||||||
|
public required string ClientId { get; init; }
|
||||||
|
/// <summary>Interim credential (username = orgId, password = scoped per-org secret).
|
||||||
|
/// Supplied at runtime from secure storage / config — never hard-coded.</summary>
|
||||||
|
public string? Username { get; init; }
|
||||||
|
public string? Password { get; init; }
|
||||||
|
public TimeSpan SessionExpiry { get; init; } = TimeSpan.FromHours(24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MQTTnet-backed <see cref="IMqttTransport"/> against the real UlHub broker. QoS 1 + persistent
|
||||||
|
/// session so durable records survive reconnect; TLS mandatory. Ack payloads on the subscribed
|
||||||
|
/// ack topic are surfaced via <see cref="AckReceived"/>. Not exercised by the headless tests
|
||||||
|
/// (they use a fake transport) — this is the production path.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MqttnetTransport : IMqttTransport, IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly MqttBrokerConfig _config;
|
||||||
|
private readonly IMqttClient _client;
|
||||||
|
private string? _ackTopic;
|
||||||
|
|
||||||
|
public event Func<AckBatch, Task>? AckReceived;
|
||||||
|
public bool IsConnected => _client.IsConnected;
|
||||||
|
|
||||||
|
public MqttnetTransport(MqttBrokerConfig config)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_client = new MqttFactory().CreateMqttClient();
|
||||||
|
_client.ApplicationMessageReceivedAsync += OnMessageAsync;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ConnectAsync(string ackTopic, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!_config.UseTls)
|
||||||
|
throw new InvalidOperationException("The app MQTT transport requires TLS.");
|
||||||
|
if (string.IsNullOrWhiteSpace(_config.Username) || string.IsNullOrWhiteSpace(_config.Password))
|
||||||
|
throw new InvalidOperationException("A scoped app MQTT username and password are required.");
|
||||||
|
|
||||||
|
_ackTopic = ackTopic;
|
||||||
|
|
||||||
|
var options = new MqttClientOptionsBuilder()
|
||||||
|
.WithTcpServer(_config.Host, _config.Port)
|
||||||
|
.WithClientId(_config.ClientId)
|
||||||
|
.WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500)
|
||||||
|
.WithCleanSession(false) // persistent session (durable)
|
||||||
|
.WithSessionExpiryInterval((uint)_config.SessionExpiry.TotalSeconds)
|
||||||
|
.WithTlsOptions(o => o.UseTls(_config.UseTls))
|
||||||
|
.WithCredentials(_config.Username, _config.Password)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await _client.ConnectAsync(options, ct);
|
||||||
|
await _client.SubscribeAsync(ackTopic, MqttQualityOfServiceLevel.AtLeastOnce, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisconnectAsync() =>
|
||||||
|
_client.IsConnected ? _client.DisconnectAsync() : Task.CompletedTask;
|
||||||
|
|
||||||
|
public async Task PublishAsync(string topic, byte[] payload, string schemaVersion, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var msg = new MqttApplicationMessageBuilder()
|
||||||
|
.WithTopic(topic)
|
||||||
|
.WithPayload(payload)
|
||||||
|
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) // QoS 1 (durable)
|
||||||
|
.WithContentType("application/json")
|
||||||
|
.WithUserProperty("schemaVersion", schemaVersion)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var result = await _client.PublishAsync(msg, ct);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
throw new InvalidOperationException($"publish rejected: {result.ReasonCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnMessageAsync(MqttApplicationMessageReceivedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.ApplicationMessage.Topic == _ackTopic)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var batch = AckBatch.FromJsonUtf8(e.ApplicationMessage.PayloadSegment);
|
||||||
|
if (AckReceived is { } handlers)
|
||||||
|
{
|
||||||
|
foreach (Func<AckBatch, Task> handler in handlers.GetInvocationList())
|
||||||
|
await handler(batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Malformed ack payload — ignore; the record stays queued and is republished.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await DisconnectAsync();
|
||||||
|
_client.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/FieldLogger.Sync/OutboundStore.cs
Normal file
159
src/FieldLogger.Sync/OutboundStore.cs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace FieldLogger.Sync;
|
||||||
|
|
||||||
|
/// <summary>Lifecycle of a queued outbound record.</summary>
|
||||||
|
public enum OutboundStatus
|
||||||
|
{
|
||||||
|
/// <summary>Ready to publish.</summary>
|
||||||
|
Pending = 0,
|
||||||
|
/// <summary>Published and waiting for an application acknowledgement.</summary>
|
||||||
|
InFlight = 1,
|
||||||
|
/// <summary>Terminally rejected by the cloud (schema/validation/authz) — not retried; surfaced for LOG-7.</summary>
|
||||||
|
RejectedTerminal = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A durably-queued outbound point. Survives app restart (persisted in SQLite) and is
|
||||||
|
/// released only when the cloud application-level ack references its <see cref="PointId"/>
|
||||||
|
/// (SRS-SYN-2/7). Broker PUBACK alone does NOT release it.
|
||||||
|
/// </summary>
|
||||||
|
[Table("outbound")]
|
||||||
|
public sealed class OutboundMessage
|
||||||
|
{
|
||||||
|
[PrimaryKey, AutoIncrement] public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UUIDv7 idempotency key. Unique — a re-enqueue of the same point is a no-op.</summary>
|
||||||
|
[Indexed(Name = "ux_pointid", Order = 1, Unique = true)]
|
||||||
|
public string PointId { get; set; } = "";
|
||||||
|
|
||||||
|
public string Topic { get; set; } = "";
|
||||||
|
public string PayloadJson { get; set; } = "";
|
||||||
|
public string SchemaVersion { get; set; } = "1";
|
||||||
|
|
||||||
|
public long EnqueuedUnixMs { get; set; }
|
||||||
|
public int AttemptCount { get; set; }
|
||||||
|
public long NextAttemptUnixMs { get; set; }
|
||||||
|
public int Status { get; set; } = (int)OutboundStatus.Pending;
|
||||||
|
public string? LastReason { get; set; }
|
||||||
|
|
||||||
|
public static OutboundMessage FromPoint(
|
||||||
|
PointRecord point,
|
||||||
|
string topic,
|
||||||
|
DateTimeOffset enqueuedAt,
|
||||||
|
string? jobId = null,
|
||||||
|
string? ticket = null)
|
||||||
|
{
|
||||||
|
var payload = point.ToAppLogPayloadUtf8(jobId, ticket);
|
||||||
|
return new OutboundMessage
|
||||||
|
{
|
||||||
|
PointId = point.PointId,
|
||||||
|
Topic = topic,
|
||||||
|
PayloadJson = System.Text.Encoding.UTF8.GetString(payload),
|
||||||
|
SchemaVersion = "1",
|
||||||
|
EnqueuedUnixMs = enqueuedAt.ToUnixTimeMilliseconds(),
|
||||||
|
NextAttemptUnixMs = enqueuedAt.ToUnixTimeMilliseconds(),
|
||||||
|
Status = (int)OutboundStatus.Pending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Durable outbound-queue store. Implementations must survive process restart.</summary>
|
||||||
|
public interface IOutboundStore
|
||||||
|
{
|
||||||
|
Task InitAsync();
|
||||||
|
/// <summary>Enqueue idempotently; returns false if a row with this pointId already exists.</summary>
|
||||||
|
Task<bool> EnqueueAsync(OutboundMessage message);
|
||||||
|
/// <summary>Pending rows whose next-attempt time has arrived, oldest first.</summary>
|
||||||
|
Task<IReadOnlyList<OutboundMessage>> DequeueReadyAsync(DateTimeOffset now, int max = 50);
|
||||||
|
/// <summary>Release a record — accepted by the cloud. Removes it from the queue.</summary>
|
||||||
|
Task ReleaseAckedAsync(string pointId);
|
||||||
|
/// <summary>Record a retryable failure: bump attempt count, schedule the next attempt.</summary>
|
||||||
|
Task RescheduleAsync(string pointId, DateTimeOffset nextAttempt, string reason);
|
||||||
|
/// <summary>Mark a record published-and-awaiting-ack: reschedule a republish (idempotent via
|
||||||
|
/// UUID) after the ack window WITHOUT counting it as a failed attempt.</summary>
|
||||||
|
Task TouchAwaitingAckAsync(string pointId, DateTimeOffset nextAttempt);
|
||||||
|
/// <summary>Terminal rejection (LOG-7): keep the row for surfacing, mark it not-retryable.</summary>
|
||||||
|
Task MarkRejectedAsync(string pointId, string reason);
|
||||||
|
Task<int> PendingCountAsync();
|
||||||
|
Task<IReadOnlyList<OutboundMessage>> GetRejectedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQLite-backed <see cref="IOutboundStore"/>. The DB path is injected (not taken from a
|
||||||
|
/// platform API) so it is testable headless with a temp file; reopening the same path is a
|
||||||
|
/// process restart. Idempotent enqueue relies on the unique index on pointId.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqliteOutboundStore : IOutboundStore
|
||||||
|
{
|
||||||
|
private readonly SQLiteAsyncConnection _db;
|
||||||
|
|
||||||
|
public SqliteOutboundStore(string dbPath)
|
||||||
|
{
|
||||||
|
_db = new SQLiteAsyncConnection(dbPath,
|
||||||
|
SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitAsync()
|
||||||
|
{
|
||||||
|
await _db.CreateTableAsync<OutboundMessage>();
|
||||||
|
// A process crash can leave rows in-flight after the broker accepted them but before
|
||||||
|
// the application ack was applied. Requeue them; pointId makes the replay idempotent.
|
||||||
|
await _db.ExecuteAsync(
|
||||||
|
"UPDATE outbound SET Status = ?, NextAttemptUnixMs = ? WHERE Status = ?",
|
||||||
|
(int)OutboundStatus.Pending, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
(int)OutboundStatus.InFlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> EnqueueAsync(OutboundMessage message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _db.InsertAsync(message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (SQLiteException)
|
||||||
|
{
|
||||||
|
// Unique-index violation on pointId → already queued/known. Idempotent no-op.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<OutboundMessage>> DequeueReadyAsync(DateTimeOffset now, int max = 50)
|
||||||
|
{
|
||||||
|
long nowMs = now.ToUnixTimeMilliseconds();
|
||||||
|
return await _db.Table<OutboundMessage>()
|
||||||
|
.Where(m => (m.Status == (int)OutboundStatus.Pending || m.Status == (int)OutboundStatus.InFlight)
|
||||||
|
&& m.NextAttemptUnixMs <= nowMs)
|
||||||
|
.OrderBy(m => m.EnqueuedUnixMs)
|
||||||
|
.Take(max)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ReleaseAckedAsync(string pointId) =>
|
||||||
|
_db.ExecuteAsync("DELETE FROM outbound WHERE PointId = ?", pointId);
|
||||||
|
|
||||||
|
public Task RescheduleAsync(string pointId, DateTimeOffset nextAttempt, string reason) =>
|
||||||
|
_db.ExecuteAsync(
|
||||||
|
"UPDATE outbound SET AttemptCount = AttemptCount + 1, NextAttemptUnixMs = ?, LastReason = ? WHERE PointId = ? AND Status = ?",
|
||||||
|
nextAttempt.ToUnixTimeMilliseconds(), reason, pointId, (int)OutboundStatus.Pending);
|
||||||
|
|
||||||
|
public Task TouchAwaitingAckAsync(string pointId, DateTimeOffset nextAttempt) =>
|
||||||
|
_db.ExecuteAsync(
|
||||||
|
"UPDATE outbound SET Status = ?, AttemptCount = AttemptCount + 1, NextAttemptUnixMs = ? WHERE PointId = ? AND Status != ?",
|
||||||
|
(int)OutboundStatus.InFlight, nextAttempt.ToUnixTimeMilliseconds(), pointId,
|
||||||
|
(int)OutboundStatus.RejectedTerminal);
|
||||||
|
|
||||||
|
public Task MarkRejectedAsync(string pointId, string reason) =>
|
||||||
|
_db.ExecuteAsync(
|
||||||
|
"UPDATE outbound SET Status = ?, LastReason = ? WHERE PointId = ?",
|
||||||
|
(int)OutboundStatus.RejectedTerminal, reason, pointId);
|
||||||
|
|
||||||
|
public async Task<int> PendingCountAsync() =>
|
||||||
|
await _db.Table<OutboundMessage>()
|
||||||
|
.Where(m => m.Status == (int)OutboundStatus.Pending || m.Status == (int)OutboundStatus.InFlight)
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<OutboundMessage>> GetRejectedAsync() =>
|
||||||
|
await _db.Table<OutboundMessage>().Where(m => m.Status == (int)OutboundStatus.RejectedTerminal).ToListAsync();
|
||||||
|
}
|
||||||
250
src/FieldLogger.Sync/PointRecord.cs
Normal file
250
src/FieldLogger.Sync/PointRecord.cs
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace FieldLogger.Sync;
|
||||||
|
|
||||||
|
// Wire shape of a Point (SRS §5 / telemetry-schema.md), as published to
|
||||||
|
// ul/{orgId}/app/{clientId}/log/points. pointId (UUIDv7) is the idempotency key on every
|
||||||
|
// path. This is the app→cloud contract payload; keep field names in sync with
|
||||||
|
// telemetry-schema.md (breaking change → backend review).
|
||||||
|
|
||||||
|
/// <summary>Point origin (schema Identity group).</summary>
|
||||||
|
public enum PointOrigin { APP, LOCATOR, MAGLINK }
|
||||||
|
|
||||||
|
/// <summary>How the record reached the cloud (schema Identity group).</summary>
|
||||||
|
public enum UploadPath { APP_MQTT, DEVICE_MQTT, REST_BATCH }
|
||||||
|
|
||||||
|
/// <summary>What triggered the capture (schema Identity group).</summary>
|
||||||
|
public enum CaptureTrigger { APP_UI, LOCATOR_BUTTON }
|
||||||
|
|
||||||
|
public sealed record PointRecord
|
||||||
|
{
|
||||||
|
/// <summary>Payload schema version — also carried as an MQTT5 user property.</summary>
|
||||||
|
[JsonPropertyName("schemaVersion")] public string SchemaVersion { get; init; } = "1";
|
||||||
|
|
||||||
|
// ---- Identity & provenance ----
|
||||||
|
[JsonPropertyName("pointId")] public required string PointId { get; init; } // UUIDv7
|
||||||
|
[JsonPropertyName("ticketId")] public string? TicketId { get; init; }
|
||||||
|
[JsonPropertyName("sessionId")] public string? SessionId { get; init; }
|
||||||
|
[JsonPropertyName("pathId")] public string? PathId { get; init; }
|
||||||
|
[JsonPropertyName("category")] public string Category { get; init; } = "LOCATE";
|
||||||
|
[JsonPropertyName("origin")] public PointOrigin Origin { get; init; } = PointOrigin.APP;
|
||||||
|
[JsonPropertyName("originClientId")] public string? OriginClientId { get; init; }
|
||||||
|
[JsonPropertyName("uploadPath")] public UploadPath UploadPath { get; init; } = UploadPath.APP_MQTT;
|
||||||
|
[JsonPropertyName("captureTrigger")] public CaptureTrigger CaptureTrigger { get; init; } = CaptureTrigger.LOCATOR_BUTTON;
|
||||||
|
[JsonPropertyName("createdAt")] public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
[JsonPropertyName("author")] public string? Author { get; init; }
|
||||||
|
[JsonPropertyName("appVersion")] public string? AppVersion { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("position")] public PositionGroup? Position { get; init; }
|
||||||
|
[JsonPropertyName("gnss")] public GnssGroup? Gnss { get; init; }
|
||||||
|
[JsonPropertyName("locate")] public LocateGroup? Locate { get; init; }
|
||||||
|
[JsonPropertyName("attributes")] public AttributesGroup? Attributes { get; init; }
|
||||||
|
[JsonPropertyName("quality")] public QualityGroup? Quality { get; init; }
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
public byte[] ToJsonUtf8() => JsonSerializer.SerializeToUtf8Bytes(this, JsonOpts);
|
||||||
|
public static PointRecord FromJsonUtf8(ReadOnlySpan<byte> utf8) =>
|
||||||
|
JsonSerializer.Deserialize<PointRecord>(utf8, JsonOpts)
|
||||||
|
?? throw new JsonException("null PointRecord");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes a singleton v1 app-log batch matching telemetry-schema.md's frozen MQTT
|
||||||
|
/// profile. Throws a reason-coded exception instead of silently queuing an unusable point.
|
||||||
|
/// </summary>
|
||||||
|
public byte[] ToAppLogPayloadUtf8(string? jobId = null, string? ticket = null)
|
||||||
|
{
|
||||||
|
string? resolvedJobId = string.IsNullOrWhiteSpace(jobId) ? TicketId : jobId;
|
||||||
|
bool hasJob = !string.IsNullOrWhiteSpace(resolvedJobId);
|
||||||
|
bool hasTicket = !string.IsNullOrWhiteSpace(ticket);
|
||||||
|
if (hasJob == hasTicket)
|
||||||
|
throw new PointNotPublishableException("JOB_IDENTITY_INVALID", "Exactly one of jobId or ticket is required.");
|
||||||
|
if (!IsUuidV7(PointId))
|
||||||
|
throw new PointNotPublishableException("POINT_ID_NOT_UUIDV7", "pointId must be a UUIDv7 value.");
|
||||||
|
if (CreatedAt == default)
|
||||||
|
throw new PointNotPublishableException("CREATED_AT_MISSING", "createdAt is required.");
|
||||||
|
if (Position?.Lat is not double lat || Position.Lon is not double lng)
|
||||||
|
throw new PointNotPublishableException("POSITION_MISSING", "Latitude and longitude are required.");
|
||||||
|
|
||||||
|
var wirePoint = new AppLogPointWire
|
||||||
|
{
|
||||||
|
PointId = PointId,
|
||||||
|
CreatedAt = CreatedAt,
|
||||||
|
Origin = "APP",
|
||||||
|
UploadPath = "APP_MQTT",
|
||||||
|
Lat = lat,
|
||||||
|
Lng = lng,
|
||||||
|
Alt = Position.EllipsoidalHeight,
|
||||||
|
Ts = Position.PositionEpoch ?? Locate?.TelemetryEpoch ?? CreatedAt,
|
||||||
|
Fix = NormalizeFix(Gnss?.FixType),
|
||||||
|
HAcc = Gnss?.Hrms,
|
||||||
|
VAcc = Gnss?.Vrms,
|
||||||
|
Sats = Gnss?.SatsUsed,
|
||||||
|
Hdop = Gnss?.Hdop,
|
||||||
|
Depth = Locate?.Depth,
|
||||||
|
FreqHz = Locate?.Frequency is double frequency ? checked((int)Math.Round(frequency)) : null,
|
||||||
|
CurrentMa = Locate?.SignalCurrent,
|
||||||
|
SignalDb = Locate?.SignalStrength,
|
||||||
|
GainDb = Locate?.Gain,
|
||||||
|
Mode = Locate?.LocateMode,
|
||||||
|
PhaseDeg = Locate?.PhaseDegrees,
|
||||||
|
CompassDeg = Locate?.CompassDegrees,
|
||||||
|
DistortionPct = Locate?.DistortionPercent,
|
||||||
|
Utility = NormalizeUtility(Attributes?.UtilityType),
|
||||||
|
QualityFlag = Quality?.QualityFlag ?? "IN_SPEC",
|
||||||
|
};
|
||||||
|
var envelope = new AppLogPointsEnvelope
|
||||||
|
{
|
||||||
|
SchemaVersion = "1",
|
||||||
|
JobId = hasJob ? resolvedJobId : null,
|
||||||
|
Ticket = hasTicket ? ticket : null,
|
||||||
|
Points = [wirePoint],
|
||||||
|
};
|
||||||
|
return JsonSerializer.SerializeToUtf8Bytes(envelope, AppLogJsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsUuidV7(string value)
|
||||||
|
{
|
||||||
|
string text = value.ToLowerInvariant();
|
||||||
|
return Guid.TryParseExact(text, "D", out _)
|
||||||
|
&& text.Length == 36
|
||||||
|
&& text[14] == '7'
|
||||||
|
&& text[19] is '8' or '9' or 'a' or 'b';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeFix(string? value) => value?.ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
null or "" => null,
|
||||||
|
"AUTONOMOUS" => "AUTONOMOUS",
|
||||||
|
"DGPS" => "DGPS",
|
||||||
|
"FLOAT" or "RTK_FLOAT" or "FLOAT_RTK" => "FLOAT",
|
||||||
|
"FIXED" or "RTK_FIXED" or "FIXED_RTK" => "FIXED",
|
||||||
|
"NO_FIX" or "NONE" => "NO_FIX",
|
||||||
|
_ => throw new PointNotPublishableException("FIX_TYPE_INVALID", $"Unsupported fix type '{value}'."),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string? NormalizeUtility(string? value) => value?.ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
null or "" => null,
|
||||||
|
"ELECTRIC" or "GAS" or "WATER" or "SEWER" or "TELECOM" or "CATV" or "FIBER" or "STEAM" or "UNKNOWN"
|
||||||
|
=> value.ToUpperInvariant(),
|
||||||
|
_ => throw new PointNotPublishableException("UTILITY_TYPE_INVALID", $"Unsupported utility type '{value}'."),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions AppLogJsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PointNotPublishableException : InvalidOperationException
|
||||||
|
{
|
||||||
|
public string ReasonCode { get; }
|
||||||
|
|
||||||
|
public PointNotPublishableException(string reasonCode, string message) : base(message) =>
|
||||||
|
ReasonCode = reasonCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record AppLogPointsEnvelope
|
||||||
|
{
|
||||||
|
public string SchemaVersion { get; init; } = "1";
|
||||||
|
public string? JobId { get; init; }
|
||||||
|
public string? Ticket { get; init; }
|
||||||
|
public List<AppLogPointWire> Points { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record AppLogPointWire
|
||||||
|
{
|
||||||
|
public required string PointId { get; init; }
|
||||||
|
public required DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public string Origin { get; init; } = "APP";
|
||||||
|
public string UploadPath { get; init; } = "APP_MQTT";
|
||||||
|
public required double Lat { get; init; }
|
||||||
|
public required double Lng { get; init; }
|
||||||
|
public double? Alt { get; init; }
|
||||||
|
public required DateTimeOffset Ts { get; init; }
|
||||||
|
public string? Fix { get; init; }
|
||||||
|
public double? HAcc { get; init; }
|
||||||
|
public double? VAcc { get; init; }
|
||||||
|
public int? Sats { get; init; }
|
||||||
|
public double? Hdop { get; init; }
|
||||||
|
public double? Depth { get; init; }
|
||||||
|
public int? FreqHz { get; init; }
|
||||||
|
public double? CurrentMa { get; init; }
|
||||||
|
public double? SignalDb { get; init; }
|
||||||
|
public double? GainDb { get; init; }
|
||||||
|
public string? Mode { get; init; }
|
||||||
|
public double? PhaseDeg { get; init; }
|
||||||
|
public double? CompassDeg { get; init; }
|
||||||
|
public double? DistortionPct { get; init; }
|
||||||
|
public string? Utility { get; init; }
|
||||||
|
public string QualityFlag { get; init; } = "IN_SPEC";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PositionGroup
|
||||||
|
{
|
||||||
|
[JsonPropertyName("lat")] public double? Lat { get; init; }
|
||||||
|
[JsonPropertyName("lon")] public double? Lon { get; init; }
|
||||||
|
[JsonPropertyName("crsEpsg")] public int? CrsEpsg { get; init; }
|
||||||
|
[JsonPropertyName("ellipsoidalHeight")] public double? EllipsoidalHeight { get; init; }
|
||||||
|
[JsonPropertyName("orthometricHeight")] public double? OrthometricHeight { get; init; }
|
||||||
|
[JsonPropertyName("geoidModel")] public string? GeoidModel { get; init; }
|
||||||
|
[JsonPropertyName("antennaHeight")] public double? AntennaHeight { get; init; }
|
||||||
|
[JsonPropertyName("positionEpoch")] public DateTimeOffset? PositionEpoch { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record GnssGroup
|
||||||
|
{
|
||||||
|
[JsonPropertyName("fixType")] public string? FixType { get; init; }
|
||||||
|
[JsonPropertyName("satsUsed")] public int? SatsUsed { get; init; }
|
||||||
|
[JsonPropertyName("hdop")] public double? Hdop { get; init; }
|
||||||
|
[JsonPropertyName("hrms")] public double? Hrms { get; init; }
|
||||||
|
[JsonPropertyName("vrms")] public double? Vrms { get; init; }
|
||||||
|
[JsonPropertyName("correctionAge")] public double? CorrectionAge { get; init; }
|
||||||
|
[JsonPropertyName("receiverModel")] public string? ReceiverModel { get; init; }
|
||||||
|
[JsonPropertyName("receiverSerial")] public string? ReceiverSerial { get; init; }
|
||||||
|
[JsonPropertyName("source")] public string? Source { get; init; } // MAGLINK | PHONE
|
||||||
|
[JsonPropertyName("tiltAngle")] public double? TiltAngle { get; init; }
|
||||||
|
[JsonPropertyName("clockSource")] public string? ClockSource { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record LocateGroup
|
||||||
|
{
|
||||||
|
[JsonPropertyName("depth")] public double? Depth { get; init; }
|
||||||
|
[JsonPropertyName("depthUnits")] public string? DepthUnits { get; init; }
|
||||||
|
[JsonPropertyName("signalCurrent")] public double? SignalCurrent { get; init; }
|
||||||
|
[JsonPropertyName("signalStrength")] public double? SignalStrength { get; init; }
|
||||||
|
[JsonPropertyName("frequency")] public double? Frequency { get; init; }
|
||||||
|
[JsonPropertyName("locateMode")] public string? LocateMode { get; init; }
|
||||||
|
[JsonPropertyName("gain")] public double? Gain { get; init; }
|
||||||
|
[JsonPropertyName("signalDirection")] public double? SignalDirection { get; init; }
|
||||||
|
[JsonPropertyName("phaseDegrees")] public double? PhaseDegrees { get; init; }
|
||||||
|
[JsonPropertyName("compassDegrees")] public double? CompassDegrees { get; init; }
|
||||||
|
[JsonPropertyName("distortionPercent")] public double? DistortionPercent { get; init; }
|
||||||
|
[JsonPropertyName("warningFlags")] public int? WarningFlags { get; init; }
|
||||||
|
[JsonPropertyName("locatorModel")] public string? LocatorModel { get; init; }
|
||||||
|
[JsonPropertyName("locatorSerial")] public string? LocatorSerial { get; init; }
|
||||||
|
[JsonPropertyName("telemetryEpoch")] public DateTimeOffset? TelemetryEpoch { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AttributesGroup
|
||||||
|
{
|
||||||
|
[JsonPropertyName("utilityType")] public string? UtilityType { get; init; }
|
||||||
|
[JsonPropertyName("owner")] public string? Owner { get; init; }
|
||||||
|
[JsonPropertyName("markerColor")] public string? MarkerColor { get; init; }
|
||||||
|
[JsonPropertyName("surfaceType")] public string? SurfaceType { get; init; }
|
||||||
|
[JsonPropertyName("notes")] public string? Notes { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record QualityGroup
|
||||||
|
{
|
||||||
|
[JsonPropertyName("qualityFlag")] public string? QualityFlag { get; init; }
|
||||||
|
[JsonPropertyName("gatePolicyId")] public string? GatePolicyId { get; init; }
|
||||||
|
[JsonPropertyName("waiverId")] public string? WaiverId { get; init; }
|
||||||
|
}
|
||||||
61
tests/FieldLogger.Sync.Tests/FakeMqttTransport.cs
Normal file
61
tests/FieldLogger.Sync.Tests/FakeMqttTransport.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using FieldLogger.Sync;
|
||||||
|
|
||||||
|
namespace FieldLogger.Sync.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory stand-in for the broker. Records published payloads, lets a test toggle
|
||||||
|
/// connectivity, force publish failures, and inject application-level acks — so the engine's
|
||||||
|
/// durability/ack/backoff behaviour is exercised without MQTTnet or a live broker.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FakeMqttTransport : IMqttTransport
|
||||||
|
{
|
||||||
|
public bool IsConnected { get; set; } = true;
|
||||||
|
public bool FailNextPublish { get; set; }
|
||||||
|
public List<(string Topic, byte[] Payload)> Published { get; } = new();
|
||||||
|
|
||||||
|
public event Func<AckBatch, Task>? AckReceived;
|
||||||
|
|
||||||
|
public Task ConnectAsync(string ackTopic, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
IsConnected = true;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisconnectAsync()
|
||||||
|
{
|
||||||
|
IsConnected = false;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task PublishAsync(string topic, byte[] payload, string schemaVersion, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!IsConnected) throw new InvalidOperationException("not connected");
|
||||||
|
if (FailNextPublish)
|
||||||
|
{
|
||||||
|
FailNextPublish = false;
|
||||||
|
throw new InvalidOperationException("simulated broker publish failure");
|
||||||
|
}
|
||||||
|
Published.Add((topic, payload));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Simulate the cloud emitting an application-level ack for these pointIds.</summary>
|
||||||
|
public async Task InjectAckAsync(params AckItem[] items)
|
||||||
|
{
|
||||||
|
if (AckReceived is { } handlers)
|
||||||
|
{
|
||||||
|
var batch = new AckBatch { Results = items.ToList() };
|
||||||
|
foreach (Func<AckBatch, Task> handler in handlers.GetInvocationList())
|
||||||
|
await handler(batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InjectAcceptAsync(string pointId) =>
|
||||||
|
InjectAckAsync(new AckItem { PointId = pointId, Outcome = AckOutcome.ACCEPTED });
|
||||||
|
|
||||||
|
public Task InjectDuplicateAsync(string pointId) =>
|
||||||
|
InjectAckAsync(new AckItem { PointId = pointId, Outcome = AckOutcome.DUPLICATE });
|
||||||
|
|
||||||
|
public Task InjectRejectAsync(string pointId, string reasonCode) =>
|
||||||
|
InjectAckAsync(new AckItem { PointId = pointId, Outcome = AckOutcome.REJECTED, ReasonCode = reasonCode });
|
||||||
|
}
|
||||||
35
tests/FieldLogger.Sync.Tests/FieldLogger.Sync.Tests.csproj
Normal file
35
tests/FieldLogger.Sync.Tests/FieldLogger.Sync.Tests.csproj
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Headless tests for the cloud-sync core (S2-b/S2-c): durable-queue restart survival,
|
||||||
|
ack-release, exactly-once, backoff, broker-loss-safety, and LOG-7 no-silent-discard.
|
||||||
|
Plain net9.0 + xUnit so it runs in the QA gate (auto-discovered *.Tests.csproj) without
|
||||||
|
MAUI workloads or a live broker (uses a fake IMqttTransport).
|
||||||
|
Run: dotnet test tests/FieldLogger.Sync.Tests/FieldLogger.Sync.Tests.csproj
|
||||||
|
-->
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\FieldLogger.Sync\FieldLogger.Sync.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\..\..\meta\contracts\fixtures\app-log-*.json"
|
||||||
|
Link="ContractFixtures\%(Filename)%(Extension)"
|
||||||
|
CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
173
tests/FieldLogger.Sync.Tests/SyncEngineTests.cs
Normal file
173
tests/FieldLogger.Sync.Tests/SyncEngineTests.cs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using FieldLogger.Sync;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace FieldLogger.Sync.Tests;
|
||||||
|
|
||||||
|
public sealed class SyncEngineTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-08-21T10:00:00Z");
|
||||||
|
|
||||||
|
static SyncEngineTests() => SQLitePCL.Batteries_V2.Init();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Point_serializes_to_the_frozen_singleton_batch()
|
||||||
|
{
|
||||||
|
var point = MakePoint("018f1a00-0000-7000-8000-000000000001");
|
||||||
|
using var json = JsonDocument.Parse(point.ToAppLogPayloadUtf8(jobId: "job_1"));
|
||||||
|
|
||||||
|
var root = json.RootElement;
|
||||||
|
Assert.Equal("1", root.GetProperty("schemaVersion").GetString());
|
||||||
|
Assert.Equal("job_1", root.GetProperty("jobId").GetString());
|
||||||
|
var wire = Assert.Single(root.GetProperty("points").EnumerateArray());
|
||||||
|
Assert.Equal(point.PointId, wire.GetProperty("pointId").GetString());
|
||||||
|
Assert.Equal("APP", wire.GetProperty("origin").GetString());
|
||||||
|
Assert.Equal("APP_MQTT", wire.GetProperty("uploadPath").GetString());
|
||||||
|
Assert.Equal("FIXED", wire.GetProperty("fix").GetString());
|
||||||
|
Assert.Equal(-80.2, wire.GetProperty("lng").GetDouble());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void App_serializer_matches_the_shared_contract_fixture()
|
||||||
|
{
|
||||||
|
JsonNode? actual = JsonNode.Parse(MakePoint("018f1a00-0000-7000-8000-000000000001")
|
||||||
|
.ToAppLogPayloadUtf8(jobId: "job_1"));
|
||||||
|
string path = Path.Combine(AppContext.BaseDirectory, "ContractFixtures", "app-log-points-v1.json");
|
||||||
|
JsonNode? expected = JsonNode.Parse(File.ReadAllText(path));
|
||||||
|
|
||||||
|
Assert.True(JsonNode.DeepEquals(expected, actual));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Invalid_capture_is_refused_with_a_reason_code()
|
||||||
|
{
|
||||||
|
var point = MakePoint("018f1a00-0000-7000-8000-000000000002") with { Position = null };
|
||||||
|
var error = Assert.Throws<PointNotPublishableException>(() => point.ToAppLogPayloadUtf8(jobId: "job_1"));
|
||||||
|
Assert.Equal("POSITION_MISSING", error.ReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Queue_survives_restart_and_releases_only_after_acceptance()
|
||||||
|
{
|
||||||
|
string db = NewDbPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var firstStore = new SqliteOutboundStore(db);
|
||||||
|
await firstStore.InitAsync();
|
||||||
|
var message = OutboundMessage.FromPoint(
|
||||||
|
MakePoint("018f1a00-0000-7000-8000-000000000003"),
|
||||||
|
"ul/org_alpha/app/client_1/log/points", Now, jobId: "job_1");
|
||||||
|
Assert.True(await firstStore.EnqueueAsync(message));
|
||||||
|
|
||||||
|
var firstTransport = new FakeMqttTransport();
|
||||||
|
var firstEngine = Engine(firstTransport, firstStore);
|
||||||
|
Assert.Equal(1, await firstEngine.DrainOnceAsync());
|
||||||
|
Assert.Equal(1, await firstStore.PendingCountAsync()); // broker PUBACK is not enough
|
||||||
|
|
||||||
|
var restartedStore = new SqliteOutboundStore(db);
|
||||||
|
await restartedStore.InitAsync(); // resets interrupted in-flight work to pending
|
||||||
|
var restartedTransport = new FakeMqttTransport();
|
||||||
|
var restartedEngine = Engine(restartedTransport, restartedStore);
|
||||||
|
Assert.Equal(1, await restartedEngine.DrainOnceAsync());
|
||||||
|
await restartedTransport.InjectAcceptAsync(message.PointId);
|
||||||
|
|
||||||
|
Assert.Equal(0, await restartedStore.PendingCountAsync());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteDb(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Duplicate_ack_is_safe_to_release_and_publish_failure_is_not()
|
||||||
|
{
|
||||||
|
string db = NewDbPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var store = new SqliteOutboundStore(db);
|
||||||
|
await store.InitAsync();
|
||||||
|
var message = OutboundMessage.FromPoint(
|
||||||
|
MakePoint("018f1a00-0000-7000-8000-000000000004"),
|
||||||
|
"ul/org_alpha/app/client_1/log/points", Now, jobId: "job_1");
|
||||||
|
Assert.True(await store.EnqueueAsync(message));
|
||||||
|
|
||||||
|
var transport = new FakeMqttTransport { FailNextPublish = true };
|
||||||
|
var engine = Engine(transport, store);
|
||||||
|
Assert.Equal(0, await engine.DrainOnceAsync());
|
||||||
|
Assert.Equal(1, await store.PendingCountAsync());
|
||||||
|
|
||||||
|
await transport.InjectDuplicateAsync(message.PointId);
|
||||||
|
Assert.Equal(0, await store.PendingCountAsync());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteDb(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Terminal_rejection_is_retained_and_surfaced()
|
||||||
|
{
|
||||||
|
string db = NewDbPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var store = new SqliteOutboundStore(db);
|
||||||
|
await store.InitAsync();
|
||||||
|
var message = OutboundMessage.FromPoint(
|
||||||
|
MakePoint("018f1a00-0000-7000-8000-000000000005"),
|
||||||
|
"ul/org_alpha/app/client_1/log/points", Now, jobId: "job_1");
|
||||||
|
await store.EnqueueAsync(message);
|
||||||
|
var transport = new FakeMqttTransport();
|
||||||
|
var engine = Engine(transport, store);
|
||||||
|
OutboundMessage? surfaced = null;
|
||||||
|
engine.PointRejected += value =>
|
||||||
|
{
|
||||||
|
surfaced = value;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await transport.InjectRejectAsync(message.PointId, "VALIDATION_ERROR");
|
||||||
|
|
||||||
|
Assert.Equal(0, await store.PendingCountAsync());
|
||||||
|
Assert.Equal(message.PointId, surfaced?.PointId);
|
||||||
|
Assert.Equal("VALIDATION_ERROR", Assert.Single(await store.GetRejectedAsync()).LastReason);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteDb(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ack_parser_uses_results_and_outcome()
|
||||||
|
{
|
||||||
|
var ack = AckBatch.FromJsonUtf8(
|
||||||
|
"""{"schemaVersion":"1","results":[{"pointId":"018f1a00-0000-7000-8000-000000000006","outcome":"DUPLICATE"}]}"""u8);
|
||||||
|
Assert.Equal(AckOutcome.DUPLICATE, Assert.Single(ack.Results).Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MqttSyncEngine Engine(FakeMqttTransport transport, IOutboundStore store) =>
|
||||||
|
new(transport, store, new SyncOptions { OrgId = "org_alpha", ClientId = "client_1" },
|
||||||
|
() => Now, new BackoffPolicy(TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(1), new Random(1)));
|
||||||
|
|
||||||
|
private static PointRecord MakePoint(string id) => new()
|
||||||
|
{
|
||||||
|
PointId = id,
|
||||||
|
CreatedAt = Now,
|
||||||
|
Position = new PositionGroup { Lat = 40.1, Lon = -80.2, PositionEpoch = Now.AddSeconds(-1) },
|
||||||
|
Gnss = new GnssGroup { FixType = "RTK_FIXED", SatsUsed = 18, Hrms = 0.02, Vrms = 0.04 },
|
||||||
|
Attributes = new AttributesGroup { UtilityType = "WATER" },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string NewDbPath() => Path.Combine(Path.GetTempPath(), $"fieldlogger-sync-{Guid.NewGuid():N}.db3");
|
||||||
|
|
||||||
|
private static void DeleteDb(string db)
|
||||||
|
{
|
||||||
|
foreach (string path in new[] { db, $"{db}-shm", $"{db}-wal" })
|
||||||
|
{
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user