From 917a05b898f26edd0e3edf215211751fd6d5b207 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Tue, 1 Sep 2026 22:51:43 +0000 Subject: [PATCH] Key MMTTY the way N1MM does, with the control's PTT property Nothing was ever transmitted. SetMmttyPTT does not start a transmission: N1MM calls it with 1 to stop once the buffer is empty, which is its XmitOff, and with 0 to stop now, which is its AbortXmit. A transmission starts by setting the control's PTT property, in XmitOn. So the bridge learns `key <0|1>` for that property, and MmttyEngine now keys with it, ends a message with SetMmttyPTT(1) so the buffer still goes out, and aborts with SetMmttyPTT(0). This is the digital {TX} in the entry window and the digital window as well as the probe: none of them could key the engine before. The probe checks that the engine keys before it measures anything, and stops with a plain statement if it does not, rather than reporting numbers from an engine sitting still. It also asks a new question: whether the engine holds a word until the space after it, which is MMTTY's Way to send. Received characters are marked as noise while the engine is not transmitting, since a machine with a sound card decodes the band all the way through the run. The first run on a real engine says MMTTY 1.70 connects, the control answers TxBufLen and refuses NotAProperty with DISP_E_UNKNOWNNAME. What TxBufLen counts is still open: read while nothing was transmitting it rose over time and rose by four after four backspaces, which is not what characters-left would do. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ --- bridge/main.cpp | 10 + docs/digital-bridge.md | 3 +- docs/unfinished.md | 4 +- src/Nonemm.Digital/MmttyEngine.cs | 15 +- tools/Nonemm.EngineProbe/EngineProbe.cs | 238 +++++++++++++++++------- tools/Nonemm.EngineProbe/Program.cs | 7 +- 6 files changed, 198 insertions(+), 79 deletions(-) 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 <port> <command line>` | sets `Title`, `ComName` and `InvokeCommand`, sets `bActive`, then posts the host window handle | | `send <text>` | `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 <message> <parameter>` | `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 `<ESC>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<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 += (_, 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<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($"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<int> 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<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. + 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)