using System.Text; using Nonemm.Digital; namespace Nonemm.EngineProbe; /// 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 the engine hold a word until the space after it? That is MMTTY's /// Option ▸ Way to send, and Word out is what its help calls the usual /// setting. /// 4. Does a backspace pushed in as a character delete one 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. /// 5. 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); /// A count that has not moved for this long is not going to move. private static readonly TimeSpan Still = TimeSpan.FromSeconds(5); /// How long the engine is given to key up and to drop again. private static readonly TimeSpan Keying = TimeSpan.FromSeconds(3); private const string Message = "CQ TEST DE OM5M OM5M "; private const string Word = "ABCD"; 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; private TaskCompletionSource? keying; public EngineProbe(MmttyEngine engine, ProbeLog log) { this.engine = engine; this.log = log; engine.Reported += (_, what) => log.Write($"bridge: {what}"); engine.TransmitChanged += WhenTransmitChanged; engine.Buffered += WhenBuffered; engine.Received += WhenReceived; } public async Task RunAsync(CancellationToken cancellation) { await NamesAsync(cancellation).ConfigureAwait(false); await IdleAsync(cancellation).ConfigureAwait(false); if (!await KeyingAsync(cancellation).ConfigureAwait(false)) { return; } await MessageAsync(cancellation).ConfigureAwait(false); await WordAsync(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); } } /// Nothing else in the report means anything until the engine keys, so this /// stops the run rather than letting the rest measure an engine that is /// sitting still. private async Task KeyingAsync(CancellationToken cancellation) { log.Step("3. does the engine key up"); if (!await KeyAsync(cancellation).ConfigureAwait(false)) { log.Write("the engine did not key: nothing below this would mean anything, so stopping"); log.Write("keying is the control's PTT property; SetMmttyPTT only stops a transmission"); return false; } log.Write($"TxBufLen while keyed and idle: {Answer(await AskAsync("", cancellation).ConfigureAwait(false))}"); await UnkeyAsync(cancellation).ConfigureAwait(false); return true; } /// Questions 2 and 5. 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($"4. \"{Message}\" pushed in one go, then TxBufLen every {PollInterval.TotalMilliseconds} ms"); TakeReceived(); await KeyAsync(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 UnkeyAsync(cancellation).ConfigureAwait(false); } /// Question 3. A word with no space after it is what MMTTY holds back when /// it is set to Word out, and that changes what "still in the buffer" /// means. private async Task WordAsync(CancellationToken cancellation) { log.Step($"5. \"{Word}\" with no space after it, then the space"); TakeReceived(); await KeyAsync(cancellation).ConfigureAwait(false); await TypeAsync(Word, cancellation).ConfigureAwait(false); await Task.Delay(TimeSpan.FromSeconds(3), cancellation).ConfigureAwait(false); log.Write($"TxBufLen three seconds after the word: {Answer(await AskAsync("", cancellation).ConfigureAwait(false))}"); log.Write($"received so far: \"{TakeReceived()}\""); log.Write("nothing received here means the engine is set to Word out and is holding it"); await TypeAsync(" ", cancellation).ConfigureAwait(false); await DrainAsync(cancellation).ConfigureAwait(false); log.Write($"received after the space: \"{TakeReceived()}\""); await UnkeyAsync(cancellation).ConfigureAwait(false); } /// Question 4. The wait is there so the engine is partway through the /// alphabet when the backspaces arrive. private async Task BackspaceAsync(CancellationToken cancellation) { log.Step($"6. \"{Alphabet}\", then {Backspaces} backspaces once it is under way"); TakeReceived(); await KeyAsync(cancellation).ConfigureAwait(false); await TypeAsync(Alphabet, cancellation).ConfigureAwait(false); log.Write($"pushed {Alphabet.Length} characters"); await Task.Delay(TimeSpan.FromSeconds(1), cancellation).ConfigureAwait(false); log.Write($"TxBufLen before the backspaces: {Answer(await AskAsync("", cancellation).ConfigureAwait(false))}"); await TypeAsync(new string(Backspace, Backspaces), cancellation).ConfigureAwait(false); log.Write($"TxBufLen after the backspaces: {Answer(await AskAsync("", cancellation).ConfigureAwait(false))}"); log.Write($"a drop of {Backspaces} means the engine took them out of its buffer"); await DrainAsync(cancellation).ConfigureAwait(false); log.Write($"received while transmitting: \"{TakeReceived()}\""); log.Write($"the last {Backspaces} letters of \"{Alphabet.Trim()}\" are the ones that should be missing"); await UnkeyAsync(cancellation).ConfigureAwait(false); } /// The other half of question 4: 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($"7. \"{Short}\", then {TooManyBackspaces} backspaces near the end of it"); TakeReceived(); await KeyAsync(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.Trim()}\" once, not twice"); await UnkeyAsync(cancellation).ConfigureAwait(false); } /// Polls until the engine says it has nothing left, until the count stops /// moving, or until the patience runs out. private async Task DrainAsync(CancellationToken cancellation) { DateTime giveUp = DateTime.UtcNow + Patience; DateTime moved = DateTime.UtcNow; 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; moved = DateTime.UtcNow; } if (left == 0) { return; } if (DateTime.UtcNow - moved > Still) { log.Write($"the count has not moved for {Still.TotalSeconds} s, so it is not counting down"); return; } await Task.Delay(PollInterval, cancellation).ConfigureAwait(false); } log.Write($"gave up waiting after {Patience.TotalSeconds} s"); } /// 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 + Still; while (DateTime.UtcNow < giveUp) { int left = await AskAsync("", cancellation).ConfigureAwait(false); if (left <= wanted) { return left; } await Task.Delay(PollInterval, cancellation).ConfigureAwait(false); } log.Write($"the count never came down to {wanted}, so the backspaces go in wherever it is"); return await AskAsync("", cancellation).ConfigureAwait(false); } /// N1MM's `{TX}`: the control's PTT property, then wait for the engine to /// say it is transmitting. private async Task KeyAsync(CancellationToken cancellation) { if (engine.IsTransmitting) { return true; } TaskCompletionSource keyed = new(TaskCreationOptions.RunContinuationsAsynchronously); lock (gate) { keying = keyed; } await engine.SetPttAsync(true, cancellation).ConfigureAwait(false); try { await keyed.Task.WaitAsync(Keying, cancellation).ConfigureAwait(false); log.Write("keyed"); return true; } catch (TimeoutException) { log.Write($"no transmit report {Keying.TotalSeconds} s after keying"); return false; } } /// N1MM's `{RX}`: stop once the buffer is empty. private async Task UnkeyAsync(CancellationToken cancellation) { await engine.SetPttAsync(false, cancellation).ConfigureAwait(false); DateTime giveUp = DateTime.UtcNow + Keying; while (engine.IsTransmitting && DateTime.UtcNow < giveUp) { await Task.Delay(PollInterval, cancellation).ConfigureAwait(false); } if (engine.IsTransmitting) { log.Write("still transmitting, stopping it the hard way"); await engine.AbortAsync(cancellation).ConfigureAwait(false); } } 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(Keying, 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 WhenTransmitChanged(object? sender, bool transmitting) { log.Write(transmitting ? "engine keyed" : "engine unkeyed"); if (!transmitting) { return; } lock (gate) { keying?.TrySetResult(true); } } /// While the engine is not transmitting this is the demodulator hearing /// whatever is on the band, which is noise on a quiet frequency. Only what /// arrives while transmitting is about the transmission. private void WhenReceived(object? sender, string text) { foreach (char character in text) { log.Write($"{(engine.IsTransmitting ? "rx" : "noise")} {Printable(character)}"); } if (!engine.IsTransmitting) { return; } 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(), }; }