Compare commits

..

5 Commits

Author SHA1 Message Date
Brent Perteet
71fcb9b63f feat(S2-b): connect durable MQTT sync to capture workflow 2026-08-20 15:22:29 -05:00
Brent Perteet
a788cebea3 test(S1-b): add IF-LOC codec boundary/edge-case vectors
QA-owned boundary coverage for the frozen IF-LOC v1.0 codec, complementing
app-owner's nominal round-trip suite. Pins min/max/resolution limits per wire
field via the pure Encode/Decode seam, staying clear of the no-value sentinels,
and characterises the two sentinel-collision edges (depth -327.68 m, current
0xFFFF) so the contract's documented ranges are enforceable.

40/40 green; full QA gate green (app 43 + server 2). Trace: SRS §3.1, Risk R-3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 13:13:49 -05:00
Brent Perteet
11cad9a78a SEC-1: build-inject Google Maps Android key; remove leaked literal
Removes the leaked Google Maps API key literal from README.md and the
live com.google.android.geo.API_KEY in AndroidManifest.xml (which shipped
in every built APK). The manifest value is now the build-time placeholder
${MAPS_API_KEY}, injected via AndroidManifestPlaceholders from the
MapsApiKey MSBuild property, resolved from a CI secret (-p:MapsApiKey=),
the MAPS_API_KEY env var, or a gitignored maps.key.props at the repo root
(maps.key.props.example committed as the template). maps.key.props is
gitignored so a real key is never committed.

No rotated key is included here; the human supplies it via CI secret.
Pairs with the console key rotation to close SEC-1 (decisions.md
2026-08-20; security/sec-1-gmaps-key.md). Git history intentionally not
rewritten (recorded risk-acceptance relies on revocation of the old key).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 13:10:35 -05:00
Brent Perteet
0b4fb83546 S1-b: IF-LOC v1.0 locator simulator + round-trip test
Reference implementation of the frozen IF-LOC v1.0 contract
(meta/contracts/ble-device-interface.md): byte-accurate codec for the
telemetry, capture-trigger, and capture-result frames, plus a
LocatorSimulator that replays a canned locate session over a
transport-agnostic loopback link. Unblocks App development without
firmware (Risk R-3, SRS S3.1).

Plain net9.0 (not a MAUI head) so it builds/runs headless under the QA
gate without the Android/iOS workloads; qa-gate.sh auto-discovers
*.Tests.csproj under app/tests/. Round-trip test asserts field-level
fidelity (every SRS S3.1 telemetry + capture-result field), sentinels,
endianness (incl. RFC 4122 big-endian pointId), the REJECTED path, and
exactly-once capture correlation (no silent drop, SRS-LOG-7). 8/8 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 13:10:24 -05:00
Brent Perteet
dc3a45e699 test: stand up xUnit harness for QA gate (S1-f)
Standalone net9.0 xUnit project at tests/FieldLogger.Tests (no MAUI workload)
so `dotnet test` runs fast in the QA gate. Smoke suite only for now; real app
logic tests land once codec/sim are factored into a workload-free library.

Trace: SRS §6 gate, NFR-8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 12:53:34 -05:00
36 changed files with 2714 additions and 28 deletions

3
.gitignore vendored
View File

@@ -482,3 +482,6 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
# SEC-1: local Google Maps key injection — never commit the real key
maps.key.props

View File

@@ -1,15 +1,18 @@
using FieldLogger.Services;
using FieldLogger.Services.Sync;
namespace FieldLogger;
public partial class App : Application
{
private readonly DeviceConnectionManager _connectionManager;
private readonly IMqttSyncService _syncService;
public App(DeviceConnectionManager connectionManager)
public App(DeviceConnectionManager connectionManager, IMqttSyncService syncService)
{
InitializeComponent();
_connectionManager = connectionManager;
_syncService = syncService;
Console.WriteLine("===== FIELD LOGGER APP STARTING =====");
Console.WriteLine($"Console output is working! Time: {DateTime.Now:HH:mm:ss}");
@@ -24,6 +27,7 @@ public partial class App : Application
{
Console.WriteLine("App: Window closing, disconnecting devices...");
await DisconnectAllDevicesAsync();
await _syncService.StopAsync();
};
return window;
@@ -54,4 +58,4 @@ public partial class App : Application
Console.WriteLine($"App: Error disconnecting devices: {ex.Message}");
}
}
}
}

View File

@@ -46,6 +46,18 @@
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
</PropertyGroup>
<!-- SEC-1: the Google Maps Android key is injected at build time, never committed.
Resolution order: (1) MapsApiKey MSBuild property (pass -p:MapsApiKey=... in CI from a
secret), else (2) the MAPS_API_KEY environment variable, else (3) a gitignored
maps.key.props at the repo root (copy maps.key.props.example). Left empty for local
builds without a key — maps stay blank; the build does not embed a literal. -->
<Import Project="$(MSBuildThisFileDirectory)..\maps.key.props"
Condition="Exists('$(MSBuildThisFileDirectory)..\maps.key.props')" />
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
<MapsApiKey Condition="'$(MapsApiKey)' == ''">$(MAPS_API_KEY)</MapsApiKey>
<AndroidManifestPlaceholders>MAPS_API_KEY=$(MapsApiKey)</AndroidManifestPlaceholders>
</PropertyGroup>
<ItemGroup>
<!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
@@ -73,6 +85,10 @@
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\src\FieldLogger.Sync\FieldLogger.Sync.csproj" />
</ItemGroup>
<!-- Native map control (Google Maps on Android, Apple Maps on iOS/macOS). Not available on Windows,
where MapPage falls back to a Google Maps JavaScript WebView instead. -->
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">

View File

@@ -36,7 +36,7 @@ public static class MauiProgram
builder.Services.AddSingleton<MaglinkService>();
builder.Services.AddSingleton<DeviceConnectionManager>();
builder.Services.AddSingleton<PointLogger>();
builder.Services.AddSingleton<IMqttSyncService, NullMqttSyncService>();
builder.Services.AddSingleton<IMqttSyncService, MqttSyncService>();
// View models
builder.Services.AddSingleton<HomeViewModel>();
@@ -60,6 +60,7 @@ public static class MauiProgram
// Instantiate the point logger so it listens for receiver packets from startup.
_ = app.Services.GetRequiredService<PointLogger>();
_ = app.Services.GetRequiredService<IMqttSyncService>().StartAsync();
return app;
}

View File

@@ -69,7 +69,13 @@ public sealed class LoggedPoint
public string GpsRawSentence { get; set; } = "";
public string GpsSerialNumber { get; set; } = "";
/// <summary>Reserved for MQTT sync.</summary>
/// <summary>Stable UUIDv7 used for MQTT retries and cloud idempotency.</summary>
[Indexed(Unique = true)]
public string? SyncPointId { get; set; }
/// <summary>Machine-readable terminal/configuration error; null while pending or after success.</summary>
public string? SyncError { get; set; }
public bool Synced { get; set; }
[Ignore]

View File

