Files
ulapp/tests/IfLoc.Sim/LocatorSimulator.cs
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

146 lines
5.9 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}
}