diff --git a/bridge/main.cpp b/bridge/main.cpp
index 9cc2ace..55c0ace 100644
--- a/bridge/main.cpp
+++ b/bridge/main.cpp
@@ -145,6 +145,14 @@ void send(const std::string& text) {
VariantClear(&argument);
}
+// N1MM's {TX}: the control's own PTT property, which is what starts a
+// transmission. SetMmttyPTT is the other direction, and only that.
+void key(bool on) {
+ report("PTT", control->putBool(L"PTT", on));
+}
+
+// 1 stops when the buffer is empty, which is N1MM's XmitOff; 0 stops now,
+// which is its AbortXmit.
void setPtt(long on) {
VARIANT argument;
VariantInit(&argument);
@@ -177,6 +185,8 @@ void act(const std::string& text) {
open(line);
} else if (line.verb == "send") {
send(line.field(0));
+ } else if (line.verb == "key") {
+ key(line.number(0) != 0);
} else if (line.verb == "ptt") {
setPtt(line.number(0));
} else if (line.verb == "buffer") {
diff --git a/docs/digital-bridge.md b/docs/digital-bridge.md
index b13b7ee..bbb7e3c 100644
--- a/docs/digital-bridge.md
+++ b/docs/digital-bridge.md
@@ -30,7 +30,8 @@ To the bridge:
|---|---|
| `open
` | sets `Title`, `ComName` and `InvokeCommand`, sets `bActive`, then posts the host window handle |
| `send ` | `SendString` |
-| `ptt <0\|1>` | `SetMmttyPTT` |
+| `key <0\|1>` | sets the control's `PTT` property, which is what starts a transmission |
+| `ptt <0\|1>` | `SetMmttyPTT`: 0 stops now, 1 stops once the buffer is empty |
| `post ` | `PostMmttyMessage`, for everything in `MmttyMessage` |
| `buffer [property]` | reads `TxBufLen`, or the property named instead, and answers `buffer` |
| `close` | shuts the engine down and leaves the bridge running |
diff --git a/docs/unfinished.md b/docs/unfinished.md
index b2cac8a..d3a3572 100644
--- a/docs/unfinished.md
+++ b/docs/unfinished.md
@@ -17,7 +17,7 @@ is still sitting there undiscovered.
| `ClusterClient` | a node fake over a real socket, sending the telnet negotiation, the login prompt and spot lines | no live cluster node. Which nodes send bare CR, and which send option negotiation, is guessed from N1MM's code. |
| `StationNetwork` | the message format, round-tripped | no second station, and no N1MM on the same network. |
| `CwDaemonSender` | the UDP messages, and a fake daemon that answers the `h` reply request | no `cwdaemon`, no radio keyed. |
-| `MmttyEngine` and the Wine bridge | MMTTY 1.70 under Wine 10, started and stopped through `XMMT.ocx` | no sound card, so nothing has been decoded or transmitted and no `rx` or `tx` line has come from a real signal. No FSK through EXTFSK, no PTT on a serial port, and 2Tone has never been run. `docs/digital-bridge.md` |
+| `MmttyEngine` and the Wine bridge | MMTTY 1.70 under Wine 10, started and stopped through `XMMT.ocx`; on a machine with a sound card it connects and decodes noise off the input | nothing has been transmitted yet. Keying was wrong until 2026-09-01 — `SetMmttyPTT` stops a transmission, it does not start one, and N1MM starts one by setting the control's `PTT` property — so no `tx` line has ever come from a real transmission. No FSK through EXTFSK, no PTT on a serial port, and 2Tone has never been run. `docs/digital-bridge.md` |
| `WinkeyerSender` | the status-byte reader, on its own | no test of the serial side, and no WinKeyer. The host-mode open sequence is from the WinKeyer datasheet; the status bits are from N1MM's `Winkey.cs`. |
The Cabrillo output has not been put in front of a contest sponsor's robot.
@@ -72,7 +72,7 @@ not start on this machine:
| What is assumed | Where it comes from |
|---|---|
-| `TxBufLen` counts the characters left to transmit | the name, and a probe showing the control answers to it |
+| `TxBufLen` counts the characters left to transmit | the name, and a probe showing the control answers to it. The first probe run, before keying worked, read it while the engine was not transmitting: it rose over time and rose by four after four backspaces, which is not what a count of characters left would do. Nothing is settled until it is read while a message is actually going out |
| a backspace pushed in as a character deletes one the engine has not transmitted | MMTTY's help, which says the backspace key does it and works only until the letter is transmitted; and N1MM printing a received `\b` by deleting the last character, which can only come from MMTTY's transmit side |
| backspaces over transmitted characters are refused rather than doing something else | nothing — this is the guess with the most to lose |
diff --git a/src/Nonemm.Digital/MmttyEngine.cs b/src/Nonemm.Digital/MmttyEngine.cs
index fc6d455..a853d79 100644
--- a/src/Nonemm.Digital/MmttyEngine.cs
+++ b/src/Nonemm.Digital/MmttyEngine.cs
@@ -86,15 +86,18 @@ public sealed class MmttyEngine : DigitalEngine
public Task AskBufferedAsync(string property = "", CancellationToken cancellation = default) =>
bridge.SendAsync(BridgeLine.Write("buffer", property), cancellation);
- /// N1MM's abort, which is what its RX button and Escape do: drop PTT and
- /// let the engine stop where it is.
+ /// Escape and the RX button: stop now and leave what has not gone out
+ /// unsent. N1MM's `AbortXmit`.
public Task AbortAsync(CancellationToken cancellation = default) =>
- SetPttAsync(false, cancellation);
+ bridge.SendAsync(BridgeLine.Write("ptt", "0"), cancellation);
- /// N1MM's `{TX}` and `{RX}`. MMTTY sends what is in its buffer before it
- /// drops PTT, so a macro that ends with `{RX}` still goes out in full.
+ /// N1MM's `{TX}` and `{RX}`. Keying is the control's `PTT` property;
+ /// unkeying is `SetMmttyPTT(1)`, which waits for the buffer to empty first,
+ /// so a macro that ends with `{RX}` still goes out in full.
public Task SetPttAsync(bool on, CancellationToken cancellation = default) =>
- bridge.SendAsync(BridgeLine.Write("ptt", on ? "1" : "0"), cancellation);
+ bridge.SendAsync(
+ on ? BridgeLine.Write("key", "1") : BridgeLine.Write("ptt", "1"),
+ cancellation);
public Task ReturnToReceiveAsync(CancellationToken cancellation = default) =>
SetPttAsync(false, cancellation);
diff --git a/tools/Nonemm.EngineProbe/EngineProbe.cs b/tools/Nonemm.EngineProbe/EngineProbe.cs
index 3eb42c8..bd4ae87 100644
--- a/tools/Nonemm.EngineProbe/EngineProbe.cs
+++ b/tools/Nonemm.EngineProbe/EngineProbe.cs
@@ -3,19 +3,22 @@ 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:
+/// 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
+/// 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.
///
@@ -32,27 +35,34 @@ public sealed class EngineProbe
/// of one.
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(30);
- private const string Message = "CQ TEST DE OM5M OM5M";
- private const string Alphabet = "ABCDEFGHIJKLMNOP";
+ /// 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 string Short = "MNOPQRST ";
private const int TooManyBackspaces = 6;
- private const string Afterwards = "XX";
+ 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 += (_, on) => log.Write(on ? "engine keyed" : "engine unkeyed");
+ engine.TransmitChanged += WhenTransmitChanged;
engine.Buffered += WhenBuffered;
engine.Received += WhenReceived;
}
@@ -61,7 +71,12 @@ public sealed class EngineProbe
{
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);
}
@@ -88,55 +103,86 @@ public sealed class EngineProbe
}
}
- /// Questions 2 and 4. The whole message is pushed as fast as the bridge
+ /// 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($"3. \"{Message}\" pushed in one go, then TxBufLen every {PollInterval.TotalMilliseconds} ms");
+ log.Step($"4. \"{Message}\" pushed in one go, then TxBufLen every {PollInterval.TotalMilliseconds} ms");
TakeReceived();
- await engine.SetPttAsync(true, cancellation).ConfigureAwait(false);
+ 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 engine.SetPttAsync(false, cancellation).ConfigureAwait(false);
- await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
+ await UnkeyAsync(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.
+ /// 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($"4. \"{Alphabet}\", then {Backspaces} backspaces once it is under way");
+ log.Step($"6. \"{Alphabet}\", then {Backspaces} backspaces once it is under way");
TakeReceived();
- await engine.SetPttAsync(true, cancellation).ConfigureAwait(false);
+ 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);
- int before = await AskAsync("", cancellation).ConfigureAwait(false);
- log.Write($"TxBufLen before the backspaces: {Answer(before)}");
+ log.Write($"TxBufLen before the backspaces: {Answer(await AskAsync("", cancellation).ConfigureAwait(false))}");
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($"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);
- 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);
+ 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 3: more backspaces than there are characters
+ /// 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($"5. \"{Short}\", then {TooManyBackspaces} backspaces near the end of it");
+ log.Step($"7. \"{Short}\", then {TooManyBackspaces} backspaces near the end of it");
TakeReceived();
- await engine.SetPttAsync(true, cancellation).ConfigureAwait(false);
+ 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)}");
@@ -145,39 +191,16 @@ public sealed class EngineProbe
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);
+ log.Write($"what went out should end in \"{Afterwards.Trim()}\" once, not twice");
+ await UnkeyAsync(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.
+ /// 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)
{
@@ -186,15 +209,15 @@ public sealed class EngineProbe
{
log.Write($"TxBufLen: {Answer(left)}");
last = left;
+ moved = DateTime.UtcNow;
}
if (left == 0)
{
return;
}
- if (left < 0)
+ if (DateTime.UtcNow - moved > Still)
{
- log.Write("no answer to poll, waiting out the message instead");
- await Task.Delay(TimeSpan.FromSeconds(10), cancellation).ConfigureAwait(false);
+ 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);
@@ -202,6 +225,67 @@ public sealed class EngineProbe
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)
@@ -221,7 +305,7 @@ public sealed class EngineProbe
asking = answer;
}
await engine.AskBufferedAsync(property, cancellation).ConfigureAwait(false);
- return await answer.Task.WaitAsync(Patience, cancellation).ConfigureAwait(false);
+ return await answer.Task.WaitAsync(Keying, cancellation).ConfigureAwait(false);
}
private static string Answer(int left) => left < 0 ? "no answer" : left.ToString();
@@ -234,11 +318,31 @@ public sealed class EngineProbe
}
}
+ 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($"rx {Printable(character)}");
+ log.Write($"{(engine.IsTransmitting ? "rx" : "noise")} {Printable(character)}");
+ }
+ if (!engine.IsTransmitting)
+ {
+ return;
}
lock (received)
{
diff --git a/tools/Nonemm.EngineProbe/Program.cs b/tools/Nonemm.EngineProbe/Program.cs
index 9d08a1b..464867f 100644
--- a/tools/Nonemm.EngineProbe/Program.cs
+++ b/tools/Nonemm.EngineProbe/Program.cs
@@ -79,13 +79,14 @@ public static class Program
return 0;
}
- /// The engine is left keyed if the probe stopped partway through, so PTT is
- /// dropped before the engine is, whatever happened.
+ /// The engine is left keyed if the probe stopped partway through, so the
+ /// transmission is aborted before the engine is shut down, whatever
+ /// happened.
private static async Task Shutdown(MmttyEngine engine, ProbeLog log)
{
try
{
- await engine.SetPttAsync(false).ConfigureAwait(false);
+ await engine.AbortAsync().ConfigureAwait(false);
await engine.StopAsync().ConfigureAwait(false);
}
catch (Exception problem)