@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true">
<!-- Google Maps API key: replace with your key from https://console.cloud.google.com (Maps SDK for Android) -->
<meta-data android:name="com.google.android.geo.API_KEY" android:value="AIzaSyDhH16gF-7UN-CBsTQGfQSHNGjLC6VJ5dI" />
<!-- Google Maps API key is injected at build time from the MAPS_API_KEY MSBuild
placeholder (see FieldLogger.csproj / maps.key.props.example). Never commit a key
literal here — SEC-1. -->
<meta-data android:name="com.google.android.geo.API_KEY" android:value="${MAPS_API_KEY}" />
</application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.Input;
using FieldLogger.Models;
using FieldLogger.Services;
using FieldLogger.Services.Data;
using FieldLogger.Services.Sync;
namespace FieldLogger.ViewModels;
@@ -14,6 +15,7 @@ public sealed partial class HomeViewModel : ObservableObject
private readonly PointLogger _pointLogger;
private readonly AppDatabase _database;
private readonly SettingsService _settings;
private readonly IMqttSyncService _sync;
public DeviceConnectionManager Manager => _manager;
@@ -35,20 +37,27 @@ public sealed partial class HomeViewModel : ObservableObject
[ObservableProperty]
private string _fixAccuracy = "";
[ObservableProperty]
private string _syncStatus = "";
public ObservableCollection<LoggedPoint> RecentPoints { get; } = new();
public HomeViewModel(DeviceConnectionManager manager, MaglinkService gps,
PointLogger pointLogger, AppDatabase database, SettingsService settings)
PointLogger pointLogger, AppDatabase database, SettingsService settings, IMqttSyncService sync)
{
_manager = manager;
_gps = gps;
_pointLogger = pointLogger;
_database = database;
_settings = settings;
_sync = sync;
SyncStatus = sync.Status;
_gps.FixReceived += OnFixReceived;
_pointLogger.PointSaved += OnPointSaved;
_pointLogger.PacketIgnoredNoJob += OnPacketIgnoredNoJob;
_sync.StatusChanged += OnSyncStatusChanged;
_sync.PointRejected += OnPointRejected;
}
/// <summary>Called from the page's OnAppearing.</summary>
@@ -146,4 +155,14 @@ public sealed partial class HomeViewModel : ObservableObject
"A point was logged on the receiver, but no job is active so it was not saved. Create or select a job first.",
"OK");
}
private void OnSyncStatusChanged(object? sender, string status) =>
MainThread.BeginInvokeOnMainThread(() => SyncStatus = status);
private void OnPointRejected(object? sender, PointSyncFailureEventArgs e) =>
MainThread.BeginInvokeOnMainThread(async () =>
await Shell.Current.DisplayAlert(
"Point Saved Locally — Sync Needs Attention",
$"Point #{e.Point.Id} remains on this device and was not uploaded. Reason: {e.ReasonCode}.",
"OK"));
}

View File

@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FieldLogger.Models;
using FieldLogger.Services;
using FieldLogger.Services.Sync;
namespace FieldLogger.ViewModels;
@@ -9,23 +10,104 @@ public sealed partial class SettingsViewModel : ObservableObject
{
private readonly DeviceConnectionManager _manager;
private readonly SettingsService _settings;
private readonly IMqttSyncService _sync;
public DeviceConnectionManager Manager => _manager;
[ObservableProperty]
private string _mapsApiKey = "";
[ObservableProperty]
private bool _mqttEnabled;
[ObservableProperty]
private string _mqttHost = "";
[ObservableProperty]
private string _mqttPort = "8884";
[ObservableProperty]
private string _mqttOrgId = "";
[ObservableProperty]
private string _mqttPassword = "";
[ObservableProperty]
private string _syncStatus = "";
[ObservableProperty]
private bool _isSavingSync;
public string MqttClientId => _settings.MqttClientId;
public string AppVersion => AppInfo.Current.VersionString;
public SettingsViewModel(DeviceConnectionManager manager, SettingsService settings)
public SettingsViewModel(DeviceConnectionManager manager, SettingsService settings, IMqttSyncService sync)
{
_manager = manager;
_settings = settings;
_sync = sync;
MapsApiKey = settings.GoogleMapsApiKey;
MqttEnabled = settings.MqttEnabled;
MqttHost = settings.MqttHost;
MqttPort = settings.MqttPort.ToString();
MqttOrgId = settings.MqttOrgId;
SyncStatus = sync.Status;
_sync.StatusChanged += OnSyncStatusChanged;
}
partial void OnMapsApiKeyChanged(string value) => _settings.GoogleMapsApiKey = value.Trim();
[RelayCommand]
private async Task SaveSyncAsync()
{
if (!int.TryParse(MqttPort, out var port) || port is < 1 or > 65535)
{
await Shell.Current.DisplayAlert("Invalid MQTT Port", "Enter a port between 1 and 65535.", "OK");
return;
}
if (MqttEnabled && (string.IsNullOrWhiteSpace(MqttHost) || string.IsNullOrWhiteSpace(MqttOrgId)))
{
await Shell.Current.DisplayAlert("Incomplete Sync Settings", "Host and organization id are required.", "OK");
return;
}
IsSavingSync = true;
try
{
_settings.MqttHost = MqttHost;
_settings.MqttPort = port;
_settings.MqttOrgId = MqttOrgId;
_settings.MqttEnabled = MqttEnabled;
if (!string.IsNullOrWhiteSpace(MqttPassword))
{
await _settings.SetMqttPasswordAsync(MqttPassword);
MqttPassword = "";
}
if (MqttEnabled)
{
await _sync.ConnectAsync();
await Shell.Current.DisplayAlert("Sync Connected", "The durable MQTT queue is connected.", "OK");
}
else
{
await _sync.DisconnectAsync();
}
}
catch (Exception ex)
{
await Shell.Current.DisplayAlert("Sync Connection Failed", ex.Message, "OK");
}
finally
{
IsSavingSync = false;
}
}
private void OnSyncStatusChanged(object? sender, string status) =>
MainThread.BeginInvokeOnMainThread(() => SyncStatus = status);
[RelayCommand]
private Task ChangeLocatorAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.Locator}");

View File

@@ -63,6 +63,7 @@
</Grid>
<Label Text="Press the log button on the receiver to capture a point."
FontSize="12" TextColor="Gray" IsVisible="{Binding HasActiveJob}" />
<Label Text="{Binding SyncStatus}" FontSize="11" TextColor="DodgerBlue" />
</VerticalStackLayout>
</Border>

View File

@@ -52,8 +52,19 @@
<Label Text="Sync" FontAttributes="Bold" FontSize="16" Margin="0,12,0,0" />
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<Label Text="MQTT job sync is planned. Data is currently stored locally on this device."
FontSize="12" TextColor="Gray" />
<VerticalStackLayout Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<Label Text="Durable cloud sync" VerticalOptions="Center" />
<Switch Grid.Column="1" IsToggled="{Binding MqttEnabled}" />
</Grid>
<Entry Text="{Binding MqttHost}" Placeholder="dev.hub.umagul.net" />
<Entry Text="{Binding MqttPort}" Placeholder="8884" Keyboard="Numeric" />
<Entry Text="{Binding MqttOrgId}" Placeholder="Organization ID / MQTT username" />
<Entry Text="{Binding MqttPassword}" Placeholder="New MQTT password (stored securely)" IsPassword="True" />
<Label Text="{Binding MqttClientId, StringFormat='Client: {0}'}" FontSize="10" TextColor="Gray" />
<Label Text="{Binding SyncStatus}" FontSize="12" TextColor="DodgerBlue" />
<Button Text="Save &amp; Connect" Command="{Binding SaveSyncCommand}" />
</VerticalStackLayout>
</Border>
<Label Text="{Binding AppVersion, StringFormat='Field Logger v{0}'}"

View File

