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