using System.Text; using Nonemm.Digital; namespace Nonemm.EngineProbe; /// Four questions about MMTTY that cannot be answered without MMTTY running, /// asked in order and written to the report: /// /// 1. Does the control answer `TxBufLen`, and is a name it does not know /// distinguishable from one it does? /// 2. While a message is going out, does that number count down at the /// character rate? If it does, it says exactly how much of the message is /// still in the engine and can still be taken back. /// 3. Does a backspace pushed in as a character delete a character the engine /// has not transmitted yet, and what happens to backspaces over characters /// that have already gone? Those have to be refused: text typed after them /// would otherwise go out twice. /// 4. What comes back on the receive side while transmitting, and when? With /// the sound loopback off that is MMTTY echoing its transmit window; with it /// on it is the demodulator hearing the transmission. /// /// The answers decide whether the transmit buffer can be handed to MMTTY /// instead of being paced from here. `docs/unfinished.md` states what each one /// means. public sealed class EngineProbe { /// Long enough for a character at 45.45 baud, short enough to see the count /// move. private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50); /// A message at 45.45 baud takes a few seconds; this is well past the end /// of one. private static readonly TimeSpan Patience = TimeSpan.FromSeconds(30); private const string Message = "CQ TEST DE OM5M OM5M"; private const string Alphabet = "ABCDEFGHIJKLMNOP"; private const int Backspaces = 4; private const char Backspace = '\b'; private const string Short = "MNOPQRST"; private const int TooManyBackspaces = 6; private const string Afterwards = "XX"; private readonly MmttyEngine engine; private readonly ProbeLog log; private readonly StringBuilder received = new(); private readonly Lock gate = new(); private TaskCompletionSource? asking; public EngineProbe(MmttyEngine engine, ProbeLog log) { this.engine = engine; this.log = log; engine.Reported += (_, what) => log.Write($"bridge: {what}"); engine.TransmitChanged += (_, on) => log.Write(on ? "engine keyed" : "engine unkeyed"); engine.Buffered += WhenBuffered; engine.Received += WhenReceived; } public async Task RunAsync(CancellationToken cancellation) { await NamesAsync(cancellation).ConfigureAwait(false); await IdleAsync(cancellation).ConfigureAwait(false); await MessageAsync(cancellation).ConfigureAwait(false); await BackspaceAsync(cancellation).ConfigureAwait(false); await TooLateAsync(cancellation).ConfigureAwait(false); } /// Question 1. `DISP_E_UNKNOWNNAME` is 0x80020006; a name the control knows /// answers with a number instead. private async Task NamesAsync(CancellationToken cancellation) { log.Step("1. does the control answer TxBufLen"); log.Write($"TxBufLen: {Answer(await AskAsync("TxBufLen", cancellation).ConfigureAwait(false))}"); log.Write($"NotAProperty: {Answer(await AskAsync("NotAProperty", cancellation).ConfigureAwait(false))}"); log.Write("a number for the first and no answer for the second is what makes the count usable"); } /// The number with nothing to transmit, which is what the count has to /// start and end at. private async Task IdleAsync(CancellationToken cancellation) { log.Step("2. TxBufLen with nothing to send"); for (int look = 0; look < 3; look++) { log.Write($"TxBufLen: {Answer(await AskAsync("", cancellation).ConfigureAwait(false))}"); await Task.Delay(PollInterval, cancellation).ConfigureAwait(false); } } /// Questions 2 and 4. The whole message is pushed as fast as the bridge /// takes it, so what the count does afterwards is the engine transmitting /// rather than the probe feeding. private async Task MessageAsync(CancellationToken cancellation) { log.Step($"3. \"{Message}\" pushed in one go, then TxBufLen every {PollInterval.TotalMilliseconds} ms"); TakeReceived(); await engine.SetPttAsync(true, cancellation).ConfigureAwait(false); await TypeAsync(Message, cancellation).ConfigureAwait(false); log.Write($"pushed {Message.Length} characters"); await DrainAsync(cancellation).ConfigureAwait(false); log.Write($"received while transmitting: \"{TakeReceived()}\""); await engine.SetPttAsync(false, cancellation).ConfigureAwait(false); await Task.Delay(PollInterval, cancellation).ConfigureAwait(false); } /// Question 3. The wait is there so the engine is partway through the /// alphabet when the backspaces arrive: the ones over transmitted /// characters are the ones MMTTY has to refuse. private async Task BackspaceAsync(CancellationToken cancellation) { log.Step($"4. \"{Alphabet}\", then {Backspaces} backspaces once it is under way"); TakeReceived(); await engine.SetPttAsync(true, cancellation).ConfigureAwait(false); await TypeAsync(Alphabet, cancellation).ConfigureAwait(false); log.Write($"pushed {Alphabet.Length} characters"); await Task.Delay(TimeSpan.FromSeconds(1), cancellation).ConfigureAwait(false); int before = await AskAsync("", cancellation).ConfigureAwait(false); log.Write($"TxBufLen before the backspaces: {Answer(before)}"); await TypeAsync(new string(Backspace, Backspaces), cancellation).ConfigureAwait(false); int after = await AskAsync("", cancellation).ConfigureAwait(false); log.Write($"TxBufLen after the backspaces: {Answer(after)}"); log.Write($"a drop of {Backspaces} means the engine took them out of its buffer"); await DrainAsync(cancellation).ConfigureAwait(false); string went = TakeReceived(); log.Write($"received while transmitting: \"{went}\""); log.Write($"the last {Backspaces} letters of \"{Alphabet}\" are the ones that should be missing"); await engine.SetPttAsync(false, cancellation).ConfigureAwait(false); } /// The other half of question 3: more backspaces than there are characters /// left to take back. If the extra ones are ignored, what goes out is the /// transmitted part followed by the new text. If they do something else, /// this is where it shows. private async Task TooLateAsync(CancellationToken cancellation) { log.Step($"5. \"{Short}\", then {TooManyBackspaces} backspaces near the end of it"); TakeReceived(); await engine.SetPttAsync(true, cancellation).ConfigureAwait(false); await TypeAsync(Short, cancellation).ConfigureAwait(false); int left = await WaitForAsync(2, cancellation).ConfigureAwait(false); log.Write($"TxBufLen when the backspaces go in: {Answer(left)}"); await TypeAsync(new string(Backspace, TooManyBackspaces), cancellation).ConfigureAwait(false); log.Write($"TxBufLen after them: {Answer(await AskAsync("", cancellation).ConfigureAwait(false))}"); await TypeAsync(Afterwards, cancellation).ConfigureAwait(false); await DrainAsync(cancellation).ConfigureAwait(false); log.Write($"received while transmitting: \"{TakeReceived()}\""); log.Write($"what went out should end in \"{Afterwards}\" once, not twice"); await engine.SetPttAsync(false, cancellation).ConfigureAwait(false); } /// Waits until the engine holds no more than `wanted` characters, so the /// next thing the probe does lands at a known point in the message. private async Task WaitForAsync(int wanted, CancellationToken cancellation) { DateTime giveUp = DateTime.UtcNow + Patience; while (DateTime.UtcNow < giveUp) { int left = await AskAsync("", cancellation).ConfigureAwait(false); if (left < 0) { log.Write($"no answer to wait on, guessing at {Short.Length - wanted} characters gone"); await Task.Delay(TimeSpan.FromSeconds(1), cancellation).ConfigureAwait(false); return left; } if (left <= wanted) { return left; } await Task.Delay(PollInterval, cancellation).ConfigureAwait(false); } return -1; } /// Polls until the engine says it has nothing left, or until the patience /// runs out. A control that does not answer has nothing to poll, so the /// wait is the whole of a message instead. private async Task DrainAsync(CancellationToken cancellation) { DateTime giveUp = DateTime.UtcNow + Patience; int last = int.MinValue; while (DateTime.UtcNow < giveUp) { int left = await AskAsync("", cancellation).ConfigureAwait(false); if (left != last) { log.Write($"TxBufLen: {Answer(left)}"); last = left; } if (left == 0) { return; } if (left < 0) { log.Write("no answer to poll, waiting out the message instead"); await Task.Delay(TimeSpan.FromSeconds(10), cancellation).ConfigureAwait(false); return; } await Task.Delay(PollInterval, cancellation).ConfigureAwait(false); } log.Write($"gave up waiting after {Patience.TotalSeconds} s"); } private async Task TypeAsync(string text, CancellationToken cancellation) { foreach (char character in text) { await engine.TypeAsync(character, cancellation).ConfigureAwait(false); } } /// Sends one question and waits for the answer. The bridge answers every /// question, with -1 when the control would not, so this cannot hang while /// the bridge is alive. private async Task AskAsync(string property, CancellationToken cancellation) { TaskCompletionSource answer = new(TaskCreationOptions.RunContinuationsAsynchronously); lock (gate) { asking = answer; } await engine.AskBufferedAsync(property, cancellation).ConfigureAwait(false); return await answer.Task.WaitAsync(Patience, cancellation).ConfigureAwait(false); } private static string Answer(int left) => left < 0 ? "no answer" : left.ToString(); private void WhenBuffered(object? sender, int left) { lock (gate) { asking?.TrySetResult(left); } } private void WhenReceived(object? sender, string text) { foreach (char character in text) { log.Write($"rx {Printable(character)}"); } lock (received) { received.Append(text); } } private string TakeReceived() { lock (received) { string text = received.ToString(); received.Clear(); return text; } } /// Control codes matter here: a backspace coming back is the engine saying /// it removed a character. private static string Printable(char character) => character switch { '\b' => "\\b (backspace)", '\r' => "\\r", '\n' => "\\n", _ when char.IsControl(character) => $"\\x{(int)character:x2}", _ => character.ToString(), }; }