The second probe run read TxBufLen as 0 one millisecond and again fifty milliseconds after twenty-one characters had been pushed, while those characters were already going out: the first of them was decoded back off the air 420 ms later. The number is right when it is a second old and wrong when it is fresh, so it runs behind the engine. A pump that fed on it would have handed over the whole message in half a second and put all of it beyond reach, which is worse than the clock it replaced. So the clock is the pace again, and the count is a check on it: the engine is never given more than Lead + Slack characters however fast the clock says to feed, and a message ends when the count and the clock estimate both say it has. Characters fed since the last answer are added to it, so a stale answer cannot be spent twice. The engine is set to Character out: ABCD with no space after it went out at once. Word out would have held it, so the holding case stays, but it now needs the count to be neither going down nor being added to, since an engine kept exactly at the cap has a count that does not move either. The probe writes down every reading for two seconds after a push and does not believe an empty one in that window, which is what the run needed to measure the lag and did not do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
370 lines
15 KiB
C#
370 lines
15 KiB
C#
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 `{RX}` — `SetMmttyPTT(1)`, stop once the buffer is empty — send a
|
|
/// word the engine is holding, or drop it? A macro whose last word has no
|
|
/// space after it hangs on the answer.
|
|
/// 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.
|
|
///
|
|
/// A backspace is not on the list any more: pushed in as a character, MMTTY
|
|
/// counted it as one more character to transmit and sent the text unchanged.
|
|
/// It is not an edit.
|
|
///
|
|
/// 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 after a push the count is written down every time it is read,
|
|
/// and an empty answer is not believed. The count runs behind the engine,
|
|
/// and this is where the report says by how much.
|
|
private static readonly TimeSpan Settling = TimeSpan.FromSeconds(2);
|
|
|
|
/// 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 readonly MmttyEngine engine;
|
|
private readonly ProbeLog log;
|
|
private readonly StringBuilder received = new();
|
|
private readonly Lock gate = new();
|
|
private TaskCompletionSource<int>? asking;
|
|
private TaskCompletionSource<bool>? 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 UnfinishedWordAsync(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<bool> 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");
|
|
log.Write("every reading for the next two seconds: the first one that is not 0 says how far behind the count is");
|
|
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 word has no space after it, so an engine set to Word
|
|
/// out is still holding it when the transmission is told to stop.
|
|
private async Task UnfinishedWordAsync(CancellationToken cancellation)
|
|
{
|
|
log.Step($"6. \"{Word}\" with no space after it, then {{RX}}");
|
|
TakeReceived();
|
|
await KeyAsync(cancellation).ConfigureAwait(false);
|
|
await TypeAsync(Word, cancellation).ConfigureAwait(false);
|
|
await Task.Delay(TimeSpan.FromSeconds(2), cancellation).ConfigureAwait(false);
|
|
log.Write($"TxBufLen before {{RX}}: {Answer(await AskAsync("", cancellation).ConfigureAwait(false))}");
|
|
await UnkeyAsync(cancellation).ConfigureAwait(false);
|
|
log.Write($"received: \"{TakeReceived()}\"");
|
|
log.Write($"\"{Word}\" here means SetMmttyPTT(1) sends a held word before it stops");
|
|
}
|
|
|
|
/// Polls until the engine says it has nothing left, until the count stops
|
|
/// moving, or until the patience runs out.
|
|
///
|
|
/// The engine counts what it has been given a moment after it is given it,
|
|
/// so a single empty answer straight after a push means the push has not
|
|
/// registered, not that the message has gone.
|
|
private async Task DrainAsync(CancellationToken cancellation)
|
|
{
|
|
DateTime started = DateTime.UtcNow;
|
|
DateTime giveUp = started + Patience;
|
|
DateTime moved = started;
|
|
int last = int.MinValue;
|
|
bool wasEmpty = false;
|
|
while (DateTime.UtcNow < giveUp)
|
|
{
|
|
int left = await AskAsync("", cancellation).ConfigureAwait(false);
|
|
bool settling = DateTime.UtcNow - started < Settling;
|
|
if (left != last || settling)
|
|
{
|
|
log.Write($"TxBufLen: {Answer(left)}");
|
|
moved = left != last ? DateTime.UtcNow : moved;
|
|
last = left;
|
|
}
|
|
if (left == 0 && wasEmpty && !settling)
|
|
{
|
|
return;
|
|
}
|
|
wasEmpty = left == 0;
|
|
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<int> 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<bool> KeyAsync(CancellationToken cancellation)
|
|
{
|
|
if (engine.IsTransmitting)
|
|
{
|
|
return true;
|
|
}
|
|
TaskCompletionSource<bool> 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. Waits for the engine to
|
|
/// say it has stopped, because the next step keys again and would otherwise
|
|
/// see the old state and skip it.
|
|
private async Task UnkeyAsync(CancellationToken cancellation)
|
|
{
|
|
await engine.SetPttAsync(false, cancellation).ConfigureAwait(false);
|
|
if (await StoppedAsync(cancellation).ConfigureAwait(false))
|
|
{
|
|
return;
|
|
}
|
|
log.Write("still transmitting, stopping it the hard way");
|
|
await engine.AbortAsync(cancellation).ConfigureAwait(false);
|
|
if (!await StoppedAsync(cancellation).ConfigureAwait(false))
|
|
{
|
|
log.Write("the engine is still reporting a transmission after the abort");
|
|
}
|
|
}
|
|
|
|
private async Task<bool> StoppedAsync(CancellationToken cancellation)
|
|
{
|
|
DateTime giveUp = DateTime.UtcNow + Keying;
|
|
while (engine.IsTransmitting && DateTime.UtcNow < giveUp)
|
|
{
|
|
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
|
|
}
|
|
return !engine.IsTransmitting;
|
|
}
|
|
|
|
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<int> AskAsync(string property, CancellationToken cancellation)
|
|
{
|
|
TaskCompletionSource<int> 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(),
|
|
};
|
|
}
|