Pace the pump on what MMTTY says it has left
The engine probe answered two questions on the air, and they point opposite
ways.
TxBufLen is the number of characters left to transmit. Seventeen characters
pushed, five decoded back, and it read 12, counting down to 0 as the message
went out. So the pump no longer counts character times off the clock: it asks
the engine, feeds while the answer is under Lead, and asks again. The baud rate
is now only the fallback for an engine that will not answer.
A backspace is not an edit. Pushed in as a character it made the count go up by
four and the text went out unchanged, so text the engine has been given cannot
be taken back, only aborted. EngineTypeAhead was built on the opposite belief
and is gone, along with the setting that chose it and the interface that existed
to switch between the two.
A count that stops going down means the engine is holding what it has: MMTTY set
to Word out keeps a word until the space after it. The pump feeds one character
per look while that lasts, so the space arrives and the word goes out rather
than the message sitting there.
The probe kept two bugs of its own that this run showed: it read the count a
millisecond after pushing, saw 0 and called the message finished, and it started
a step while the previous unkey was still in flight, so that step ran with the
engine down. It now needs two empty answers in a row and waits for the engine to
say it has stopped. It also asks what {RX} does to a word the engine is holding,
which decides whether a macro without a trailing space loses its last word.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
This commit is contained in:
@@ -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`; 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -114,10 +114,6 @@
|
||||
<TextBox Name="BaudBox" Width="120" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="label" TextWrapping="Wrap"
|
||||
Text="The speed the engine transmits at, which paces the transmit pane. Set it to the same speed as the engine: 45.45 baud unless the contest says otherwise." />
|
||||
<CheckBox Name="EngineBufferBox" Margin="0,8,0,0"
|
||||
Content="Let the engine hold the text waiting to go out" />
|
||||
<TextBlock Classes="label" TextWrapping="Wrap"
|
||||
Text="MMTTY keeps its own type-ahead buffer and says how much of it is left, so the transmit pane can push everything to it and take back what has not been transmitted. The baud rate is then MMTTY's business and this setting stops pacing anything. Untested against a real engine." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</HeaderedContentControl>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Instruction> outgoing = Channel.CreateUnbounded<Instruction>();
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<char, CancellationToken, Task> 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<int>? asking;
|
||||
private int cursor = NoCursor;
|
||||
|
||||
public TypeAhead(Func<char, CancellationToken, Task> 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,22 +259,101 @@ 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
|
||||
{
|
||||
if (counter is not null && Counts && await CountedAsync(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<bool> CountedAsync(CancellationToken cancellation)
|
||||
{
|
||||
DateTime moved = DateTime.UtcNow;
|
||||
int last = int.MaxValue;
|
||||
bool wasEmpty = false;
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
if (Take() is { } next)
|
||||
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 (!IsSending)
|
||||
if (inEngine == 0 && !IsSending)
|
||||
{
|
||||
Drained?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
@@ -264,10 +361,6 @@ public sealed class TypeAhead : TransmitBuffer
|
||||
await Task.Delay(Tick, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan Tick
|
||||
{
|
||||
@@ -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<int> AskAsync(CancellationToken cancellation)
|
||||
{
|
||||
if (counter is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
TaskCompletionSource<int> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool> 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<int>? 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<int>? 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool> 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)
|
||||
|
||||
Reference in New Issue
Block a user