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>
This commit is contained in:
27
tests/IfLoc.Sim.Tests/IfLoc.Sim.Tests.csproj
Normal file
27
tests/IfLoc.Sim.Tests/IfLoc.Sim.Tests.csproj
Normal 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>
|
||||
228
tests/IfLoc.Sim.Tests/RoundTripTests.cs
Normal file
228
tests/IfLoc.Sim.Tests/RoundTripTests.cs
Normal 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
216
tests/IfLoc.Sim/Frames.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
19
tests/IfLoc.Sim/IfLoc.Sim.csproj
Normal file
19
tests/IfLoc.Sim/IfLoc.Sim.csproj
Normal 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>
|
||||
145
tests/IfLoc.Sim/LocatorSimulator.cs
Normal file
145
tests/IfLoc.Sim/LocatorSimulator.cs
Normal 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
150
tests/IfLoc.Sim/Protocol.cs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user