Let MMTTY hold the text waiting to go out, behind a setting
MMTTY has a type-ahead buffer of its own: characters go into it, a backspace takes back one it has not transmitted, and TxBufLen says how many are left. Used that way it paces itself, so there is no gap between characters to tune and no baud rate to keep in step with the engine. EngineTypeAhead does that, and Config > Digital picks between it and the pump that is there now. Off is still the default: three things it rests on have never been seen with a real engine. tools/Nonemm.EngineProbe asks the engine those three questions and writes the answers to a file. It starts MMTTY through the bridge, pushes a message, polls TxBufLen while it goes out, backspaces over text that has and has not been transmitted, and logs what came back on the receive side and when. The bridge learns one verb for it: `buffer` reads TxBufLen and answers with the count, or -1 when the control will not say. A property the control does not know is a log line rather than an error, since it stops nothing. TypeAhead and EngineTypeAhead share the TransmitBuffer interface, which is what the digital window now works through, so the window does not know which one it has. docs/unfinished.md states what each buffer assumes and how to run the probe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ obj/
|
||||
/bridge/nonemm-mmtty-bridge.exe
|
||||
/mmtty/
|
||||
/bridge/XMMT.ocx
|
||||
engine-probe.log
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<Project Path="src/Nonemm.Session/Nonemm.Session.csproj" />
|
||||
<Project Path="src/Nonemm.App/Nonemm.App.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tools/">
|
||||
<Project Path="tools/Nonemm.EngineProbe/Nonemm.EngineProbe.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Nonemm.Core.Tests/Nonemm.Core.Tests.csproj" />
|
||||
<Project Path="tests/Nonemm.Contests.Tests/Nonemm.Contests.Tests.csproj" />
|
||||
|
||||
@@ -178,15 +178,25 @@ HRESULT XmmrControl::callLong(const wchar_t* name, long first, long second) {
|
||||
}
|
||||
|
||||
long XmmrControl::getLong(const wchar_t* name) {
|
||||
VARIANT value;
|
||||
VariantInit(&value);
|
||||
long value = 0;
|
||||
tryGetLong(name, &value);
|
||||
return value;
|
||||
}
|
||||
|
||||
HRESULT XmmrControl::tryGetLong(const wchar_t* name, long* value) {
|
||||
*value = 0;
|
||||
VARIANT answer;
|
||||
VariantInit(&answer);
|
||||
DISPPARAMS none = {nullptr, nullptr, 0, 0};
|
||||
if (FAILED(invoke(name, DISPATCH_PROPERTYGET, &none, &value))) {
|
||||
return 0;
|
||||
HRESULT result = invoke(name, DISPATCH_PROPERTYGET, &none, &answer);
|
||||
if (SUCCEEDED(result)) {
|
||||
result = VariantChangeType(&answer, &answer, 0, VT_I4);
|
||||
if (SUCCEEDED(result)) {
|
||||
*value = answer.lVal;
|
||||
}
|
||||
}
|
||||
long found = SUCCEEDED(VariantChangeType(&value, &value, 0, VT_I4)) ? value.lVal : 0;
|
||||
VariantClear(&value);
|
||||
return found;
|
||||
VariantClear(&answer);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string XmmrControl::getString(const wchar_t* name) {
|
||||
|
||||
@@ -32,6 +32,11 @@ public:
|
||||
HRESULT callLong(const wchar_t* name, long first, long second);
|
||||
|
||||
long getLong(const wchar_t* name);
|
||||
|
||||
// The same, saying whether the control answered. A property that is not
|
||||
// there returns DISP_E_UNKNOWNNAME, which is how a name can be checked.
|
||||
HRESULT tryGetLong(const wchar_t* name, long* value);
|
||||
|
||||
std::string getString(const wchar_t* name);
|
||||
|
||||
private:
|
||||
|
||||
@@ -153,6 +153,24 @@ void setPtt(long on) {
|
||||
control->call(L"SetMmttyPTT", &argument, 1);
|
||||
}
|
||||
|
||||
// How many characters MMTTY still has to transmit. It is asked for rather than
|
||||
// reported: the control has no event for it.
|
||||
void askBuffer(const std::string& name) {
|
||||
const std::wstring wanted = toWide(name.empty() ? "TxBufLen" : name);
|
||||
long left = 0;
|
||||
HRESULT result = control->tryGetLong(wanted.c_str(), &left);
|
||||
if (FAILED(result)) {
|
||||
char message[128];
|
||||
std::snprintf(message, sizeof(message), "%s failed: 0x%08lx", wanted.empty() ? "" : name.c_str(),
|
||||
static_cast<unsigned long>(result));
|
||||
// not an "error": a property the control will not answer stops nothing
|
||||
protocol::write("log", {message});
|
||||
protocol::write("buffer", -1);
|
||||
return;
|
||||
}
|
||||
protocol::write("buffer", left);
|
||||
}
|
||||
|
||||
void act(const std::string& text) {
|
||||
protocol::Line line = protocol::read(text);
|
||||
if (line.verb == "open") {
|
||||
@@ -161,6 +179,8 @@ void act(const std::string& text) {
|
||||
send(line.field(0));
|
||||
} else if (line.verb == "ptt") {
|
||||
setPtt(line.number(0));
|
||||
} else if (line.verb == "buffer") {
|
||||
askBuffer(line.field(0));
|
||||
} else if (line.verb == "post") {
|
||||
control->callLong(L"PostMmttyMessage", line.number(0), line.number(1));
|
||||
} else if (line.verb == "close") {
|
||||
|
||||
@@ -32,6 +32,7 @@ To the bridge:
|
||||
| `send <text>` | `SendString` |
|
||||
| `ptt <0\|1>` | `SetMmttyPTT` |
|
||||
| `post <message> <parameter>` | `PostMmttyMessage`, for everything in `MmttyMessage` |
|
||||
| `buffer [property]` | reads `TxBufLen`, or the property named instead, and answers `buffer` |
|
||||
| `close` | shuts the engine down and leaves the bridge running |
|
||||
| `quit` | shuts the engine down and exits |
|
||||
|
||||
@@ -46,9 +47,17 @@ From the bridge:
|
||||
| `tx <0\|1>` | the engine started or stopped transmitting |
|
||||
| `mark <hz>`, `space <hz>` | the tone pair moved |
|
||||
| `switch <bits>`, `view <bits>` | AFC (4), net (8) and reverse (256), and the engine's view state |
|
||||
| `buffer <n>` | how many characters the engine still has to transmit, or -1 when the control would not say |
|
||||
| `log <text>`, `error <text>` | anything the bridge has to say |
|
||||
|
||||
Each of those comes from an event of the control's own: `OnCharRcvd`,
|
||||
`buffer` is the one thing that is asked for rather than reported: the control
|
||||
has no event for the length of the transmit buffer, so it is polled. A property
|
||||
name in the request is only for finding out what the control answers to; a name
|
||||
it does not know fails with `DISP_E_UNKNOWNNAME`, which comes back as a `log`
|
||||
line and `buffer -1` rather than an `error`, because a property the control will
|
||||
not answer stops nothing.
|
||||
|
||||
Each of the others comes from an event of the control's own: `OnCharRcvd`,
|
||||
`OnPttEvent`, `OnFreqChanged` (mark and space in one event), `OnSwitchChanged`
|
||||
and `OnViewChanged`. The control raises `OnTranslateMessage` only for the
|
||||
MMTTY messages it has no event for — width, resolution, thread and the like —
|
||||
|
||||
@@ -44,23 +44,58 @@ 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.
|
||||
|
||||
**What paces the type-ahead pump.** The pump keeps two characters in the
|
||||
engine and counts character times off the clock at the baud rate in the digital
|
||||
settings to work out when the engine has room for the next one. Two characters
|
||||
is what stops the engine running dry between characters, which would make it
|
||||
transmit idle and add that idle to the time the message takes; the price is that
|
||||
the last two characters cannot be taken back. The estimate is corrected when the
|
||||
engine reports that it has stopped transmitting, which means its buffer is
|
||||
empty.
|
||||
**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.
|
||||
|
||||
The engine can be asked instead of estimated: `XMMT.ocx` has a `TxBufLen`
|
||||
property, and a probe against the control shows it answers, though what it
|
||||
answers while a message is going out could not be measured here because the
|
||||
engine will not start. Reporting it from the bridge would make the red text
|
||||
exact and would take out the one error the estimate can still make: a baud rate
|
||||
that does not match what the engine transmits at leaves a small gap between
|
||||
*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.
|
||||
|
||||
*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.
|
||||
|
||||
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 |
|
||||
|---|---|
|
||||
| `TxBufLen` counts the characters left to transmit | the name, and a probe showing the control answers to it |
|
||||
| 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 |
|
||||
|
||||
`tools/Nonemm.EngineProbe` asks the engine all three and writes the answers to a
|
||||
file. It has to run where MMTTY runs:
|
||||
|
||||
```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.
|
||||
|
||||
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.
|
||||
|
||||
**Voice keying.** `MessageSender` was written to cover a voice keyer playing a
|
||||
recording, and nothing implements it. Each operator's recordings folder is
|
||||
stored with the rest of his settings and waits on the same thing. No DVK support either, so the second radio
|
||||
|
||||
@@ -333,7 +333,10 @@ public sealed class AppSession : IDisposable
|
||||
MmttyEngine started = new(channel, options);
|
||||
await started.StartAsync();
|
||||
digital = started;
|
||||
digitalSender = new DigitalEngineSender(started, Settings.DigitalBaud);
|
||||
digitalSender = new DigitalEngineSender(
|
||||
started,
|
||||
Settings.DigitalBaud,
|
||||
Settings.DigitalEngineHoldsBuffer);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
return started;
|
||||
}
|
||||
|
||||
@@ -260,6 +260,12 @@ 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,6 +114,10 @@
|
||||
<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,6 +89,7 @@ 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];
|
||||
@@ -305,6 +306,7 @@ 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,
|
||||
|
||||
@@ -157,7 +157,7 @@ public sealed partial class DigitalWindow
|
||||
{
|
||||
return;
|
||||
}
|
||||
keyer.TypeAhead.Rewrite(TransmitBox.Text ?? "", CursorInBox());
|
||||
keyer.Buffer.Rewrite(TransmitBox.Text ?? "", CursorInBox());
|
||||
}
|
||||
|
||||
/// The cursor holds the pump back: nothing behind it goes out, so the
|
||||
@@ -169,7 +169,7 @@ public sealed partial class DigitalWindow
|
||||
{
|
||||
if (session.DigitalKeyer is { } keyer)
|
||||
{
|
||||
keyer.TypeAhead.Cursor = TransmitBox.IsFocused ? CursorInBox() : TypeAhead.NoCursor;
|
||||
keyer.Buffer.Cursor = TransmitBox.IsFocused ? CursorInBox() : TransmitBuffer.NoCursor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,8 +188,8 @@ public sealed partial class DigitalWindow
|
||||
showingBuffer = true;
|
||||
try
|
||||
{
|
||||
SentText.Text = keyer.TypeAhead.Sent;
|
||||
string pending = keyer.TypeAhead.Pending;
|
||||
SentText.Text = keyer.Buffer.Sent;
|
||||
string pending = keyer.Buffer.Pending;
|
||||
string was = TransmitBox.Text ?? "";
|
||||
if (was == pending)
|
||||
{
|
||||
@@ -275,7 +275,7 @@ public sealed partial class DigitalWindow
|
||||
|
||||
private void OnClearTransmit(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
session.DigitalKeyer?.TypeAhead.Clear();
|
||||
session.DigitalKeyer?.Buffer.Clear();
|
||||
ShowBuffer();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 TypeAhead? buffer;
|
||||
private TransmitBuffer? 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 not null)
|
||||
if (buffer is TypeAhead paced)
|
||||
{
|
||||
buffer.Baud = Settings.DigitalBaud;
|
||||
paced.Baud = Settings.DigitalBaud;
|
||||
}
|
||||
FollowRunAndFrequency();
|
||||
ShowState();
|
||||
@@ -147,10 +147,13 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
}
|
||||
Detach();
|
||||
engine = started;
|
||||
buffer = session.DigitalKeyer?.TypeAhead;
|
||||
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,26 +6,36 @@ namespace Nonemm.Digital;
|
||||
/// through the same expander, the same `{END}` handling and the same ESM as a
|
||||
/// CW message does.
|
||||
///
|
||||
/// A message is not handed to the engine whole. It goes into the type-ahead
|
||||
/// buffer, which keeps the engine a couple of characters ahead of the operator,
|
||||
/// so the rest can still be rewritten. `Finished` is raised when the buffer has
|
||||
/// run dry and the engine has transmitted what it was given.
|
||||
/// 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
|
||||
/// 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.
|
||||
public sealed class DigitalEngineSender : MessageSender
|
||||
{
|
||||
private readonly DigitalEngine engine;
|
||||
|
||||
public DigitalEngineSender(DigitalEngine engine, double baud = TypeAhead.DefaultBaud)
|
||||
public DigitalEngineSender(
|
||||
DigitalEngine engine,
|
||||
double baud = TypeAhead.DefaultBaud,
|
||||
bool engineHoldsBuffer = false)
|
||||
{
|
||||
this.engine = engine;
|
||||
TypeAhead = new TypeAhead(
|
||||
(character, cancellation) => engine.SendAsync(character.ToString(), cancellation),
|
||||
baud);
|
||||
TypeAhead.Drained += WhenDrained;
|
||||
Buffer = engineHoldsBuffer && engine is EngineBuffer holder
|
||||
? new EngineTypeAhead(holder)
|
||||
: new TypeAhead(
|
||||
(character, cancellation) => engine.SendAsync(character.ToString(), cancellation),
|
||||
baud);
|
||||
Buffer.Drained += WhenDrained;
|
||||
engine.TransmitChanged += WhenTransmitChanged;
|
||||
}
|
||||
|
||||
/// What is waiting to go out, which the digital window shows and edits.
|
||||
public TypeAhead TypeAhead { get; }
|
||||
public TransmitBuffer Buffer { get; }
|
||||
|
||||
public bool IsReady => engine.IsConnected;
|
||||
|
||||
@@ -37,7 +47,7 @@ public sealed class DigitalEngineSender : MessageSender
|
||||
|
||||
public Task SendAsync(string text, CancellationToken cancellation = default)
|
||||
{
|
||||
TypeAhead.Append(text);
|
||||
Buffer.Append(text);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -45,7 +55,7 @@ public sealed class DigitalEngineSender : MessageSender
|
||||
/// and the engine drops what it still holds.
|
||||
public Task AbortAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
TypeAhead.Drop();
|
||||
Buffer.Drop();
|
||||
return engine.AbortAsync(cancellation);
|
||||
}
|
||||
|
||||
@@ -56,8 +66,8 @@ public sealed class DigitalEngineSender : MessageSender
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
TypeAhead.Drained -= WhenDrained;
|
||||
TypeAhead.Dispose();
|
||||
Buffer.Drained -= WhenDrained;
|
||||
Buffer.Dispose();
|
||||
engine.TransmitChanged -= WhenTransmitChanged;
|
||||
}
|
||||
|
||||
@@ -75,8 +85,8 @@ public sealed class DigitalEngineSender : MessageSender
|
||||
{
|
||||
return;
|
||||
}
|
||||
TypeAhead.EngineIdle();
|
||||
if (!TypeAhead.IsSending)
|
||||
Buffer.EngineIdle();
|
||||
if (!Buffer.IsSending)
|
||||
{
|
||||
Finished?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
23
src/Nonemm.Digital/EngineBuffer.cs
Normal file
23
src/Nonemm.Digital/EngineBuffer.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// An engine that holds a transmit buffer of its own, takes characters into it
|
||||
/// one at a time, and says how many it still has. MMTTY does: its type-ahead is
|
||||
/// what its own keyboard drives, and `XMMT.ocx` exposes both the keystroke and
|
||||
/// the count.
|
||||
///
|
||||
/// A backspace is a character like any other. MMTTY's help says it erases from
|
||||
/// the end of the buffer and works only until the letter has been transmitted,
|
||||
/// which is what makes `EngineTypeAhead` possible.
|
||||
public interface EngineBuffer
|
||||
{
|
||||
/// The answer to `AskBufferedAsync`: how many characters are left to
|
||||
/// transmit, or -1 when the engine would not say.
|
||||
event EventHandler<int>? Buffered;
|
||||
|
||||
/// One character into the engine's buffer.
|
||||
Task TypeAsync(char character, CancellationToken cancellation = default);
|
||||
|
||||
/// Asks how many characters are left. There is no event for it, so it is
|
||||
/// polled.
|
||||
Task AskBufferedAsync(string property = "", CancellationToken cancellation = default);
|
||||
}
|
||||
370
src/Nonemm.Digital/EngineTypeAhead.cs
Normal file
370
src/Nonemm.Digital/EngineTypeAhead.cs
Normal file
@@ -0,0 +1,370 @@
|
||||
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);
|
||||
}
|
||||
@@ -54,6 +54,10 @@ public sealed class MmttyEngine : DigitalEngine
|
||||
/// Anything the bridge says about itself, for the status line.
|
||||
public event EventHandler<string>? Reported;
|
||||
|
||||
/// The answer to `AskBufferedAsync`: how many characters the engine still
|
||||
/// has to transmit.
|
||||
public event EventHandler<int>? Buffered;
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
settings = MmttySettings.Read(options.SettingsPath);
|
||||
@@ -75,6 +79,13 @@ public sealed class MmttyEngine : DigitalEngine
|
||||
public Task TypeAsync(char character, CancellationToken cancellation = default) =>
|
||||
PostAsync(MmttyMessage.TypeCharacter, character, cancellation);
|
||||
|
||||
/// Asks how many characters the engine still holds. The control has no
|
||||
/// event for it, so it is polled; the answer arrives on `Buffered`. A
|
||||
/// property name other than `TxBufLen` is only for finding out what the
|
||||
/// control answers to.
|
||||
public Task AskBufferedAsync(string property = "", CancellationToken cancellation = default) =>
|
||||
bridge.SendAsync(BridgeLine.Write("buffer", property), cancellation);
|
||||
|
||||
/// N1MM's abort, which is what its RX button and Escape do: drop PTT and
|
||||
/// let the engine stop where it is.
|
||||
public Task AbortAsync(CancellationToken cancellation = default) =>
|
||||
@@ -154,6 +165,9 @@ public sealed class MmttyEngine : DigitalEngine
|
||||
case "rx":
|
||||
Received?.Invoke(this, ((char)Number(fields, 0)).ToString());
|
||||
break;
|
||||
case "buffer":
|
||||
Buffered?.Invoke(this, Number(fields, 0));
|
||||
break;
|
||||
case "tx":
|
||||
IsTransmitting = Number(fields, 0) == 1;
|
||||
TransmitChanged?.Invoke(this, IsTransmitting);
|
||||
|
||||
57
src/Nonemm.Digital/TransmitBuffer.cs
Normal file
57
src/Nonemm.Digital/TransmitBuffer.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
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();
|
||||
}
|
||||
@@ -30,7 +30,7 @@ namespace Nonemm.Digital;
|
||||
/// 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 : IDisposable
|
||||
public sealed class TypeAhead : TransmitBuffer
|
||||
{
|
||||
/// A RTTY character is a start bit, five data bits and a stop bit and a
|
||||
/// half.
|
||||
@@ -45,14 +45,6 @@ public sealed class TypeAhead : IDisposable
|
||||
/// message beyond reach.
|
||||
public const int DefaultLead = 2;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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.
|
||||
@@ -65,7 +57,7 @@ public sealed class TypeAhead : IDisposable
|
||||
|
||||
private CancellationTokenSource? stopping;
|
||||
private Task pump = Task.CompletedTask;
|
||||
private int cursor = NoCursor;
|
||||
private int cursor = TransmitBuffer.NoCursor;
|
||||
private int inEngine;
|
||||
private DateTime nextOut = DateTime.MinValue;
|
||||
|
||||
@@ -110,7 +102,7 @@ public sealed class TypeAhead : IDisposable
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// where the operator's cursor is; `TransmitBuffer.NoCursor` while nobody is typing.
|
||||
public int Cursor
|
||||
{
|
||||
get
|
||||
@@ -165,7 +157,7 @@ public sealed class TypeAhead : IDisposable
|
||||
lock (gate)
|
||||
{
|
||||
pending.Append(text);
|
||||
if (cursor != NoCursor)
|
||||
if (cursor != TransmitBuffer.NoCursor)
|
||||
{
|
||||
// text added behind the operator's cursor is still text to
|
||||
// send, so the cursor moves out with it
|
||||
@@ -209,7 +201,7 @@ public sealed class TypeAhead : IDisposable
|
||||
{
|
||||
pending.Clear();
|
||||
inEngine = 0;
|
||||
cursor = NoCursor;
|
||||
cursor = TransmitBuffer.NoCursor;
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -223,7 +215,7 @@ public sealed class TypeAhead : IDisposable
|
||||
pending.Clear();
|
||||
sent.Clear();
|
||||
inEngine = 0;
|
||||
cursor = NoCursor;
|
||||
cursor = TransmitBuffer.NoCursor;
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -301,7 +293,7 @@ public sealed class TypeAhead : IDisposable
|
||||
}
|
||||
char next = pending[0];
|
||||
pending.Remove(0, 1);
|
||||
if (cursor != NoCursor)
|
||||
if (cursor != TransmitBuffer.NoCursor)
|
||||
{
|
||||
cursor--;
|
||||
}
|
||||
@@ -311,9 +303,9 @@ public sealed class TypeAhead : IDisposable
|
||||
}
|
||||
inEngine++;
|
||||
sent.Append(next);
|
||||
if (sent.Length > KeptSent)
|
||||
if (sent.Length > TransmitBuffer.KeptSent)
|
||||
{
|
||||
sent.Remove(0, sent.Length - KeptSent);
|
||||
sent.Remove(0, sent.Length - TransmitBuffer.KeptSent);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
227
tests/Nonemm.Digital.Tests/EngineTypeAheadTests.cs
Normal file
227
tests/Nonemm.Digital.Tests/EngineTypeAheadTests.cs
Normal file
@@ -0,0 +1,227 @@
|
||||
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", TypeAhead.NoCursor);
|
||||
buffer.Rewrite("599 002", TransmitBuffer.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 = TypeAhead.NoCursor;
|
||||
buffer.Cursor = TransmitBuffer.NoCursor;
|
||||
|
||||
await WaitForAsync(() => Sent == "CQ TEST");
|
||||
Assert.Equal("CQ TEST", Sent);
|
||||
|
||||
269
tools/Nonemm.EngineProbe/EngineProbe.cs
Normal file
269
tools/Nonemm.EngineProbe/EngineProbe.cs
Normal file
@@ -0,0 +1,269 @@
|
||||
using System.Text;
|
||||
using Nonemm.Digital;
|
||||
|
||||
namespace Nonemm.EngineProbe;
|
||||
|
||||
/// Four questions about MMTTY that cannot be answered without MMTTY running,
|
||||
/// asked in order and written to the report:
|
||||
///
|
||||
/// 1. Does the control answer `TxBufLen`, and is a name it does not know
|
||||
/// distinguishable from one it does?
|
||||
/// 2. While a message is going out, does that number count down at the
|
||||
/// character rate? If it does, it says exactly how much of the message is
|
||||
/// still in the engine and can still be taken back.
|
||||
/// 3. Does a backspace pushed in as a character delete a character the engine
|
||||
/// has not transmitted yet, and what happens to backspaces over characters
|
||||
/// that have already gone? Those have to be refused: text typed after them
|
||||
/// would otherwise go out twice.
|
||||
/// 4. What comes back on the receive side while transmitting, and when? With
|
||||
/// the sound loopback off that is MMTTY echoing its transmit window; with it
|
||||
/// on it is the demodulator hearing the transmission.
|
||||
///
|
||||
/// The answers decide whether the transmit buffer can be handed to MMTTY
|
||||
/// instead of being paced from here. `docs/unfinished.md` states what each one
|
||||
/// means.
|
||||
public sealed class EngineProbe
|
||||
{
|
||||
/// Long enough for a character at 45.45 baud, short enough to see the count
|
||||
/// move.
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
/// A message at 45.45 baud takes a few seconds; this is well past the end
|
||||
/// of one.
|
||||
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(30);
|
||||
|
||||
private const string Message = "CQ TEST DE OM5M OM5M";
|
||||
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;
|
||||
private readonly StringBuilder received = new();
|
||||
private readonly Lock gate = new();
|
||||
private TaskCompletionSource<int>? asking;
|
||||
|
||||
public EngineProbe(MmttyEngine engine, ProbeLog log)
|
||||
{
|
||||
this.engine = engine;
|
||||
this.log = log;
|
||||
engine.Reported += (_, what) => log.Write($"bridge: {what}");
|
||||
engine.TransmitChanged += (_, on) => log.Write(on ? "engine keyed" : "engine unkeyed");
|
||||
engine.Buffered += WhenBuffered;
|
||||
engine.Received += WhenReceived;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellation)
|
||||
{
|
||||
await NamesAsync(cancellation).ConfigureAwait(false);
|
||||
await IdleAsync(cancellation).ConfigureAwait(false);
|
||||
await MessageAsync(cancellation).ConfigureAwait(false);
|
||||
await BackspaceAsync(cancellation).ConfigureAwait(false);
|
||||
await TooLateAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// Question 1. `DISP_E_UNKNOWNNAME` is 0x80020006; a name the control knows
|
||||
/// answers with a number instead.
|
||||
private async Task NamesAsync(CancellationToken cancellation)
|
||||
{
|
||||
log.Step("1. does the control answer TxBufLen");
|
||||
log.Write($"TxBufLen: {Answer(await AskAsync("TxBufLen", cancellation).ConfigureAwait(false))}");
|
||||
log.Write($"NotAProperty: {Answer(await AskAsync("NotAProperty", cancellation).ConfigureAwait(false))}");
|
||||
log.Write("a number for the first and no answer for the second is what makes the count usable");
|
||||
}
|
||||
|
||||
/// The number with nothing to transmit, which is what the count has to
|
||||
/// start and end at.
|
||||
private async Task IdleAsync(CancellationToken cancellation)
|
||||
{
|
||||
log.Step("2. TxBufLen with nothing to send");
|
||||
for (int look = 0; look < 3; look++)
|
||||
{
|
||||
log.Write($"TxBufLen: {Answer(await AskAsync("", cancellation).ConfigureAwait(false))}");
|
||||
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Questions 2 and 4. The whole message is pushed as fast as the bridge
|
||||
/// takes it, so what the count does afterwards is the engine transmitting
|
||||
/// rather than the probe feeding.
|
||||
private async Task MessageAsync(CancellationToken cancellation)
|
||||
{
|
||||
log.Step($"3. \"{Message}\" pushed in one go, then TxBufLen every {PollInterval.TotalMilliseconds} ms");
|
||||
TakeReceived();
|
||||
await engine.SetPttAsync(true, cancellation).ConfigureAwait(false);
|
||||
await TypeAsync(Message, cancellation).ConfigureAwait(false);
|
||||
log.Write($"pushed {Message.Length} characters");
|
||||
await DrainAsync(cancellation).ConfigureAwait(false);
|
||||
log.Write($"received while transmitting: \"{TakeReceived()}\"");
|
||||
await engine.SetPttAsync(false, cancellation).ConfigureAwait(false);
|
||||
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// Question 3. The wait is there so the engine is partway through the
|
||||
/// alphabet when the backspaces arrive: the ones over transmitted
|
||||
/// characters are the ones MMTTY has to refuse.
|
||||
private async Task BackspaceAsync(CancellationToken cancellation)
|
||||
{
|
||||
log.Step($"4. \"{Alphabet}\", then {Backspaces} backspaces once it is under way");
|
||||
TakeReceived();
|
||||
await engine.SetPttAsync(true, cancellation).ConfigureAwait(false);
|
||||
await TypeAsync(Alphabet, cancellation).ConfigureAwait(false);
|
||||
log.Write($"pushed {Alphabet.Length} characters");
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellation).ConfigureAwait(false);
|
||||
int before = await AskAsync("", cancellation).ConfigureAwait(false);
|
||||
log.Write($"TxBufLen before the backspaces: {Answer(before)}");
|
||||
await TypeAsync(new string(Backspace, Backspaces), cancellation).ConfigureAwait(false);
|
||||
int after = await AskAsync("", cancellation).ConfigureAwait(false);
|
||||
log.Write($"TxBufLen after the backspaces: {Answer(after)}");
|
||||
log.Write($"a drop of {Backspaces} means the engine took them out of its buffer");
|
||||
await DrainAsync(cancellation).ConfigureAwait(false);
|
||||
string went = TakeReceived();
|
||||
log.Write($"received while transmitting: \"{went}\"");
|
||||
log.Write($"the last {Backspaces} letters of \"{Alphabet}\" are the ones that should be missing");
|
||||
await engine.SetPttAsync(false, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// The other half of question 3: more backspaces than there are characters
|
||||
/// left to take back. If the extra ones are ignored, what goes out is the
|
||||
/// transmitted part followed by the new text. If they do something else,
|
||||
/// this is where it shows.
|
||||
private async Task TooLateAsync(CancellationToken cancellation)
|
||||
{
|
||||
log.Step($"5. \"{Short}\", then {TooManyBackspaces} backspaces near the end of it");
|
||||
TakeReceived();
|
||||
await engine.SetPttAsync(true, 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}\" once, not twice");
|
||||
await engine.SetPttAsync(false, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// Waits until the engine holds no more than `wanted` characters, so the
|
||||
/// next thing the probe does lands at a known point in the message.
|
||||
private async Task<int> WaitForAsync(int wanted, CancellationToken cancellation)
|
||||
{
|
||||
DateTime giveUp = DateTime.UtcNow + Patience;
|
||||
while (DateTime.UtcNow < giveUp)
|
||||
{
|
||||
int left = await AskAsync("", cancellation).ConfigureAwait(false);
|
||||
if (left < 0)
|
||||
{
|
||||
log.Write($"no answer to wait on, guessing at {Short.Length - wanted} characters gone");
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellation).ConfigureAwait(false);
|
||||
return left;
|
||||
}
|
||||
if (left <= wanted)
|
||||
{
|
||||
return left;
|
||||
}
|
||||
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// Polls until the engine says it has nothing left, or until the patience
|
||||
/// runs out. A control that does not answer has nothing to poll, so the
|
||||
/// wait is the whole of a message instead.
|
||||
private async Task DrainAsync(CancellationToken cancellation)
|
||||
{
|
||||
DateTime giveUp = DateTime.UtcNow + Patience;
|
||||
int last = int.MinValue;
|
||||
while (DateTime.UtcNow < giveUp)
|
||||
{
|
||||
int left = await AskAsync("", cancellation).ConfigureAwait(false);
|
||||
if (left != last)
|
||||
{
|
||||
log.Write($"TxBufLen: {Answer(left)}");
|
||||
last = left;
|
||||
}
|
||||
if (left == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (left < 0)
|
||||
{
|
||||
log.Write("no answer to poll, waiting out the message instead");
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), cancellation).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
log.Write($"gave up waiting after {Patience.TotalSeconds} s");
|
||||
}
|
||||
|
||||
private async Task TypeAsync(string text, CancellationToken cancellation)
|
||||
{
|
||||
foreach (char character in text)
|
||||
{
|
||||
await engine.TypeAsync(character, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends one question and waits for the answer. The bridge answers every
|
||||
/// question, with -1 when the control would not, so this cannot hang while
|
||||
/// the bridge is alive.
|
||||
private async Task<int> AskAsync(string property, CancellationToken cancellation)
|
||||
{
|
||||
TaskCompletionSource<int> answer = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
lock (gate)
|
||||
{
|
||||
asking = answer;
|
||||
}
|
||||
await engine.AskBufferedAsync(property, cancellation).ConfigureAwait(false);
|
||||
return await answer.Task.WaitAsync(Patience, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string Answer(int left) => left < 0 ? "no answer" : left.ToString();
|
||||
|
||||
private void WhenBuffered(object? sender, int left)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
asking?.TrySetResult(left);
|
||||
}
|
||||
}
|
||||
|
||||
private void WhenReceived(object? sender, string text)
|
||||
{
|
||||
foreach (char character in text)
|
||||
{
|
||||
log.Write($"rx {Printable(character)}");
|
||||
}
|
||||
lock (received)
|
||||
{
|
||||
received.Append(text);
|
||||
}
|
||||
}
|
||||
|
||||
private string TakeReceived()
|
||||
{
|
||||
lock (received)
|
||||
{
|
||||
string text = received.ToString();
|
||||
received.Clear();
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/// Control codes matter here: a backspace coming back is the engine saying
|
||||
/// it removed a character.
|
||||
private static string Printable(char character) => character switch
|
||||
{
|
||||
'\b' => "\\b (backspace)",
|
||||
'\r' => "\\r",
|
||||
'\n' => "\\n",
|
||||
_ when char.IsControl(character) => $"\\x{(int)character:x2}",
|
||||
_ => character.ToString(),
|
||||
};
|
||||
}
|
||||
13
tools/Nonemm.EngineProbe/Nonemm.EngineProbe.csproj
Normal file
13
tools/Nonemm.EngineProbe/Nonemm.EngineProbe.csproj
Normal file
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>Nonemm.EngineProbe</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Nonemm.Digital\Nonemm.Digital.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
45
tools/Nonemm.EngineProbe/ProbeLog.cs
Normal file
45
tools/Nonemm.EngineProbe/ProbeLog.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
namespace Nonemm.EngineProbe;
|
||||
|
||||
/// The probe's report: every line on the screen and in the file, stamped with
|
||||
/// the milliseconds since the probe started. The timing is the point of the
|
||||
/// report, so nothing is written without it.
|
||||
public sealed class ProbeLog : IDisposable
|
||||
{
|
||||
private readonly StreamWriter file;
|
||||
private readonly Lock gate = new();
|
||||
private readonly long started = Environment.TickCount64;
|
||||
|
||||
public ProbeLog(string path)
|
||||
{
|
||||
Path = System.IO.Path.GetFullPath(path);
|
||||
file = new StreamWriter(Path, append: false) { AutoFlush = true };
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public long Elapsed => Environment.TickCount64 - started;
|
||||
|
||||
public void Write(string text)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
string line = $"{Elapsed,7} ms {text}";
|
||||
Console.WriteLine(line);
|
||||
file.WriteLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// A heading, so the file can be read a step at a time.
|
||||
public void Step(string name)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"== {name}");
|
||||
file.WriteLine();
|
||||
file.WriteLine($"== {name}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => file.Dispose();
|
||||
}
|
||||
122
tools/Nonemm.EngineProbe/Program.cs
Normal file
122
tools/Nonemm.EngineProbe/Program.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using Nonemm.Digital;
|
||||
|
||||
namespace Nonemm.EngineProbe;
|
||||
|
||||
/// Starts MMTTY through the bridge, asks it the questions in `EngineProbe`, and
|
||||
/// writes what it answered to a file. It has to run where MMTTY runs: a sound
|
||||
/// card and a Wine prefix with XMMT.ocx registered.
|
||||
public static class Program
|
||||
{
|
||||
private const string Usage = """
|
||||
usage: engine probe --engine <path to MMTTY.EXE> [options]
|
||||
|
||||
--engine <path> the engine to start, as a Linux path
|
||||
--bridge <path> the bridge program (default bridge/nonemm-mmtty-bridge.exe)
|
||||
--prefix <path> WINEPREFIX to run in (default: Wine's own)
|
||||
--ptt <port> serial port to key (default: none, so no radio is keyed)
|
||||
--out <file> where to write the report (default engine-probe.log)
|
||||
|
||||
The probe transmits: MMTTY makes tones on the sound card for about half a
|
||||
minute. It keys no serial port unless --ptt says so, but a rig listening to
|
||||
that sound card through VOX will still go on the air.
|
||||
""";
|
||||
|
||||
public static async Task<int> Main(string[] arguments)
|
||||
{
|
||||
Dictionary<string, string> options;
|
||||
try
|
||||
{
|
||||
options = Read(arguments);
|
||||
}
|
||||
catch (ArgumentException problem)
|
||||
{
|
||||
Console.Error.WriteLine(problem.Message);
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine(Usage);
|
||||
return 1;
|
||||
}
|
||||
|
||||
using CancellationTokenSource stopping = new();
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
stopping.Cancel();
|
||||
};
|
||||
|
||||
using ProbeLog log = new(Option(options, "out", "engine-probe.log"));
|
||||
MmttyEngine engine = new(
|
||||
new WineBridgeChannel(
|
||||
Option(options, "bridge", Path.Combine("bridge", "nonemm-mmtty-bridge.exe")),
|
||||
options.GetValueOrDefault("prefix")),
|
||||
new MmttyOptions
|
||||
{
|
||||
EnginePath = options["engine"],
|
||||
PttPort = Option(options, "ptt", ""),
|
||||
});
|
||||
try
|
||||
{
|
||||
log.Write($"starting {options["engine"]}");
|
||||
await engine.StartAsync(stopping.Token).ConfigureAwait(false);
|
||||
log.Write($"MMTTY {engine.Version} is up");
|
||||
await new EngineProbe(engine, log).RunAsync(stopping.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
log.Write("stopped");
|
||||
}
|
||||
catch (Exception problem)
|
||||
{
|
||||
log.Write($"failed: {problem.Message}");
|
||||
Console.Error.WriteLine(problem);
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Shutdown(engine, log).ConfigureAwait(false);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"the report is in {log.Path}");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// The engine is left keyed if the probe stopped partway through, so PTT is
|
||||
/// dropped before the engine is, whatever happened.
|
||||
private static async Task Shutdown(MmttyEngine engine, ProbeLog log)
|
||||
{
|
||||
try
|
||||
{
|
||||
await engine.SetPttAsync(false).ConfigureAwait(false);
|
||||
await engine.StopAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception problem)
|
||||
{
|
||||
log.Write($"the engine did not shut down cleanly: {problem.Message}");
|
||||
}
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> Read(string[] arguments)
|
||||
{
|
||||
Dictionary<string, string> options = [];
|
||||
for (int i = 0; i < arguments.Length; i += 2)
|
||||
{
|
||||
if (!arguments[i].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException($"expected an option, found {arguments[i]}");
|
||||
}
|
||||
if (i + 1 >= arguments.Length)
|
||||
{
|
||||
throw new ArgumentException($"{arguments[i]} needs a value");
|
||||
}
|
||||
options[arguments[i][2..]] = arguments[i + 1];
|
||||
}
|
||||
if (!options.ContainsKey("engine"))
|
||||
{
|
||||
throw new ArgumentException("--engine is required");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private static string Option(Dictionary<string, string> options, string name, string fallback) =>
|
||||
options.TryGetValue(name, out string? given) ? given : fallback;
|
||||
}
|
||||
Reference in New Issue
Block a user