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>
146 lines
5.9 KiB
C#
146 lines
5.9 KiB
C#
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,
|
||
};
|
||
}
|
||
}
|