diff --git a/docs/unfinished.md b/docs/unfinished.md index d3a3572..ed73183 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`; 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` | +| `MmttyEngine` and the Wine bridge | MMTTY 1.70 under Wine 10 on a machine with a sound card: started, keyed, a message transmitted and decoded back off the air, and stopped | no radio. No FSK through EXTFSK, no PTT on a serial port, and 2Tone has never been run. Keying was wrong until 2026-09-01: `SetMmttyPTT` stops a transmission and does not start one, which is why nothing ever went out before then. `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. @@ -44,57 +44,53 @@ type-ahead transmit pane were checked. Nothing has been decoded from a real signal and nothing has gone on the air: this machine has no sound card, and MMTTY does not start on it. -**Who holds the text waiting to go out.** There are two answers in the code and -a setting to pick between them: Config ▸ Digital ▸ "Let the engine hold the text -waiting to go out". Off is what runs today. +**What paces the type-ahead pump.** The text is held in `TypeAhead` and the +engine is kept `Lead` characters ahead: one being transmitted and one behind it, +so it never runs dry and never transmits idle in the middle of a message. Those +two characters cannot be taken back; everything behind them can still be +rewritten. -*Off — `TypeAhead`.* The text is held here and the engine is kept two characters -ahead: one being transmitted and one behind it, so it never runs dry and never -transmits idle in the middle of a message. When the engine has room is worked -out from the clock at the baud rate in the digital settings, corrected whenever -the engine reports that it has stopped transmitting. The last two characters -cannot be taken back. The one error left is the baud rate: if it does not match -what the engine transmits at, the drift shows up as a small gap between -characters near the end of a long message. +How far the engine has got comes from the engine. MMTTY answers `TxBufLen` with +the number of characters it still has to transmit, so the pump asks, feeds while +the answer is under `Lead`, and asks again, 50 ms at a time. An engine that will +not answer is paced by the clock at the baud rate in the digital settings +instead, which drifts; that is what the baud setting is still for. -*On — `EngineTypeAhead`.* MMTTY has a type-ahead buffer of its own, which is what -its own keyboard drives, so everything is pushed straight into it and an edit is -expressed as backspaces followed by the new text. MMTTY paces itself, so no baud -rate is needed and there is no gap by construction. `TxBufLen` says how many -characters are left, which is what splits the pane into what has gone and what -can still be changed. One character more than the count says is held back -(`EngineTypeAhead.Guard`), because the count is a poll old and a backspace over a -transmitted character is refused, which would put the text after it on the air -twice. +What the engine probe established on 2026-09-01, with MMTTY 1.70 under Wine on a +machine with a sound card: -Three things it rests on have never been seen with a real engine, and MMTTY does -not start on this machine: - -| What is assumed | Where it comes from | +| What | Answer | |---|---| -| `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 | +| Does the control answer `TxBufLen`? | Yes, and a name it does not know fails with `DISP_E_UNKNOWNNAME`, so the two are distinguishable. | +| What does it count? | Characters left to transmit. Seventeen were pushed; a second later, with five decoded back off the air, it read 12, and it counted down to 0 as the message went out. | +| Does a backspace take back a character the engine has not transmitted? | No. Pushed in as a character it made the count go **up** by four, and the text went out unchanged. MMTTY's help describes its backspace key doing this in its own window; through `PostMmttyMessage(4, 8)` it is just another character. So text in the engine cannot be retracted, only aborted. | +| How long after a push does the count include it? | Not immediately. A count read a millisecond after the push still read 0, so one empty answer is not the end of a message. | -`tools/Nonemm.EngineProbe` asks the engine all three and writes the answers to a -file. It has to run where MMTTY runs: +Two things the probe has still to answer, and it now asks both: + +- **Way to send.** MMTTY set to Word out holds a word until the space after it, + which the first run appeared to show but could not confirm, because the engine + was not keyed for that step. The pump handles it either way: a count that + stops going down means the engine is holding what it has, so the pump feeds + one character per look until it moves again. +- **Whether `{RX}` sends a held word.** `SetMmttyPTT(1)` stops once the buffer is + empty. If a held word does not count as buffer, a macro whose last word has no + space after it loses that word. ```sh ./build.sh run --project tools/Nonemm.EngineProbe -- \ --engine ~/mmtty/MMTTY.EXE --prefix ~/.wine-nonemm --out engine-probe.log ``` -It transmits for about half a minute on the sound card and keys no serial port -unless `--ptt` names one. The report says, step by step, what the engine -answered; `EngineProbe` states what each answer means. +It transmits on the sound card for about twenty seconds and keys no serial port +unless `--ptt` names one. `EngineProbe` states what each answer means. -There is a fourth question in the report that is not needed for either buffer, -but decides whether `TxBufLen` can be dropped later: what comes back on the -receive side while transmitting, and when. With MMTTY's sound loopback off, its -help says the receive window is fed from the transmit window, which would be a -per-character report of what has gone out, with no polling. With loopback on it -is the demodulator hearing the transmission instead, which is late and can -contain decode errors, so the count is what the pane uses. +The receive side is worth watching in the report as well. With MMTTY's sound +loopback off, its help says the receive window is fed from the transmit window, +which would be a per-character report of what has gone out with no polling at +all. With loopback on it is the demodulator hearing the transmission, which +arrives about half a second late and can carry decode errors, so the count is +what the pump uses. **Voice keying.** `MessageSender` was written to cover a voice keyer playing a recording, and nothing implements it. Each operator's recordings folder is diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 420f30f..40dcb81 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -333,10 +333,7 @@ public sealed class AppSession : IDisposable MmttyEngine started = new(channel, options); await started.StartAsync(); digital = started; - digitalSender = new DigitalEngineSender( - started, - Settings.DigitalBaud, - Settings.DigitalEngineHoldsBuffer); + digitalSender = new DigitalEngineSender(started, Settings.DigitalBaud); Changed?.Invoke(this, EventArgs.Empty); return started; } diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index 84b3735..060eae0 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -260,12 +260,6 @@ public sealed record Settings /// MMTTY's own default, and what nearly every RTTY contest runs at. public double DigitalBaud { get; init; } = 45.45; - /// The engine holds the text waiting to go out instead of Nonemm feeding it - /// a couple of characters at a time. MMTTY has a buffer of its own and can - /// say how much of it is left; see `docs/unfinished.md` for what has been - /// checked against a real engine and what has not. - public bool DigitalEngineHoldsBuffer { get; init; } - /// N1MM caps the receive pane's font at 14 points. public int DigitalFontSize { get; init; } = 12; diff --git a/src/Nonemm.App/Dialogs/DigitalDialog.axaml b/src/Nonemm.App/Dialogs/DigitalDialog.axaml index c52f9cb..0d4cc05 100644 --- a/src/Nonemm.App/Dialogs/DigitalDialog.axaml +++ b/src/Nonemm.App/Dialogs/DigitalDialog.axaml @@ -114,10 +114,6 @@ - - diff --git a/src/Nonemm.App/Dialogs/DigitalDialog.axaml.cs b/src/Nonemm.App/Dialogs/DigitalDialog.axaml.cs index 5394143..ab29993 100644 --- a/src/Nonemm.App/Dialogs/DigitalDialog.axaml.cs +++ b/src/Nonemm.App/Dialogs/DigitalDialog.axaml.cs @@ -89,7 +89,6 @@ public sealed partial class DigitalDialog : Window BackgroundHighlightBox.IsChecked = settings.DigitalHighlightBackground; MarkBox.Text = settings.DigitalMarkHertz.ToString(CultureInfo.InvariantCulture); BaudBox.Text = settings.DigitalBaud.ToString(CultureInfo.InvariantCulture); - EngineBufferBox.IsChecked = settings.DigitalEngineHoldsBuffer; WindowBox.ItemsSource = WindowSizes; WindowBox.SelectedItem = WindowSizes.FirstOrDefault(s => s == settings.DigitalEngineWindow) ?? WindowSizes[0]; @@ -306,7 +305,6 @@ public sealed partial class DigitalDialog : Window out double baud) && baud > 0 ? baud : settings.DigitalBaud, - DigitalEngineHoldsBuffer = EngineBufferBox.IsChecked == true, DigitalEngineWindow = WindowBox.SelectedItem as string ?? "Normal", DigitalEngineOnTop = OnTopBox.IsChecked == true, DigitalEnabled = (EngineBox.Text?.Trim().Length ?? 0) > 0, diff --git a/src/Nonemm.App/Windows/DigitalWindow.Macros.cs b/src/Nonemm.App/Windows/DigitalWindow.Macros.cs index 62d88ee..9ef36f7 100644 --- a/src/Nonemm.App/Windows/DigitalWindow.Macros.cs +++ b/src/Nonemm.App/Windows/DigitalWindow.Macros.cs @@ -169,7 +169,7 @@ public sealed partial class DigitalWindow { if (session.DigitalKeyer is { } keyer) { - keyer.Buffer.Cursor = TransmitBox.IsFocused ? CursorInBox() : TransmitBuffer.NoCursor; + keyer.Buffer.Cursor = TransmitBox.IsFocused ? CursorInBox() : TypeAhead.NoCursor; } } diff --git a/src/Nonemm.App/Windows/DigitalWindow.axaml.cs b/src/Nonemm.App/Windows/DigitalWindow.axaml.cs index f1abd2e..c70a3a8 100644 --- a/src/Nonemm.App/Windows/DigitalWindow.axaml.cs +++ b/src/Nonemm.App/Windows/DigitalWindow.axaml.cs @@ -60,7 +60,7 @@ public sealed partial class DigitalWindow : RefreshableWindow private bool showingBuffer; /// The buffer this window is drawing, or null while no engine is running. - private TransmitBuffer? buffer; + private TypeAhead? buffer; /// Where the next line goes in a non-scrolling pane. It walks down the /// window and starts again at the top; -1 is before the first line. @@ -107,9 +107,9 @@ public sealed partial class DigitalWindow : RefreshableWindow SentText.Foreground = Transmitted; SentText.FontSize = Settings.DigitalFontSize; TransmitBox.FontSize = Settings.DigitalFontSize; - if (buffer is TypeAhead paced) + if (buffer is not null) { - paced.Baud = Settings.DigitalBaud; + buffer.Baud = Settings.DigitalBaud; } FollowRunAndFrequency(); ShowState(); @@ -148,12 +148,9 @@ public sealed partial class DigitalWindow : RefreshableWindow Detach(); engine = started; buffer = session.DigitalKeyer?.Buffer; - if (buffer is TypeAhead paced) - { - paced.Baud = Settings.DigitalBaud; - } if (buffer is not null) { + buffer.Baud = Settings.DigitalBaud; buffer.Changed += WhenBufferChanged; } started.Received += WhenReceived; diff --git a/src/Nonemm.Digital/DigitalEngineSender.cs b/src/Nonemm.Digital/DigitalEngineSender.cs index 343bd5e..9050e65 100644 --- a/src/Nonemm.Digital/DigitalEngineSender.cs +++ b/src/Nonemm.Digital/DigitalEngineSender.cs @@ -6,27 +6,22 @@ namespace Nonemm.Digital; /// through the same expander, the same `{END}` handling and the same ESM as a /// CW message does. /// -/// A message goes into the transmit buffer rather than to the engine whole, so -/// the part that has not been transmitted can still be rewritten. `Finished` is +/// A message goes into the type-ahead buffer rather than to the engine whole, +/// so the part that has not gone out can still be rewritten. `Finished` is /// raised when the buffer has run dry and the engine has transmitted what it /// was given. /// -/// Which buffer depends on `engineHoldsBuffer`: `EngineTypeAhead` pushes -/// everything to the engine and takes it back with backspaces, `TypeAhead` -/// holds it here and feeds the engine a couple of characters at a time. Only an -/// engine that has a buffer of its own can hold one. +/// An engine that can say how much it still has to transmit paces the buffer; +/// the rest are paced by the clock at `baud`. public sealed class DigitalEngineSender : MessageSender { private readonly DigitalEngine engine; - public DigitalEngineSender( - DigitalEngine engine, - double baud = TypeAhead.DefaultBaud, - bool engineHoldsBuffer = false) + public DigitalEngineSender(DigitalEngine engine, double baud = TypeAhead.DefaultBaud) { this.engine = engine; - Buffer = engineHoldsBuffer && engine is EngineBuffer holder - ? new EngineTypeAhead(holder) + Buffer = engine is EngineBuffer counter + ? new TypeAhead(counter, baud) : new TypeAhead( (character, cancellation) => engine.SendAsync(character.ToString(), cancellation), baud); @@ -35,7 +30,7 @@ public sealed class DigitalEngineSender : MessageSender } /// What is waiting to go out, which the digital window shows and edits. - public TransmitBuffer Buffer { get; } + public TypeAhead Buffer { get; } public bool IsReady => engine.IsConnected; @@ -73,19 +68,16 @@ public sealed class DigitalEngineSender : MessageSender private void WhenDrained(object? sender, EventArgs e) => Finished?.Invoke(this, EventArgs.Empty); - /// The engine dropping the transmitter empties its buffer, which the - /// type-ahead buffer is told so it stops waiting for characters that have - /// already gone. It ends the message only when there is nothing left to - /// send: an engine that keys itself off what it is given drops between two - /// characters of a message the pump is still feeding, and that is not the - /// end of anything. + /// The engine dropping the transmitter ends the message, but only when + /// there is nothing left to send: an engine that keys itself off what it is + /// given drops between two characters of a message the pump is still + /// feeding, and that is not the end of anything. private void WhenTransmitChanged(object? sender, bool transmitting) { if (transmitting) { return; } - Buffer.EngineIdle(); if (!Buffer.IsSending) { Finished?.Invoke(this, EventArgs.Empty); diff --git a/src/Nonemm.Digital/EngineTypeAhead.cs b/src/Nonemm.Digital/EngineTypeAhead.cs deleted file mode 100644 index ca94d36..0000000 --- a/src/Nonemm.Digital/EngineTypeAhead.cs +++ /dev/null @@ -1,370 +0,0 @@ -using System.Text; -using System.Threading.Channels; - -namespace Nonemm.Digital; - -/// The transmit buffer with the engine holding the text rather than this. -/// -/// MMTTY has a type-ahead buffer of its own, which is what its keyboard drives: -/// characters go into it, a backspace takes back one that has not been -/// transmitted, and `TxBufLen` says how many are left. Everything is pushed to -/// it as soon as it is typed, so the engine never runs out of characters -/// partway through a message and never transmits idle in the middle of one. -/// -/// An edit is expressed as backspaces followed by the new text. How much can be -/// taken back is what `TxBufLen` last said, less `Guard`: the count is a poll -/// old, and a backspace over a character that has already been transmitted is -/// refused by the engine, which would put the text after it on the air twice. -/// Holding one character back stops that, at the cost of one character that -/// could have been retracted. -/// -/// Characters, backspaces and the questions all go to the engine through one -/// queue, in the order they were made, so an answer is about the text the -/// engine had when it was asked. An answer that arrives after something else -/// has been written is thrown away rather than guessed at. -/// -/// `docs/unfinished.md` lists what has been checked against a real engine and -/// what has not. -public sealed class EngineTypeAhead : TransmitBuffer -{ - /// How often the engine is asked how much it has left. Under a third of a - /// character at 45.45 baud, which is what keeps `Guard` at one character. - public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50); - - /// How many characters at the front of the engine's buffer are treated as - /// gone even though the last count said they were still there. - public const int Guard = 1; - - private readonly EngineBuffer engine; - private readonly TimeSpan pollInterval; - private readonly Lock gate = new(); - private readonly StringBuilder sent = new(); - private readonly StringBuilder pending = new(); - private readonly Channel outgoing = Channel.CreateUnbounded(); - - private CancellationTokenSource? stopping; - private Task pump = Task.CompletedTask; - private Task writer = Task.CompletedTask; - - /// Characters of `pending` that have been queued for the engine. - private int held; - - /// Characters of `pending` the engine has actually been given. It trails - /// `held` by whatever is still in the queue. - private int given; - - /// Counts everything written to the engine, so an answer can be checked - /// against the state it was asked in. - private long written; - private long writtenWhenAsked = -1; - private int givenWhenAsked; - private int cursor = TransmitBuffer.NoCursor; - - public EngineTypeAhead(EngineBuffer engine, TimeSpan? pollInterval = null) - { - this.engine = engine; - this.pollInterval = pollInterval ?? PollInterval; - engine.Buffered += WhenBuffered; - } - - /// False once the engine has refused to say how much it holds. It still - /// takes text; nothing can be taken back and the pane shows what has been - /// handed over rather than what has gone out. - public bool Counts { get; private set; } = true; - - public string Sent - { - get - { - lock (gate) - { - return sent.ToString() + pending.ToString(0, Frozen); - } - } - } - - public string Pending - { - get - { - lock (gate) - { - return pending.ToString(Frozen, pending.Length - Frozen); - } - } - } - - public int Cursor - { - get - { - lock (gate) - { - return cursor; - } - } - set - { - lock (gate) - { - cursor = Math.Max(0, value); - Push(); - } - Start(); - } - } - - public bool IsSending - { - get - { - lock (gate) - { - return pending.Length > 0; - } - } - } - - public event EventHandler? Changed; - - public event EventHandler? Drained; - - public void Append(string text) - { - if (text.Length == 0) - { - return; - } - lock (gate) - { - pending.Append(text); - if (cursor != TransmitBuffer.NoCursor) - { - cursor += text.Length; - } - Push(); - } - Changed?.Invoke(this, EventArgs.Empty); - Start(); - } - - /// The text the operator left in the box, which is everything except what - /// this has already given up on taking back. - public void Rewrite(string text, int wanted) - { - lock (gate) - { - string whole = pending.ToString(0, Frozen) + text; - int shared = Shared(pending, whole); - for (int over = held - shared; over > 0; over--) - { - Write(new Instruction('\b')); - } - held = Math.Min(held, shared); - pending.Clear(); - pending.Append(whole); - cursor = Math.Clamp(wanted, 0, text.Length); - Push(); - } - Start(); - } - - /// The engine's own abort empties its buffer, so nothing has to be taken - /// back a character at a time. - public void Drop() - { - lock (gate) - { - Empty(); - } - Changed?.Invoke(this, EventArgs.Empty); - } - - public void Clear() - { - lock (gate) - { - Empty(); - sent.Clear(); - } - Changed?.Invoke(this, EventArgs.Empty); - } - - /// The engine stopped transmitting, so everything it had been given has - /// gone out. What is still in the queue has not. - public void EngineIdle() - { - lock (gate) - { - Transmitted(given); - } - Changed?.Invoke(this, EventArgs.Empty); - } - - public void Dispose() - { - engine.Buffered -= WhenBuffered; - outgoing.Writer.TryComplete(); - stopping?.Cancel(); - stopping?.Dispose(); - stopping = null; - } - - /// How many characters two strings start the same. - private static int Shared(StringBuilder was, string now) - { - int same = 0; - while (same < was.Length && same < now.Length && was[same] == now[same]) - { - same++; - } - return same; - } - - private int Frozen => Math.Min(held, Guard); - - /// Queues everything the operator has released. Called with the lock held. - private void Push() - { - int limit = cursor == TransmitBuffer.NoCursor - ? pending.Length - : Math.Min(pending.Length, Frozen + cursor); - while (held < limit) - { - Write(new Instruction(pending[held])); - held++; - } - } - - private void Write(Instruction instruction) - { - written++; - outgoing.Writer.TryWrite(instruction); - } - - private void Empty() - { - pending.Clear(); - held = 0; - given = 0; - cursor = TransmitBuffer.NoCursor; - } - - /// An answer to a question asked when the engine held `givenWhenAsked` - /// characters. Anything written since then makes it useless: the engine had - /// already moved on when it answered. - private void WhenBuffered(object? sender, int left) - { - if (left < 0) - { - Counts = false; - return; - } - Counts = true; - lock (gate) - { - if (written != writtenWhenAsked) - { - return; - } - Transmitted(givenWhenAsked - left); - } - Changed?.Invoke(this, EventArgs.Empty); - } - - /// The first `gone` characters have been transmitted and cannot be taken - /// back. Called with the lock held. - private void Transmitted(int gone) - { - gone = Math.Min(gone, pending.Length); - if (gone <= 0) - { - return; - } - int frozenBefore = Frozen; - sent.Append(pending.ToString(0, gone)); - if (sent.Length > TransmitBuffer.KeptSent) - { - sent.Remove(0, sent.Length - TransmitBuffer.KeptSent); - } - pending.Remove(0, gone); - held -= gone; - given -= gone; - if (cursor != TransmitBuffer.NoCursor) - { - // the visible text starts at `Frozen`, which moves as well - cursor = Math.Max(0, cursor - gone - Frozen + frozenBefore); - } - } - - private void Start() - { - lock (gate) - { - if (writer.IsCompleted) - { - writer = Task.Run(WriteAsync); - } - if (!pump.IsCompleted) - { - return; - } - stopping?.Dispose(); - stopping = new CancellationTokenSource(); - pump = Task.Run(() => PollAsync(stopping.Token)); - } - } - - /// Puts a question in the queue behind whatever is waiting, until there is - /// nothing left to send. - private async Task PollAsync(CancellationToken cancellation) - { - try - { - while (!cancellation.IsCancellationRequested) - { - lock (gate) - { - Write(new Instruction(Ask: true)); - } - await Task.Delay(pollInterval, cancellation).ConfigureAwait(false); - if (!IsSending) - { - Drained?.Invoke(this, EventArgs.Empty); - return; - } - } - } - catch (OperationCanceledException) - { - } - } - - /// The one place the engine is written to, so characters, backspaces and - /// questions reach it in the order they were made. - private async Task WriteAsync() - { - await foreach (Instruction instruction in outgoing.Reader.ReadAllAsync().ConfigureAwait(false)) - { - if (instruction.Ask) - { - lock (gate) - { - writtenWhenAsked = written; - givenWhenAsked = given; - } - await engine.AskBufferedAsync("").ConfigureAwait(false); - continue; - } - await engine.TypeAsync(instruction.Character).ConfigureAwait(false); - lock (gate) - { - given += instruction.Character == '\b' ? -1 : 1; - } - } - } - - /// One thing to do to the engine: a character to type, a backspace, or the - /// question about how much is left. - private readonly record struct Instruction(char Character = '\0', bool Ask = false); -} diff --git a/src/Nonemm.Digital/TransmitBuffer.cs b/src/Nonemm.Digital/TransmitBuffer.cs deleted file mode 100644 index 78f807d..0000000 --- a/src/Nonemm.Digital/TransmitBuffer.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace Nonemm.Digital; - -/// The text waiting to go out. Two of these exist: `TypeAhead` holds the text -/// here and feeds the engine, `EngineTypeAhead` hands it to the engine and -/// takes it back with backspaces. The digital window works through this and -/// does not know which one it has. -public interface TransmitBuffer : IDisposable -{ - /// `Cursor` set to this lets everything pending go out, which is where it - /// stands while the operator is not typing into the pane. - public const int NoCursor = int.MaxValue; - - /// How much of the text that has gone out is kept. It is there to be read - /// back, not to be a log. - public const int KeptSent = 2000; - - /// What has gone out and can no longer be changed. - string Sent { get; } - - /// What is still to go, which is what the operator edits. - string Pending { get; } - - /// How many of the pending characters may go out. The window sets it to - /// where the operator's cursor is; `NoCursor` while nobody is typing. - int Cursor { get; set; } - - /// There is text to send, or the engine has not finished what it was given. - bool IsSending { get; } - - /// The text moved: a character went out, or a message was added. Raised off - /// the screen thread, so a handler that draws has to post. - event EventHandler? Changed; - - /// Everything that was waiting has gone out and the engine has transmitted - /// it. This is what tells the entry window that a message is finished, so - /// what stands after `{END}` runs and `{RX}` drops the transmitter at the - /// right moment. - event EventHandler? Drained; - - /// A message to send. It goes on the end of what is already waiting, so two - /// function keys pressed together send one after the other rather than one - /// over the other. - void Append(string text); - - /// The operator rewrote what has not gone out yet. - void Rewrite(string text, int cursor); - - /// Drops what has not gone out. Escape and the RX button do this. - void Drop(); - - /// Empties the pane, which is what the CLR button does between - /// transmissions. - void Clear(); - - /// The engine has stopped transmitting, so whatever it held has gone out. - void EngineIdle(); -} diff --git a/src/Nonemm.Digital/TypeAhead.cs b/src/Nonemm.Digital/TypeAhead.cs index 7b3070f..5da8c30 100644 --- a/src/Nonemm.Digital/TypeAhead.cs +++ b/src/Nonemm.Digital/TypeAhead.cs @@ -7,30 +7,29 @@ namespace Nonemm.Digital; /// /// A digital engine takes a whole message and transmits it at the baud rate, /// which is slow: a callsign and a report take several seconds. Once the engine -/// has the message nothing can be changed, so the operator who sees a wrong -/// call go out has to stop the transmission and start again. This keeps the -/// message here instead and feeds it to the engine a few characters at a time, -/// so everything that has not gone out yet can still be rewritten, added to or -/// deleted. +/// has a character nothing can take it back — MMTTY treats a backspace as +/// another character to transmit rather than as an edit, which the engine probe +/// showed on the air. So the message is held here and the engine is given +/// `Lead` characters at a time: one being transmitted and one behind it, so it +/// never runs dry and never transmits idle in the middle of a message, while +/// everything further back can still be rewritten, added to or deleted. /// /// `Sent` is what has gone to the engine and cannot be taken back. `Pending` is /// what is still to go. `Cursor` is how much of the pending text may go out, /// which is where the operator is typing: the pump stops when it reaches the /// cursor, because the operator has not finished the word yet. /// -/// The pump keeps `Lead` characters in the engine rather than one. An engine -/// that runs out of characters partway through a message does not wait: it -/// transmits idle until the next one arrives, so every gap the pump leaves is -/// added to the time the message takes. Keeping a second character queued -/// behind the one being transmitted means the engine never runs dry, and the -/// cost is that the last `Lead` characters cannot be taken back rather than the -/// last one. +/// How far the engine has got comes from the engine when it can say. MMTTY +/// answers `TxBufLen` with the number of characters it still has to transmit, +/// so the pump asks, feeds while the answer is under `Lead`, and asks again. An +/// engine that will not answer is paced by the clock at the baud rate instead, +/// which drifts and is what the baud setting is for. /// -/// `Lead` characters at the front are timed off the clock at the baud rate, -/// which is an estimate of how far the engine has got. It is corrected by -/// `EngineIdle`: an engine that has stopped transmitting has an empty buffer, -/// whatever the estimate says. -public sealed class TypeAhead : TransmitBuffer +/// 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 arrives. The +/// pump feeds one character per look while that lasts, so the space gets there +/// and the word goes out. +public sealed class TypeAhead : IDisposable { /// A RTTY character is a start bit, five data bits and a stop bit and a /// half. @@ -45,21 +44,35 @@ public sealed class TypeAhead : TransmitBuffer /// message beyond reach. public const int DefaultLead = 2; - /// The longest the pump sleeps between looks at the buffer. It is what - /// stands between the operator moving the cursor on and the next character - /// going out, so it is short against a character time. + /// How much of the text that has gone out is kept. It is there to be read + /// back, not to be a log. + public const int KeptSent = 2000; + + /// `Cursor` set to this lets everything pending go out, which is where it + /// stands while the operator is not typing into the pane. + public const int NoCursor = int.MaxValue; + + /// How often the engine is asked how much it has left. + public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50); + + /// The longest the clock-paced pump sleeps between looks at the buffer. It + /// is what stands between the operator moving the cursor on and the next + /// character going out, so it is short against a character time. private static readonly TimeSpan LongestTick = TimeSpan.FromMilliseconds(10); + /// An engine that has not answered by now is not going to. + private static readonly TimeSpan AnswerPatience = TimeSpan.FromSeconds(2); + private readonly Func send; + private readonly EngineBuffer? counter; private readonly Lock gate = new(); private readonly StringBuilder pending = new(); private readonly StringBuilder sent = new(); private CancellationTokenSource? stopping; private Task pump = Task.CompletedTask; - private int cursor = TransmitBuffer.NoCursor; - private int inEngine; - private DateTime nextOut = DateTime.MinValue; + private TaskCompletionSource? asking; + private int cursor = NoCursor; public TypeAhead(Func send, double baud = DefaultBaud) { @@ -67,16 +80,34 @@ public sealed class TypeAhead : TransmitBuffer Baud = baud; } - /// The speed the engine transmits at, which is what the estimate of its - /// buffer is paced by. + /// An engine that holds a buffer of its own and can say how much of it is + /// left, which is what paces the pump instead of the clock. + public TypeAhead(EngineBuffer engine, double baud = DefaultBaud) + : this((character, cancellation) => engine.TypeAsync(character, cancellation), baud) + { + counter = engine; + engine.Buffered += WhenBuffered; + } + + /// The speed the engine transmits at, which paces the pump when the engine + /// will not say how much it holds. public double Baud { get; set; } /// How many characters may sit in the engine at once. public int Lead { get; set; } = DefaultLead; + /// False once the engine has refused to say how much it holds, which puts + /// the pump back on the clock. + public bool Counts { get; private set; } = true; + public TimeSpan CharacterTime => TimeSpan.FromSeconds(BitsPerCharacter / (Baud > 0 ? Baud : DefaultBaud)); + /// How long the engine may hold what it has before the pump feeds it + /// anyway. Three characters is longer than any gap between transmitted + /// characters and short enough that a held word goes out at once. + public TimeSpan HoldingPatience => CharacterTime * 3; + /// What has gone to the engine. public string Sent { @@ -102,7 +133,7 @@ public sealed class TypeAhead : TransmitBuffer } /// How many of the pending characters may go out. The window sets it to - /// where the operator's cursor is; `TransmitBuffer.NoCursor` while nobody is typing. + /// where the operator's cursor is; `NoCursor` while nobody is typing. public int Cursor { get @@ -121,16 +152,13 @@ public sealed class TypeAhead : TransmitBuffer } } - /// True while there is text to send or the engine is estimated to be still - /// transmitting what it was given. public bool IsSending { get { lock (gate) { - Advance(DateTime.UtcNow); - return pending.Length > 0 || inEngine > 0; + return pending.Length > 0; } } } @@ -139,10 +167,10 @@ public sealed class TypeAhead : TransmitBuffer /// the pump's thread, so a handler that touches the screen has to post. public event EventHandler? Changed; - /// Everything that was waiting has gone out, and the engine is estimated to - /// have transmitted it. This is what tells the entry window that a message - /// is finished, so what stands after `{END}` runs and `{RX}` drops the - /// transmitter at the right moment. + /// Everything that was waiting has gone out and the engine has transmitted + /// it. This is what tells the entry window that a message is finished, so + /// what stands after `{END}` runs and `{RX}` drops the transmitter at the + /// right moment. public event EventHandler? Drained; /// A message to send. It goes on the end of what is already waiting, so two @@ -157,7 +185,7 @@ public sealed class TypeAhead : TransmitBuffer lock (gate) { pending.Append(text); - if (cursor != TransmitBuffer.NoCursor) + if (cursor != NoCursor) { // text added behind the operator's cursor is still text to // send, so the cursor moves out with it @@ -180,18 +208,6 @@ public sealed class TypeAhead : TransmitBuffer Start(); } - /// The engine has stopped transmitting, so whatever it was given has gone - /// out. This corrects the estimate: an engine that is faster than the - /// estimate would otherwise be left waiting for a character it could have - /// had. - public void EngineIdle() - { - lock (gate) - { - inEngine = 0; - } - } - /// Drops what has not gone out. Escape and the RX button do this: what is /// already in the engine cannot be stopped from here, and the engine's own /// abort takes care of that. @@ -200,8 +216,7 @@ public sealed class TypeAhead : TransmitBuffer lock (gate) { pending.Clear(); - inEngine = 0; - cursor = TransmitBuffer.NoCursor; + cursor = NoCursor; } Changed?.Invoke(this, EventArgs.Empty); } @@ -214,14 +229,17 @@ public sealed class TypeAhead : TransmitBuffer { pending.Clear(); sent.Clear(); - inEngine = 0; - cursor = TransmitBuffer.NoCursor; + cursor = NoCursor; } Changed?.Invoke(this, EventArgs.Empty); } public void Dispose() { + if (counter is not null) + { + counter.Buffered -= WhenBuffered; + } stopping?.Cancel(); stopping?.Dispose(); stopping = null; @@ -241,34 +259,109 @@ public sealed class TypeAhead : TransmitBuffer } } - /// Hands the engine a character whenever it has room for one, until there - /// is nothing left to send. A pump held at the cursor keeps running: the - /// operator is typing, and the next character is theirs to release. private async Task RunAsync(CancellationToken cancellation) { try { - while (!cancellation.IsCancellationRequested) + if (counter is not null && Counts && await CountedAsync(cancellation).ConfigureAwait(false)) { - if (Take() is { } next) - { - await send(next, cancellation).ConfigureAwait(false); - Changed?.Invoke(this, EventArgs.Empty); - continue; - } - if (!IsSending) - { - Drained?.Invoke(this, EventArgs.Empty); - return; - } - await Task.Delay(Tick, cancellation).ConfigureAwait(false); + return; } + await PacedAsync(cancellation).ConfigureAwait(false); } catch (OperationCanceledException) { } } + /// Asks the engine how much it has left, feeds it up to `Lead`, and asks + /// again. The pump is the only thing that writes to the engine, so an + /// answer is about characters it has already been given. + /// + /// Returns false if the engine will not say, which puts the pump on the + /// clock instead. + private async Task CountedAsync(CancellationToken cancellation) + { + DateTime moved = DateTime.UtcNow; + int last = int.MaxValue; + bool wasEmpty = false; + while (!cancellation.IsCancellationRequested) + { + int left = await AskAsync(cancellation).ConfigureAwait(false); + if (left < 0) + { + Counts = false; + return false; + } + if (left < last) + { + moved = DateTime.UtcNow; + } + bool holding = DateTime.UtcNow - moved > HoldingPatience; + while ((left < Lead || holding) && Take() is { } next) + { + await send(next, cancellation).ConfigureAwait(false); + Changed?.Invoke(this, EventArgs.Empty); + left++; + holding = false; + moved = DateTime.UtcNow; + } + last = left; + // the engine takes a moment to count what it has just been given, + // so one empty answer is not the end of the message + if (left == 0 && !IsSending) + { + if (wasEmpty) + { + Drained?.Invoke(this, EventArgs.Empty); + return true; + } + wasEmpty = true; + } + else + { + wasEmpty = false; + } + await Task.Delay(PollInterval, cancellation).ConfigureAwait(false); + } + return true; + } + + /// The fallback: count character times off the clock at the baud rate, so + /// the engine is fed at the speed it transmits. It drifts, which shows up + /// as a gap between characters near the end of a long message. + private async Task PacedAsync(CancellationToken cancellation) + { + int inEngine = 0; + DateTime nextOut = DateTime.MinValue; + while (!cancellation.IsCancellationRequested) + { + DateTime now = DateTime.UtcNow; + while (inEngine > 0 && now >= nextOut) + { + inEngine--; + nextOut += CharacterTime; + } + if (inEngine < Lead && Take() is { } next) + { + if (inEngine == 0) + { + nextOut = now + CharacterTime; + } + inEngine++; + await send(next, cancellation).ConfigureAwait(false); + Changed?.Invoke(this, EventArgs.Empty); + continue; + } + if (inEngine == 0 && !IsSending) + { + Drained?.Invoke(this, EventArgs.Empty); + return; + } + await Task.Delay(Tick, cancellation).ConfigureAwait(false); + } + } + private TimeSpan Tick { get @@ -278,47 +371,59 @@ public sealed class TypeAhead : TransmitBuffer } } - /// The next character to send, or null when there is none to send now: the - /// engine is full, nothing is waiting, or what is waiting is behind the - /// cursor. + /// One question and its answer, or -1 when the engine would not say. + private async Task AskAsync(CancellationToken cancellation) + { + if (counter is null) + { + return -1; + } + TaskCompletionSource answer = new(TaskCreationOptions.RunContinuationsAsynchronously); + lock (gate) + { + asking = answer; + } + await counter.AskBufferedAsync("", cancellation).ConfigureAwait(false); + try + { + return await answer.Task.WaitAsync(AnswerPatience, cancellation).ConfigureAwait(false); + } + catch (TimeoutException) + { + return -1; + } + } + + private void WhenBuffered(object? sender, int left) + { + lock (gate) + { + asking?.TrySetResult(left); + } + } + + /// The next character to send, or null when there is none to send now: + /// nothing is waiting, or what is waiting is behind the cursor. private char? Take() { lock (gate) { - DateTime now = DateTime.UtcNow; - Advance(now); - if (pending.Length == 0 || cursor == 0 || inEngine >= Lead) + if (pending.Length == 0 || cursor == 0) { return null; } char next = pending[0]; pending.Remove(0, 1); - if (cursor != TransmitBuffer.NoCursor) + if (cursor != NoCursor) { cursor--; } - if (inEngine == 0) - { - nextOut = now + CharacterTime; - } - inEngine++; sent.Append(next); - if (sent.Length > TransmitBuffer.KeptSent) + if (sent.Length > KeptSent) { - sent.Remove(0, sent.Length - TransmitBuffer.KeptSent); + sent.Remove(0, sent.Length - KeptSent); } return next; } } - - /// Takes off the estimate the characters the engine has had time to - /// transmit since the last look. Called with the lock held. - private void Advance(DateTime now) - { - while (inEngine > 0 && now >= nextOut) - { - inEngine--; - nextOut += CharacterTime; - } - } } diff --git a/tests/Nonemm.Digital.Tests/EngineTypeAheadTests.cs b/tests/Nonemm.Digital.Tests/EngineTypeAheadTests.cs deleted file mode 100644 index 0e32566..0000000 --- a/tests/Nonemm.Digital.Tests/EngineTypeAheadTests.cs +++ /dev/null @@ -1,227 +0,0 @@ -using System.Text; -using Nonemm.Digital; - -namespace Nonemm.Digital.Tests; - -/// The transmit buffer with the engine holding the text. The engine here is a -/// stand-in that behaves the way MMTTY's help says MMTTY does: it takes -/// characters, a backspace removes the last one it has not transmitted yet, and -/// it says how many are left. Whether the real engine does that is what -/// `tools/Nonemm.EngineProbe` is for. -public class EngineTypeAheadTests -{ - private static readonly TimeSpan Patience = TimeSpan.FromSeconds(5); - private static readonly TimeSpan Poll = TimeSpan.FromMilliseconds(5); - - private readonly FakeEngine engine = new(); - - private EngineTypeAhead Buffer() => new(engine, Poll); - - private static async Task WaitForAsync(Func ready) - { - DateTime giveUp = DateTime.UtcNow + Patience; - while (!ready() && DateTime.UtcNow < giveUp) - { - await Task.Delay(2); - } - } - - /// The whole message goes to the engine as fast as the engine takes it, - /// which is the point: an engine with characters in hand never transmits - /// idle in the middle of a message. - [Fact] - public async Task AMessageGoesToTheEngineWhole() - { - using EngineTypeAhead buffer = Buffer(); - - buffer.Append("CQ TEST"); - - await WaitForAsync(() => engine.Waiting == "CQ TEST"); - Assert.Equal("CQ TEST", engine.Waiting); - } - - /// What the engine has transmitted moves out of the box, along with the one - /// character held back because the count is a poll old. - [Fact] - public async Task WhatTheEngineHasTransmittedIsNotPendingAnyMore() - { - using EngineTypeAhead buffer = Buffer(); - buffer.Append("CQ TEST"); - await WaitForAsync(() => engine.Waiting == "CQ TEST"); - - engine.Transmit(3); - - await WaitForAsync(() => buffer.Sent.Length >= 4); - Assert.Equal("CQ T", buffer.Sent); - Assert.Equal("EST", buffer.Pending); - } - - [Fact] - public async Task AnEditTakesBackWhatHasNotBeenTransmitted() - { - using EngineTypeAhead buffer = Buffer(); - buffer.Append("OM5X 599 001"); - await WaitForAsync(() => engine.Waiting.Length == 12); - engine.Transmit(5); - await WaitForAsync(() => buffer.Sent.Length >= 6); - - buffer.Rewrite("99 002", TransmitBuffer.NoCursor); - - await WaitForAsync(() => engine.Waiting.EndsWith("599 002", StringComparison.Ordinal)); - engine.Transmit(engine.Waiting.Length); - await WaitForAsync(() => !buffer.IsSending); - Assert.Equal("OM5X 599 002", engine.Transmitted); - } - - /// The character the engine was last known to be holding is treated as - /// gone. Taking it back would race with the engine transmitting it, and a - /// backspace the engine refuses would put the text after it on the air - /// twice. - [Fact] - public async Task TheGuardCharacterIsNotTakenBack() - { - using EngineTypeAhead buffer = Buffer(); - buffer.Append("ABCDEF"); - await WaitForAsync(() => engine.Waiting == "ABCDEF"); - engine.Transmit(2); - await WaitForAsync(() => buffer.Sent == "ABC"); - - buffer.Rewrite("", TransmitBuffer.NoCursor); - - await WaitForAsync(() => engine.Waiting == "C"); - Assert.Equal("C", engine.Waiting); - Assert.Equal("", buffer.Pending); - } - - [Fact] - public async Task NothingGoesOutFromBehindTheCursor() - { - using EngineTypeAhead buffer = Buffer(); - - buffer.Rewrite("CQ TEST", 2); - - await WaitForAsync(() => engine.Waiting == "CQ"); - await Task.Delay(30); - Assert.Equal("CQ", engine.Waiting); - // the first queued character is the guard, so it shows as gone - Assert.Equal("Q TEST", buffer.Pending); - } - - [Fact] - public async Task TheRestGoesOutWhenTheCursorMovesOn() - { - using EngineTypeAhead buffer = Buffer(); - buffer.Rewrite("CQ TEST", 2); - await WaitForAsync(() => engine.Waiting == "CQ"); - - buffer.Cursor = TransmitBuffer.NoCursor; - - await WaitForAsync(() => engine.Waiting == "CQ TEST"); - Assert.Equal("CQ TEST", engine.Waiting); - } - - [Fact] - public async Task TheBufferSaysWhenTheMessageHasGoneOut() - { - using EngineTypeAhead buffer = Buffer(); - int drained = 0; - buffer.Drained += (_, _) => Interlocked.Increment(ref drained); - buffer.Append("TU"); - await WaitForAsync(() => engine.Waiting == "TU"); - - engine.Transmit(2); - - await WaitForAsync(() => Volatile.Read(ref drained) == 1); - Assert.Equal(1, Volatile.Read(ref drained)); - Assert.Equal("TU", buffer.Sent); - } - - /// The engine unkeying means its buffer is empty, whatever the last count - /// said. - [Fact] - public async Task AnIdleEngineHasTransmittedEverythingItHeld() - { - using EngineTypeAhead buffer = Buffer(); - buffer.Append("TU"); - await WaitForAsync(() => engine.Waiting == "TU"); - await Task.Delay(20); - - buffer.EngineIdle(); - - Assert.Equal("TU", buffer.Sent); - Assert.Equal("", buffer.Pending); - } - - /// MMTTY, as its help describes it: characters go in, a backspace takes the - /// last one back while it has not been transmitted, and the count is what - /// is left. - private sealed class FakeEngine : EngineBuffer - { - private readonly Lock gate = new(); - private readonly StringBuilder waiting = new(); - private readonly StringBuilder transmitted = new(); - - public event EventHandler? Buffered; - - /// What the engine holds and has not transmitted. - public string Waiting - { - get - { - lock (gate) - { - return waiting.ToString(); - } - } - } - - public string Transmitted - { - get - { - lock (gate) - { - return transmitted.ToString(); - } - } - } - - public Task TypeAsync(char character, CancellationToken cancellation = default) - { - lock (gate) - { - if (character != '\b') - { - waiting.Append(character); - } - else if (waiting.Length > 0) - { - waiting.Remove(waiting.Length - 1, 1); - } - } - return Task.CompletedTask; - } - - public Task AskBufferedAsync(string property = "", CancellationToken cancellation = default) - { - int left; - lock (gate) - { - left = waiting.Length; - } - Buffered?.Invoke(this, left); - return Task.CompletedTask; - } - - /// The engine transmitting, driven by the test rather than a clock. - public void Transmit(int count) - { - lock (gate) - { - int going = Math.Min(count, waiting.Length); - transmitted.Append(waiting.ToString(0, going)); - waiting.Remove(0, going); - } - } - } -} diff --git a/tests/Nonemm.Digital.Tests/TypeAheadTests.cs b/tests/Nonemm.Digital.Tests/TypeAheadTests.cs index c9cb6b7..d89e89d 100644 --- a/tests/Nonemm.Digital.Tests/TypeAheadTests.cs +++ b/tests/Nonemm.Digital.Tests/TypeAheadTests.cs @@ -84,7 +84,7 @@ public class TypeAheadTests buffer.Append("OM5X 599 001"); await WaitForAsync(() => buffer.Sent.Length >= 5); - buffer.Rewrite("599 002", TransmitBuffer.NoCursor); + buffer.Rewrite("599 002", TypeAhead.NoCursor); await WaitForAsync(() => buffer.Pending.Length == 0); Assert.EndsWith("599 002", Sent, StringComparison.Ordinal); @@ -111,7 +111,7 @@ public class TypeAheadTests buffer.Rewrite("CQ TEST", 2); await WaitForAsync(() => Sent.Length == 2); - buffer.Cursor = TransmitBuffer.NoCursor; + buffer.Cursor = TypeAhead.NoCursor; await WaitForAsync(() => Sent == "CQ TEST"); Assert.Equal("CQ TEST", Sent); @@ -212,21 +212,53 @@ public class TypeAheadTests Assert.Equal(2, Sent.Length); } - /// The estimate of what the engine still holds is only an estimate. An - /// engine that says it has stopped transmitting has an empty buffer, and - /// the next character goes to it at once rather than a character time - /// later. + /// An engine that says how much it holds is asked rather than timed, so + /// nothing depends on the baud rate being right. [Fact] - public async Task AnIdleEngineIsFedWithoutWaiting() + public async Task ACountingEngineIsAskedHowMuchItHolds() { - using TypeAhead buffer = Buffer(baud: Slow); + FakeEngine engine = new(); + using TypeAhead buffer = new(engine, baud: Slow); + buffer.Append("CQ TEST"); - await WaitForAsync(() => Sent.Length >= 2); - buffer.EngineIdle(); + await WaitForAsync(() => engine.Waiting.Length >= 2); + await Task.Delay(30); + Assert.Equal("CQ", engine.Waiting); - await WaitForAsync(() => Sent.Length >= 4); - Assert.Equal("CQ T", Sent); + engine.Transmit(2); + + await WaitForAsync(() => engine.Transmitted.Length + engine.Waiting.Length >= 4); + Assert.Equal("CQ T", engine.Transmitted + engine.Waiting); + } + + /// MMTTY set to Word out holds a word until the space after it, so the + /// count stops going down. Waiting for it to move would leave the message + /// sitting there, so the pump feeds on. + [Fact] + public async Task AnEngineHoldingWhatItHasIsFedAnyway() + { + FakeEngine engine = new(); + using TypeAhead buffer = new(engine, baud: Slow); + + buffer.Append("CQ TEST"); + + await WaitForAsync(() => engine.Waiting == "CQ TEST"); + Assert.Equal("CQ TEST", engine.Waiting); + } + + /// The clock is the fallback for an engine that will not answer. + [Fact] + public async Task AnEngineThatWillNotCountIsPacedByTheClock() + { + FakeEngine engine = new() { Counts = false }; + using TypeAhead buffer = new(engine, baud: Fast); + + buffer.Append("CQ TEST"); + + await WaitForAsync(() => engine.Waiting == "CQ TEST"); + Assert.False(buffer.Counts); + Assert.Equal("CQ TEST", engine.Waiting); } [Fact] @@ -236,4 +268,70 @@ public class TypeAheadTests Assert.Equal(165, buffer.CharacterTime.TotalMilliseconds, 0.5); } + + /// An engine with a buffer of its own: it takes characters, holds them + /// until the test says they have been transmitted, and says how many it + /// has. `Counts` false is the engine that will not answer. + private sealed class FakeEngine : EngineBuffer + { + private readonly Lock gate = new(); + private readonly StringBuilder waiting = new(); + private readonly StringBuilder transmitted = new(); + + public bool Counts { get; init; } = true; + + public event EventHandler? Buffered; + + public string Waiting + { + get + { + lock (gate) + { + return waiting.ToString(); + } + } + } + + public string Transmitted + { + get + { + lock (gate) + { + return transmitted.ToString(); + } + } + } + + public Task TypeAsync(char character, CancellationToken cancellation = default) + { + lock (gate) + { + waiting.Append(character); + } + return Task.CompletedTask; + } + + public Task AskBufferedAsync(string property = "", CancellationToken cancellation = default) + { + int left; + lock (gate) + { + left = Counts ? waiting.Length : -1; + } + Buffered?.Invoke(this, left); + return Task.CompletedTask; + } + + public void Transmit(int count) + { + lock (gate) + { + int going = Math.Min(count, waiting.Length); + transmitted.Append(waiting.ToString(0, going)); + waiting.Remove(0, going); + } + } + } } diff --git a/tools/Nonemm.EngineProbe/EngineProbe.cs b/tools/Nonemm.EngineProbe/EngineProbe.cs index bd4ae87..7797bb7 100644 --- a/tools/Nonemm.EngineProbe/EngineProbe.cs +++ b/tools/Nonemm.EngineProbe/EngineProbe.cs @@ -14,14 +14,17 @@ namespace Nonemm.EngineProbe; /// 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. +/// 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. @@ -43,12 +46,6 @@ public sealed class EngineProbe 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; @@ -77,8 +74,7 @@ public sealed class EngineProbe } await MessageAsync(cancellation).ConfigureAwait(false); await WordAsync(cancellation).ConfigureAwait(false); - await BackspaceAsync(cancellation).ConfigureAwait(false); - await TooLateAsync(cancellation).ConfigureAwait(false); + await UnfinishedWordAsync(cancellation).ConfigureAwait(false); } /// Question 1. `DISP_E_UNKNOWNNAME` is 0x80020006; a name the control knows @@ -154,54 +150,33 @@ public sealed class EngineProbe 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) + /// 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. \"{Alphabet}\", then {Backspaces} backspaces once it is under way"); + log.Step($"6. \"{Word}\" with no space after it, then {{RX}}"); 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 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); @@ -211,10 +186,11 @@ public sealed class EngineProbe last = left; moved = DateTime.UtcNow; } - if (left == 0) + 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"); @@ -270,20 +246,32 @@ public sealed class EngineProbe } } - /// N1MM's `{RX}`: stop once the buffer is empty. + /// 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 StoppedAsync(CancellationToken cancellation) + { 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); - } + return !engine.IsTransmitting; } private async Task TypeAsync(string text, CancellationToken cancellation)