@@ -4,8 +4,6 @@ Cross-platform .NET MAUI app (Windows, macOS, iOS, Android) for logging utility
points from an **Underground Magnetics locating receiver** paired with RTK GPS positions
from a **Maglink (H11) RTK receiver**, both over BLE.
Maps API Key: AIzaSyDhH16gF-7UN-CBsTQGfQSHNGjLC6VJ5dI
## Solution layout
```
@@ -40,8 +38,13 @@ doc/ Device protocol documentation
## Setup required before running
1. **Google Maps key (Android):** replace `YOUR_GOOGLE_MAPS_ANDROID_API_KEY` in
`FieldLogger/Platforms/Android/AndroidManifest.xml` (Google Cloud Console → Maps SDK for Android).
1. **Google Maps key (Android):** the manifest key is **injected at build time**, never
committed (SEC-1). Provide it one of three ways: pass `-p:MapsApiKey=<key>` to
`dotnet build` (how CI supplies it from a secret), set the `MAPS_API_KEY` environment
variable, or copy `maps.key.props.example` to `maps.key.props` (gitignored) at the repo
root and put your key there. Use a key restricted to the app package + release SHA-1
(Google Cloud Console → Maps SDK for Android). Without a key, maps render blank but the
app builds.
2. **Google Maps key (Windows):** create a Maps JavaScript API key and paste it into the
Settings page of the app.
3. **Maglink GATT UUIDs:** the Maglink docs describe the serial protocol but not its GATT

13
maps.key.props.example Normal file
View File

@@ -0,0 +1,13 @@
<!--
SEC-1: local Google Maps key injection for Android builds.
Copy this file to `maps.key.props` (same directory) and paste your restricted key.
`maps.key.props` is gitignored — never commit a real key. CI supplies the key instead
via `-p:MapsApiKey=$SECRET` or the MAPS_API_KEY environment variable, so this file is
only for local development.
-->
<Project>
<PropertyGroup>
<MapsApiKey>YOUR_GOOGLE_MAPS_ANDROID_API_KEY</MapsApiKey>
</PropertyGroup>
</Project>

View 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);
}
}

View 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>

View 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; }
}

View 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);
}
}
}
}
}

View 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();
}
}

View 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();
}

View 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; }
}

View 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 });
}

View 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>

View 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);
}
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
QA smoke/regression harness for the Field Logger app (S1-f, trace: SRS §6 gate / NFR-8).
Targets plain net9.0 (NOT the MAUI platform TFMs) so `dotnet test` runs on any dev/CI
machine without the MAUI workload, keeping the QA gate fast. It deliberately does NOT
reference FieldLogger.csproj yet, because that project pulls in MAUI/platform deps.
When app logic that needs testing (e.g. the S1-b IF-LOC codec / locator simulator) is
factored into a plain .NET class library, add a ProjectReference to it here and the
round-trip tests move in. Coordinated with app-owner.
-->
<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>
</Project>

View File

@@ -0,0 +1,24 @@
using Xunit;
namespace FieldLogger.Tests;
// Smoke test for the QA gate (S1-f, trace: SRS §6 gate / NFR-8).
// Proves the .NET test toolchain (dotnet test + xUnit) is wired and green in the app repo.
// Substantive coverage (e.g. the S1-b IF-LOC codec round-trip) is added once the app's
// pure logic is factored into a workload-free library this project can reference.
public class SmokeTests
{
[Fact]
public void Harness_Runs()
{
Assert.True(true);
}
[Theory]
[InlineData(1, 1, 2)]
[InlineData(2, 3, 5)]
public void Arithmetic_Sanity(int a, int b, int expected)
{
Assert.Equal(expected, a + b);
}
}

View File

@@ -0,0 +1,158 @@
using IfLoc.Sim;
using Xunit;
namespace IfLoc.Sim.Tests;
/// <summary>
/// QA-owned boundary/edge-case vectors for the frozen IF-LOC v1.0 codec (S1-b, SRS §3.1).
/// Complements <see cref="RoundTripTests"/> (nominal fidelity + capture round-trip) by
/// pinning the min/max/resolution limits of each wire field via the pure
/// EncodePayload()/EncodeFrame()/DecodeFrame() seam. Values are chosen to stay clear of the
/// no-value sentinels; the exact sentinel-collision boundaries are characterised at the
/// bottom so the contract's documented ranges are enforceable, not just implied.
/// </summary>
public class BoundaryTests
{
// --- Telemetry: depth (int16 centimetres, 0.01 m resolution, sentinel 0x8000) --------
[Theory]
[InlineData(0.0)]
[InlineData(0.01)] // one resolution step
[InlineData(-0.01)]
[InlineData(12.34)]
[InlineData(327.67)] // max representable magnitude (32767 cm)
[InlineData(-327.67)] // min valid magnitude (-32767 cm); -327.68 would alias the sentinel
public void Depth_roundtrips_at_range_and_resolution_limits(double meters)
{
var back = Telemetry.DecodePayload(new Telemetry { DepthMeters = meters }.EncodePayload());
Assert.NotNull(back.DepthMeters);
Assert.Equal(meters, back.DepthMeters!.Value, 2);
}
// --- Telemetry: signal current (uint16 mA, sentinel 0xFFFF) ---------------------------
[Theory]
[InlineData((ushort)0)]
[InlineData((ushort)1)]
[InlineData((ushort)65534)] // max valid; 65535 == CurrentInvalid sentinel
public void SignalCurrent_roundtrips_to_max_valid(ushort mA)
{
var back = Telemetry.DecodePayload(new Telemetry { SignalCurrentMa = mA }.EncodePayload());
Assert.Equal(mA, back.SignalCurrentMa);
}
// --- Telemetry: frequency (uint32 Hz, sentinel 0xFFFFFFFF) ----------------------------
[Theory]
[InlineData(0u)]
[InlineData(82_500u)]
[InlineData(4_294_967_294u)] // 0xFFFFFFFE — max valid; 0xFFFFFFFF is n/a sentinel
public void Frequency_roundtrips_to_max_valid(uint hz)
{
var back = Telemetry.DecodePayload(new Telemetry { FrequencyHz = hz }.EncodePayload());
Assert.Equal(hz, back.FrequencyHz);
}
// --- Telemetry: guidance offset (int16, documented range -1000..+1000; sentinel 0x8000) --
[Theory]
[InlineData((short)-1000)]
[InlineData((short)-1)]
[InlineData((short)0)]
[InlineData((short)1)]
[InlineData((short)1000)]
[InlineData((short)-32767)] // wire minimum (just above the sentinel)
[InlineData((short)32767)] // wire maximum
public void GuidanceOffset_roundtrips_across_range(short offset)
{
var back = Telemetry.DecodePayload(new Telemetry { GuidanceOffset = offset }.EncodePayload());
Assert.Equal(offset, back.GuidanceOffset);
}
// --- Telemetry: single-byte fields at 0 and 0xFF -------------------------------------
[Theory]
[InlineData((byte)0)]
[InlineData((byte)100)]
[InlineData((byte)255)]
public void Byte_fields_roundtrip_at_extremes(byte v)
{
var t = new Telemetry
{
GainDb = v, SignalLevel = v, DistortionQualityPct = v,
SignalDirection = v, CompassAngleDeg = v, BatteryPercent = v,
};
var back = Telemetry.DecodePayload(t.EncodePayload());
Assert.Equal(v, back.GainDb);
Assert.Equal(v, back.SignalLevel);
Assert.Equal(v, back.DistortionQualityPct);
Assert.Equal(v, back.SignalDirection);
Assert.Equal(v, back.CompassAngleDeg);
Assert.Equal(v, back.BatteryPercent);
}
// --- Telemetry: full bitfields set ---------------------------------------------------
[Fact]
public void All_warning_and_status_flags_roundtrip()
{
var allWarn = WarningFlags.Shallow | WarningFlags.Overload | WarningFlags.SwingTilt
| WarningFlags.DepthInvalid | WarningFlags.CurrentInvalid | WarningFlags.OutOfRange
| WarningFlags.DistortionHigh | WarningFlags.LowBattery;
var allStatus = StatusFlags.Locating | StatusFlags.MenuActive
| StatusFlags.TimeSynced | StatusFlags.DepthModeAuto;
var back = Telemetry.DecodePayload(
new Telemetry { Warnings = allWarn, Status = allStatus }.EncodePayload());
Assert.Equal(allWarn, back.Warnings);
Assert.Equal(allStatus, back.Status);
}
// --- CaptureResult: WGS84 position extremes (int32 * 1e7) -----------------------------
[Theory]
[InlineData(90.0, 180.0)]
[InlineData(-90.0, -180.0)]
[InlineData(0.0, 0.0)]
[InlineData(45.7649321, 4.8354792)]
public void CaptureResult_position_roundtrips_at_wgs84_extremes(double lat, double lon)
{
var r = new CaptureResult
{
Outcome = CaptureOutcome.Stored, Reason = ReasonCode.Ok, FixType = FixType.RtkFixed,
Lat = lat, Lon = lon, OrthometricHeightM = 0.0,
};
var back = CaptureResult.DecodeFrame(r.EncodeFrame(0));
Assert.Equal(lat, back.Lat!.Value, 7);
Assert.Equal(lon, back.Lon!.Value, 7);
}
// --- CaptureResult: RMS accuracy (uint16 mm, sentinel 0xFFFF) -------------------------
[Theory]
[InlineData(0.0)]
[InlineData(0.001)] // 1 mm resolution step
[InlineData(65.534)] // 65534 mm — max valid; 65535 == RmsUnknown sentinel
public void CaptureResult_rms_roundtrips_to_max_valid(double meters)
{
var r = new CaptureResult
{
Outcome = CaptureOutcome.Stored, Reason = ReasonCode.Ok, FixType = FixType.RtkFixed,
Lat = 0, Lon = 0, OrthometricHeightM = 0, HrmsM = meters, VrmsM = meters,
};
var back = CaptureResult.DecodeFrame(r.EncodeFrame(0));
Assert.Equal(meters, back.HrmsM!.Value, 3);
Assert.Equal(meters, back.VrmsM!.Value, 3);
}
// --- Characterisation of the sentinel-collision boundaries ---------------------------
// These pin the *edges* of the representable ranges so the frozen contract documents
// them explicitly. A magnitude one step beyond the max valid value aliases the field's
// no-value sentinel and therefore decodes to null — i.e. it is NOT representable. Flagged
// to app-owner for the range notes in ble-device-interface.md (IF-LOC v1.0).
[Fact]
public void Depth_negative_full_scale_aliases_the_no_value_sentinel()
{
// -327.68 m -> -32768 cm == DepthNoValue (0x8000): not a representable depth.
var back = Telemetry.DecodePayload(new Telemetry { DepthMeters = -327.68 }.EncodePayload());
Assert.Null(back.DepthMeters);
}
[Fact]
public void SignalCurrent_0xFFFF_aliases_the_invalid_sentinel()
{
var back = Telemetry.DecodePayload(new Telemetry { SignalCurrentMa = 0xFFFF }.EncodePayload());
Assert.Null(back.SignalCurrentMa);
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Round-trip test for the frozen IF-LOC v1.0 contract — the mapped test for S1-b.
Plain net9.0 + xUnit so `dotnet test` runs headless in CI without MAUI workloads.
Run: dotnet test tests/IfLoc.Sim.Tests/IfLoc.Sim.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="..\IfLoc.Sim\IfLoc.Sim.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,228 @@
using IfLoc.Sim;
using Xunit;
namespace IfLoc.Sim.Tests;
/// <summary>
/// Mapped test for S1-b (Risk R-3, SRS §3.1). Verifies (1) every telemetry dictionary
/// field survives a byte-level encode/decode with correct units and sentinels, and
/// (2) the capture round-trip: every locator trigger yields exactly one correctly
/// correlated capture-result and no capture is silently dropped (SRS-LOG-7).
/// </summary>
public class RoundTripTests
{
[Fact]
public void Telemetry_frame_is_exactly_32_bytes()
{
var frame = LocatorSimulator.BuildTelemetry(0, 0).EncodeFrame(0);
Assert.Equal(IfLoc.TelemetryFrameLen, frame.Length);
Assert.Equal(IfLoc.FrameVersion, frame[0]);
Assert.Equal((byte)MessageType.Telemetry, frame[1]);
}
[Fact]
public void Telemetry_roundtrips_every_field_with_units()
{
var t = new Telemetry
{
LocatorUptimeMs = 1_234_567,
DepthMeters = 1.23, // 0.01 m resolution → 123 cm on wire
SignalCurrentMa = 742,
FrequencyHz = 82_500,
Mode = LocateMode.TwinSweep,
SignalType = SignalType.Sonde,
GainDb = 137,
SignalLevel = 88,
DistortionQualityPct = 91,
SignalDirection = 2,
CompassAngleDeg = 174,
GuidanceOffset = -375, // negative = left
Warnings = WarningFlags.Shallow | WarningFlags.DistortionHigh,
Utility = UtilityType.Gas,
BatteryPercent = 64,
Status = StatusFlags.Locating | StatusFlags.TimeSynced,
};
var back = Telemetry.DecodePayload(t.EncodePayload());
Assert.Equal(t.LocatorUptimeMs, back.LocatorUptimeMs);
Assert.Equal(1.23, back.DepthMeters!.Value, 3);
Assert.Equal(t.SignalCurrentMa, back.SignalCurrentMa);
Assert.Equal(t.FrequencyHz, back.FrequencyHz);
Assert.Equal(t.Mode, back.Mode);
Assert.Equal(t.SignalType, back.SignalType);
Assert.Equal(t.GainDb, back.GainDb);
Assert.Equal(t.SignalLevel, back.SignalLevel);
Assert.Equal(t.DistortionQualityPct, back.DistortionQualityPct);
Assert.Equal(t.SignalDirection, back.SignalDirection);
Assert.Equal(t.CompassAngleDeg, back.CompassAngleDeg);
Assert.Equal(t.GuidanceOffset, back.GuidanceOffset);
Assert.Equal(t.Warnings, back.Warnings);
Assert.Equal(t.Utility, back.Utility);
Assert.Equal(t.BatteryPercent, back.BatteryPercent);
Assert.Equal(t.Status, back.Status);
}
[Fact]
public void Telemetry_sentinels_decode_to_null()
{
var t = new Telemetry
{
DepthMeters = null, // no depth
SignalCurrentMa = null, // invalid current
FrequencyHz = null, // n/a
GuidanceOffset = null, // n/a
};
var back = Telemetry.DecodePayload(t.EncodePayload());
Assert.Null(back.DepthMeters);
Assert.Null(back.SignalCurrentMa);
Assert.Null(back.FrequencyHz);
Assert.Null(back.GuidanceOffset);
}
[Fact]
public void Depth_is_little_endian_centimetres()
{
// 1.23 m → 123 cm → 0x007B, little-endian at frame offset 8..9 = 7B 00
var frame = new Telemetry { DepthMeters = 1.23 }.EncodeFrame(0);
Assert.Equal(0x7B, frame[8]);
Assert.Equal(0x00, frame[9]);
}
[Fact]
public void CaptureResult_pointId_is_rfc4122_big_endian()
{
var id = Guid.Parse("018f5b2c-1a2b-7c3d-9e4f-a0b1c2d3e4f5"); // UUIDv7 shape
var frame = new CaptureResult { PointId = id }.EncodeFrame(0);
// §5.3: pointId at offset 36, most-significant byte first.
Assert.Equal(0x01, frame[36]);
Assert.Equal(0x8f, frame[37]);
Assert.Equal(0xf5, frame[51]);
Assert.Equal(id, CaptureResult.DecodeFrame(frame).PointId);
}
[Fact]
public void CaptureResult_roundtrips_position_and_accuracy()
{
var r = new CaptureResult
{
CaptureSeq = 7,
Outcome = CaptureOutcome.Stored,
Reason = ReasonCode.Ok,
FixType = FixType.RtkFixed,
Lat = 45.7649321,
Lon = 4.8354792,
OrthometricHeightM = 172.418,
HrmsM = 0.008,
VrmsM = 0.015,
Utc = DateTimeOffset.FromUnixTimeMilliseconds(1_760_000_000_000),
PointId = Guid.NewGuid(),
};
var back = CaptureResult.DecodeFrame(r.EncodeFrame(0));
Assert.Equal(IfLoc.CaptureResultFrameLen, r.EncodeFrame(0).Length);
Assert.Equal(r.CaptureSeq, back.CaptureSeq);
Assert.Equal(r.Outcome, back.Outcome);
Assert.Equal(r.FixType, back.FixType);
Assert.Equal(45.7649321, back.Lat!.Value, 7);
Assert.Equal(4.8354792, back.Lon!.Value, 7);
Assert.Equal(172.418, back.OrthometricHeightM!.Value, 3);
Assert.Equal(0.008, back.HrmsM!.Value, 3);
Assert.Equal(0.015, back.VrmsM!.Value, 3);
Assert.Equal(r.Utc, back.Utc);
Assert.Equal(r.PointId, back.PointId);
}
[Fact]
public void Rejected_result_carries_reason_and_no_position()
{
var r = new CaptureResult
{
CaptureSeq = 3,
Outcome = CaptureOutcome.Rejected,
Reason = ReasonCode.HrmsExceeded,
FixType = FixType.RtkFloat,
Lat = null, Lon = null, OrthometricHeightM = null,
};
var back = CaptureResult.DecodeFrame(r.EncodeFrame(0));
Assert.Equal(CaptureOutcome.Rejected, back.Outcome);
Assert.Equal(ReasonCode.HrmsExceeded, back.Reason);
Assert.Null(back.Lat);
Assert.Null(back.Lon);
Assert.Equal(Guid.Empty, back.PointId);
}
/// <summary>
/// End-to-end capture round-trip over the loopback link: the simulator replays a
/// session and raises capture-triggers; a stand-in App decodes each trigger and writes
/// back a capture-result gated on accuracy. Asserts every trigger is answered exactly
/// once (no silent-failure) and correlation holds by captureSeq.
/// </summary>
[Fact]
public async Task Capture_round_trip_answers_every_trigger_exactly_once()
{
var link = new LoopbackLink();
var sim = new LocatorSimulator(link);
int telemetrySeen = 0;
int triggersSeen = 0;
// Stand-in App: consume telemetry, and on each trigger apply a simple accuracy gate
// and write back a capture-result. This is what Field Logger's Locator Driver does.
link.ToApp += frame =>
{
switch (Frames.PeekType(frame))
{
case MessageType.Telemetry:
_ = Telemetry.DecodePayload(frame.AsSpan(IfLoc.HeaderLen, IfLoc.TelemetryPayloadLen));
telemetrySeen++;
break;
case MessageType.CaptureTrigger:
triggersSeen++;
var trig = CaptureTrigger.DecodeFrame(frame);
// Alternate a good fix and an out-of-spec fix to exercise both outcomes.
bool good = trig.CaptureSeq % 2 == 1;
var result = good
? new CaptureResult
{
CaptureSeq = trig.CaptureSeq,
Outcome = CaptureOutcome.Stored,
Reason = ReasonCode.Ok,
FixType = FixType.RtkFixed,
Lat = 45.76 + trig.CaptureSeq * 1e-5,
Lon = 4.83,
OrthometricHeightM = 170 + trig.Snapshot.DepthMeters ?? 170,
HrmsM = 0.009,
VrmsM = 0.014,
Utc = DateTimeOffset.UtcNow,
PointId = Guid.NewGuid(),
}
: new CaptureResult
{
CaptureSeq = trig.CaptureSeq,
Outcome = CaptureOutcome.Rejected,
Reason = ReasonCode.HrmsExceeded,
FixType = FixType.RtkFloat,
};
link.WriteToLocator(result.EncodeFrame((ushort)(1000 + trig.CaptureSeq)));
break;
}
};
var captureAt = new HashSet<int> { 5, 17, 42, 88 };
var report = await sim.ReplayAsync(frames: 100, captureAtFrames: captureAt, realTime: false);
Assert.Equal(100, telemetrySeen);
Assert.Equal(captureAt.Count, triggersSeen);
Assert.Equal(captureAt.Count, report.CaptureTriggersEmitted);
Assert.Equal(captureAt.Count, report.CaptureResultsReceived);
Assert.Empty(report.PendingCaptures); // SRS-LOG-7: no silent drop
// Every result correlates to a distinct trigger, and both outcomes occurred.
Assert.Equal(captureAt.Count, report.Results.Select(r => r.CaptureSeq).Distinct().Count());
Assert.Contains(report.Results, r => r.Outcome == CaptureOutcome.Stored);
Assert.Contains(report.Results, r => r.Outcome == CaptureOutcome.Rejected);
foreach (var r in report.Results.Where(r => r.Outcome == CaptureOutcome.Stored))
Assert.NotEqual(Guid.Empty, r.PointId);
}
}

216
tests/IfLoc.Sim/Frames.cs Normal file
View File

@@ -0,0 +1,216 @@
using System.Buffers.Binary;
namespace IfLoc.Sim;
// Byte-accurate codec for the frozen IF-LOC v1.0 frames.
// All multi-byte integers little-endian except pointId (RFC 4122 big-endian, §5.3).
/// <summary>§4 telemetry payload — the locate data dictionary.</summary>
public sealed record Telemetry
{
public uint LocatorUptimeMs { get; init; }
/// <summary>Depth in metres (resolution 0.01 m). Null = no depth (sentinel on wire).</summary>
public double? DepthMeters { get; init; }
/// <summary>Signal current in mA. Null = invalid.</summary>
public ushort? SignalCurrentMa { get; init; }
/// <summary>Frequency in Hz. Null = n/a.</summary>
public uint? FrequencyHz { get; init; }
public LocateMode Mode { get; init; } = LocateMode.Single;
public SignalType SignalType { get; init; } = SignalType.Active;
public byte GainDb { get; init; }
public byte SignalLevel { get; init; }
public byte DistortionQualityPct { get; init; }
public byte SignalDirection { get; init; }
public byte CompassAngleDeg { get; init; }
/// <summary>Guidance offset 1000..+1000 (negative = left). Null = n/a.</summary>
public short? GuidanceOffset { get; init; }
public WarningFlags Warnings { get; init; }
public UtilityType Utility { get; init; } = UtilityType.None;
public byte BatteryPercent { get; init; }
public StatusFlags Status { get; init; }
/// <summary>Encodes the 28-byte payload (frame offsets 4..31).</summary>
public byte[] EncodePayload()
{
var p = new byte[IfLoc.TelemetryPayloadLen];
var s = p.AsSpan();
BinaryPrimitives.WriteUInt32LittleEndian(s[0..], LocatorUptimeMs); // 4
BinaryPrimitives.WriteInt16LittleEndian(s[4..], DepthMeters is { } d ? (short)Math.Round(d * 100) : IfLoc.DepthNoValue); // 8
BinaryPrimitives.WriteUInt16LittleEndian(s[6..], SignalCurrentMa ?? IfLoc.CurrentInvalid); // 10
BinaryPrimitives.WriteUInt32LittleEndian(s[8..], FrequencyHz ?? IfLoc.FrequencyNa); // 12
s[12] = (byte)Mode; // 16
s[13] = (byte)SignalType; // 17
s[14] = GainDb; // 18
s[15] = SignalLevel; // 19
s[16] = DistortionQualityPct; // 20
s[17] = SignalDirection; // 21
s[18] = CompassAngleDeg; // 22
s[19] = 0; // 23 reserved
BinaryPrimitives.WriteInt16LittleEndian(s[20..], GuidanceOffset ?? IfLoc.GuidanceNa); // 24
BinaryPrimitives.WriteUInt16LittleEndian(s[22..], (ushort)Warnings); // 26
s[24] = (byte)Utility; // 28
s[25] = BatteryPercent; // 29
s[26] = (byte)Status; // 30
s[27] = 0; // 31 reserved
return p;
}
/// <summary>Decodes a 28-byte payload (frame offsets 4..31).</summary>
public static Telemetry DecodePayload(ReadOnlySpan<byte> p)
{
if (p.Length < IfLoc.TelemetryPayloadLen)
throw new ArgumentException($"telemetry payload must be {IfLoc.TelemetryPayloadLen} bytes");
short depth = BinaryPrimitives.ReadInt16LittleEndian(p[4..]);
ushort cur = BinaryPrimitives.ReadUInt16LittleEndian(p[6..]);
uint freq = BinaryPrimitives.ReadUInt32LittleEndian(p[8..]);
short guid = BinaryPrimitives.ReadInt16LittleEndian(p[20..]);
return new Telemetry
{
LocatorUptimeMs = BinaryPrimitives.ReadUInt32LittleEndian(p[0..]),
DepthMeters = depth == IfLoc.DepthNoValue ? null : depth / 100.0,
SignalCurrentMa = cur == IfLoc.CurrentInvalid ? null : cur,
FrequencyHz = freq == IfLoc.FrequencyNa ? null : freq,
Mode = (LocateMode)p[12],
SignalType = (SignalType)p[13],
GainDb = p[14],
SignalLevel = p[15],
DistortionQualityPct = p[16],
SignalDirection = p[17],
CompassAngleDeg = p[18],
GuidanceOffset = guid == IfLoc.GuidanceNa ? null : guid,
Warnings = (WarningFlags)BinaryPrimitives.ReadUInt16LittleEndian(p[22..]),
Utility = (UtilityType)p[24],
BatteryPercent = p[25],
Status = (StatusFlags)p[26],
};
}
/// <summary>Encodes a full 32-byte TELEMETRY frame (header + payload).</summary>
public byte[] EncodeFrame(ushort seq)
{
var f = new byte[IfLoc.TelemetryFrameLen];
Frames.WriteHeader(f, MessageType.Telemetry, seq);
EncodePayload().CopyTo(f.AsSpan(IfLoc.HeaderLen));
return f;
}
}
/// <summary>§5.1 capture-trigger frame (locator → app).</summary>
public sealed record CaptureTrigger
{
public ushort CaptureSeq { get; init; }
public TriggerType TriggerType { get; init; }
public required Telemetry Snapshot { get; init; }
public byte[] EncodeFrame(ushort seq)
{
var f = new byte[IfLoc.CaptureTriggerFrameLen];
Frames.WriteHeader(f, MessageType.CaptureTrigger, seq);
BinaryPrimitives.WriteUInt16LittleEndian(f.AsSpan(4), CaptureSeq); // 4
f[6] = (byte)TriggerType; // 6
f[7] = 0; // 7 reserved
Snapshot.EncodePayload().CopyTo(f.AsSpan(8)); // 8..35
return f;
}
public static CaptureTrigger DecodeFrame(ReadOnlySpan<byte> f)
{
Frames.Expect(f, MessageType.CaptureTrigger, IfLoc.CaptureTriggerFrameLen);
return new CaptureTrigger
{
CaptureSeq = BinaryPrimitives.ReadUInt16LittleEndian(f[4..]),
TriggerType = (TriggerType)f[6],
Snapshot = Telemetry.DecodePayload(f.Slice(8, IfLoc.TelemetryPayloadLen)),
};
}
}
/// <summary>§5.2 capture-result frame (app → locator).</summary>
public sealed record CaptureResult
{
public ushort CaptureSeq { get; init; }
public CaptureOutcome Outcome { get; init; }
public ReasonCode Reason { get; init; }
public FixType FixType { get; init; }
/// <summary>WGS84 latitude in degrees. Null = no position.</summary>
public double? Lat { get; init; }
public double? Lon { get; init; }
/// <summary>Orthometric height in metres. Null = no position.</summary>
public double? OrthometricHeightM { get; init; }
/// <summary>Horizontal RMS (1σ) in metres. Null = unknown.</summary>
public double? HrmsM { get; init; }
public double? VrmsM { get; init; }
/// <summary>Position epoch, UTC.</summary>
public DateTimeOffset? Utc { get; init; }
/// <summary>Stored point UUIDv7. All-zero on REJECTED.</summary>
public Guid PointId { get; init; }
public byte[] EncodeFrame(ushort seq)
{
var f = new byte[IfLoc.CaptureResultFrameLen];
var s = f.AsSpan();
Frames.WriteHeader(f, MessageType.CaptureResult, seq);
BinaryPrimitives.WriteUInt16LittleEndian(s[4..], CaptureSeq);
s[6] = (byte)Outcome;
s[7] = (byte)Reason;
s[8] = (byte)FixType;
s[9] = 0;
BinaryPrimitives.WriteInt32LittleEndian(s[10..], Lat is { } la ? (int)Math.Round(la * 1e7) : IfLoc.PositionNoValue);
BinaryPrimitives.WriteInt32LittleEndian(s[14..], Lon is { } lo ? (int)Math.Round(lo * 1e7) : IfLoc.PositionNoValue);
BinaryPrimitives.WriteInt32LittleEndian(s[18..], OrthometricHeightM is { } h ? (int)Math.Round(h * 1000) : IfLoc.PositionNoValue);
BinaryPrimitives.WriteUInt16LittleEndian(s[22..], HrmsM is { } hr ? (ushort)Math.Round(hr * 1000) : IfLoc.RmsUnknown);
BinaryPrimitives.WriteUInt16LittleEndian(s[24..], VrmsM is { } vr ? (ushort)Math.Round(vr * 1000) : IfLoc.RmsUnknown);
BinaryPrimitives.WriteUInt16LittleEndian(s[26..], 0); // reserved
BinaryPrimitives.WriteUInt64LittleEndian(s[28..], Utc is { } t ? (ulong)t.ToUnixTimeMilliseconds() : 0);
PointId.TryWriteBytes(s.Slice(36, 16), bigEndian: true, out _); // §5.3 RFC 4122 network order
return f;
}
public static CaptureResult DecodeFrame(ReadOnlySpan<byte> f)
{
Frames.Expect(f, MessageType.CaptureResult, IfLoc.CaptureResultFrameLen);
int lat = BinaryPrimitives.ReadInt32LittleEndian(f[10..]);
int lon = BinaryPrimitives.ReadInt32LittleEndian(f[14..]);
int h = BinaryPrimitives.ReadInt32LittleEndian(f[18..]);
ushort hr = BinaryPrimitives.ReadUInt16LittleEndian(f[22..]);
ushort vr = BinaryPrimitives.ReadUInt16LittleEndian(f[24..]);
ulong ms = BinaryPrimitives.ReadUInt64LittleEndian(f[28..]);
return new CaptureResult
{
CaptureSeq = BinaryPrimitives.ReadUInt16LittleEndian(f[4..]),
Outcome = (CaptureOutcome)f[6],
Reason = (ReasonCode)f[7],
FixType = (FixType)f[8],
Lat = lat == IfLoc.PositionNoValue ? null : lat / 1e7,
Lon = lon == IfLoc.PositionNoValue ? null : lon / 1e7,
OrthometricHeightM = h == IfLoc.PositionNoValue ? null : h / 1000.0,
HrmsM = hr == IfLoc.RmsUnknown ? null : hr / 1000.0,
VrmsM = vr == IfLoc.RmsUnknown ? null : vr / 1000.0,
Utc = ms == 0 ? null : DateTimeOffset.FromUnixTimeMilliseconds((long)ms),
PointId = new Guid(f.Slice(36, 16), bigEndian: true),
};
}
}
/// <summary>Shared header helpers.</summary>
public static class Frames
{
public static void WriteHeader(Span<byte> f, MessageType type, ushort seq)
{
f[0] = IfLoc.FrameVersion;
f[1] = (byte)type;
BinaryPrimitives.WriteUInt16LittleEndian(f[2..], seq);
}
public static MessageType PeekType(ReadOnlySpan<byte> f) => (MessageType)f[1];
public static void Expect(ReadOnlySpan<byte> f, MessageType type, int fixedLen)
{
if (f.Length < fixedLen)
throw new ArgumentException($"{type} frame must be ≥ {fixedLen} bytes, got {f.Length}");
if (f[0] != IfLoc.FrameVersion)
throw new ArgumentException($"unsupported frameVersion 0x{f[0]:X2}");
if ((MessageType)f[1] != type)
throw new ArgumentException($"expected {type}, got 0x{f[1]:X2}");
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
IF-LOC locator simulator + wire codec.
Deliberately a plain net9.0 library (NOT a MAUI target head) so it builds and runs
headless in CI without the Android/iOS workloads. It is the reference implementation
of the frozen IF-LOC v1.0 contract (meta/contracts/ble-device-interface.md) and exists
to unblock App development without firmware (Risk R-3, SRS §3.1).
-->
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<RootNamespace>IfLoc.Sim</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,145 @@
namespace IfLoc.Sim;
/// <summary>
/// A bidirectional in-memory link modelling the bonded BLE GATT session between the
/// locator (peripheral) and the App (central). Frames pushed toward the App surface on
/// <see cref="ToApp"/>; frames the App writes back surface on <see cref="ToLocator"/>.
/// A transport-agnostic stand-in for the real GATT characteristics — a TCP or real-BLE
/// implementation can replace it without touching the simulator or codec.
/// </summary>
public sealed class LoopbackLink
{
/// <summary>Locator → App (Telemetry / Event characteristics).</summary>
public event Action<byte[]>? ToApp;
/// <summary>App → Locator (Command / Capture Result characteristics).</summary>
public event Action<byte[]>? ToLocator;
public void PublishToApp(byte[] frame) => ToApp?.Invoke(frame);
public void WriteToLocator(byte[] frame) => ToLocator?.Invoke(frame);
}
/// <summary>Result of a replayed session, for test assertions.</summary>
public sealed record SessionReport
{
public int TelemetryFramesEmitted { get; init; }
public int CaptureTriggersEmitted { get; init; }
public int CaptureResultsReceived { get; init; }
public IReadOnlyList<CaptureResult> Results { get; init; } = Array.Empty<CaptureResult>();
/// <summary>Trigger captureSeqs still awaiting a result — must be empty (no silent-failure, SRS-LOG-7).</summary>
public IReadOnlyList<ushort> PendingCaptures { get; init; } = Array.Empty<ushort>();
}
/// <summary>
/// Replays a canned locate session over the IF-LOC contract: emits telemetry at the
/// configured rate, raises capture-triggers at scripted frames, and consumes the App's
/// capture-results — correlating each result to its trigger by captureSeq. Built purely
/// from the frozen data dictionary so App development is unblocked without firmware
/// (Risk R-3).
/// </summary>
public sealed class LocatorSimulator
{
private readonly LoopbackLink _link;
private ushort _seq;
private ushort _captureSeq;
private readonly HashSet<ushort> _pending = new();
private readonly List<CaptureResult> _results = new();
public LocatorSimulator(LoopbackLink link)
{
_link = link;
_link.ToLocator += OnAppFrame;
}
private void OnAppFrame(byte[] frame)
{
if (frame.Length < IfLoc.HeaderLen) return; // §14 ignore malformed
if (Frames.PeekType(frame) != MessageType.CaptureResult) return;
var result = CaptureResult.DecodeFrame(frame);
_results.Add(result);
_pending.Remove(result.CaptureSeq);
}
/// <summary>
/// Emits <paramref name="frames"/> telemetry frames, raising a capture-trigger at each
/// index in <paramref name="captureAtFrames"/>. When <paramref name="realTime"/> is true,
/// paces at <paramref name="rateHz"/> for a live demo; otherwise emits as fast as possible
/// for deterministic tests. Returns once all frames are emitted (results may still be
/// arriving synchronously on the loopback).
/// </summary>
public async Task<SessionReport> ReplayAsync(
int frames,
IReadOnlySet<int> captureAtFrames,
double rateHz = 5.0,
bool realTime = false,
CancellationToken ct = default)
{
int triggers = 0;
uint uptime = 0;
int stepMs = (int)Math.Round(1000.0 / rateHz);
for (int i = 0; i < frames; i++)
{
ct.ThrowIfCancellationRequested();
var tele = BuildTelemetry(i, uptime);
_link.PublishToApp(tele.EncodeFrame(_seq++));
if (captureAtFrames.Contains(i))
{
var trigger = new CaptureTrigger
{
CaptureSeq = ++_captureSeq, // locator-initiated: high bit clear (§5.1)
TriggerType = TriggerType.ButtonSingle,
Snapshot = tele,
};
_pending.Add(trigger.CaptureSeq);
triggers++;
_link.PublishToApp(trigger.EncodeFrame(_seq++));
}
uptime += (uint)stepMs;
if (realTime) await Task.Delay(stepMs, ct).ConfigureAwait(false);
}
return new SessionReport
{
TelemetryFramesEmitted = frames,
CaptureTriggersEmitted = triggers,
CaptureResultsReceived = _results.Count,
Results = _results.ToArray(),
PendingCaptures = _pending.ToArray(),
};
}
/// <summary>
/// A deterministic, physically-plausible canned telemetry frame for step <paramref name="i"/>.
/// Sweeps depth, current, and signal across their ranges and exercises a warning flag
/// and a no-depth sentinel so consumers see the full dictionary.
/// </summary>
public static Telemetry BuildTelemetry(int i, uint uptimeMs)
{
bool noDepth = i % 20 == 19; // periodically drop depth (sentinel path)
var warn = WarningFlags.None;
if (i % 25 == 12) warn |= WarningFlags.Shallow;
if (i % 40 == 30) warn |= WarningFlags.Overload;
return new Telemetry
{
LocatorUptimeMs = uptimeMs,
DepthMeters = noDepth ? null : 0.50 + 0.01 * (i % 200), // 0.50 → 2.49 m
SignalCurrentMa = (ushort)(50 + (i % 300)),
FrequencyHz = 32768,
Mode = LocateMode.Twin,
SignalType = SignalType.Active,
GainDb = (byte)(40 + (i % 60)),
SignalLevel = (byte)(60 + (i % 40)),
DistortionQualityPct = (byte)(100 - (i % 15)),
SignalDirection = 1,
CompassAngleDeg = (byte)(i % 181),
GuidanceOffset = (short)(((i % 41) - 20) * 25), // 500 → +500, negative = left
Warnings = warn,
Utility = UtilityType.Water,
BatteryPercent = (byte)Math.Max(5, 100 - i / 10),
Status = StatusFlags.Locating,
};
}
}

150
tests/IfLoc.Sim/Protocol.cs Normal file
View File

@@ -0,0 +1,150 @@
namespace IfLoc.Sim;
// Reference constants for the frozen IF-LOC v1.0 contract.
// Source of truth: meta/contracts/ble-device-interface.md. Keep the two in lockstep;
// enum values and message-type ids are append-only once frozen.
/// <summary>Wire message-type ids (frame header byte 1).</summary>
public enum MessageType : byte
{
Telemetry = 0x01,
CaptureTrigger = 0x02,
CaptureResult = 0x03,
Alert = 0x04,
Ack = 0x05,
CmdRequestSnapshot = 0x10,
CmdSetFrequency = 0x11,
CmdSetMode = 0x12,
CmdSetTelemetryRate = 0x13,
CmdTimeSync = 0x14,
CmdSetUtility = 0x15,
OtaBegin = 0x20,
OtaData = 0x21,
OtaEnd = 0x22,
UsageLogRequest = 0x30,
UsageLogRecord = 0x31,
UsageLogEnd = 0x32,
ClaimChallenge = 0x40,
ClaimAssertion = 0x41,
ClaimCertReq = 0x42,
ClaimCert = 0x43,
}
/// <summary>§4.1 locateMode.</summary>
public enum LocateMode : byte
{
Single = 0, Twin = 1, Null = 2, Sweep = 3, TwinSweep = 4, Omni = 5, TwinOmni = 6,
NotAvailable = 0xFF,
}
/// <summary>§4.2 signalType.</summary>
public enum SignalType : byte
{
Active = 0, LineDropActive = 1, Power = 2, GroupedPower = 3, Cathodic = 4,
Sonde = 5, Radio = 6, FaultFind = 7, NotAvailable = 0xFF,
}
/// <summary>§4.4 utilityType (APWA-aligned).</summary>
public enum UtilityType : byte
{
None = 0, Gas = 1, Power = 2, Communications = 3, Water = 4, Sewer = 5, Fiber = 6,
Other = 7, NotAvailable = 0xFF,
}
/// <summary>§4.3 warningFlags bitfield.</summary>
[Flags]
public enum WarningFlags : ushort
{
None = 0,
Shallow = 1 << 0,
Overload = 1 << 1,
SwingTilt = 1 << 2,
DepthInvalid = 1 << 3,
CurrentInvalid = 1 << 4,
OutOfRange = 1 << 5,
DistortionHigh = 1 << 6,
LowBattery = 1 << 7,
}
/// <summary>§4.5 statusFlags bitfield.</summary>
[Flags]
public enum StatusFlags : byte
{
None = 0,
Locating = 1 << 0,
MenuActive = 1 << 1,
TimeSynced = 1 << 2,
DepthModeAuto = 1 << 3,
}
/// <summary>§5.1 triggerType.</summary>
public enum TriggerType : byte
{
ButtonSingle = 0, ButtonHold = 1, OffsetRequest = 2, AppInitiated = 3,
}
/// <summary>§5.2 outcome — the deterministic capture outcome (SRS-LOG-7).</summary>
public enum CaptureOutcome : byte
{
Stored = 0, StoredFlagged = 1, Rejected = 2,
}
/// <summary>§5.4 reasonCode / gateStatus.</summary>
public enum ReasonCode : byte
{
Ok = 0,
FixTypeTooLow = 1,
HrmsExceeded = 2,
VrmsExceeded = 3,
CorrectionAgeExceeded = 4,
NoActiveTicket = 5,
ImuCalibrationInvalid = 6,
HeadingConfidenceLow = 7,
BufferFull = 8,
NoPosition = 9,
WaiverRequired = 10,
Other = 255,
}
/// <summary>§5.2 fixType (GGA-quality mapping; 3 reserved).</summary>
public enum FixType : byte
{
NoFix = 0, Autonomous = 1, Dgps = 2, RtkFixed = 4, RtkFloat = 5,
}
/// <summary>Frozen wire constants and sentinels.</summary>
public static class IfLoc
{
public const byte FrameVersion = 0x01;
// Fixed frame lengths (Appendix A).
public const int HeaderLen = 4;
public const int TelemetryFrameLen = 32;
public const int TelemetryPayloadLen = 28; // offsets 4..31
public const int CaptureTriggerFrameLen = 36;
public const int CaptureResultFrameLen = 52;
// Sentinels (§1).
public const short DepthNoValue = unchecked((short)0x8000);
public const ushort CurrentInvalid = 0xFFFF;
public const uint FrequencyNa = 0xFFFFFFFF;
public const short GuidanceNa = unchecked((short)0x8000);
public const int PositionNoValue = unchecked((int)0x80000000);
public const ushort RmsUnknown = 0xFFFF;
// GATT (§2) — recommended UUIDs the App builds against.
public const string BaseUuidFormat = "A9E1{0:X4}-1B4C-4F9A-9B7E-2D6F0C3A5E11";
public static string LocateServiceUuid => string.Format(BaseUuidFormat, 0x0001);
public static string ProtocolInfoUuid => string.Format(BaseUuidFormat, 0x0002);
public static string TelemetryUuid => string.Format(BaseUuidFormat, 0x0003);
public static string EventUuid => string.Format(BaseUuidFormat, 0x0004);
public static string CommandUuid => string.Format(BaseUuidFormat, 0x0005);
public static string CaptureResultUuid => string.Format(BaseUuidFormat, 0x0006);
public static string LinkStateUuid => string.Format(BaseUuidFormat, 0x0007);
public static string BulkOtaUuid => string.Format(BaseUuidFormat, 0x0008);
public static string ClaimUuid => string.Format(BaseUuidFormat, 0x0009);
}