Files
Nonemm/tools/Nonemm.EngineProbe/EngineProbe.cs
ericek111 4118cd6d33 Pace the pump on what MMTTY says it has left
The engine probe answered two questions on the air, and they point opposite
ways.

TxBufLen is the number of characters left to transmit. Seventeen characters
pushed, five decoded back, and it read 12, counting down to 0 as the message
went out. So the pump no longer counts character times off the clock: it asks
the engine, feeds while the answer is under Lead, and asks again. The baud rate
is now only the fallback for an engine that will not answer.

A backspace is not an edit. Pushed in as a character it made the count go up by
four and the text went out unchanged, so text the engine has been given cannot
be taken back, only aborted. EngineTypeAhead was built on the opposite belief
and is gone, along with the setting that chose it and the interface that existed
to switch between the two.

A count that stops going down means the engine is holding what it has: MMTTY set
to Word out keeps a word until the space after it. The pump feeds one character
per look while that lasts, so the space arrives and the word goes out rather
than the message sitting there.

The probe kept two bugs of its own that this run showed: it read the count a
millisecond after pushing, saw 0 and called the message finished, and it started
a step while the previous unkey was still in flight, so that step ran with the
engine down. It now needs two empty answers in a row and waits for the engine to
say it has stopped. It also asks what {RX} does to a word the engine is holding,
which decides whether a macro without a trailing space loses its last word.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
2026-09-01 23:02:00 +00:00

362 lines
14 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 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");
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 giveUp = DateTime.UtcNow + Patience;
DateTime moved = DateTime.UtcNow;
int last = int.MinValue;
bool wasEmpty = false;
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 && wasEmpty)
{
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(),
};
}