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