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:
Brent Perteet
2026-08-20 13:10:24 -05:00
parent dc3a45e699
commit 0b4fb83546
6 changed files with 785 additions and 0 deletions

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