diff --git a/tests/IfLoc.Sim.Tests/IfLoc.Sim.Tests.csproj b/tests/IfLoc.Sim.Tests/IfLoc.Sim.Tests.csproj new file mode 100644 index 0000000..ea9682d --- /dev/null +++ b/tests/IfLoc.Sim.Tests/IfLoc.Sim.Tests.csproj @@ -0,0 +1,27 @@ + + + + + + net9.0 + enable + enable + false + true + + + + + + + + + + + + + diff --git a/tests/IfLoc.Sim.Tests/RoundTripTests.cs b/tests/IfLoc.Sim.Tests/RoundTripTests.cs new file mode 100644 index 0000000..314c033 --- /dev/null +++ b/tests/IfLoc.Sim.Tests/RoundTripTests.cs @@ -0,0 +1,228 @@ +using IfLoc.Sim; +using Xunit; + +namespace IfLoc.Sim.Tests; + +/// +/// 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). +/// +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); + } + + /// + /// 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. + /// + [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 { 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); + } +} diff --git a/tests/IfLoc.Sim/Frames.cs b/tests/IfLoc.Sim/Frames.cs new file mode 100644 index 0000000..8426a9f --- /dev/null +++ b/tests/IfLoc.Sim/Frames.cs @@ -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). + +/// §4 telemetry payload — the locate data dictionary. +public sealed record Telemetry +{ + public uint LocatorUptimeMs { get; init; } + /// Depth in metres (resolution 0.01 m). Null = no depth (sentinel on wire). + public double? DepthMeters { get; init; } + /// Signal current in mA. Null = invalid. + public ushort? SignalCurrentMa { get; init; } + /// Frequency in Hz. Null = n/a. + 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; } + /// Guidance offset −1000..+1000 (negative = left). Null = n/a. + 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; } + + /// Encodes the 28-byte payload (frame offsets 4..31). + 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; + } + + /// Decodes a 28-byte payload (frame offsets 4..31). + public static Telemetry DecodePayload(ReadOnlySpan 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], + }; + } + + /// Encodes a full 32-byte TELEMETRY frame (header + payload). + 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; + } +} + +/// §5.1 capture-trigger frame (locator → app). +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 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)), + }; + } +} + +/// §5.2 capture-result frame (app → locator). +public sealed record CaptureResult +{ + public ushort CaptureSeq { get; init; } + public CaptureOutcome Outcome { get; init; } + public ReasonCode Reason { get; init; } + public FixType FixType { get; init; } + /// WGS84 latitude in degrees. Null = no position. + public double? Lat { get; init; } + public double? Lon { get; init; } + /// Orthometric height in metres. Null = no position. + public double? OrthometricHeightM { get; init; } + /// Horizontal RMS (1σ) in metres. Null = unknown. + public double? HrmsM { get; init; } + public double? VrmsM { get; init; } + /// Position epoch, UTC. + public DateTimeOffset? Utc { get; init; } + /// Stored point UUIDv7. All-zero on REJECTED. + 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 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), + }; + } +} + +/// Shared header helpers. +public static class Frames +{ + public static void WriteHeader(Span f, MessageType type, ushort seq) + { + f[0] = IfLoc.FrameVersion; + f[1] = (byte)type; + BinaryPrimitives.WriteUInt16LittleEndian(f[2..], seq); + } + + public static MessageType PeekType(ReadOnlySpan f) => (MessageType)f[1]; + + public static void Expect(ReadOnlySpan 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}"); + } +} diff --git a/tests/IfLoc.Sim/IfLoc.Sim.csproj b/tests/IfLoc.Sim/IfLoc.Sim.csproj new file mode 100644 index 0000000..dd31fef --- /dev/null +++ b/tests/IfLoc.Sim/IfLoc.Sim.csproj @@ -0,0 +1,19 @@ + + + + + + net9.0 + enable + enable + latest + IfLoc.Sim + + + diff --git a/tests/IfLoc.Sim/LocatorSimulator.cs b/tests/IfLoc.Sim/LocatorSimulator.cs new file mode 100644 index 0000000..f20284e --- /dev/null +++ b/tests/IfLoc.Sim/LocatorSimulator.cs @@ -0,0 +1,145 @@ +namespace IfLoc.Sim; + +/// +/// 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 +/// ; frames the App writes back surface on . +/// 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. +/// +public sealed class LoopbackLink +{ + /// Locator → App (Telemetry / Event characteristics). + public event Action? ToApp; + /// App → Locator (Command / Capture Result characteristics). + public event Action? ToLocator; + + public void PublishToApp(byte[] frame) => ToApp?.Invoke(frame); + public void WriteToLocator(byte[] frame) => ToLocator?.Invoke(frame); +} + +/// Result of a replayed session, for test assertions. +public sealed record SessionReport +{ + public int TelemetryFramesEmitted { get; init; } + public int CaptureTriggersEmitted { get; init; } + public int CaptureResultsReceived { get; init; } + public IReadOnlyList Results { get; init; } = Array.Empty(); + /// Trigger captureSeqs still awaiting a result — must be empty (no silent-failure, SRS-LOG-7). + public IReadOnlyList PendingCaptures { get; init; } = Array.Empty(); +} + +/// +/// 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). +/// +public sealed class LocatorSimulator +{ + private readonly LoopbackLink _link; + private ushort _seq; + private ushort _captureSeq; + private readonly HashSet _pending = new(); + private readonly List _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); + } + + /// + /// Emits telemetry frames, raising a capture-trigger at each + /// index in . When is true, + /// paces at 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). + /// + public async Task ReplayAsync( + int frames, + IReadOnlySet 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(), + }; + } + + /// + /// A deterministic, physically-plausible canned telemetry frame for step . + /// Sweeps depth, current, and signal across their ranges and exercises a warning flag + /// and a no-depth sentinel so consumers see the full dictionary. + /// + 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, + }; + } +} diff --git a/tests/IfLoc.Sim/Protocol.cs b/tests/IfLoc.Sim/Protocol.cs new file mode 100644 index 0000000..b81cdff --- /dev/null +++ b/tests/IfLoc.Sim/Protocol.cs @@ -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. + +/// Wire message-type ids (frame header byte 1). +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, +} + +/// §4.1 locateMode. +public enum LocateMode : byte +{ + Single = 0, Twin = 1, Null = 2, Sweep = 3, TwinSweep = 4, Omni = 5, TwinOmni = 6, + NotAvailable = 0xFF, +} + +/// §4.2 signalType. +public enum SignalType : byte +{ + Active = 0, LineDropActive = 1, Power = 2, GroupedPower = 3, Cathodic = 4, + Sonde = 5, Radio = 6, FaultFind = 7, NotAvailable = 0xFF, +} + +/// §4.4 utilityType (APWA-aligned). +public enum UtilityType : byte +{ + None = 0, Gas = 1, Power = 2, Communications = 3, Water = 4, Sewer = 5, Fiber = 6, + Other = 7, NotAvailable = 0xFF, +} + +/// §4.3 warningFlags bitfield. +[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, +} + +/// §4.5 statusFlags bitfield. +[Flags] +public enum StatusFlags : byte +{ + None = 0, + Locating = 1 << 0, + MenuActive = 1 << 1, + TimeSynced = 1 << 2, + DepthModeAuto = 1 << 3, +} + +/// §5.1 triggerType. +public enum TriggerType : byte +{ + ButtonSingle = 0, ButtonHold = 1, OffsetRequest = 2, AppInitiated = 3, +} + +/// §5.2 outcome — the deterministic capture outcome (SRS-LOG-7). +public enum CaptureOutcome : byte +{ + Stored = 0, StoredFlagged = 1, Rejected = 2, +} + +/// §5.4 reasonCode / gateStatus. +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, +} + +/// §5.2 fixType (GGA-quality mapping; 3 reserved). +public enum FixType : byte +{ + NoFix = 0, Autonomous = 1, Dgps = 2, RtkFixed = 4, RtkFloat = 5, +} + +/// Frozen wire constants and sentinels. +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); +}