Compare commits
10 Commits
da7f7fb3f5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f2f39768c | |||
| c0e1bc1e1b | |||
| 9f635550bd | |||
| 0fc8d7e405 | |||
| f49a8c10fd | |||
| 7ae60be4a0 | |||
| 5370ad3f6d | |||
| 4118cd6d33 | |||
| 917a05b898 | |||
| 54b9181c06 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ obj/
|
|||||||
/bridge/nonemm-mmtty-bridge.exe
|
/bridge/nonemm-mmtty-bridge.exe
|
||||||
/mmtty/
|
/mmtty/
|
||||||
/bridge/XMMT.ocx
|
/bridge/XMMT.ocx
|
||||||
|
engine-probe.log
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
<Project Path="src/Nonemm.Session/Nonemm.Session.csproj" />
|
<Project Path="src/Nonemm.Session/Nonemm.Session.csproj" />
|
||||||
<Project Path="src/Nonemm.App/Nonemm.App.csproj" />
|
<Project Path="src/Nonemm.App/Nonemm.App.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/tools/">
|
||||||
|
<Project Path="tools/Nonemm.EngineProbe/Nonemm.EngineProbe.csproj" />
|
||||||
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/Nonemm.Core.Tests/Nonemm.Core.Tests.csproj" />
|
<Project Path="tests/Nonemm.Core.Tests/Nonemm.Core.Tests.csproj" />
|
||||||
<Project Path="tests/Nonemm.Contests.Tests/Nonemm.Contests.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) {
|
long XmmrControl::getLong(const wchar_t* name) {
|
||||||
VARIANT value;
|
long value = 0;
|
||||||
VariantInit(&value);
|
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};
|
DISPPARAMS none = {nullptr, nullptr, 0, 0};
|
||||||
if (FAILED(invoke(name, DISPATCH_PROPERTYGET, &none, &value))) {
|
HRESULT result = invoke(name, DISPATCH_PROPERTYGET, &none, &answer);
|
||||||
return 0;
|
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);
|
VariantClear(&answer);
|
||||||
return found;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string XmmrControl::getString(const wchar_t* name) {
|
std::string XmmrControl::getString(const wchar_t* name) {
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ public:
|
|||||||
HRESULT callLong(const wchar_t* name, long first, long second);
|
HRESULT callLong(const wchar_t* name, long first, long second);
|
||||||
|
|
||||||
long getLong(const wchar_t* name);
|
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);
|
std::string getString(const wchar_t* name);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -145,6 +145,14 @@ void send(const std::string& text) {
|
|||||||
VariantClear(&argument);
|
VariantClear(&argument);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// N1MM's {TX}: the control's own PTT property, which is what starts a
|
||||||
|
// transmission. SetMmttyPTT is the other direction, and only that.
|
||||||
|
void key(bool on) {
|
||||||
|
report("PTT", control->putBool(L"PTT", on));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 stops when the buffer is empty, which is N1MM's XmitOff; 0 stops now,
|
||||||
|
// which is its AbortXmit.
|
||||||
void setPtt(long on) {
|
void setPtt(long on) {
|
||||||
VARIANT argument;
|
VARIANT argument;
|
||||||
VariantInit(&argument);
|
VariantInit(&argument);
|
||||||
@@ -153,14 +161,36 @@ void setPtt(long on) {
|
|||||||
control->call(L"SetMmttyPTT", &argument, 1);
|
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) {
|
void act(const std::string& text) {
|
||||||
protocol::Line line = protocol::read(text);
|
protocol::Line line = protocol::read(text);
|
||||||
if (line.verb == "open") {
|
if (line.verb == "open") {
|
||||||
open(line);
|
open(line);
|
||||||
} else if (line.verb == "send") {
|
} else if (line.verb == "send") {
|
||||||
send(line.field(0));
|
send(line.field(0));
|
||||||
|
} else if (line.verb == "key") {
|
||||||
|
key(line.number(0) != 0);
|
||||||
} else if (line.verb == "ptt") {
|
} else if (line.verb == "ptt") {
|
||||||
setPtt(line.number(0));
|
setPtt(line.number(0));
|
||||||
|
} else if (line.verb == "buffer") {
|
||||||
|
askBuffer(line.field(0));
|
||||||
} else if (line.verb == "post") {
|
} else if (line.verb == "post") {
|
||||||
control->callLong(L"PostMmttyMessage", line.number(0), line.number(1));
|
control->callLong(L"PostMmttyMessage", line.number(0), line.number(1));
|
||||||
} else if (line.verb == "close") {
|
} else if (line.verb == "close") {
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ To the bridge:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `open <title> <port> <command line>` | sets `Title`, `ComName` and `InvokeCommand`, sets `bActive`, then posts the host window handle |
|
| `open <title> <port> <command line>` | sets `Title`, `ComName` and `InvokeCommand`, sets `bActive`, then posts the host window handle |
|
||||||
| `send <text>` | `SendString` |
|
| `send <text>` | `SendString` |
|
||||||
| `ptt <0\|1>` | `SetMmttyPTT` |
|
| `key <0\|1>` | sets the control's `PTT` property, which is what starts a transmission |
|
||||||
|
| `ptt <0\|1>` | `SetMmttyPTT`: 0 stops now, 1 stops once the buffer is empty |
|
||||||
| `post <message> <parameter>` | `PostMmttyMessage`, for everything in `MmttyMessage` |
|
| `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 |
|
| `close` | shuts the engine down and leaves the bridge running |
|
||||||
| `quit` | shuts the engine down and exits |
|
| `quit` | shuts the engine down and exits |
|
||||||
|
|
||||||
@@ -46,9 +48,17 @@ From the bridge:
|
|||||||
| `tx <0\|1>` | the engine started or stopped transmitting |
|
| `tx <0\|1>` | the engine started or stopped transmitting |
|
||||||
| `mark <hz>`, `space <hz>` | the tone pair moved |
|
| `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 |
|
| `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 |
|
| `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`
|
`OnPttEvent`, `OnFreqChanged` (mark and space in one event), `OnSwitchChanged`
|
||||||
and `OnViewChanged`. The control raises `OnTranslateMessage` only for the
|
and `OnViewChanged`. The control raises `OnTranslateMessage` only for the
|
||||||
MMTTY messages it has no event for — width, resolution, thread and the like —
|
MMTTY messages it has no event for — width, resolution, thread and the like —
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ is still sitting there undiscovered.
|
|||||||
| `OtrspBox` | a `MemoryStream`, checking the bytes | no real SO2R box. Command forms are from N1MM's `N1MMPort.cs`. |
|
| `OtrspBox` | a `MemoryStream`, checking the bytes | no real SO2R box. Command forms are from N1MM's `N1MMPort.cs`. |
|
||||||
| `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. |
|
| `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. |
|
| `StationNetwork` | the message format, round-tripped | no second station, and no N1MM on the same network. |
|
||||||
|
| `StationLink` | the bytes of every message, and two links talking over loopback | no real N1MM has ever been on the other end. The version check is the thing to try first: N1MM turns away a station whose version is not its own, and this program has to be told what to claim. |
|
||||||
| `CwDaemonSender` | the UDP messages, and a fake daemon that answers the `<ESC>h` reply request | no `cwdaemon`, no radio keyed. |
|
| `CwDaemonSender` | the UDP messages, and a fake daemon that answers the `<ESC>h` reply request | no `cwdaemon`, no radio keyed. |
|
||||||
| `MmttyEngine` and the Wine bridge | MMTTY 1.70 under Wine 10, started and stopped through `XMMT.ocx` | no sound card, so nothing has been decoded or transmitted and no `rx` or `tx` line has come from a real signal. No FSK through EXTFSK, no PTT on a serial port, and 2Tone has never been run. `docs/digital-bridge.md` |
|
| `MmttyEngine` and the Wine bridge | MMTTY 1.70 under Wine 10 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`. |
|
| `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.
|
The Cabrillo output has not been put in front of a contest sponsor's robot.
|
||||||
@@ -44,22 +45,358 @@ 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
|
signal and nothing has gone on the air: this machine has no sound card, and
|
||||||
MMTTY does not start on it.
|
MMTTY does not start on it.
|
||||||
|
|
||||||
**What paces the type-ahead pump.** The pump keeps two characters in the
|
**The entry window's function keys on a digital mode.** They are the digital
|
||||||
engine and counts character times off the clock at the baud rate in the digital
|
macros, not the CW file: N1MM loads its send buttons from the RTTYBTN set on a
|
||||||
settings to work out when the engine has room for the next one. Two characters
|
digital mode, and the CW messages carry no `{TX}`, so a key pressed there fed
|
||||||
is what stops the engine running dry between characters, which would make it
|
the engine without keying the transmitter and nothing went out. The first ten
|
||||||
transmit idle and add that idle to the time the message takes; the price is that
|
digital macros are F1 to F10; F11 and F12 stay Spot and Wipe, as in every other
|
||||||
the last two characters cannot be taken back. The estimate is corrected when the
|
mode, and the macros past the tenth are on the digital window's own buttons.
|
||||||
engine reports that it has stopped transmitting, which means its buffer is
|
Right-clicking a key still opens the CW or phone messages, so the digital macros
|
||||||
empty.
|
are edited from the digital window.
|
||||||
|
|
||||||
The engine can be asked instead of estimated: `XMMT.ocx` has a `TxBufLen`
|
**How the transmit pane works.** The pane is one editable box holding the whole
|
||||||
property, and a probe against the control shows it answers, though what it
|
message: what the engine has transmitted, coloured red, then what is still to
|
||||||
answers while a message is going out could not be measured here because the
|
go. The red stops where the air is, not where the feeder is: the last characters
|
||||||
engine will not start. Reporting it from the bridge would make the red text
|
handed over are still in the engine, and colouring those too turned every
|
||||||
exact and would take out the one error the estimate can still make: a baud rate
|
character red as it was typed once the transmission had caught up. An edit that
|
||||||
that does not match what the engine transmits at leaves a small gap between
|
reaches into the red is undone, and so is one into the characters the engine
|
||||||
characters near the end of a long message.
|
holds but has not sent yet: the engine cannot give a character back.
|
||||||
|
|
||||||
|
The two are one text, so they wrap together and neither takes width from the
|
||||||
|
other. A character moving from one half to the other only moves the colour
|
||||||
|
boundary, so the caret and what is being typed stay where they are.
|
||||||
|
|
||||||
|
Nothing goes on the air until the transmitter is keyed, which is the TX button,
|
||||||
|
Ctrl+Enter or Alt+T. From then on the pane goes out and so does whatever is
|
||||||
|
typed into it, and a function key pressed meanwhile goes on the end of what is
|
||||||
|
already waiting. Enter is a new line in the message. The message ends when the
|
||||||
|
transmitter has been told to drop and the engine says it has: what went out is
|
||||||
|
cleared off the pane, what was typed ahead is kept, and nothing goes out again
|
||||||
|
until TX is pressed. A new message clears the pane the same way, for the case
|
||||||
|
where two of them run close enough together that the transmitter never drops.
|
||||||
|
|
||||||
|
`{RX}` runs when the message it stands in has been handed over, wherever it
|
||||||
|
stands in the macro. Every other action macro runs before the text unless it
|
||||||
|
stands after `{END}`, but the transmitter cannot drop before the text has gone
|
||||||
|
out. Running it in place, in front of the text, was what made the first click on
|
||||||
|
a CQ button beep for half a second, unkey, then send the rest: the stop was
|
||||||
|
issued while the engine held the two characters of the feeder's lead, so MMTTY
|
||||||
|
sent them and dropped, and nothing was left to stop the transmitter at the end.
|
||||||
|
The bridge log showed `> ptt 1` one millisecond after `> key 1` and before the
|
||||||
|
first character. N1MM does the same as this now: it takes `{RX}` out of the text
|
||||||
|
wherever it is, sends the text, then calls `StopTX`.
|
||||||
|
|
||||||
|
The transmitter drops on `{RX}`, on the RX button or on Alt+T, and all three do
|
||||||
|
what N1MM does at the end of a message, which is not what it does anywhere else.
|
||||||
|
N1MM hands MMTTY the whole message in one `SendString` and calls
|
||||||
|
`SetMmttyPTT(1)` with the message still in the engine's buffer. MMTTY then ends
|
||||||
|
the transmission itself, at the last character. It never asks MMTTY how much is
|
||||||
|
left; `TxBufLen` does not appear in N1MM at all.
|
||||||
|
|
||||||
|
What sits between the text and the stop there is nothing at two of N1MM's three
|
||||||
|
MMTTY send paths (`DigitalInterface.cs:19177` and `:19451`) and `sSleep(400)` at
|
||||||
|
the third, the macro buttons (`:21553`). The sleep is not the mechanism — a full
|
||||||
|
buffer is — and as a wait it is both too long on a long message and wrong on a
|
||||||
|
short one: `TU` is 330 ms of air at 45.45 baud, so 400 ms of sleeping puts the
|
||||||
|
stop after the buffer has emptied, which is where it does nothing. This program
|
||||||
|
asks instead. `TypeAhead.WhenHoldingAsync` sends the stop as soon as the count
|
||||||
|
says the engine holds something, and gives up after N1MM's 400 ms.
|
||||||
|
|
||||||
|
So `{RX}` stops feeding: `TypeAhead.TakePending` hands over everything that is
|
||||||
|
left in one piece, and the stop goes out 400 ms behind it. Nothing after that
|
||||||
|
can be rewritten, which is what `{RX}` means. The feeder still paces the middle
|
||||||
|
of a message, which is what makes type-ahead work; only the ending is N1MM's.
|
||||||
|
|
||||||
|
`{RX}` runs as soon as the message has been given to the keyer, not after the
|
||||||
|
message has gone out. That is the whole point: waiting for the feeder to hand
|
||||||
|
the last character over leaves the engine empty, and a stop that reaches an
|
||||||
|
empty engine does nothing. `EntryWindow.SendThenAsync` takes `{RX}` out of what
|
||||||
|
stands after `{END}` and runs it there; the rest of `{END}` still waits for the
|
||||||
|
message to go out.
|
||||||
|
|
||||||
|
What the ending costs on the air, from a bridge log of one CQ: the last
|
||||||
|
character was fed at 9616 ms and went out at about 10606 ms, and the transmitter
|
||||||
|
did not drop until 11549 ms. That is 950 ms of dead carrier per transmission
|
||||||
|
paid on the old timer. The key-down fallback brings it to about 230 ms. N1MM's
|
||||||
|
ending costs nothing, because MMTTY drops the transmitter on the last character
|
||||||
|
itself.
|
||||||
|
|
||||||
|
The reason is that `SetMmttyPTT(1)` does nothing at an engine that has been fed
|
||||||
|
one character at a time and is therefore nearly empty. Probe question 9 sent it
|
||||||
|
with `TxBufLen` at 0 and MMTTY transmitted for another eight seconds; a bridge
|
||||||
|
log of three CQs shows the same on the air, with the transmitter staying up
|
||||||
|
until this program forced it down. Whether it is the empty buffer or the way the
|
||||||
|
text arrives that MMTTY objects to is not settled: probe question 11 sent N1MM's
|
||||||
|
exact sequence and it did not drop either, but that machine has no sound card,
|
||||||
|
so MMTTY never really transmits and its buffer reads 0 whatever it is given.
|
||||||
|
|
||||||
|
If the engine still does not drop the transmitter, the key goes down here: the
|
||||||
|
control's `PTT` property back to false, which took 251 ms in the probe. What it
|
||||||
|
waits for is the engine, not a clock: `StopPatience` is how long the engine may
|
||||||
|
make no progress, not how long the whole wait may take. Measured against the
|
||||||
|
whole wait it cut a CQ off with 21 symbols still in the engine, because a
|
||||||
|
flushed message is seconds of transmission and the engine is entitled to all of
|
||||||
|
it.
|
||||||
|
|
||||||
|
Two things had to be right before the flush worked at all, and both were wrong
|
||||||
|
first. The flush takes the same turn the feeder takes, so a character the feeder
|
||||||
|
had already taken and was still handing over cannot end up behind the rest of
|
||||||
|
the message — it did once, and the CQ went out with its first letter at the end.
|
||||||
|
And what the engine holds is counted across feeders rather than reset when one
|
||||||
|
starts: a feeder that started after a flush forgot a whole message the engine
|
||||||
|
was still holding, called the transmission over and put the key down in the
|
||||||
|
middle of it.
|
||||||
|
`TypeAhead.WhenEmptyAsync` waits for `Aired` first, which is two things at
|
||||||
|
once: the engine's own count at 0, and the clock saying the characters the
|
||||||
|
engine held have had time to go out. `StopPatience` caps the wait for an engine
|
||||||
|
that never reports itself empty.
|
||||||
|
|
||||||
|
One character time is held after that and no more, because the count reaching 0
|
||||||
|
is late news rather than early. In a bridge log the count read 0 at 10670 ms and
|
||||||
|
the last character came back decoded at 11026 ms, and the decoder runs about
|
||||||
|
420 ms behind the air, so that character went out at about 10606 ms — before the
|
||||||
|
count read 0. What the character time covers is the reading, which happens every
|
||||||
|
`TypeAhead.PollInterval`, not the transmission. Turnaround time is worth more
|
||||||
|
than padding in a contest.
|
||||||
|
|
||||||
|
MMTTY has no stop character to send instead. Its macro language ends a
|
||||||
|
transmission with `\` at the end of a macro and stops the carrier with `~`, but
|
||||||
|
those are read by the macro interpreter, and the only way into the engine from
|
||||||
|
here is `PostMmttyMessage(4, ...)`, one typed character. Probe question 10 typed
|
||||||
|
both at the end of a message: both were swallowed and the transmitter stayed up.
|
||||||
|
|
||||||
|
Before this the stop was `AbortXmit` a fixed `StopPatience` after the polite
|
||||||
|
stop, which is a race. It was lost once in the log: on one of three CQs the
|
||||||
|
abort fired 4 ms before the count reached 0 and the carriage return at the end
|
||||||
|
of the message never went out.
|
||||||
|
|
||||||
|
Escape is the one that stops now: what has not gone to the engine is dropped and
|
||||||
|
the engine drops what it holds.
|
||||||
|
|
||||||
|
**What paces the feeder.** The clock, at one Baudot symbol every symbol time for
|
||||||
|
the baud rate in the digital settings, with the engine kept `Ahead` characters
|
||||||
|
ahead: one on the air and one in hand for when that one finishes. RTTY runs at a
|
||||||
|
fixed speed, so the clock is right.
|
||||||
|
|
||||||
|
A character is not a symbol. Most are one, but a digit sent while the engine is
|
||||||
|
in the letters shift costs a shift symbol and the digit, and the letter after it
|
||||||
|
costs a shift back: `OM5M` is six symbols, not four. Pacing one character per
|
||||||
|
symbol time ran ahead of the air by about one part in nine — 26 characters of
|
||||||
|
one CQ went out in 29 symbols — which showed up as the red text in the transmit
|
||||||
|
pane running ahead of the transmission, and as the engine's count building up
|
||||||
|
until the brake caught it. `TypeAhead.Symbols` prices a character against the
|
||||||
|
shift the engine is in, and both the feeder and the model of what is on the air
|
||||||
|
use it. They keep separate shift states, because the feeder is `Ahead`
|
||||||
|
characters in front of what is being transmitted.
|
||||||
|
|
||||||
|
**What a character costs cannot be calculated, so it is bounded.** The shift a
|
||||||
|
character needs depends on settings this program cannot see: unshift-on-space is
|
||||||
|
`TXUOS` in MMTTY's `UserPara.ini`, written per profile, and it is also a button
|
||||||
|
on MMTTY's own display that the operator can press during a contest. The FIG
|
||||||
|
button is another. `Symbols` assumes unshift-on-space is on, because MMTTY's
|
||||||
|
help says that is the usual setting and because assuming it charges a symbol too
|
||||||
|
many, which leaves the pane behind the air rather than in front of it.
|
||||||
|
|
||||||
|
The engine's count is what settles it, and it needs no setting to be read. The
|
||||||
|
count is in symbols and falls as they are transmitted, so what it drops between
|
||||||
|
two answers is what went on the air between them. That is the air's own rate,
|
||||||
|
measured rather than reckoned: `WentOutByCount` spends those symbols on the
|
||||||
|
characters at the front of what has not gone out and marks them as gone.
|
||||||
|
|
||||||
|
Two things make it self-correcting. The rate comes from the engine, so a clock
|
||||||
|
that runs a little fast or slow cannot drift. And a count of 0 means the engine
|
||||||
|
holds nothing, so everything it was given has gone out, whatever the symbols
|
||||||
|
added up to along the way — a wrong guess about the shift is squared off at the
|
||||||
|
end of every message rather than accumulating over a contest.
|
||||||
|
|
||||||
|
The one thing to be careful of is that the count trails what the engine was last
|
||||||
|
given by 100 to 150 ms, which the probe measured. A 0 newer than `CountLag` is
|
||||||
|
the answer not having caught up, not an empty engine, and is passed over. An
|
||||||
|
engine that does not count at all falls back to the clock.
|
||||||
|
|
||||||
|
Not the decoder. MMTTY hears its own transmission with sound loopback on and
|
||||||
|
reports it back, which would be the air itself, but that is the receive path: it
|
||||||
|
is off at many stations, and with a receiver actually listening another
|
||||||
|
station's `CQ` matches ours by chance. The transmit buffer count is the
|
||||||
|
transmitter's own report and has neither problem.
|
||||||
|
|
||||||
|
`MmttyEngine` has to declare `EngineBuffer` for any of the count to be used.
|
||||||
|
Without it the sender falls back to the plain path: whole `SendString` calls
|
||||||
|
instead of `PostMmttyMessage(4, ...)` one character at a time, and no count at
|
||||||
|
all. It ran that way for a while, and a bridge log of a CQ shows what it costs.
|
||||||
|
The feeder put 26 characters out at 165 ms each; MMTTY put them on the air at
|
||||||
|
183 ms each, because the digits in the callsign cost a shift to figures and the
|
||||||
|
letter after each one a shift back. By the end of the message the engine was
|
||||||
|
1.5 s behind, and `{RX}` was answered by the hard stop rather than by MMTTY
|
||||||
|
running dry. The brake is what holds that gap down.
|
||||||
|
|
||||||
|
The clock advances by exactly one symbol time per symbol handed over,
|
||||||
|
never from the time the poll happened. A poll is up to 50 ms late, and starting
|
||||||
|
the next character from there made every character a little late, the lateness
|
||||||
|
added up, and the engine ran dry and transmitted the idle tone between the
|
||||||
|
characters of a long word.
|
||||||
|
|
||||||
|
The engine's own count cannot be the pace, which the probe log shows twice over.
|
||||||
|
`TxBufLen` read 0 for the first 150 ms after twenty-one characters were pushed
|
||||||
|
and were already going out, and it read 0 for three seconds while the engine sat
|
||||||
|
holding `ABCD` on Word out. The count is only to be believed while it is large
|
||||||
|
and going down, and the feeder keeps the engine nearly empty on purpose — that is
|
||||||
|
what leaves the message editable — so it lives in the range where the count
|
||||||
|
reads 0. A feeder that took that 0 as room fed on it every poll and handed the
|
||||||
|
engine about three characters for every one on the air, which is what put the
|
||||||
|
red text ahead of the transmission.
|
||||||
|
|
||||||
|
So the count is a brake. `Slack` is how many symbols the engine may be behind
|
||||||
|
before the feeder waits for it: MMTTY answers in symbols, which is more than the
|
||||||
|
characters it was given, so an exchange full of digits is slower than the clock
|
||||||
|
thinks and this is what stops the clock running away on it. A count that is not
|
||||||
|
going down means the engine is holding what it has, and the brake lets go after
|
||||||
|
`HoldingPatience` so the space that releases a held word can get there.
|
||||||
|
|
||||||
|
What the engine probe established, with MMTTY 1.70 under Wine on a machine with
|
||||||
|
a sound card:
|
||||||
|
|
||||||
|
The answer being in symbols is why `Slack` is in symbols and the feeder keeps no
|
||||||
|
character count of its own to compare against it.
|
||||||
|
|
||||||
|
The report is worth reading for the receive side 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, which is how this station runs it, the decoded text
|
||||||
|
arrives about 420 ms after the character goes out and matches what was sent.
|
||||||
|
|
||||||
|
**The protocol log.** Every line in and out of the bridge is written to
|
||||||
|
`Diagnostics/digital-<when>.log` under the settings folder, one file per run of
|
||||||
|
the engine, with a millisecond stamp on each. The buffer question is left out
|
||||||
|
and its answer written only when the count changes, so what is left is the
|
||||||
|
keying, the characters and the transmit reports. It is what says who dropped the
|
||||||
|
transmitter and when, which nothing else in the program can answer: the engine
|
||||||
|
probe cannot, because it runs the engine rather than the window.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./build.sh run --project tools/Nonemm.EngineProbe -- \
|
||||||
|
--engine ~/mmtty/MMTTY.EXE --prefix ~/.wine-nonemm --out engine-probe.log
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**The transmitter dropping, and what ends a message.** `DigitalEngineSender`
|
||||||
|
holds three states — `Down`, `Keyed`, `Ending` — and its class comment carries
|
||||||
|
the transition table. The state is there rather than a flag because ending a
|
||||||
|
message takes as long as the engine takes to transmit what it holds, which is
|
||||||
|
seconds, and the operator can key again inside that time. With a flag, the
|
||||||
|
ending of one message went on running and put the key down in the middle of the
|
||||||
|
message after it. Each ending now belongs to one transmission and checks after
|
||||||
|
every step that it is still that transmission.
|
||||||
|
|
||||||
|
Coming back out of `Ending` costs something too. By then the stop is inside the
|
||||||
|
engine, waiting for its buffer to empty, and MMTTY fed while that stands stayed
|
||||||
|
keyed and transmitted nothing — the characters went in and never came out, and
|
||||||
|
the pane coloured them because `TxBufLen` answered 0, which is what an engine
|
||||||
|
that has transmitted everything also answers. So a macro pressed while the last
|
||||||
|
one is ending waits for what the engine holds to go out, clears the stop with
|
||||||
|
`AbortXmit`, and keys again. The wait is nothing at all in the case that shows
|
||||||
|
it: the last message has finished playing, which is why the operator pressed the
|
||||||
|
next key.
|
||||||
|
|
||||||
|
A message ends when `{RX}` or an abort has told the engine to stop and the
|
||||||
|
engine then reports the transmitter down. A report nobody asked for is
|
||||||
|
ignored. MMTTY reports the
|
||||||
|
transmitter down at other times — the buffer hands it one character at a time
|
||||||
|
and keeps it nearly empty — and taking that as the end cleared the pane and shut
|
||||||
|
the gate two or three characters into a message, leaving the rest of it to go
|
||||||
|
out on the next function key.
|
||||||
|
|
||||||
|
That report is not the only thing that starts the pane again, because it does
|
||||||
|
not always come. Two macros pressed one after the other keep the transmitter up
|
||||||
|
from the first to the last character, so the engine never reports a drop between
|
||||||
|
them, and the pane kept both messages. Everything the engine has been given is
|
||||||
|
locked — it cannot be taken back — so a run of macros left a pane with nothing
|
||||||
|
in it that could be edited. `DigitalEngineSender.StartAsync` now calls
|
||||||
|
`TypeAhead.Started` whenever it keys a new transmission, which drops the last
|
||||||
|
message off the pane and keeps whatever was typed ahead.
|
||||||
|
|
||||||
|
`TypeAhead.Ended` is called while the state lock is held, for the same reason.
|
||||||
|
The drop arrives from the engine on a thread of its own, and a macro pressed on
|
||||||
|
the last character of a message got as far as handing its own text over in the
|
||||||
|
gap between reading the state and clearing the pane: the new text was wiped off
|
||||||
|
the pane while the engine transmitted it. The order was found by reading the
|
||||||
|
code, not on the air, and the fix has not been tested against a real engine.
|
||||||
|
|
||||||
|
Which of the control's two paths carries the transmit state has not been
|
||||||
|
settled. The bridge takes it from `OnPttEvent`, and N1MM's `DigitalInterface`
|
||||||
|
binds an empty handler there and reads MMTTY's window message 32772 out of
|
||||||
|
`OnTranslateMessage` instead. Both are in the same class, so one of them is a
|
||||||
|
leftover, and `docs/digital-bridge.md` argues 32772 cannot reach
|
||||||
|
`OnTranslateMessage` at all while the control has an event for it. `OnPttEvent`
|
||||||
|
does report drops here, so nothing is missing; it is worth measuring only if the
|
||||||
|
reports turn out to be coarser than the message.
|
||||||
|
|
||||||
|
Keying is the keyer's, not the window's: `{TX}` and the TX button both go
|
||||||
|
through `DigitalEngineSender.Transmit`, so the order to key is always the first
|
||||||
|
thing the engine is told. MMTTY keys itself off a character given to it while
|
||||||
|
the transmitter is down and drops again when it has sent it, so a key that
|
||||||
|
arrived behind the text put a keyed-up gap in the middle of the message. A drop
|
||||||
|
in the middle of a message that is still going out also keys the transmitter
|
||||||
|
again, so what is left of the message does not go out into a transmitter that
|
||||||
|
is down.
|
||||||
|
|
||||||
|
The idle tone between what the operator types is MMTTY's own diddle, the Diddle
|
||||||
|
setting on its TX tab: LTR, the standard one, is what `mmtty/UserPara.ini`
|
||||||
|
carries here. Nothing is fed to produce it, so it needs the transmitter to stay
|
||||||
|
keyed. Whether MMTTY holds it there with an empty buffer has not been measured:
|
||||||
|
the probe run that says it does was on a machine with no sound card, where
|
||||||
|
MMTTY does not really transmit.
|
||||||
|
|
||||||
|
**The link between the logging computers.** N1MM has two networks and this
|
||||||
|
program now has both. Port 12060 carries XML to other programs — a spot tool, a
|
||||||
|
score poster — which is `StationNetwork`. Computer to computer, N1MM uses port
|
||||||
|
12070 and a different protocol: `StationLink`.
|
||||||
|
|
||||||
|
That protocol is a UDP broadcast to find the other computers and a TCP
|
||||||
|
connection to each of them, opened both ways. A message reads
|
||||||
|
|
||||||
|
```
|
||||||
|
DATA__07%SHACK-PC%QSO%2026-09-03 12:34:56%DL1ABC%…~__DATA
|
||||||
|
```
|
||||||
|
|
||||||
|
which is the sending station's number, its computer name, the message type and
|
||||||
|
the fields of that type. `%` and `~` cannot appear in a field, so N1MM writes
|
||||||
|
`!` in their place. What is read and sent: contacts, edits, deletes, resyncs,
|
||||||
|
`IAM`, the echo pair, chat, the pass frequency, transmit on and off, and where a
|
||||||
|
station is. The rest of N1MM's forty-odd types — the score and sked windows, the
|
||||||
|
log check, the spot lists, the serial-number pool — are passed over, which
|
||||||
|
N1MM's own reader also does for a type it does not know.
|
||||||
|
|
||||||
|
**The version has to match.** N1MM compares the version in the beacon with its
|
||||||
|
own and, when they differ, puts up "Software versions must match. Update N1MM+."
|
||||||
|
and drops the station. So the version this program broadcasts is a setting —
|
||||||
|
Config ▸ Edit Networked-Computer Names — and it has to be the version of the
|
||||||
|
N1MM copies beside it. Help ▸ About in N1MM says which. A station that
|
||||||
|
broadcasts something else is kept in the list and the network status window
|
||||||
|
names it, because that is the one fault where everything looks connected and
|
||||||
|
nothing arrives.
|
||||||
|
|
||||||
|
Where a station is goes out once a second when it has changed, rather than from
|
||||||
|
each of the dozen places it can change from. In a multi-single entry that is
|
||||||
|
what the other operators watch.
|
||||||
|
|
||||||
|
**The ten-minute rule.** `BandChangeRules` counts band changes and the stay on a
|
||||||
|
band, and the entry window shows the countdown. What was missing was the rule
|
||||||
|
itself: only a user-defined contest carried one, so every built-in contest
|
||||||
|
allowed anything. `BandChangeRules.ForCategory` now gives a multi-operator entry
|
||||||
|
with one or two transmitters a ten-minute stay, which is N1MM's fallback for
|
||||||
|
every contest that does not name a number of its own, and a contest that caps
|
||||||
|
the changes per hour overrides `Contest.BandChangesFor`. N1MM's per-contest table
|
||||||
|
is not repeated: it is a few hundred cases in a decompiled hash switch, and the
|
||||||
|
contests this station enters are the ones worth reading out of it one at a time.
|
||||||
|
|
||||||
|
What the link does not do yet: N1MM's resync, where a station that has been
|
||||||
|
away asks another for the contacts it missed, and the log check that compares
|
||||||
|
two stations' logs row by row. Both are message types this program can already
|
||||||
|
frame; neither is written. There is also no guard against two stations taking
|
||||||
|
the same station number.
|
||||||
|
|
||||||
**Voice keying.** `MessageSender` was written to cover a voice keyer playing a
|
**Voice keying.** `MessageSender` was written to cover a voice keyer playing a
|
||||||
recording, and nothing implements it. Each operator's recordings folder is
|
recording, and nothing implements it. Each operator's recordings folder is
|
||||||
@@ -68,13 +405,29 @@ of an SO2R station cannot call CQ by voice. Alternating CQ therefore works on CW
|
|||||||
only, though nothing in it is CW-specific: a voice keyer that reports when the
|
only, though nothing in it is CW-specific: a voice keyer that reports when the
|
||||||
recording has finished would drive it as it stands.
|
recording has finished would drive it as it stands.
|
||||||
|
|
||||||
**The QTC window transmits on CW only.** The header, the lines, the QRV, the TU
|
**The QTC window sends on CW and RTTY, not on SSB.** On CW the header, the
|
||||||
and the again messages go out through the keyer, with N1MM's messages and
|
lines, the QRV, the TU and the again messages go out through the keyer, with
|
||||||
N1MM's defaults. Nothing goes out on SSB or RTTY: N1MM plays four recordings on
|
N1MM's messages and N1MM's defaults.
|
||||||
SSB — QRV, Agn, Cfm and TU — and sends RTTY from its digital window, and this
|
|
||||||
program has neither a voice keyer nor a digital window. N1MM's Send All, which
|
On RTTY the station reading traffic out has Send All, which is N1MM's: the whole
|
||||||
keys a whole series at once, belongs to that RTTY window and is not here
|
series as one message, the heading, then every line with the operator's spacing
|
||||||
either.
|
between them, then the ending, and `{TX}` and `{RX}` around the lot so it keys
|
||||||
|
the transmitter and drops it again. A line is the three fields joined with
|
||||||
|
hyphens, which is how N1MM writes them and it offers no setting for it. Snd n
|
||||||
|
sends one line again for a station that missed it. The heading, the ending and
|
||||||
|
the spacing are settings, N1MM's `WAESendAllHeadingText`,
|
||||||
|
`WAESendAllEndingText` and `WAESQTCSpacing`, with its defaults; `{ENTERLF}` in
|
||||||
|
them is new to the expander and stands for a carriage return and a line feed.
|
||||||
|
|
||||||
|
A whole series is the best part of a minute of transmission at 45 baud, which is
|
||||||
|
why it is one message rather than a button per line: the type-ahead buffer holds
|
||||||
|
it and paces the engine, and the operator presses one button. This has been
|
||||||
|
tested as text — what the message comes out as — and not on the air.
|
||||||
|
|
||||||
|
What is not here: N1MM's four SSB recordings — QRV, Agn, Cfm and TU — which need
|
||||||
|
a voice keyer, and its RTTY messages for the station taking traffic down, which
|
||||||
|
are `WAERXReadyText`, `WAEAllAgnText`, `WAEAGNText` and `WAESaveQTCText`. Those
|
||||||
|
four are typed into the digital window's transmit pane here.
|
||||||
|
|
||||||
**Cut numbers are not sent.** N1MM can key a serial number as letters — `N` for
|
**Cut numbers are not sent.** N1MM can key a serial number as letters — `N` for
|
||||||
9, `T` for 0 — and offers several styles. Nothing here does, in a QTC line or in
|
9, `T` for 0 — and offers several styles. Nothing here does, in a QTC line or in
|
||||||
@@ -124,9 +477,11 @@ ones the station's log needs — and both of N1MM's names open a log with it. A
|
|||||||
sprint log would be scored by the wrong rules, and a log started here writes
|
sprint log would be scored by the wrong rules, and a log started here writes
|
||||||
`BARTGRTTYS` into the contest name, which N1MM would read as the sprint.
|
`BARTGRTTYS` into the contest name, which N1MM would read as the sprint.
|
||||||
|
|
||||||
**Digital modes.** A contact can be logged as RTTY or another digital mode, and
|
**Digital interfaces other than MMTTY.** A contact can be logged as RTTY or
|
||||||
the contest rules score it, but there is no digital window: no decoding, no
|
another digital mode and the contest rules score it, and the digital window
|
||||||
transmitting, no interface to fldigi, MMTTY or similar.
|
decodes and transmits through MMTTY over the Wine bridge. What is not here:
|
||||||
|
fldigi, MMVARI, 2Tone and the hardware TNCs, all of which N1MM drives.
|
||||||
|
`DigitalEngine` is the place to add them.
|
||||||
|
|
||||||
**Contest coverage.** Twenty-five families are built in, plus whatever `.udc` files
|
**Contest coverage.** Twenty-five families are built in, plus whatever `.udc` files
|
||||||
are in the user-defined folder. N1MM ships well over a hundred. Opening a log
|
are in the user-defined folder. N1MM ships well over a hundred. Opening a log
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ public sealed class AppSession : IDisposable
|
|||||||
private int activeRadio;
|
private int activeRadio;
|
||||||
private ClusterClient? cluster;
|
private ClusterClient? cluster;
|
||||||
private StationNetwork? network;
|
private StationNetwork? network;
|
||||||
|
private StationLink? link;
|
||||||
|
|
||||||
|
/// What was last said to the other stations about where this one is, so it
|
||||||
|
/// is said again only when it changes.
|
||||||
|
private (Frequency Where, string Mode, bool Running, int Radio) announced;
|
||||||
|
private DispatcherTimer? announcing;
|
||||||
private MessageSender? keyer;
|
private MessageSender? keyer;
|
||||||
private AlternatingCq? alternating;
|
private AlternatingCq? alternating;
|
||||||
private MmttyEngine? digital;
|
private MmttyEngine? digital;
|
||||||
@@ -124,6 +130,10 @@ public sealed class AppSession : IDisposable
|
|||||||
|
|
||||||
public StationNetwork? Network => network;
|
public StationNetwork? Network => network;
|
||||||
|
|
||||||
|
/// The link to the other logging computers, or null when the operator has
|
||||||
|
/// not turned it on. The network status window reads it.
|
||||||
|
public StationLink? Link => link;
|
||||||
|
|
||||||
public MessageSender? Keyer => keyer;
|
public MessageSender? Keyer => keyer;
|
||||||
|
|
||||||
/// The digital modem, started by the digital window rather than at startup:
|
/// The digital modem, started by the digital window rather than at startup:
|
||||||
@@ -221,6 +231,12 @@ public sealed class AppSession : IDisposable
|
|||||||
Logging.Edited += (_, change) => _ = network?.SendEditAsync(
|
Logging.Edited += (_, change) => _ = network?.SendEditAsync(
|
||||||
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
|
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
|
||||||
Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(qso, Settings.Station.Callsign);
|
Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(qso, Settings.Station.Callsign);
|
||||||
|
// and the same three to the other logging computers, which is a
|
||||||
|
// different protocol on a different port
|
||||||
|
Logging.Logged += (_, qso) => _ = link?.SendLoggedAsync(qso);
|
||||||
|
Logging.Edited += (_, change) =>
|
||||||
|
_ = link?.SendEditedAsync(change.Qso, change.OldCall, change.OldTimestampUtc);
|
||||||
|
Logging.Deleted += (_, qso) => _ = link?.SendDeletedAsync(qso);
|
||||||
Check = new CheckWindowSources(positions[0], Calls, Bandmap);
|
Check = new CheckWindowSources(positions[0], Calls, Bandmap);
|
||||||
Available = new AvailableStations(positions[0], Bandmap);
|
Available = new AvailableStations(positions[0], Bandmap);
|
||||||
Save(Settings with { ContestNumber = contestNumber });
|
Save(Settings with { ContestNumber = contestNumber });
|
||||||
@@ -234,7 +250,9 @@ public sealed class AppSession : IDisposable
|
|||||||
/// multi-operator entry, following what the settings now say.
|
/// multi-operator entry, following what the settings now say.
|
||||||
public void ApplyNetworkSettings()
|
public void ApplyNetworkSettings()
|
||||||
{
|
{
|
||||||
|
announcing?.Stop();
|
||||||
network?.Dispose();
|
network?.Dispose();
|
||||||
|
link?.Dispose();
|
||||||
network = null;
|
network = null;
|
||||||
if (!Settings.NetworkEnabled)
|
if (!Settings.NetworkEnabled)
|
||||||
{
|
{
|
||||||
@@ -253,6 +271,87 @@ public sealed class AppSession : IDisposable
|
|||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starts, restarts or stops the link to the other logging computers. It is
|
||||||
|
/// separate from `ApplyNetworkSettings` because the two are separate
|
||||||
|
/// networks: one carries XML to other programs, the other carries contacts
|
||||||
|
/// to the other computers of this entry.
|
||||||
|
public void ApplyStationLinkSettings()
|
||||||
|
{
|
||||||
|
link?.Dispose();
|
||||||
|
link = null;
|
||||||
|
if (!Settings.StationLinkEnabled)
|
||||||
|
{
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
link = new StationLink(
|
||||||
|
Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName,
|
||||||
|
Settings.StationLinkVersion,
|
||||||
|
Settings.StationNumber,
|
||||||
|
Settings.StationLinkPort)
|
||||||
|
{
|
||||||
|
Operator = Settings.Station.Callsign,
|
||||||
|
};
|
||||||
|
// the socket thread must not touch the log: every other change to it is
|
||||||
|
// made where the windows read it
|
||||||
|
link.UpdateArrived += (_, update) =>
|
||||||
|
Dispatcher.UIThread.Post(() => TakeFromNetwork(update));
|
||||||
|
foreach (string peer in Settings.StationLinkPeers)
|
||||||
|
{
|
||||||
|
if (Peer(peer) is var (name, address, port))
|
||||||
|
{
|
||||||
|
link.AddStation(name, address, port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
link.Start();
|
||||||
|
// where this station is has a dozen places it can change from — the
|
||||||
|
// radio moving, a band button, a QSY typed into the callsign box, run
|
||||||
|
// turning on — so it is read once a second and sent when it has
|
||||||
|
// changed, rather than announced from each of them
|
||||||
|
announcing?.Stop();
|
||||||
|
announced = default;
|
||||||
|
announcing = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||||||
|
announcing.Tick += (_, _) => AnnounceWhereIAm();
|
||||||
|
announcing.Start();
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells the other stations where this one is, if it has moved. In a
|
||||||
|
/// multi-single entry this is what the other operators watch: two stations
|
||||||
|
/// on one band is a contact nobody can make.
|
||||||
|
private void AnnounceWhereIAm()
|
||||||
|
{
|
||||||
|
if (link is null || Position is not { } position)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(Frequency, string, bool, int) now =
|
||||||
|
(position.Frequency, position.Mode.Name, position.IsRunning, position.RadioNumber);
|
||||||
|
if (now == announced)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
announced = now;
|
||||||
|
_ = link.SendBandAsync(position.Frequency, position.Mode, position.IsRunning, position.RadioNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A station named by hand, written `RUN-PC@192.168.1.5` with `:port` on
|
||||||
|
/// the end when that station is not on the usual one. Null for anything
|
||||||
|
/// else, because a line the operator has half typed is not an address.
|
||||||
|
private (string Name, string Address, int Port)? Peer(string text)
|
||||||
|
{
|
||||||
|
string[] parts = text.Split('@');
|
||||||
|
if (parts.Length != 2 || parts[0].Trim().Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
string[] host = parts[1].Split(':');
|
||||||
|
return (
|
||||||
|
parts[0].Trim(),
|
||||||
|
host[0].Trim(),
|
||||||
|
host.Length > 1 && int.TryParse(host[1], out int given) ? given : Settings.StationLinkPort);
|
||||||
|
}
|
||||||
|
|
||||||
/// What another station did to its log, applied to ours. Nothing goes back
|
/// What another station did to its log, applied to ours. Nothing goes back
|
||||||
/// out to the network, and the contact is scored here from the rules
|
/// out to the network, and the contact is scored here from the rules
|
||||||
/// instead of trusting what the sender put in the message.
|
/// instead of trusting what the sender put in the message.
|
||||||
@@ -329,7 +428,8 @@ public sealed class AppSession : IDisposable
|
|||||||
WineBridgeChannel channel = new(
|
WineBridgeChannel channel = new(
|
||||||
Settings.DigitalBridgePath,
|
Settings.DigitalBridgePath,
|
||||||
Settings.DigitalWinePrefix.Trim().Length > 0 ? Settings.DigitalWinePrefix : null,
|
Settings.DigitalWinePrefix.Trim().Length > 0 ? Settings.DigitalWinePrefix : null,
|
||||||
Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine");
|
Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine",
|
||||||
|
Paths.Diagnostics);
|
||||||
MmttyEngine started = new(channel, options);
|
MmttyEngine started = new(channel, options);
|
||||||
await started.StartAsync();
|
await started.StartAsync();
|
||||||
digital = started;
|
digital = started;
|
||||||
@@ -594,7 +694,9 @@ public sealed class AppSession : IDisposable
|
|||||||
spotFlush.Dispose();
|
spotFlush.Dispose();
|
||||||
DisposeRadios();
|
DisposeRadios();
|
||||||
cluster?.Dispose();
|
cluster?.Dispose();
|
||||||
|
announcing?.Stop();
|
||||||
network?.Dispose();
|
network?.Dispose();
|
||||||
|
link?.Dispose();
|
||||||
alternating?.Dispose();
|
alternating?.Dispose();
|
||||||
keyer?.Dispose();
|
keyer?.Dispose();
|
||||||
box?.Dispose();
|
box?.Dispose();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Nonemm.Core;
|
using Nonemm.Core;
|
||||||
|
using Nonemm.Network;
|
||||||
using Nonemm.Session;
|
using Nonemm.Session;
|
||||||
|
|
||||||
namespace Nonemm.App.Configuration;
|
namespace Nonemm.App.Configuration;
|
||||||
@@ -118,6 +119,29 @@ public sealed record Settings
|
|||||||
|
|
||||||
public IReadOnlyList<string> NetworkPeers { get; init; } = [];
|
public IReadOnlyList<string> NetworkPeers { get; init; } = [];
|
||||||
|
|
||||||
|
/// The link to the other logging computers of a multi-operator entry, on
|
||||||
|
/// N1MM's port 12070. It is not the same thing as `NetworkEnabled`, which
|
||||||
|
/// is the XML broadcast on 12060 that other programs read; a station can
|
||||||
|
/// want either, or both.
|
||||||
|
public bool StationLinkEnabled { get; init; }
|
||||||
|
|
||||||
|
public int StationLinkPort { get; init; } = StationBeacon.DefaultPort;
|
||||||
|
|
||||||
|
/// Which station of the entry this computer is. It goes into every message
|
||||||
|
/// and lets a contact say which position made it.
|
||||||
|
public int StationNumber { get; init; } = 1;
|
||||||
|
|
||||||
|
/// The version this program claims to be on the network. N1MM refuses a
|
||||||
|
/// station whose version is not its own, so to work beside N1MM this has to
|
||||||
|
/// be the version those copies are running. The default is the version of
|
||||||
|
/// the N1MM this program was written against.
|
||||||
|
public string StationLinkVersion { get; init; } = "1.0.11364";
|
||||||
|
|
||||||
|
/// Stations named by hand, for a network where a broadcast does not reach
|
||||||
|
/// every computer. Each is a name and an address — `RUN-PC@192.168.1.5`,
|
||||||
|
/// with `:port` on the end when that station is not on the usual port.
|
||||||
|
public IReadOnlyList<string> StationLinkPeers { get; init; } = [];
|
||||||
|
|
||||||
/// The call history file for this contest, or empty for none. They are
|
/// The call history file for this contest, or empty for none. They are
|
||||||
/// published per contest, so this is not a fixed name.
|
/// published per contest, so this is not a fixed name.
|
||||||
public string CallHistoryFile { get; init; } = "";
|
public string CallHistoryFile { get; init; } = "";
|
||||||
@@ -169,6 +193,16 @@ public sealed record Settings
|
|||||||
|
|
||||||
public string QtcCwTu { get; init; } = "";
|
public string QtcCwTu { get; init; } = "";
|
||||||
|
|
||||||
|
/// The RTTY messages the QTC window sends, with N1MM's defaults. The
|
||||||
|
/// spacing goes between the lines of a series rather than between the
|
||||||
|
/// fields of one, and all three are macro templates: `{ENTER}` for a
|
||||||
|
/// carriage return, `{QTC}` for the header of the series.
|
||||||
|
public string QtcRttySpacing { get; init; } = QtcMessages.DefaultRttySpacing;
|
||||||
|
|
||||||
|
public string QtcRttySendAllHeading { get; init; } = QtcMessages.DefaultSendAllHeading;
|
||||||
|
|
||||||
|
public string QtcRttySendAllEnding { get; init; } = QtcMessages.DefaultSendAllEnding;
|
||||||
|
|
||||||
/// Sub-band boundaries the operator has changed. Empty means the defaults
|
/// Sub-band boundaries the operator has changed. Empty means the defaults
|
||||||
/// in `BandPlan.Default`; an entry replaces one band's boundaries.
|
/// in `BandPlan.Default`; an entry replaces one band's boundaries.
|
||||||
public IReadOnlyList<StoredSubBand> SubBands { get; init; } = [];
|
public IReadOnlyList<StoredSubBand> SubBands { get; init; } = [];
|
||||||
|
|||||||
40
src/Nonemm.App/Controls/TransmitPane.axaml
Normal file
40
src/Nonemm.App/Controls/TransmitPane.axaml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:controls="using:Nonemm.App.Controls">
|
||||||
|
<Style Selector="controls|TransmitPane">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource FieldBackground}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource FieldForeground}" />
|
||||||
|
<Setter Property="CaretBrush" Value="{DynamicResource FieldForeground}" />
|
||||||
|
<Setter Property="SelectionBrush" Value="#3399FF" />
|
||||||
|
<Setter Property="SelectionForegroundBrush" Value="#FFFFFF" />
|
||||||
|
<Setter Property="Padding" Value="3,2" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<!-- a TextBox subclass gets no theme of its own, so the pane carries
|
||||||
|
its own template. It is the Fluent one cut down to what the pane
|
||||||
|
uses: no watermark, no clear button, no border of its own -->
|
||||||
|
<ControlTemplate>
|
||||||
|
<ScrollViewer Name="PART_ScrollViewer"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
Padding="{TemplateBinding Padding}"
|
||||||
|
HorizontalScrollBarVisibility="Disabled"
|
||||||
|
VerticalScrollBarVisibility="Auto">
|
||||||
|
<controls:TransmitPresenter Name="PART_TextPresenter"
|
||||||
|
Text="{TemplateBinding Text}"
|
||||||
|
CaretIndex="{TemplateBinding CaretIndex}"
|
||||||
|
SelectionStart="{TemplateBinding SelectionStart}"
|
||||||
|
SelectionEnd="{TemplateBinding SelectionEnd}"
|
||||||
|
SelectionBrush="{TemplateBinding SelectionBrush}"
|
||||||
|
SelectionForegroundBrush="{TemplateBinding SelectionForegroundBrush}"
|
||||||
|
CaretBrush="{TemplateBinding CaretBrush}"
|
||||||
|
TextAlignment="{TemplateBinding TextAlignment}"
|
||||||
|
TextWrapping="{TemplateBinding TextWrapping}"
|
||||||
|
LineHeight="{TemplateBinding LineHeight}"
|
||||||
|
LetterSpacing="{TemplateBinding LetterSpacing}"
|
||||||
|
SentLength="{TemplateBinding SentLength}"
|
||||||
|
SentBrush="{TemplateBinding SentBrush}"
|
||||||
|
VerticalAlignment="Top" />
|
||||||
|
</ScrollViewer>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
37
src/Nonemm.App/Controls/TransmitPane.cs
Normal file
37
src/Nonemm.App/Controls/TransmitPane.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace Nonemm.App.Controls;
|
||||||
|
|
||||||
|
/// The digital transmit pane: one editable box in which the first `SentLength`
|
||||||
|
/// characters — what has already gone to the engine — are drawn in `SentBrush`.
|
||||||
|
///
|
||||||
|
/// Avalonia's TextBox draws all of its text in one brush, so this was a label
|
||||||
|
/// beside a box before. That took width from the box as the label grew and the
|
||||||
|
/// label did not wrap. A TextBox builds its text through a TextPresenter, and
|
||||||
|
/// the TextLayout under it does take a brush per run, so the pane is a TextBox
|
||||||
|
/// with `TransmitPresenter` in place of the plain presenter. The template in
|
||||||
|
/// `DigitalWindow.axaml` is what puts it there.
|
||||||
|
public class TransmitPane : TextBox
|
||||||
|
{
|
||||||
|
public static readonly StyledProperty<int> SentLengthProperty =
|
||||||
|
AvaloniaProperty.Register<TransmitPane, int>(nameof(SentLength));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<IBrush?> SentBrushProperty =
|
||||||
|
AvaloniaProperty.Register<TransmitPane, IBrush?>(nameof(SentBrush));
|
||||||
|
|
||||||
|
/// How many characters at the front of the text have gone out.
|
||||||
|
public int SentLength
|
||||||
|
{
|
||||||
|
get => GetValue(SentLengthProperty);
|
||||||
|
set => SetValue(SentLengthProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What those characters are drawn in.
|
||||||
|
public IBrush? SentBrush
|
||||||
|
{
|
||||||
|
get => GetValue(SentBrushProperty);
|
||||||
|
set => SetValue(SentBrushProperty, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/Nonemm.App/Controls/TransmitPresenter.cs
Normal file
133
src/Nonemm.App/Controls/TransmitPresenter.cs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls.Presenters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.TextFormatting;
|
||||||
|
using Avalonia.Utilities;
|
||||||
|
|
||||||
|
namespace Nonemm.App.Controls;
|
||||||
|
|
||||||
|
/// The presenter behind `TransmitPane`: the same text layout Avalonia builds
|
||||||
|
/// for a TextBox, with the first `SentLength` characters given `SentBrush`.
|
||||||
|
///
|
||||||
|
/// TextPresenter builds its layout in `CreateTextLayout` and already passes
|
||||||
|
/// per-run overrides for the selection, so this adds one more run to that list.
|
||||||
|
/// The selection has to keep its own colour on top, which is why the coloured
|
||||||
|
/// run is cut around it rather than laid over it: overlapping runs are not
|
||||||
|
/// defined.
|
||||||
|
public class TransmitPresenter : TextPresenter
|
||||||
|
{
|
||||||
|
public static readonly StyledProperty<int> SentLengthProperty =
|
||||||
|
AvaloniaProperty.Register<TransmitPresenter, int>(nameof(SentLength));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<IBrush?> SentBrushProperty =
|
||||||
|
AvaloniaProperty.Register<TransmitPresenter, IBrush?>(nameof(SentBrush));
|
||||||
|
|
||||||
|
/// The width the layout is built to. `TextPresenter` keeps its own copy and
|
||||||
|
/// does not hand it out, so it is read off the measure pass here.
|
||||||
|
private Size constraint;
|
||||||
|
|
||||||
|
public int SentLength
|
||||||
|
{
|
||||||
|
get => GetValue(SentLengthProperty);
|
||||||
|
set => SetValue(SentLengthProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IBrush? SentBrush
|
||||||
|
{
|
||||||
|
get => GetValue(SentBrushProperty);
|
||||||
|
set => SetValue(SentBrushProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Size MeasureOverride(Size availableSize)
|
||||||
|
{
|
||||||
|
constraint = availableSize;
|
||||||
|
return base.MeasureOverride(availableSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||||
|
{
|
||||||
|
base.OnPropertyChanged(change);
|
||||||
|
if (change.Property == SentLengthProperty || change.Property == SentBrushProperty)
|
||||||
|
{
|
||||||
|
InvalidateTextLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override TextLayout CreateTextLayout()
|
||||||
|
{
|
||||||
|
string text = Text ?? "";
|
||||||
|
int sent = Math.Clamp(SentLength, 0, text.Length);
|
||||||
|
// nothing to colour, or a case the base class handles on its own: the
|
||||||
|
// password character replaces the text, and a preedit run is the input
|
||||||
|
// method's, not ours
|
||||||
|
if (sent == 0 || SentBrush is null || PasswordChar != '\0'
|
||||||
|
|| !string.IsNullOrEmpty(PreeditText))
|
||||||
|
{
|
||||||
|
return base.CreateTextLayout();
|
||||||
|
}
|
||||||
|
Typeface typeface = new(FontFamily, FontStyle, FontWeight, FontStretch);
|
||||||
|
// a zero constraint is a measure with no bound, which is infinity to
|
||||||
|
// the layout
|
||||||
|
double width = constraint.Width > 0 ? constraint.Width : double.PositiveInfinity;
|
||||||
|
double height = constraint.Height > 0 ? constraint.Height : double.PositiveInfinity;
|
||||||
|
return new TextLayout(
|
||||||
|
text,
|
||||||
|
typeface,
|
||||||
|
FontSize,
|
||||||
|
Foreground,
|
||||||
|
TextAlignment,
|
||||||
|
TextWrapping,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
FlowDirection,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
LineHeight,
|
||||||
|
LetterSpacing,
|
||||||
|
0,
|
||||||
|
FontFeatures,
|
||||||
|
Runs(typeface, sent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The coloured runs, in order and not overlapping. The selection, when
|
||||||
|
/// there is one with a colour of its own, cuts the coloured run in two.
|
||||||
|
private List<ValueSpan<TextRunProperties>> Runs(Typeface typeface, int sent)
|
||||||
|
{
|
||||||
|
int from = Math.Min(SelectionStart, SelectionEnd);
|
||||||
|
int to = Math.Max(SelectionStart, SelectionEnd);
|
||||||
|
bool selected = ShowSelectionHighlight && to > from && SelectionForegroundBrush is not null;
|
||||||
|
List<ValueSpan<TextRunProperties>> runs = [];
|
||||||
|
Add(runs, 0, selected ? Math.Min(sent, from) : sent, SentBrush, typeface);
|
||||||
|
if (selected)
|
||||||
|
{
|
||||||
|
Add(runs, from, to, SelectionForegroundBrush, typeface);
|
||||||
|
Add(runs, to, sent, SentBrush, typeface);
|
||||||
|
}
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Add(
|
||||||
|
List<ValueSpan<TextRunProperties>> runs,
|
||||||
|
int start,
|
||||||
|
int end,
|
||||||
|
IBrush? brush,
|
||||||
|
Typeface typeface)
|
||||||
|
{
|
||||||
|
if (end <= start)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runs.Add(new ValueSpan<TextRunProperties>(
|
||||||
|
start,
|
||||||
|
end - start,
|
||||||
|
new GenericTextRunProperties(
|
||||||
|
typeface,
|
||||||
|
FontSize,
|
||||||
|
null,
|
||||||
|
brush,
|
||||||
|
null,
|
||||||
|
BaselineAlignment.Baseline,
|
||||||
|
null,
|
||||||
|
FontFeatures)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,23 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
<TextBlock Text="Other stations, one address per line" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
<TextBlock Text="Other stations, one address per line" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||||
<TextBox Name="PeersBox" AcceptsReturn="True" Height="90" PlaceholderText="192.168.1.11" />
|
<TextBox Name="PeersBox" AcceptsReturn="True" Height="90" PlaceholderText="192.168.1.11" />
|
||||||
<CheckBox Name="EnabledBox" Content="Share contacts with the other stations" Margin="0,6,0,0" />
|
<CheckBox Name="EnabledBox" Content="Broadcast contacts to other programs (port 12060)" Margin="0,6,0,0" />
|
||||||
|
<TextBlock Text="The other logging computers" FontWeight="Bold" Margin="0,12,0,0" />
|
||||||
|
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||||
|
Text="This is N1MM's own link between the computers of one entry, on port 12070: contacts, edits, deletes and chat, over a connection to each station. Stations are found by broadcast, so nothing has to be listed." />
|
||||||
|
<Grid ColumnDefinitions="90,8,110,8,*" RowDefinitions="Auto,Auto">
|
||||||
|
<TextBlock Text="Station number" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||||
|
<TextBox Name="StationNumberBox" Grid.Row="1" />
|
||||||
|
<TextBlock Grid.Column="2" Text="Port" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||||
|
<TextBox Name="LinkPortBox" Grid.Row="1" Grid.Column="2" />
|
||||||
|
<TextBlock Grid.Column="4" Text="Version to claim" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||||
|
<TextBox Name="VersionBox" Grid.Row="1" Grid.Column="4" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||||
|
Text="N1MM turns away a station whose version is not its own, so this has to be the version the N1MM copies beside it are running. Help ▸ About in N1MM says which." />
|
||||||
|
<TextBlock Text="Stations to reach by address, one per line, as NAME@address" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||||
|
<TextBox Name="LinkPeersBox" AcceptsReturn="True" Height="60" PlaceholderText="RUN-PC@192.168.1.11" />
|
||||||
|
<CheckBox Name="LinkEnabledBox" Content="Share contacts with the other logging computers (port 12070)" Margin="0,6,0,0" />
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
|
||||||
<Button Content="Cancel" Click="OnCancel" />
|
<Button Content="Cancel" Click="OnCancel" />
|
||||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ public sealed partial class NetworkDialog : Window
|
|||||||
PortBox.Text = settings.NetworkPort.ToString();
|
PortBox.Text = settings.NetworkPort.ToString();
|
||||||
PeersBox.Text = string.Join("\n", settings.NetworkPeers);
|
PeersBox.Text = string.Join("\n", settings.NetworkPeers);
|
||||||
EnabledBox.IsChecked = settings.NetworkEnabled;
|
EnabledBox.IsChecked = settings.NetworkEnabled;
|
||||||
|
StationNumberBox.Text = settings.StationNumber.ToString();
|
||||||
|
LinkPortBox.Text = settings.StationLinkPort.ToString();
|
||||||
|
VersionBox.Text = settings.StationLinkVersion;
|
||||||
|
LinkPeersBox.Text = string.Join("\n", settings.StationLinkPeers);
|
||||||
|
LinkEnabledBox.IsChecked = settings.StationLinkEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +36,23 @@ public sealed partial class NetworkDialog : Window
|
|||||||
.Where(l => l.Length > 0)
|
.Where(l => l.Length > 0)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
NetworkEnabled = EnabledBox.IsChecked == true,
|
NetworkEnabled = EnabledBox.IsChecked == true,
|
||||||
|
StationNumber = int.TryParse(StationNumberBox.Text, out int number)
|
||||||
|
? Math.Clamp(number, 1, 99)
|
||||||
|
: settings.StationNumber,
|
||||||
|
StationLinkPort = int.TryParse(LinkPortBox.Text, out int linkPort)
|
||||||
|
? linkPort
|
||||||
|
: settings.StationLinkPort,
|
||||||
|
StationLinkVersion = (VersionBox.Text ?? "").Trim(),
|
||||||
|
StationLinkPeers = Lines(LinkPeersBox.Text),
|
||||||
|
StationLinkEnabled = LinkEnabledBox.IsChecked == true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private static List<string> Lines(string? text) =>
|
||||||
|
(text ?? "")
|
||||||
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(l => l.Trim())
|
||||||
|
.Where(l => l.Length > 0)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,19 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
<TextBlock Text="The spacing goes between the fields of a QTC line, written N1MM's way with S for a space. The three again messages go out on shift and Enter in that box while taking traffic down. TU goes out when the window closes after reading a series out; empty sends nothing."
|
<TextBlock Text="The spacing goes between the fields of a QTC line, written N1MM's way with S for a space. The three again messages go out on shift and Enter in that box while taking traffic down. TU goes out when the window closes after reading a series out; empty sends nothing."
|
||||||
FontSize="11" Opacity="0.7" TextWrapping="Wrap" Margin="0,-2,0,0" />
|
FontSize="11" Opacity="0.7" TextWrapping="Wrap" Margin="0,-2,0,0" />
|
||||||
|
<TextBlock Text="RTTY messages" FontWeight="Bold" Margin="0,10,0,0" />
|
||||||
|
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto">
|
||||||
|
<TextBlock Text="Line spacing" VerticalAlignment="Center" Margin="0,2,8,2" />
|
||||||
|
<TextBox Grid.Column="1" Name="RttySpacingBox" Margin="0,2" />
|
||||||
|
<TextBlock Grid.Row="1" Text="Send All heading" VerticalAlignment="Center" Margin="0,2,8,2" />
|
||||||
|
<TextBox Grid.Row="1" Grid.Column="1" Name="SendAllHeadingBox" Margin="0,2" />
|
||||||
|
<TextBlock Grid.Row="2" Text="Send All ending" VerticalAlignment="Center" Margin="0,2,8,2" />
|
||||||
|
<TextBox Grid.Row="2" Grid.Column="1" Name="SendAllEndingBox" Margin="0,2" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="These go out when the All button reads a whole series out on RTTY. The spacing goes between the lines, not between the fields of one — use {ENTER} or {ENTERLF} for a carriage return. {QTC} stands for the header of the series, and the other function key macros work as well."
|
||||||
|
FontSize="11" Opacity="0.7" TextWrapping="Wrap" Margin="0,-2,0,0" />
|
||||||
<TextBlock Name="MissingText" FontSize="11" Opacity="0.7" TextWrapping="Wrap" Margin="0,6,0,0"
|
<TextBlock Name="MissingText" FontSize="11" Opacity="0.7" TextWrapping="Wrap" Margin="0,6,0,0"
|
||||||
Text="N1MM's SSB recordings and RTTY message templates are left out: sending those needs a voice keyer and a digital window, and this program has neither." />
|
Text="N1MM's SSB recordings are left out: playing those needs a voice keyer, and this program has none. So are its RTTY messages for the station taking traffic down — RX Ready, All Agn, Agn and Save — which are typed into the digital window's transmit pane here." />
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
|
||||||
<Button Content="Cancel" Click="OnCancel" />
|
<Button Content="Cancel" Click="OnCancel" />
|
||||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ public sealed partial class QtcSetupDialog : Window
|
|||||||
CallAgainBox.Text = settings.QtcCwCallAgain;
|
CallAgainBox.Text = settings.QtcCwCallAgain;
|
||||||
NumberAgainBox.Text = settings.QtcCwNumberAgain;
|
NumberAgainBox.Text = settings.QtcCwNumberAgain;
|
||||||
TuBox.Text = settings.QtcCwTu;
|
TuBox.Text = settings.QtcCwTu;
|
||||||
|
RttySpacingBox.Text = settings.QtcRttySpacing;
|
||||||
|
SendAllHeadingBox.Text = settings.QtcRttySendAllHeading;
|
||||||
|
SendAllEndingBox.Text = settings.QtcRttySendAllEnding;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
|
private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
|
||||||
@@ -40,6 +43,9 @@ public sealed partial class QtcSetupDialog : Window
|
|||||||
QtcCwCallAgain = CallAgainBox.Text ?? "",
|
QtcCwCallAgain = CallAgainBox.Text ?? "",
|
||||||
QtcCwNumberAgain = NumberAgainBox.Text ?? "",
|
QtcCwNumberAgain = NumberAgainBox.Text ?? "",
|
||||||
QtcCwTu = TuBox.Text ?? "",
|
QtcCwTu = TuBox.Text ?? "",
|
||||||
|
QtcRttySpacing = RttySpacingBox.Text ?? "",
|
||||||
|
QtcRttySendAllHeading = SendAllHeadingBox.Text ?? "",
|
||||||
|
QtcRttySendAllEnding = SendAllEndingBox.Text ?? "",
|
||||||
});
|
});
|
||||||
|
|
||||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||||
|
|||||||
@@ -127,17 +127,26 @@ public sealed partial class DigitalWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What is typed in the transmit pane goes into the type-ahead buffer,
|
/// What is typed in the transmit pane goes into the type-ahead buffer.
|
||||||
/// which feeds the engine. A carriage return is what the engine takes as a
|
/// Nothing goes on the air until the transmitter is keyed, which is the TX
|
||||||
/// new line, so Enter puts one in rather than the newline the box would.
|
/// button, Ctrl+Enter or Alt+T; from then on what is typed goes out as it
|
||||||
|
/// is typed. Enter is a new line in the message, which the engine takes as
|
||||||
|
/// a carriage return.
|
||||||
|
///
|
||||||
|
/// Escape stops now: what has not gone to the engine is dropped and the
|
||||||
|
/// engine drops what it holds.
|
||||||
private void OnTransmitKeyDown(object? sender, KeyEventArgs e)
|
private void OnTransmitKeyDown(object? sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (e.Key == Key.Enter && e.KeyModifiers == KeyModifiers.Control)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
StartTransmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.Key == Key.Enter)
|
if (e.Key == Key.Enter)
|
||||||
{
|
{
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
int at = Math.Clamp(TransmitBox.CaretIndex, 0, TransmitBox.Text?.Length ?? 0);
|
Type("\r");
|
||||||
TransmitBox.Text = (TransmitBox.Text ?? "").Insert(at, "\r");
|
|
||||||
TransmitBox.CaretIndex = at + 1;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.Key == Key.Escape)
|
if (e.Key == Key.Escape)
|
||||||
@@ -148,37 +157,61 @@ public sealed partial class DigitalWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The operator rewrote what has not gone out yet. Only the box holds it;
|
/// Puts text in at the caret, never before the text that has gone out.
|
||||||
/// what is already in the engine is in the label beside it and cannot be
|
private void Type(string text)
|
||||||
/// reached from here.
|
{
|
||||||
|
string was = TransmitBox.Text ?? "";
|
||||||
|
int at = Math.Clamp(TransmitBox.CaretIndex, locked, was.Length);
|
||||||
|
TransmitBox.Text = was.Insert(at, text);
|
||||||
|
TransmitBox.CaretIndex = at + text.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The operator rewrote the pane. What has already gone to the engine
|
||||||
|
/// cannot be taken back, so an edit that reaches into it is undone; the
|
||||||
|
/// rest goes to the buffer, which sends it if the transmitter is up and
|
||||||
|
/// holds it if it is not.
|
||||||
private void OnTransmitTextChanged(object? sender, TextChangedEventArgs e)
|
private void OnTransmitTextChanged(object? sender, TextChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (showingBuffer || session.DigitalKeyer is not { } keyer)
|
if (showingBuffer || session.DigitalKeyer is not { } keyer)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
keyer.TypeAhead.Rewrite(TransmitBox.Text ?? "", CursorInBox());
|
string now = TransmitBox.Text ?? "";
|
||||||
}
|
if (FirstDifference(pane, now) < locked)
|
||||||
|
|
||||||
/// The cursor holds the pump back: nothing behind it goes out, so the
|
|
||||||
/// engine idles rather than transmitting text the operator is still
|
|
||||||
/// typing. With the box out of focus there is no cursor to hold anything.
|
|
||||||
private void OnTransmitFocus(object? sender, RoutedEventArgs e) => ShowCursor();
|
|
||||||
|
|
||||||
private void ShowCursor()
|
|
||||||
{
|
{
|
||||||
if (session.DigitalKeyer is { } keyer)
|
showingBuffer = true;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
keyer.TypeAhead.Cursor = TransmitBox.IsFocused ? CursorInBox() : TypeAhead.NoCursor;
|
TransmitBox.Text = pane;
|
||||||
|
TransmitBox.CaretIndex = locked;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
showingBuffer = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pane = now;
|
||||||
|
keyer.Buffer.Edit(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
private int CursorInBox() =>
|
/// Where two versions of the pane first differ, which is the length of both
|
||||||
Math.Clamp(TransmitBox.CaretIndex, 0, TransmitBox.Text?.Length ?? 0);
|
/// when one is the other with text added or taken off the end.
|
||||||
|
private static int FirstDifference(string was, string now)
|
||||||
|
{
|
||||||
|
int most = Math.Min(was.Length, now.Length);
|
||||||
|
int at = 0;
|
||||||
|
while (at < most && was[at] == now[at])
|
||||||
|
{
|
||||||
|
at++;
|
||||||
|
}
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
|
||||||
/// Draws the buffer: what has gone out in the label, what is still to go in
|
/// Draws the buffer: what has gone out and what is still to go, as one
|
||||||
/// the box. The pump takes characters off the front, so the cursor moves
|
/// text, with the length of the first half telling the pane how much of it
|
||||||
/// back with them and the operator can go on typing while it does.
|
/// to colour. A character moving from one half to the other leaves the text
|
||||||
|
/// the same, so the caret and what the operator is typing do not move.
|
||||||
private void ShowBuffer()
|
private void ShowBuffer()
|
||||||
{
|
{
|
||||||
if (session.DigitalKeyer is not { } keyer)
|
if (session.DigitalKeyer is not { } keyer)
|
||||||
@@ -188,20 +221,29 @@ public sealed partial class DigitalWindow
|
|||||||
showingBuffer = true;
|
showingBuffer = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
SentText.Text = keyer.TypeAhead.Sent;
|
string sent = keyer.Buffer.Sent;
|
||||||
string pending = keyer.TypeAhead.Pending;
|
string now = sent + keyer.Buffer.Pending;
|
||||||
string was = TransmitBox.Text ?? "";
|
locked = sent.Length;
|
||||||
if (was == pending)
|
// only what the engine has transmitted is coloured. What it is
|
||||||
|
// still holding cannot be taken back either, but the operator has
|
||||||
|
// not heard it go yet, and marking it as gone turned every
|
||||||
|
// character red as it was typed once the transmission caught up
|
||||||
|
TransmitBox.SentLength = keyer.Buffer.OnAir;
|
||||||
|
if ((TransmitBox.Text ?? "") == now)
|
||||||
{
|
{
|
||||||
|
pane = now;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
int taken = was.Length > pending.Length
|
// the end of a message drops what has gone out off the front of
|
||||||
&& was.EndsWith(pending, StringComparison.Ordinal)
|
// the pane, so the caret moves back with the text it is in
|
||||||
? was.Length - pending.Length
|
string was = TransmitBox.Text ?? "";
|
||||||
|
int dropped = was.Length > now.Length && was.EndsWith(now, StringComparison.Ordinal)
|
||||||
|
? was.Length - now.Length
|
||||||
: 0;
|
: 0;
|
||||||
int caret = TransmitBox.CaretIndex;
|
int caret = TransmitBox.CaretIndex - dropped;
|
||||||
TransmitBox.Text = pending;
|
TransmitBox.Text = now;
|
||||||
TransmitBox.CaretIndex = Math.Clamp(caret - taken, 0, pending.Length);
|
TransmitBox.CaretIndex = Math.Clamp(caret, locked, now.Length);
|
||||||
|
pane = now;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -211,7 +253,8 @@ public sealed partial class DigitalWindow
|
|||||||
|
|
||||||
/// N1MM's keys for the digital window: Alt+T turns the transmitter on and
|
/// N1MM's keys for the digital window: Alt+T turns the transmitter on and
|
||||||
/// puts the cursor where what is typed goes out, Ctrl+K does the same, and
|
/// puts the cursor where what is typed goes out, Ctrl+K does the same, and
|
||||||
/// Alt+G takes the next call off the grab list.
|
/// Alt+G takes the next call off the grab list. Escape stops now, wherever
|
||||||
|
/// the focus is.
|
||||||
protected override void OnKeyDown(KeyEventArgs e)
|
protected override void OnKeyDown(KeyEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.KeyModifiers == KeyModifiers.Alt && e.Key == Key.T)
|
if (e.KeyModifiers == KeyModifiers.Alt && e.Key == Key.T)
|
||||||
@@ -235,14 +278,15 @@ public sealed partial class DigitalWindow
|
|||||||
if (e.Key == Key.Escape)
|
if (e.Key == Key.Escape)
|
||||||
{
|
{
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
|
_ = session.DigitalKeyer?.AbortAsync();
|
||||||
_ = Running()?.AbortAsync();
|
_ = Running()?.AbortAsync();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
base.OnKeyDown(e);
|
base.OnKeyDown(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alt+T: on to transmit with the cursor in the transmit pane, off back to
|
/// Alt+T: on to transmit with the cursor in the transmit pane, off to drop
|
||||||
/// receive.
|
/// the transmitter at the end of what is waiting.
|
||||||
private void ToggleTransmit()
|
private void ToggleTransmit()
|
||||||
{
|
{
|
||||||
if (Running() is not { } running)
|
if (Running() is not { } running)
|
||||||
@@ -251,31 +295,56 @@ public sealed partial class DigitalWindow
|
|||||||
}
|
}
|
||||||
if (running.IsTransmitting)
|
if (running.IsTransmitting)
|
||||||
{
|
{
|
||||||
_ = session.DigitalKeyer?.AbortAsync();
|
ReturnToReceive();
|
||||||
_ = running.ReturnToReceiveAsync();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
StartTransmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTransmit(object? sender, RoutedEventArgs e) => StartTransmit();
|
||||||
|
|
||||||
|
/// Keys the transmitter and opens the gate, so what is in the pane goes out
|
||||||
|
/// and so does whatever is typed into it after this. The keyer does the
|
||||||
|
/// keying, so the engine is never fed before it is keyed.
|
||||||
|
private void StartTransmit()
|
||||||
|
{
|
||||||
|
if (Running() is not { } running)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.DigitalKeyer is { } keyer)
|
||||||
|
{
|
||||||
|
keyer.Transmit();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
_ = running.SetPttAsync(true);
|
_ = running.SetPttAsync(true);
|
||||||
|
}
|
||||||
TransmitBox.Focus();
|
TransmitBox.Focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnTransmit(object? sender, RoutedEventArgs e)
|
/// The RX button is the `{RX}` macro by hand: the transmitter drops at the
|
||||||
{
|
/// end of what is waiting rather than in the middle of it. Escape is what
|
||||||
_ = Running()?.SetPttAsync(true);
|
/// stops now.
|
||||||
TransmitBox.Focus();
|
private void OnReceive(object? sender, RoutedEventArgs e) => ReturnToReceive();
|
||||||
}
|
|
||||||
|
|
||||||
/// The RX button stops where it is: what has not gone to the engine is
|
private void ReturnToReceive()
|
||||||
/// dropped, and the engine drops what it holds.
|
|
||||||
private void OnReceive(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
{
|
||||||
_ = session.DigitalKeyer?.AbortAsync();
|
if (Running() is not { } running)
|
||||||
_ = Running()?.AbortAsync();
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.DigitalKeyer is { } keyer)
|
||||||
|
{
|
||||||
|
keyer.ReturnToReceiveWhenSent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ = running.ReturnToReceiveAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnClearTransmit(object? sender, RoutedEventArgs e)
|
private void OnClearTransmit(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
session.DigitalKeyer?.TypeAhead.Clear();
|
session.DigitalKeyer?.Buffer.Clear();
|
||||||
ShowBuffer();
|
ShowBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:local="using:Nonemm.App.Windows"
|
xmlns:local="using:Nonemm.App.Windows"
|
||||||
|
xmlns:controls="using:Nonemm.App.Controls"
|
||||||
x:Class="Nonemm.App.Windows.DigitalWindow"
|
x:Class="Nonemm.App.Windows.DigitalWindow"
|
||||||
Title="Digital Interface" Width="900" Height="640">
|
Title="Digital Interface" Width="900" Height="640">
|
||||||
<Window.Styles>
|
<Window.Styles>
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
<Setter Property="Margin" Value="4,2" />
|
<Setter Property="Margin" Value="4,2" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<StyleInclude Source="avares://Nonemm.App/Controls/TransmitPane.axaml" />
|
||||||
</Window.Styles>
|
</Window.Styles>
|
||||||
|
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
@@ -166,17 +168,14 @@
|
|||||||
|
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="*,Auto,150">
|
<Grid Grid.Row="2" ColumnDefinitions="*,Auto,150">
|
||||||
<Border BorderThickness="1" BorderBrush="#40808080">
|
<Border BorderThickness="1" BorderBrush="#40808080">
|
||||||
<!-- what has gone out, then what is still to go: the first is a
|
<!-- one box for the whole message: what has gone out is coloured and
|
||||||
label so it cannot be edited, the second the box the operator
|
cannot be edited, what is still to go is typed into the same
|
||||||
types in -->
|
text, so the two wrap together and neither takes width from the
|
||||||
<Grid ColumnDefinitions="Auto,*">
|
other -->
|
||||||
<TextBlock Name="SentText" FontFamily="monospace" Margin="3,3,0,0"
|
<controls:TransmitPane Name="TransmitBox" AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
VerticalAlignment="Top" />
|
FontFamily="monospace"
|
||||||
<TextBox Grid.Column="1" Name="TransmitBox" AcceptsReturn="True" TextWrapping="Wrap"
|
KeyDown="OnTransmitKeyDown"
|
||||||
FontFamily="monospace" BorderThickness="0"
|
TextChanged="OnTransmitTextChanged" />
|
||||||
KeyDown="OnTransmitKeyDown" TextChanged="OnTransmitTextChanged"
|
|
||||||
GotFocus="OnTransmitFocus" LostFocus="OnTransmitFocus" />
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
</Border>
|
||||||
<Grid Grid.Column="2" RowDefinitions="*,Auto">
|
<Grid Grid.Column="2" RowDefinitions="*,Auto">
|
||||||
<Border BorderThickness="1" BorderBrush="#40808080">
|
<Border BorderThickness="1" BorderBrush="#40808080">
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
|||||||
/// redraw is not read back as an edit by the operator.
|
/// redraw is not read back as an edit by the operator.
|
||||||
private bool showingBuffer;
|
private bool showingBuffer;
|
||||||
|
|
||||||
|
/// The transmit pane as it was last drawn, so an edit can be told from a
|
||||||
|
/// redraw and the changed character found.
|
||||||
|
private string pane = "";
|
||||||
|
|
||||||
|
/// How much of the pane is in the engine's hands and cannot be edited. It
|
||||||
|
/// is more than what is coloured, which is only what has gone out over the
|
||||||
|
/// air.
|
||||||
|
private int locked;
|
||||||
|
|
||||||
/// The buffer this window is drawing, or null while no engine is running.
|
/// The buffer this window is drawing, or null while no engine is running.
|
||||||
private TypeAhead? buffer;
|
private TypeAhead? buffer;
|
||||||
|
|
||||||
@@ -82,9 +91,6 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
|||||||
entry.Activated += WhenEntryActivated;
|
entry.Activated += WhenEntryActivated;
|
||||||
BuildMacros();
|
BuildMacros();
|
||||||
ApplySettings();
|
ApplySettings();
|
||||||
// the cursor holds the pump back, so it has to follow the caret as it
|
|
||||||
// moves, not only as the text changes
|
|
||||||
TransmitBox.GetObservable(TextBox.CaretIndexProperty).Subscribe(new Watcher(ShowCursor));
|
|
||||||
Attach(session.Digital);
|
Attach(session.Digital);
|
||||||
Refresh();
|
Refresh();
|
||||||
Closed += (_, _) =>
|
Closed += (_, _) =>
|
||||||
@@ -104,8 +110,8 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
|||||||
{
|
{
|
||||||
ReceiveScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
ReceiveScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||||
GrabScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
GrabScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||||
SentText.Foreground = Transmitted;
|
TransmitBox.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||||
SentText.FontSize = Settings.DigitalFontSize;
|
TransmitBox.SentBrush = Transmitted;
|
||||||
TransmitBox.FontSize = Settings.DigitalFontSize;
|
TransmitBox.FontSize = Settings.DigitalFontSize;
|
||||||
if (buffer is not null)
|
if (buffer is not null)
|
||||||
{
|
{
|
||||||
@@ -147,7 +153,7 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
|||||||
}
|
}
|
||||||
Detach();
|
Detach();
|
||||||
engine = started;
|
engine = started;
|
||||||
buffer = session.DigitalKeyer?.TypeAhead;
|
buffer = session.DigitalKeyer?.Buffer;
|
||||||
if (buffer is not null)
|
if (buffer is not null)
|
||||||
{
|
{
|
||||||
buffer.Baud = Settings.DigitalBaud;
|
buffer.Baud = Settings.DigitalBaud;
|
||||||
@@ -188,21 +194,16 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
|||||||
private void WhenBufferChanged(object? sender, EventArgs e) =>
|
private void WhenBufferChanged(object? sender, EventArgs e) =>
|
||||||
Dispatcher.UIThread.Post(ShowBuffer);
|
Dispatcher.UIThread.Post(ShowBuffer);
|
||||||
|
|
||||||
/// The pane starts empty for the next message: the transmitter dropping
|
/// The transmitter dropping ends the message. The buffer clears what has
|
||||||
/// with nothing left to send is the end of this one. An engine that keys
|
/// gone out and keeps what the operator typed ahead, so the pane is left
|
||||||
/// itself drops between two characters as well, and that text is left
|
/// with the next message in it rather than empty.
|
||||||
/// where it is.
|
|
||||||
private void WhenTransmitChanged(object? sender, bool transmitting) =>
|
private void WhenTransmitChanged(object? sender, bool transmitting) =>
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
TransmitDot.Background = transmitting
|
TransmitDot.Background = transmitting
|
||||||
? Themes.Brush(Themes.Current.TransmitLight)
|
? Themes.Brush(Themes.Current.TransmitLight)
|
||||||
: Brushes.Transparent;
|
: Brushes.Transparent;
|
||||||
if (!transmitting && buffer is { IsSending: false })
|
|
||||||
{
|
|
||||||
buffer.Clear();
|
|
||||||
ShowBuffer();
|
ShowBuffer();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
private void WhenConnectionChanged(object? sender, bool connected) =>
|
private void WhenConnectionChanged(object? sender, bool connected) =>
|
||||||
@@ -568,18 +569,3 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
|||||||
_ => StackOrder.Disabled,
|
_ => StackOrder.Disabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Watches one property. Avalonia hands out observables and this program has no
|
|
||||||
/// other use for Rx, so a handler that takes no value is enough.
|
|
||||||
internal sealed class Watcher(Action changed) : IObserver<int>
|
|
||||||
{
|
|
||||||
public void OnCompleted()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnError(Exception error)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnNext(int value) => changed();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,14 +35,34 @@ public sealed partial class EntryWindow
|
|||||||
|
|
||||||
/// The twelve keys for this radio, which are a different twelve while
|
/// The twelve keys for this radio, which are a different twelve while
|
||||||
/// running and while searching, as N1MM's file holds them.
|
/// running and while searching, as N1MM's file holds them.
|
||||||
|
///
|
||||||
|
/// On a digital mode they come from the digital macros instead, which is
|
||||||
|
/// where N1MM reads them from as well: its entry window loads the RTTYBTN
|
||||||
|
/// set there rather than the CW file. Without this the keys held CW text
|
||||||
|
/// with no `{TX}` in it, so pressing one fed the engine without keying the
|
||||||
|
/// transmitter and nothing went on the air.
|
||||||
private IReadOnlyList<FunctionKey> Keys() =>
|
private IReadOnlyList<FunctionKey> Keys() =>
|
||||||
Messages
|
Logging?.Mode.Category == ModeCategory.Digital
|
||||||
|
? DigitalKeys()
|
||||||
|
: Messages
|
||||||
.For(
|
.For(
|
||||||
Logging?.Mode.Category ?? Core.ModeCategory.Cw,
|
Logging?.Mode.Category ?? Core.ModeCategory.Cw,
|
||||||
session.Settings.CwMessageFile,
|
session.Settings.CwMessageFile,
|
||||||
session.Settings.PhoneMessageFile)
|
session.Settings.PhoneMessageFile)
|
||||||
.Keys(Logging?.IsRunning ?? false);
|
.Keys(Logging?.IsRunning ?? false);
|
||||||
|
|
||||||
|
/// The first ten digital macros, then Spot and Wipe. F11 and F12 are what
|
||||||
|
/// they are in every other mode, so the labels say what the buttons do;
|
||||||
|
/// the macros past the tenth are on the digital window's own buttons.
|
||||||
|
private IReadOnlyList<FunctionKey> DigitalKeys()
|
||||||
|
{
|
||||||
|
IReadOnlyList<FunctionKey> macros = Messages.Digital(session.Settings.DigitalMessageFile).Buttons;
|
||||||
|
List<FunctionKey> keys = [.. macros.Take(MessageFile.KeyCount - 2)];
|
||||||
|
keys.Add(new FunctionKey("Spot", ""));
|
||||||
|
keys.Add(new FunctionKey("Wipe", ""));
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
/// Right-clicking a function key button opens the messages for the mode the
|
/// Right-clicking a function key button opens the messages for the mode the
|
||||||
/// radio is in, which is where import and export live too.
|
/// radio is in, which is where import and export live too.
|
||||||
private Task EditMessages() => EditMessages(Logging?.Mode.Category ?? ModeCategory.Cw);
|
private Task EditMessages() => EditMessages(Logging?.Mode.Category ?? ModeCategory.Cw);
|
||||||
@@ -140,7 +160,12 @@ public sealed partial class EntryWindow
|
|||||||
}
|
}
|
||||||
if (plan.Text.Length == 0)
|
if (plan.Text.Length == 0)
|
||||||
{
|
{
|
||||||
// a button that only acts: {WIPE}, {LOG}, {RUN}
|
// a button that only acts: {WIPE}, {LOG}, {RUN}, or an {RX} on its
|
||||||
|
// own, which has nothing to wait for
|
||||||
|
foreach (MessageAction action in plan.After)
|
||||||
|
{
|
||||||
|
Run(action);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if ((through ?? Sender) is not { } keyer)
|
if ((through ?? Sender) is not { } keyer)
|
||||||
@@ -170,7 +195,22 @@ public sealed partial class EntryWindow
|
|||||||
Status(e.Message);
|
Status(e.Message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_ = RunWhenSentAsync(keyer, after);
|
// `{RX}` goes to the keyer as soon as the message is in it, which is
|
||||||
|
// N1MM's ending: the text in one piece, the stop 400 ms behind it, and
|
||||||
|
// the engine unkeys itself at the last character. Waiting for the
|
||||||
|
// message to go out first leaves the engine empty, and a stop that
|
||||||
|
// arrives there does nothing. Everything else after `{END}` still
|
||||||
|
// waits.
|
||||||
|
foreach (MessageAction action in after)
|
||||||
|
{
|
||||||
|
if (action.Command == MessageCommand.ReturnToReceive)
|
||||||
|
{
|
||||||
|
Run(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = RunWhenSentAsync(
|
||||||
|
keyer,
|
||||||
|
[.. after.Where(action => action.Command != MessageCommand.ReturnToReceive)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Waits for the keyer, turns the transmit light off, and runs whatever
|
/// Waits for the keyer, turns the transmit light off, and runs whatever
|
||||||
@@ -292,14 +332,30 @@ public sealed partial class EntryWindow
|
|||||||
Logging.Stack.Clear();
|
Logging.Stack.Clear();
|
||||||
ShowCallStack();
|
ShowCallStack();
|
||||||
break;
|
break;
|
||||||
// the digital engine keys itself while it has text to send, so
|
// {TX} keys the transmitter and {RX} drops it. {RX} waits until
|
||||||
// {TX} and {RX} only matter when a macro wants the transmitter
|
// everything waiting has gone out, so it ends the transmission
|
||||||
// held open around what it sends
|
// rather than cutting it off
|
||||||
case MessageCommand.StartTransmit:
|
case MessageCommand.StartTransmit:
|
||||||
|
// through the keyer, which keys the engine before it feeds it
|
||||||
|
// the message
|
||||||
|
if (session.DigitalKeyer is { } keying)
|
||||||
|
{
|
||||||
|
keying.Transmit();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
_ = session.Digital?.SetPttAsync(true);
|
_ = session.Digital?.SetPttAsync(true);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case MessageCommand.ReturnToReceive:
|
case MessageCommand.ReturnToReceive:
|
||||||
|
if (session.DigitalKeyer is { } digital)
|
||||||
|
{
|
||||||
|
digital.ReturnToReceiveWhenSent();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
_ = session.Digital?.ReturnToReceiveAsync();
|
_ = session.Digital?.ReturnToReceiveAsync();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,6 +431,9 @@ public sealed partial class EntryWindow
|
|||||||
|
|
||||||
private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session));
|
private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session));
|
||||||
|
|
||||||
|
private void OnShowNetworkStatus(object? sender, RoutedEventArgs e) =>
|
||||||
|
Show(() => new NetworkStatusWindow(session));
|
||||||
|
|
||||||
/// N1MM's grey line window: where the daylight is now, and where it will be.
|
/// N1MM's grey line window: where the daylight is now, and where it will be.
|
||||||
private void OnShowGrayline(object? sender, RoutedEventArgs e) =>
|
private void OnShowGrayline(object? sender, RoutedEventArgs e) =>
|
||||||
Show(() => new GraylineWindow(session));
|
Show(() => new GraylineWindow(session));
|
||||||
@@ -518,10 +521,21 @@ public sealed partial class EntryWindow
|
|||||||
{
|
{
|
||||||
session.Save(updated);
|
session.Save(updated);
|
||||||
session.ApplyNetworkSettings();
|
session.ApplyNetworkSettings();
|
||||||
Status(updated.NetworkEnabled ? "networked with the other stations" : "networking off");
|
session.ApplyStationLinkSettings();
|
||||||
|
Status(Networking(updated));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the two networks are now doing, for the status line.
|
||||||
|
private static string Networking(Settings settings) =>
|
||||||
|
(settings.NetworkEnabled, settings.StationLinkEnabled) switch
|
||||||
|
{
|
||||||
|
(true, true) => "broadcasting contacts and linked to the other computers",
|
||||||
|
(true, false) => "broadcasting contacts to other programs",
|
||||||
|
(false, true) => "linked to the other logging computers",
|
||||||
|
_ => "networking off",
|
||||||
|
};
|
||||||
|
|
||||||
private async void OnKeyerSettings(object? sender, RoutedEventArgs e)
|
private async void OnKeyerSettings(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
KeyerDialog dialog = new(session.Settings);
|
KeyerDialog dialog = new(session.Settings);
|
||||||
|
|||||||
@@ -119,6 +119,7 @@
|
|||||||
<MenuItem Header="Call Stack" Click="OnShowCallStack" />
|
<MenuItem Header="Call Stack" Click="OnShowCallStack" />
|
||||||
<MenuItem Header="Check" Click="OnShowCheck" />
|
<MenuItem Header="Check" Click="OnShowCheck" />
|
||||||
<MenuItem Header="Log" Click="OnShowLog" InputGesture="Ctrl+L" />
|
<MenuItem Header="Log" Click="OnShowLog" InputGesture="Ctrl+L" />
|
||||||
|
<MenuItem Header="Network Status" Click="OnShowNetworkStatus" />
|
||||||
<MenuItem Header="Grey Line" Click="OnShowGrayline" />
|
<MenuItem Header="Grey Line" Click="OnShowGrayline" />
|
||||||
<MenuItem Header="Score Summary" Click="OnShowScore" />
|
<MenuItem Header="Score Summary" Click="OnShowScore" />
|
||||||
<MenuItem Header="Telnet" Click="OnShowTelnet" />
|
<MenuItem Header="Telnet" Click="OnShowTelnet" />
|
||||||
|
|||||||
23
src/Nonemm.App/Windows/NetworkStatusWindow.axaml
Normal file
23
src/Nonemm.App/Windows/NetworkStatusWindow.axaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="using:Nonemm.App.Windows"
|
||||||
|
x:Class="Nonemm.App.Windows.NetworkStatusWindow"
|
||||||
|
Title="Network status" Width="820" Height="320">
|
||||||
|
<DockPanel Margin="8">
|
||||||
|
<StackPanel DockPanel.Dock="Bottom" Spacing="6" Margin="0,8,0,0">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<TextBox Name="TalkBox" Width="420" PlaceholderText="a line to the other operators" />
|
||||||
|
<Button Content="Send" Click="OnTalk" IsDefault="True" />
|
||||||
|
<Button Content="Echo" Click="OnEcho" />
|
||||||
|
<Button Name="LinkButton" Content="Setup…" Click="OnSetup" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Name="StateText" FontSize="11" Opacity="0.75" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<Grid Name="Table" />
|
||||||
|
<TextBlock Name="TalkText" FontFamily="monospace" FontSize="11" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
</local:RefreshableWindow>
|
||||||
236
src/Nonemm.App/Windows/NetworkStatusWindow.axaml.cs
Normal file
236
src/Nonemm.App/Windows/NetworkStatusWindow.axaml.cs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using Nonemm.App.Dialogs;
|
||||||
|
using Nonemm.App.Configuration;
|
||||||
|
using Nonemm.App.Theming;
|
||||||
|
using Nonemm.Network;
|
||||||
|
|
||||||
|
namespace Nonemm.App.Windows;
|
||||||
|
|
||||||
|
/// The other computers of this entry: who they are, where they are on the
|
||||||
|
/// bands, and whether anything is arriving from them. N1MM's network status
|
||||||
|
/// window, with its columns.
|
||||||
|
///
|
||||||
|
/// A row per station, this computer included, which is how N1MM shows it: the
|
||||||
|
/// operator reads its own station number and the version it is claiming off the
|
||||||
|
/// same window as everybody else's.
|
||||||
|
///
|
||||||
|
/// The window redraws on every message, which on a busy network is several a
|
||||||
|
/// second. That is what a status window is for, and the table is a dozen rows.
|
||||||
|
public sealed partial class NetworkStatusWindow : RefreshableWindow
|
||||||
|
{
|
||||||
|
/// The columns, in N1MM's order as far as this program has the answers.
|
||||||
|
private static readonly string[] Columns =
|
||||||
|
["Computer", "Nr", "Address", "Operator", "Band", "Mode", "Run", "TX", "Pass", "Last", "Heard", "Sent", "Read", "Echo"];
|
||||||
|
|
||||||
|
/// How much chat is kept on screen.
|
||||||
|
private const int TalkLines = 8;
|
||||||
|
|
||||||
|
private readonly AppSession session;
|
||||||
|
private readonly List<string> talk = [];
|
||||||
|
private readonly DispatcherTimer clock;
|
||||||
|
private StationLink? link;
|
||||||
|
|
||||||
|
public NetworkStatusWindow(AppSession session)
|
||||||
|
{
|
||||||
|
this.session = session;
|
||||||
|
InitializeComponent();
|
||||||
|
Attach();
|
||||||
|
// the Heard column is a countdown, so it has to redraw with nothing
|
||||||
|
// arriving
|
||||||
|
clock = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||||||
|
clock.Tick += (_, _) => Refresh();
|
||||||
|
clock.Start();
|
||||||
|
Closed += (_, _) =>
|
||||||
|
{
|
||||||
|
clock.Stop();
|
||||||
|
Detach();
|
||||||
|
};
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Refresh()
|
||||||
|
{
|
||||||
|
Attach();
|
||||||
|
Table.Children.Clear();
|
||||||
|
Table.ColumnDefinitions.Clear();
|
||||||
|
Table.RowDefinitions.Clear();
|
||||||
|
if (link is null)
|
||||||
|
{
|
||||||
|
StateText.Text =
|
||||||
|
"the link to the other logging computers is off — Setup turns it on";
|
||||||
|
TalkText.Text = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (string _ in Columns)
|
||||||
|
{
|
||||||
|
Table.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
|
||||||
|
}
|
||||||
|
AddRow(0, Columns, header: true);
|
||||||
|
int row = 1;
|
||||||
|
DateTime now = DateTime.UtcNow;
|
||||||
|
foreach (NetworkedStation station in link.Stations.OrderBy(s => s.StationNumber))
|
||||||
|
{
|
||||||
|
AddRow(row++, Cells(station, now), refused: station.Refused.Length > 0 && !station.IsMine);
|
||||||
|
}
|
||||||
|
StateText.Text = State();
|
||||||
|
TalkText.Text = string.Join("\n", talk);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] Cells(NetworkedStation station, DateTime now) =>
|
||||||
|
[
|
||||||
|
station.ComputerName + (station.IsMine ? " (this one)" : ""),
|
||||||
|
station.StationNumber > 0 ? station.StationNumber.ToString() : "",
|
||||||
|
station.Address,
|
||||||
|
station.Operator,
|
||||||
|
station.Band?.Name ?? "",
|
||||||
|
station.Mode?.Name ?? "",
|
||||||
|
station.IsRunning ? "run" : "",
|
||||||
|
station.IsTransmitting ? "TX" : "",
|
||||||
|
station.PassFrequency.Hertz > 0 ? $"{station.PassFrequency.Kilohertz:0.0} {station.PassCall}" : "",
|
||||||
|
station.LastMessage,
|
||||||
|
station.IsMine ? "" : Ago(now - station.LastHeardUtc),
|
||||||
|
station.Sent.ToString(),
|
||||||
|
station.Read.ToString(),
|
||||||
|
station.EchoTime is { } echo ? $"{echo.TotalMilliseconds:0} ms" : "",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// How long ago, in the shortest form that says it. A station heard from
|
||||||
|
/// less than a second ago reads as now rather than as 0 s.
|
||||||
|
private static string Ago(TimeSpan since) => since switch
|
||||||
|
{
|
||||||
|
{ TotalSeconds: < 2 } => "now",
|
||||||
|
{ TotalMinutes: < 1 } => $"{since.Seconds} s",
|
||||||
|
{ TotalHours: < 1 } => $"{since.Minutes} min",
|
||||||
|
_ => "over an hour",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// What the link is doing, under the table. A station that broadcast the
|
||||||
|
/// wrong version is named here: it is the one fault that leaves everything
|
||||||
|
/// looking connected and nothing arriving.
|
||||||
|
private string State()
|
||||||
|
{
|
||||||
|
if (link is null)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
List<NetworkedStation> others = link.Stations.Where(s => !s.IsMine).ToList();
|
||||||
|
int connected = others.Count(s => s.IsConnected);
|
||||||
|
string what = $"station {link.StationNumber} as {link.ComputerName}, "
|
||||||
|
+ $"claiming version {session.Settings.StationLinkVersion} — "
|
||||||
|
+ $"{connected} of {others.Count} other stations connected";
|
||||||
|
List<string> refused = others
|
||||||
|
.Where(s => s.Refused.Length > 0)
|
||||||
|
.Select(s => $"{s.ComputerName}: {s.Refused}")
|
||||||
|
.ToList();
|
||||||
|
return refused.Count > 0 ? $"{what}\n{string.Join("\n", refused)}" : what;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Attach()
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(link, session.Link))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Detach();
|
||||||
|
link = session.Link;
|
||||||
|
if (link is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
link.StationsChanged += WhenStationsChanged;
|
||||||
|
link.TalkArrived += WhenTalkArrived;
|
||||||
|
link.Failed += WhenFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Detach()
|
||||||
|
{
|
||||||
|
if (link is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
link.StationsChanged -= WhenStationsChanged;
|
||||||
|
link.TalkArrived -= WhenTalkArrived;
|
||||||
|
link.Failed -= WhenFailed;
|
||||||
|
link = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The link raises its events on a socket thread, so everything that draws
|
||||||
|
/// is posted.
|
||||||
|
private void WhenStationsChanged(object? sender, EventArgs e) =>
|
||||||
|
Dispatcher.UIThread.Post(Refresh);
|
||||||
|
|
||||||
|
private void WhenTalkArrived(object? sender, string text) =>
|
||||||
|
Dispatcher.UIThread.Post(() => Say(text));
|
||||||
|
|
||||||
|
private void WhenFailed(object? sender, string why) =>
|
||||||
|
Dispatcher.UIThread.Post(() => Say(why));
|
||||||
|
|
||||||
|
private void Say(string text)
|
||||||
|
{
|
||||||
|
talk.Add(text);
|
||||||
|
if (talk.Count > TalkLines)
|
||||||
|
{
|
||||||
|
talk.RemoveRange(0, talk.Count - TalkLines);
|
||||||
|
}
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnTalk(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (link is null || (TalkBox.Text ?? "").Trim() is not { Length: > 0 } text)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TalkBox.Text = "";
|
||||||
|
Say($"[{link.ComputerName}] {text}");
|
||||||
|
if (await link.SendTalkAsync(text) == 0)
|
||||||
|
{
|
||||||
|
Say("nobody is connected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnEcho(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (link is not null)
|
||||||
|
{
|
||||||
|
await link.SendEchoRequestAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnSetup(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
NetworkDialog dialog = new(session.Settings);
|
||||||
|
if (await dialog.ShowDialog<Settings?>(this) is { } updated)
|
||||||
|
{
|
||||||
|
session.Save(updated);
|
||||||
|
session.ApplyNetworkSettings();
|
||||||
|
session.ApplyStationLinkSettings();
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddRow(int row, IReadOnlyList<string> cells, bool header = false, bool refused = false)
|
||||||
|
{
|
||||||
|
Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||||
|
for (int column = 0; column < cells.Count; column++)
|
||||||
|
{
|
||||||
|
TextBlock text = new()
|
||||||
|
{
|
||||||
|
Text = cells[column],
|
||||||
|
FontWeight = header ? FontWeight.Bold : FontWeight.Normal,
|
||||||
|
Margin = new Avalonia.Thickness(4, 2, 8, 2),
|
||||||
|
FontSize = 12,
|
||||||
|
};
|
||||||
|
if (refused)
|
||||||
|
{
|
||||||
|
text.Foreground = Themes.Brush(Themes.Current.BadBackground);
|
||||||
|
}
|
||||||
|
Grid.SetRow(text, row);
|
||||||
|
Grid.SetColumn(text, column);
|
||||||
|
Table.Children.Add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
<Button Name="ReadyButton" Content="RX Ready" Click="OnReady" />
|
<Button Name="ReadyButton" Content="RX Ready" Click="OnReady" />
|
||||||
<Button Name="HeaderAgainButton" Content="Hdr Agn" Click="OnHeaderAgain" />
|
<Button Name="HeaderAgainButton" Content="Hdr Agn" Click="OnHeaderAgain" />
|
||||||
<Button Name="HeaderCfmButton" Content="Cfm" Click="OnHeaderConfirm" />
|
<Button Name="HeaderCfmButton" Content="Cfm" Click="OnHeaderConfirm" />
|
||||||
|
<Button Name="SendAllButton" Content="Send All" Click="OnSendAll" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Grid Name="Lines" Margin="0,6,0,0" />
|
<Grid Name="Lines" Margin="0,6,0,0" />
|
||||||
<TextBlock Name="KeysText" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
|
<TextBlock Name="KeysText" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ namespace Nonemm.App.Windows;
|
|||||||
/// the log and cannot be edited — what is being reported is what was worked.
|
/// the log and cannot be edited — what is being reported is what was worked.
|
||||||
///
|
///
|
||||||
/// On CW it puts the traffic on the air through the entry window's keyer, the
|
/// On CW it puts the traffic on the air through the entry window's keyer, the
|
||||||
/// way N1MM's QTC window sends through its own entry window. On SSB and RTTY
|
/// way N1MM's QTC window sends through its own entry window. On RTTY it sends
|
||||||
/// nothing is sent: one needs a voice keyer and the other a digital window.
|
/// through the digital engine the same way, but a line at a time rather than a
|
||||||
|
/// field at a time, and Send All reads the whole series out in one message. On
|
||||||
|
/// SSB nothing is sent: that needs a voice keyer, and there is none here.
|
||||||
public sealed partial class QtcWindow : Window
|
public sealed partial class QtcWindow : Window
|
||||||
{
|
{
|
||||||
private static IBrush Saved => Themes.Brush(Themes.Current.GoodBackground);
|
private static IBrush Saved => Themes.Brush(Themes.Current.GoodBackground);
|
||||||
@@ -83,7 +85,9 @@ public sealed partial class QtcWindow : Window
|
|||||||
if (!IsCw)
|
if (!IsCw)
|
||||||
{
|
{
|
||||||
return isSending
|
return isSending
|
||||||
? $"{traffic.Remaining(station)} of the ten QTCs for {station.Text} are still free"
|
? IsRtty
|
||||||
|
? "Send All reads the whole series out — Snd n sends one QTC again"
|
||||||
|
: $"{traffic.Remaining(station)} of the ten QTCs for {station.Text} are still free"
|
||||||
: "Type the header, then a line per QTC: time, callsign, serial number.";
|
: "Type the header, then a line per QTC: time, callsign, serial number.";
|
||||||
}
|
}
|
||||||
return isSending
|
return isSending
|
||||||
@@ -91,10 +95,17 @@ public sealed partial class QtcWindow : Window
|
|||||||
: "Shift 1 = ask time Shift 2 = ask call Shift 3 = ask serial";
|
: "Shift 1 = ask time Shift 2 = ask call Shift 3 = ask serial";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Traffic goes out on CW alone. The contest says which mode it is, not
|
/// Which mode the traffic goes out on. The contest says, not what the radio
|
||||||
/// what the radio happens to be on, which is how N1MM decides too.
|
/// happens to be on, which is how N1MM decides too.
|
||||||
private bool IsCw => position.Contest.Modes is [ModeCategory.Cw];
|
private bool IsCw => position.Contest.Modes is [ModeCategory.Cw];
|
||||||
|
|
||||||
|
private bool IsRtty => position.Contest.Modes.Contains(ModeCategory.Digital);
|
||||||
|
|
||||||
|
/// The header of the series as it goes out in a message, which is N1MM's
|
||||||
|
/// `QTC 3/10`. The box shows the station callsign after it on RTTY, and
|
||||||
|
/// that is for the operator rather than for the air.
|
||||||
|
private string SeriesHeader => ready.Count > 0 ? $"QTC {ready[0].SeriesText}" : "";
|
||||||
|
|
||||||
private void SetDirection(bool sending)
|
private void SetDirection(bool sending)
|
||||||
{
|
{
|
||||||
if (sending == isSending)
|
if (sending == isSending)
|
||||||
@@ -118,6 +129,9 @@ public sealed partial class QtcWindow : Window
|
|||||||
HeaderAgainButton.Content = isSending && IsCw ? "Snd Hdr" : "Hdr Agn";
|
HeaderAgainButton.Content = isSending && IsCw ? "Snd Hdr" : "Hdr Agn";
|
||||||
HeaderAgainButton.IsVisible = !isSending || IsCw;
|
HeaderAgainButton.IsVisible = !isSending || IsCw;
|
||||||
HeaderCfmButton.IsVisible = !isSending;
|
HeaderCfmButton.IsVisible = !isSending;
|
||||||
|
// N1MM's Send All, which reads a whole series out in one message. It
|
||||||
|
// needs the engine to hold the text, so it is RTTY only
|
||||||
|
SendAllButton.IsVisible = isSending && IsRtty;
|
||||||
ClearButton.IsVisible = !isSending;
|
ClearButton.IsVisible = !isSending;
|
||||||
CloseButton.Content = IsCw ? "Exit" : "Close";
|
CloseButton.Content = IsCw ? "Exit" : "Close";
|
||||||
HeaderBox.IsReadOnly = isSending;
|
HeaderBox.IsReadOnly = isSending;
|
||||||
@@ -144,7 +158,7 @@ public sealed partial class QtcWindow : Window
|
|||||||
TextBox number = Box(at, 2);
|
TextBox number = Box(at, 2);
|
||||||
Button again = new()
|
Button again = new()
|
||||||
{
|
{
|
||||||
Content = isSending && IsCw ? $"Snd{at + 1}" : $"Agn{at + 1}",
|
Content = isSending && (IsCw || IsRtty) ? $"Snd{at + 1}" : $"Agn{at + 1}",
|
||||||
Margin = new Avalonia.Thickness(2, 1, 2, 1),
|
Margin = new Avalonia.Thickness(2, 1, 2, 1),
|
||||||
};
|
};
|
||||||
Button confirm = new() { Content = $"Cfm{at + 1}", Margin = new Avalonia.Thickness(2, 1, 2, 1) };
|
Button confirm = new() { Content = $"Cfm{at + 1}", Margin = new Avalonia.Thickness(2, 1, 2, 1) };
|
||||||
@@ -195,13 +209,11 @@ public sealed partial class QtcWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ready.AddRange(traffic.ToSend(station, session.Settings.QtcLinesPerSeries));
|
ready.AddRange(traffic.ToSend(station, session.Settings.QtcLinesPerSeries));
|
||||||
// the contest is what makes it RTTY, not whatever the radio is on now
|
|
||||||
bool rtty = position.Contest.Modes.Contains(ModeCategory.Digital);
|
|
||||||
HeaderBox.Text = ready.Count == 0
|
HeaderBox.Text = ready.Count == 0
|
||||||
? ""
|
? ""
|
||||||
: rtty
|
: IsRtty
|
||||||
? $"QTC {ready[0].SeriesText} - {station.Text}"
|
? $"{SeriesHeader} - {station.Text}"
|
||||||
: $"QTC {ready[0].SeriesText}";
|
: SeriesHeader;
|
||||||
for (int at = 0; at < rows.Count; at++)
|
for (int at = 0; at < rows.Count; at++)
|
||||||
{
|
{
|
||||||
bool has = at < ready.Count;
|
bool has = at < ready.Count;
|
||||||
@@ -353,7 +365,7 @@ public sealed partial class QtcWindow : Window
|
|||||||
{
|
{
|
||||||
if (isSending)
|
if (isSending)
|
||||||
{
|
{
|
||||||
if (IsCw)
|
if (IsCw || IsRtty)
|
||||||
{
|
{
|
||||||
SendLine(at);
|
SendLine(at);
|
||||||
return;
|
return;
|
||||||
@@ -375,7 +387,9 @@ public sealed partial class QtcWindow : Window
|
|||||||
private void SendLine(int at)
|
private void SendLine(int at)
|
||||||
{
|
{
|
||||||
lastSent = at;
|
lastSent = at;
|
||||||
_ = send(QtcMessages.Line(
|
_ = send(IsRtty
|
||||||
|
? QtcMessages.SendOne(session.Settings.QtcRttySpacing, RttyLine(at))
|
||||||
|
: QtcMessages.Line(
|
||||||
rows[at].Time.Text ?? "",
|
rows[at].Time.Text ?? "",
|
||||||
rows[at].Call.Text ?? "",
|
rows[at].Call.Text ?? "",
|
||||||
rows[at].Number.Text ?? "",
|
rows[at].Number.Text ?? "",
|
||||||
@@ -391,6 +405,43 @@ public sealed partial class QtcWindow : Window
|
|||||||
CloseButton.Focus();
|
CloseButton.Focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string RttyLine(int at) => QtcMessages.RttyLine(
|
||||||
|
rows[at].Time.Text ?? "",
|
||||||
|
rows[at].Call.Text ?? "",
|
||||||
|
rows[at].Number.Text ?? "");
|
||||||
|
|
||||||
|
/// N1MM's Send All: the whole series in one message. RTTY runs at 45 baud,
|
||||||
|
/// so a series is the best part of a minute of transmission, and sending it
|
||||||
|
/// a line at a time means the operator presses a button between each one
|
||||||
|
/// while the transmitter is up. The heading, the spacing between the lines
|
||||||
|
/// and the ending are the operator's, and the message keys the transmitter
|
||||||
|
/// and drops it again itself.
|
||||||
|
private void OnSendAll(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
List<string> lines = [];
|
||||||
|
for (int at = 0; at < rows.Count; at++)
|
||||||
|
{
|
||||||
|
if (!rows[at].IsEmpty)
|
||||||
|
{
|
||||||
|
lines.Add(RttyLine(at));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
string message = QtcMessages.SendAll(
|
||||||
|
session.Settings.QtcRttySendAllHeading,
|
||||||
|
session.Settings.QtcRttySendAllEnding,
|
||||||
|
session.Settings.QtcRttySpacing,
|
||||||
|
SeriesHeader,
|
||||||
|
lines);
|
||||||
|
if (message.Length == 0)
|
||||||
|
{
|
||||||
|
StatusText.Text = "nothing to send";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSent = lines.Count - 1;
|
||||||
|
_ = send(message);
|
||||||
|
CloseButton.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
/// 1, 2, 3 and 4 as N1MM numbers them: the time, the call, the serial
|
/// 1, 2, 3 and 4 as N1MM numbers them: the time, the call, the serial
|
||||||
/// number and the header.
|
/// number and the header.
|
||||||
private static int FieldOf(Key key) =>
|
private static int FieldOf(Key key) =>
|
||||||
|
|||||||
@@ -22,6 +22,48 @@ public sealed record BandChangeRules(
|
|||||||
/// A contest that does not limit band changes at all.
|
/// A contest that does not limit band changes at all.
|
||||||
public static readonly BandChangeRules None = new(0);
|
public static readonly BandChangeRules None = new(0);
|
||||||
|
|
||||||
|
/// How long a multi-operator entry with one transmitter has to stay on a
|
||||||
|
/// band. It is the ten-minute rule, and it is N1MM's fallback for every
|
||||||
|
/// contest that does not name a number of its own.
|
||||||
|
public static readonly TimeSpan MultiOneStay = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
/// What a contest allows an entry in this category, before the contest has
|
||||||
|
/// its say. These are N1MM's defaults, from
|
||||||
|
/// `ContestInstance.BandChangeTimerDuration` and `BandChangeCountMax`:
|
||||||
|
///
|
||||||
|
/// | Category | Changes counted | Stay |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | single operator | no | none |
|
||||||
|
/// | multi-operator, one transmitter | no | ten minutes |
|
||||||
|
/// | multi-operator, two transmitters | no | ten minutes |
|
||||||
|
/// | multi-operator, more | no | none |
|
||||||
|
///
|
||||||
|
/// The count is off by default because N1MM's own fallback for it is zero:
|
||||||
|
/// the contests that cap band changes per hour name the cap themselves, and
|
||||||
|
/// a contest class that does so overrides `Contest.BandChangesFor`. The
|
||||||
|
/// stay is the other way round — ten minutes is the fallback, and a contest
|
||||||
|
/// with no rule of its own gets it.
|
||||||
|
///
|
||||||
|
/// A station with a transmitter per band changes band by moving to another
|
||||||
|
/// radio, so nothing is counted for it.
|
||||||
|
public static BandChangeRules ForCategory(ContestEntry entry)
|
||||||
|
{
|
||||||
|
if (!entry.OperatorCategory.StartsWith("MULTI", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// N1MM reads the number of transmitters off the operator category on
|
||||||
|
// Cabrillo 2.0 and off the transmitter category after it. Both are
|
||||||
|
// checked, so an entry written either way is read
|
||||||
|
bool one = Is(entry, "MULTI-ONE", "ONE");
|
||||||
|
bool two = Is(entry, "MULTI-TWO", "TWO");
|
||||||
|
return one || two ? None with { MinimumStay = MultiOneStay } : None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Is(ContestEntry entry, string operatorCategory, string transmitterCategory) =>
|
||||||
|
entry.OperatorCategory.Equals(operatorCategory, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| entry.TransmitterCategory.Equals(transmitterCategory, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsCounted => Max > 0;
|
public bool IsCounted => Max > 0;
|
||||||
|
|
||||||
/// True for a contest that says how long a station has to stay on a band.
|
/// True for a contest that says how long a station has to stay on a band.
|
||||||
|
|||||||
@@ -77,7 +77,12 @@ public interface Contest
|
|||||||
|
|
||||||
/// How many band changes the entry may make, and over what stretch of time
|
/// How many band changes the entry may make, and over what stretch of time
|
||||||
/// they are counted. Most contests do not limit them.
|
/// they are counted. Most contests do not limit them.
|
||||||
BandChangeRules BandChangesFor(ContestEntry entry) => BandChangeRules.None;
|
/// What this contest allows in the way of band changes. The default is
|
||||||
|
/// what the entry's category alone says, which is the ten-minute rule for a
|
||||||
|
/// multi-operator entry with one or two transmitters and nothing for a
|
||||||
|
/// single operator. A contest that caps the changes per hour, or asks for a
|
||||||
|
/// different stay, says so here.
|
||||||
|
BandChangeRules BandChangesFor(ContestEntry entry) => BandChangeRules.ForCategory(entry);
|
||||||
|
|
||||||
/// How long a gap between contacts has to be before it counts as time off.
|
/// How long a gap between contacts has to be before it counts as time off.
|
||||||
/// N1MM asks each contest and takes 30 minutes when it says nothing.
|
/// N1MM asks each contest and takes 30 minutes when it says nothing.
|
||||||
|
|||||||
@@ -28,4 +28,18 @@ public interface DigitalEngine : IDisposable
|
|||||||
|
|
||||||
/// Drops whatever has not gone out yet, which is what Escape does.
|
/// Drops whatever has not gone out yet, which is what Escape does.
|
||||||
Task AbortAsync(CancellationToken cancellation = default);
|
Task AbortAsync(CancellationToken cancellation = default);
|
||||||
|
|
||||||
|
/// N1MM's `{TX}`: the transmitter comes up now, before there is anything
|
||||||
|
/// to send.
|
||||||
|
Task KeyAsync(CancellationToken cancellation = default);
|
||||||
|
|
||||||
|
/// N1MM's `{RX}`: the transmitter drops once the modem has transmitted what
|
||||||
|
/// it still holds.
|
||||||
|
Task ReturnToReceiveAsync(CancellationToken cancellation = default);
|
||||||
|
|
||||||
|
/// The key back down, without waiting for anything. MMTTY needs this: its
|
||||||
|
/// `SetMmttyPTT(1)` leaves the transmitter up whatever N1MM's source says,
|
||||||
|
/// so `{RX}` waits for the modem to say its buffer is empty and then puts
|
||||||
|
/// the key down here.
|
||||||
|
Task ReleaseKeyAsync(CancellationToken cancellation = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,26 +6,87 @@ namespace Nonemm.Digital;
|
|||||||
/// through the same expander, the same `{END}` handling and the same ESM as a
|
/// through the same expander, the same `{END}` handling and the same ESM as a
|
||||||
/// CW message does.
|
/// CW message does.
|
||||||
///
|
///
|
||||||
/// A message is not handed to the engine whole. It goes into the type-ahead
|
/// A message goes into the type-ahead buffer rather than to the engine whole,
|
||||||
/// buffer, which keeps the engine a couple of characters ahead of the operator,
|
/// so the part that has not gone out can still be rewritten. `Finished` is
|
||||||
/// so the rest can still be rewritten. `Finished` is raised when the buffer has
|
/// raised when the buffer has run dry, with the last characters of the message
|
||||||
/// run dry and the engine has transmitted what it was given.
|
/// in the engine and about a third of a second of it still to go out.
|
||||||
|
///
|
||||||
|
/// The buffer is paced by the clock at `baud`, and the engine's own count of
|
||||||
|
/// what it has left is a brake on it.
|
||||||
|
///
|
||||||
|
/// Three states, which is what the transmitter can be doing:
|
||||||
|
///
|
||||||
|
/// | State | Key | Gate | Engine |
|
||||||
|
/// |---|---|---|---|
|
||||||
|
/// | `Down` | down | shut | holds nothing |
|
||||||
|
/// | `Keyed` | up | open, the feeder paces text over | holds `Ahead` characters |
|
||||||
|
/// | `Ending` | up | shut, everything left was flushed in one piece | holds the rest of the message |
|
||||||
|
///
|
||||||
|
/// | From | Event | To |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | `Down` | `Transmit`, or a message to send | `Keyed` |
|
||||||
|
/// | `Keyed` | more text | `Keyed` |
|
||||||
|
/// | `Keyed` | `{RX}` | `Ending` |
|
||||||
|
/// | `Keyed` | the engine drops with the message unfinished | `Keyed`, keyed again |
|
||||||
|
/// | `Ending` | the engine drops | `Down` |
|
||||||
|
/// | `Ending` | the engine never drops | `Down`, the key put down here |
|
||||||
|
/// | `Ending` | `Transmit`, or a message to send | `Keyed`, once the engine is empty and its stop is cleared |
|
||||||
|
/// | any | `AbortAsync` | `Down` |
|
||||||
|
///
|
||||||
|
/// The last two rows are why there is a state at all rather than a flag.
|
||||||
|
/// Ending a message takes as long as the engine takes to transmit what it
|
||||||
|
/// holds, and the operator can key again inside that time. An ending that went
|
||||||
|
/// on running put the key down in the middle of the message after it.
|
||||||
|
///
|
||||||
|
/// Coming back out of `Ending` is not free either. The stop is inside the
|
||||||
|
/// engine by then, waiting for its buffer to empty, and MMTTY fed while that
|
||||||
|
/// stands stayed keyed and transmitted nothing: the characters went in and
|
||||||
|
/// never came out. So a new message waits for what the engine holds to go out,
|
||||||
|
/// clears the stop with an abort, and keys again.
|
||||||
public sealed class DigitalEngineSender : MessageSender
|
public sealed class DigitalEngineSender : MessageSender
|
||||||
{
|
{
|
||||||
|
/// How long the engine may make no progress at all, after it has been told
|
||||||
|
/// to stop, before the key goes down anyway. It is not a limit on the whole
|
||||||
|
/// wait: a flushed message is seconds of transmission and the engine is
|
||||||
|
/// entitled to all of it. Measured against the whole wait instead, it cut
|
||||||
|
/// a CQ off with 21 symbols still in the engine.
|
||||||
|
public static readonly TimeSpan StopPatience = TimeSpan.FromSeconds(1.5);
|
||||||
|
|
||||||
|
/// The longest the stop waits for the engine to say it has the message.
|
||||||
|
/// The stop does nothing at all if it arrives at an engine with an empty
|
||||||
|
/// buffer, so it goes out as soon as the count says there is something to
|
||||||
|
/// stop, and after this long whether the count says so or not. It is
|
||||||
|
/// N1MM's number, which N1MM sleeps outright.
|
||||||
|
public static readonly TimeSpan StopDelay = TimeSpan.FromMilliseconds(400);
|
||||||
|
|
||||||
private readonly DigitalEngine engine;
|
private readonly DigitalEngine engine;
|
||||||
|
|
||||||
|
private readonly Lock gate = new();
|
||||||
|
|
||||||
|
/// Where the transmitter is, as far as this program knows.
|
||||||
|
private Keying state = Keying.Down;
|
||||||
|
|
||||||
|
/// Which transmission this is. It goes up whenever one starts or is
|
||||||
|
/// abandoned, and the ending of a message checks it after every step: an
|
||||||
|
/// ending belongs to one transmission and must not act on the next one.
|
||||||
|
/// Without it the ending of one message put the key down in the middle of
|
||||||
|
/// the message after it.
|
||||||
|
private int transmission;
|
||||||
|
|
||||||
public DigitalEngineSender(DigitalEngine engine, double baud = TypeAhead.DefaultBaud)
|
public DigitalEngineSender(DigitalEngine engine, double baud = TypeAhead.DefaultBaud)
|
||||||
{
|
{
|
||||||
this.engine = engine;
|
this.engine = engine;
|
||||||
TypeAhead = new TypeAhead(
|
Buffer = engine is EngineBuffer counter
|
||||||
|
? new TypeAhead(counter, baud)
|
||||||
|
: new TypeAhead(
|
||||||
(character, cancellation) => engine.SendAsync(character.ToString(), cancellation),
|
(character, cancellation) => engine.SendAsync(character.ToString(), cancellation),
|
||||||
baud);
|
baud);
|
||||||
TypeAhead.Drained += WhenDrained;
|
Buffer.Given += WhenGiven;
|
||||||
engine.TransmitChanged += WhenTransmitChanged;
|
engine.TransmitChanged += WhenTransmitChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What is waiting to go out, which the digital window shows and edits.
|
/// What is waiting to go out, which the digital window shows and edits.
|
||||||
public TypeAhead TypeAhead { get; }
|
public TypeAhead Buffer { get; }
|
||||||
|
|
||||||
public bool IsReady => engine.IsConnected;
|
public bool IsReady => engine.IsConnected;
|
||||||
|
|
||||||
@@ -35,17 +96,158 @@ public sealed class DigitalEngineSender : MessageSender
|
|||||||
|
|
||||||
public event EventHandler? Finished;
|
public event EventHandler? Finished;
|
||||||
|
|
||||||
public Task SendAsync(string text, CancellationToken cancellation = default)
|
/// A message to send. Text is the operator asking for the transmitter, so
|
||||||
|
/// it keys as well: text arriving while the last message was ending
|
||||||
|
/// abandons that ending, and text arriving with the transmitter down brings
|
||||||
|
/// it up rather than letting MMTTY key itself off the first character.
|
||||||
|
public async Task SendAsync(string text, CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
TypeAhead.Append(text);
|
await StartAsync(cancellation).ConfigureAwait(false);
|
||||||
return Task.CompletedTask;
|
Buffer.Append(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Escape and the RX button: what has not gone to the engine is dropped,
|
private async Task TransmitAsync()
|
||||||
/// and the engine drops what it still holds.
|
{
|
||||||
|
await StartAsync().ConfigureAwait(false);
|
||||||
|
Buffer.Transmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The TX button, Ctrl+Enter and `{TX}`: the transmitter comes up and what
|
||||||
|
/// is in the pane goes out, and so does whatever is typed into it from now
|
||||||
|
/// on.
|
||||||
|
///
|
||||||
|
/// Keying belongs here rather than in the window so that it is always the
|
||||||
|
/// first thing the engine is told. MMTTY keys itself off a character it is
|
||||||
|
/// given while the transmitter is down, sends it and drops again, so a key
|
||||||
|
/// that arrives behind the text puts a keyed-up gap in the middle of a
|
||||||
|
/// message.
|
||||||
|
public void Transmit() => _ = TransmitAsync();
|
||||||
|
|
||||||
|
/// N1MM's `{RX}`: everything still waiting goes to the engine in one piece
|
||||||
|
/// and the engine is then asked to stop. A macro that ends with it goes out
|
||||||
|
/// in full, and so does anything the operator has typed ahead of it.
|
||||||
|
///
|
||||||
|
/// It does not wait for the feeder to hand the message over first. Waiting
|
||||||
|
/// is what made the stop useless: by the time the last character had gone
|
||||||
|
/// over, the engine was empty again, and MMTTY ignores a stop that reaches
|
||||||
|
/// it empty.
|
||||||
|
public void ReturnToReceiveWhenSent() => _ = StopAsync();
|
||||||
|
|
||||||
|
/// A transmission begins, or the one that was ending goes on. True when the
|
||||||
|
/// engine has to be keyed, which is every time but one already keyed and
|
||||||
|
/// still going.
|
||||||
|
private async Task StartAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
bool ending;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
ending = state == Keying.Ending;
|
||||||
|
}
|
||||||
|
if (ending)
|
||||||
|
{
|
||||||
|
// the last message is ending and its stop is inside the engine,
|
||||||
|
// waiting for the buffer to empty. Feeding an engine with that
|
||||||
|
// standing left MMTTY keyed with nothing going out and the
|
||||||
|
// characters swallowed, so what it still holds is let out and the
|
||||||
|
// stop is cleared with an abort before it is keyed again
|
||||||
|
await WaitUntilAiredAsync(StopPatience).ConfigureAwait(false);
|
||||||
|
await engine.AbortAsync(cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
bool key;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
key = state != Keying.Keyed;
|
||||||
|
if (key)
|
||||||
|
{
|
||||||
|
transmission++;
|
||||||
|
}
|
||||||
|
state = Keying.Keyed;
|
||||||
|
}
|
||||||
|
if (key)
|
||||||
|
{
|
||||||
|
// the pane starts again on the message that has gone out. The
|
||||||
|
// engine reporting the drop does this too, but it never reports one
|
||||||
|
// when a message follows the last close enough to keep the
|
||||||
|
// transmitter up, and the pane then held the whole run with none of
|
||||||
|
// it editable
|
||||||
|
Buffer.Started();
|
||||||
|
await engine.KeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits until the engine says it holds something, or `patience` runs out.
|
||||||
|
/// The stop does nothing at an engine with an empty buffer, so it goes out
|
||||||
|
/// as soon as there is something to stop. N1MM sleeps 400 ms here instead,
|
||||||
|
/// which is the same wait without the question: it never reads the count.
|
||||||
|
/// A sleep is also wrong on a short message — `TU` is 330 ms of air at
|
||||||
|
/// 45.45 baud, so 400 ms of it puts the stop back where it does nothing.
|
||||||
|
private async Task WaitUntilHoldingAsync(TimeSpan patience)
|
||||||
|
{
|
||||||
|
DateTime giveUp = DateTime.UtcNow + patience;
|
||||||
|
while (Buffer.EngineHolds <= 0 && DateTime.UtcNow < giveUp)
|
||||||
|
{
|
||||||
|
await Task.Delay(TypeAhead.PollInterval).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits until the engine has transmitted everything it was given, or until
|
||||||
|
/// it has made no progress for `patience`.
|
||||||
|
///
|
||||||
|
/// `patience` is not a limit on the whole wait: a flushed message is
|
||||||
|
/// seconds of transmission and the engine is entitled to all of it. What it
|
||||||
|
/// catches is an engine that has stopped moving. Measured against the whole
|
||||||
|
/// wait, it cut a CQ off with 21 symbols still in the engine.
|
||||||
|
private async Task WaitUntilAiredAsync(TimeSpan patience)
|
||||||
|
{
|
||||||
|
if (Buffer.Outstanding <= 0 && Buffer.EngineHolds <= 0 && !Buffer.IsSending)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TaskCompletionSource aired = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
void WhenAired(object? sender, EventArgs e) => aired.TrySetResult();
|
||||||
|
Buffer.Aired += WhenAired;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int holds = Buffer.EngineHolds;
|
||||||
|
int outstanding = Buffer.Outstanding;
|
||||||
|
DateTime giveUp = DateTime.UtcNow + patience;
|
||||||
|
while (!aired.Task.IsCompleted && DateTime.UtcNow < giveUp)
|
||||||
|
{
|
||||||
|
await Task.Delay(TypeAhead.PollInterval).ConfigureAwait(false);
|
||||||
|
if (Buffer.EngineHolds < holds || Buffer.Outstanding < outstanding)
|
||||||
|
{
|
||||||
|
holds = Buffer.EngineHolds;
|
||||||
|
outstanding = Buffer.Outstanding;
|
||||||
|
giveUp = DateTime.UtcNow + patience;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Buffer.Aired -= WhenAired;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True while `mine` is still the transmission being ended. False once
|
||||||
|
/// something has started another one, ended this one, or aborted.
|
||||||
|
private bool Ending(int mine)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return state == Keying.Ending && transmission == mine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape: what has not gone to the engine is dropped, and the engine drops
|
||||||
|
/// what it still holds.
|
||||||
public Task AbortAsync(CancellationToken cancellation = default)
|
public Task AbortAsync(CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
TypeAhead.Drop();
|
lock (gate)
|
||||||
|
{
|
||||||
|
state = Keying.Down;
|
||||||
|
transmission++;
|
||||||
|
}
|
||||||
|
Buffer.Drop();
|
||||||
return engine.AbortAsync(cancellation);
|
return engine.AbortAsync(cancellation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,29 +258,134 @@ public sealed class DigitalEngineSender : MessageSender
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
TypeAhead.Drained -= WhenDrained;
|
Buffer.Given -= WhenGiven;
|
||||||
TypeAhead.Dispose();
|
Buffer.Dispose();
|
||||||
engine.TransmitChanged -= WhenTransmitChanged;
|
engine.TransmitChanged -= WhenTransmitChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WhenDrained(object? sender, EventArgs e) => Finished?.Invoke(this, EventArgs.Empty);
|
/// The message is finished when the last character has been handed to the
|
||||||
|
/// engine, not when the engine has transmitted it. What stands after
|
||||||
|
/// `{END}` runs there, `{RX}` among it, and `{RX}` has to reach the engine
|
||||||
|
/// while the engine still holds something to send.
|
||||||
|
private void WhenGiven(object? sender, EventArgs e) => Finished?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
/// The engine dropping the transmitter empties its buffer, which the
|
/// Tells the engine to stop, waits for it to transmit what it still holds,
|
||||||
/// type-ahead buffer is told so it stops waiting for characters that have
|
/// and puts the key down.
|
||||||
/// 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
|
/// This is N1MM's ending, which is not the same as its keying. N1MM hands
|
||||||
/// characters of a message the pump is still feeding, and that is not the
|
/// MMTTY the whole message with `SendString` and calls `SetMmttyPTT(1)`
|
||||||
/// end of anything.
|
/// with the message still in the engine, and MMTTY ends the transmission
|
||||||
|
/// itself at the last character. So the feeding stops here: what is left
|
||||||
|
/// goes over in one piece and the engine is asked to stop on a full buffer.
|
||||||
|
/// Nothing after the flush can be rewritten, which is what `{RX}` means.
|
||||||
|
///
|
||||||
|
/// What sits between the two is the count, not a sleep. Two of N1MM's three
|
||||||
|
/// MMTTY send paths call the stop straight after the text and the third
|
||||||
|
/// sleeps 400 ms first, which is a fixed wait for something this program
|
||||||
|
/// can ask about: the stop goes out as soon as the engine says it holds
|
||||||
|
/// something. A fixed sleep is also wrong on a short message — `TU` is
|
||||||
|
/// 330 ms of air at 45.45 baud, so 400 ms of sleeping puts the stop back
|
||||||
|
/// where it does nothing.
|
||||||
|
///
|
||||||
|
/// The key going down is the fallback for an engine that ignores the stop,
|
||||||
|
/// which is what MMTTY did every time it was asked on an empty buffer. It
|
||||||
|
/// waits for the engine's own count to reach 0 and one symbol time on
|
||||||
|
/// top, which covers the count being read every `TypeAhead.PollInterval`
|
||||||
|
/// rather than the transmission. Every millisecond here is turnaround time
|
||||||
|
/// in a contest.
|
||||||
|
private async Task StopAsync()
|
||||||
|
{
|
||||||
|
int mine;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (state == Keying.Down)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state = Keying.Ending;
|
||||||
|
mine = transmission;
|
||||||
|
}
|
||||||
|
string rest = await Buffer
|
||||||
|
.FlushAsync((text, cancellation) => engine.SendAsync(text, cancellation))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (rest.Length > 0 && Ending(mine))
|
||||||
|
{
|
||||||
|
await WaitUntilHoldingAsync(StopDelay).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
if (!Ending(mine))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await engine.ReturnToReceiveAsync().ConfigureAwait(false);
|
||||||
|
await WaitUntilAiredAsync(StopPatience).ConfigureAwait(false);
|
||||||
|
if (!Ending(mine))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Task.Delay(Buffer.SymbolTime).ConfigureAwait(false);
|
||||||
|
if (Ending(mine))
|
||||||
|
{
|
||||||
|
await engine.ReleaseKeyAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine dropping the transmitter ends the message, but only after
|
||||||
|
/// `{RX}` or an abort asked it to stop. The pane then starts again: what
|
||||||
|
/// has gone out is cleared, what the operator typed ahead is kept, and
|
||||||
|
/// nothing more is fed until the transmitter is keyed again.
|
||||||
|
///
|
||||||
|
/// A drop nobody asked for is the engine keying itself off what it is
|
||||||
|
/// given, which happens between the characters of a message that is still
|
||||||
|
/// going out, and is not the end of anything.
|
||||||
private void WhenTransmitChanged(object? sender, bool transmitting)
|
private void WhenTransmitChanged(object? sender, bool transmitting)
|
||||||
{
|
{
|
||||||
if (transmitting)
|
if (transmitting)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
TypeAhead.EngineIdle();
|
bool unfinished = Buffer.IsTransmitting && Buffer.IsSending;
|
||||||
if (!TypeAhead.IsSending)
|
bool ended;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
ended = state == Keying.Ending;
|
||||||
|
if (ended)
|
||||||
|
{
|
||||||
|
state = Keying.Down;
|
||||||
|
transmission++;
|
||||||
|
// inside the lock, so a message starting at this moment cannot
|
||||||
|
// have its own text cleared by the end of the one before it.
|
||||||
|
// The drop arrives from the engine on its own thread: a macro
|
||||||
|
// pressed on the last character of a message got as far as
|
||||||
|
// handing its text to the engine before this ran, and the pane
|
||||||
|
// was then wiped while the text went out
|
||||||
|
Buffer.Ended();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ended)
|
||||||
|
{
|
||||||
|
// the engine dropped in the middle of a message. What is left of it
|
||||||
|
// would go out into a transmitter that is down, so it comes back
|
||||||
|
// up; the engine keying itself off the next character would leave
|
||||||
|
// that character half sent
|
||||||
|
if (unfinished)
|
||||||
|
{
|
||||||
|
_ = engine.KeyAsync();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Buffer.IsSending)
|
||||||
{
|
{
|
||||||
Finished?.Invoke(this, EventArgs.Empty);
|
Finished?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the transmitter is. `Ending` is one message: everything left of it
|
||||||
|
/// has gone to the engine and nothing more is fed, and the engine is
|
||||||
|
/// transmitting what it holds.
|
||||||
|
private enum Keying
|
||||||
|
{
|
||||||
|
Down,
|
||||||
|
Keyed,
|
||||||
|
Ending,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -7,7 +7,11 @@ namespace Nonemm.Digital;
|
|||||||
/// N1MM's: the engine is given a title, the PTT port out of Mmtty.INI and a
|
/// N1MM's: the engine is given a title, the PTT port out of Mmtty.INI and a
|
||||||
/// command line, and an engine that fails to initialise is started again up to
|
/// command line, and an engine that fails to initialise is started again up to
|
||||||
/// ten times, which is what N1MM's retry does.
|
/// ten times, which is what N1MM's retry does.
|
||||||
public sealed class MmttyEngine : DigitalEngine
|
///
|
||||||
|
/// It holds a buffer of its own and says how much of it is left, so the
|
||||||
|
/// type-ahead feeds it one character at a time the way N1MM does and paces on
|
||||||
|
/// the count.
|
||||||
|
public sealed class MmttyEngine : DigitalEngine, EngineBuffer
|
||||||
{
|
{
|
||||||
private const int StartAttempts = 10;
|
private const int StartAttempts = 10;
|
||||||
|
|
||||||
@@ -54,6 +58,10 @@ public sealed class MmttyEngine : DigitalEngine
|
|||||||
/// Anything the bridge says about itself, for the status line.
|
/// Anything the bridge says about itself, for the status line.
|
||||||
public event EventHandler<string>? Reported;
|
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)
|
public async Task StartAsync(CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
settings = MmttySettings.Read(options.SettingsPath);
|
settings = MmttySettings.Read(options.SettingsPath);
|
||||||
@@ -75,15 +83,34 @@ public sealed class MmttyEngine : DigitalEngine
|
|||||||
public Task TypeAsync(char character, CancellationToken cancellation = default) =>
|
public Task TypeAsync(char character, CancellationToken cancellation = default) =>
|
||||||
PostAsync(MmttyMessage.TypeCharacter, character, cancellation);
|
PostAsync(MmttyMessage.TypeCharacter, character, cancellation);
|
||||||
|
|
||||||
/// N1MM's abort, which is what its RX button and Escape do: drop PTT and
|
/// Asks how many characters the engine still holds. The control has no
|
||||||
/// let the engine stop where it is.
|
/// event for it, so it is polled; the answer arrives on `Buffered`. A
|
||||||
public Task AbortAsync(CancellationToken cancellation = default) =>
|
/// property name other than `TxBufLen` is only for finding out what the
|
||||||
SetPttAsync(false, cancellation);
|
/// control answers to.
|
||||||
|
public Task AskBufferedAsync(string property = "", CancellationToken cancellation = default) =>
|
||||||
|
bridge.SendAsync(BridgeLine.Write("buffer", property), cancellation);
|
||||||
|
|
||||||
/// N1MM's `{TX}` and `{RX}`. MMTTY sends what is in its buffer before it
|
/// Escape and the RX button: stop now and leave what has not gone out
|
||||||
/// drops PTT, so a macro that ends with `{RX}` still goes out in full.
|
/// unsent. N1MM's `AbortXmit`.
|
||||||
|
public Task AbortAsync(CancellationToken cancellation = default) =>
|
||||||
|
bridge.SendAsync(BridgeLine.Write("ptt", "0"), cancellation);
|
||||||
|
|
||||||
|
/// The control's `PTT` property back to false, which is the key the other
|
||||||
|
/// way rather than a stop. N1MM never does this: it keys with the property
|
||||||
|
/// and leaves `SetMmttyPTT(1)` to drop the transmitter.
|
||||||
|
public Task ReleaseKeyAsync(CancellationToken cancellation = default) =>
|
||||||
|
bridge.SendAsync(BridgeLine.Write("key", "0"), cancellation);
|
||||||
|
|
||||||
|
/// N1MM's `{TX}` and `{RX}`. Keying is the control's `PTT` property;
|
||||||
|
/// unkeying is `SetMmttyPTT(1)`, which waits for the buffer to empty first,
|
||||||
|
/// so a macro that ends with `{RX}` still goes out in full.
|
||||||
public Task SetPttAsync(bool on, CancellationToken cancellation = default) =>
|
public Task SetPttAsync(bool on, CancellationToken cancellation = default) =>
|
||||||
bridge.SendAsync(BridgeLine.Write("ptt", on ? "1" : "0"), cancellation);
|
bridge.SendAsync(
|
||||||
|
on ? BridgeLine.Write("key", "1") : BridgeLine.Write("ptt", "1"),
|
||||||
|
cancellation);
|
||||||
|
|
||||||
|
public Task KeyAsync(CancellationToken cancellation = default) =>
|
||||||
|
SetPttAsync(true, cancellation);
|
||||||
|
|
||||||
public Task ReturnToReceiveAsync(CancellationToken cancellation = default) =>
|
public Task ReturnToReceiveAsync(CancellationToken cancellation = default) =>
|
||||||
SetPttAsync(false, cancellation);
|
SetPttAsync(false, cancellation);
|
||||||
@@ -154,6 +181,9 @@ public sealed class MmttyEngine : DigitalEngine
|
|||||||
case "rx":
|
case "rx":
|
||||||
Received?.Invoke(this, ((char)Number(fields, 0)).ToString());
|
Received?.Invoke(this, ((char)Number(fields, 0)).ToString());
|
||||||
break;
|
break;
|
||||||
|
case "buffer":
|
||||||
|
Buffered?.Invoke(this, Number(fields, 0));
|
||||||
|
break;
|
||||||
case "tx":
|
case "tx":
|
||||||
IsTransmitting = Number(fields, 0) == 1;
|
IsTransmitting = Number(fields, 0) == 1;
|
||||||
TransmitChanged?.Invoke(this, IsTransmitting);
|
TransmitChanged?.Invoke(this, IsTransmitting);
|
||||||
|
|||||||
@@ -5,69 +5,154 @@ namespace Nonemm.Digital;
|
|||||||
/// The text waiting to go out, held here rather than handed to the engine in
|
/// The text waiting to go out, held here rather than handed to the engine in
|
||||||
/// one piece.
|
/// one piece.
|
||||||
///
|
///
|
||||||
/// A digital engine takes a whole message and transmits it at the baud rate,
|
/// A digital engine transmits at the baud rate, which is slow: a callsign and a
|
||||||
/// which is slow: a callsign and a report take several seconds. Once the engine
|
/// report take several seconds. Once the engine has a character nothing can
|
||||||
/// has the message nothing can be changed, so the operator who sees a wrong
|
/// take it back — MMTTY treats a backspace as another character to transmit
|
||||||
/// call go out has to stop the transmission and start again. This keeps the
|
/// rather than as an edit, which the engine probe showed on the air. So the
|
||||||
/// message here instead and feeds it to the engine a few characters at a time,
|
/// message is held here and the engine is kept just short of running dry, and
|
||||||
/// so everything that has not gone out yet can still be rewritten, added to or
|
/// everything behind that can still be rewritten, added to or deleted.
|
||||||
/// deleted.
|
|
||||||
///
|
///
|
||||||
/// `Sent` is what has gone to the engine and cannot be taken back. `Pending` is
|
/// Two things happen to a character and they are not the same: it is **given**
|
||||||
/// what is still to go. `Cursor` is how much of the pending text may go out,
|
/// to the engine, and later it **goes out** on the air. Everything here is one
|
||||||
/// which is where the operator is typing: the pump stops when it reaches the
|
/// or the other.
|
||||||
/// cursor, because the operator has not finished the word yet.
|
|
||||||
///
|
///
|
||||||
/// The pump keeps `Lead` characters in the engine rather than one. An engine
|
/// | Given to the engine | Gone out on the air |
|
||||||
/// 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
|
/// | `Sent`, and it cannot be taken back | `OnAir`, how much of `Sent` has been transmitted |
|
||||||
/// added to the time the message takes. Keeping a second character queued
|
/// | `Outstanding`, given but not out yet | |
|
||||||
/// behind the one being transmitted means the engine never runs dry, and the
|
/// | `Given` is raised when there is nothing left to give | `Aired` is raised when the engine has transmitted it too |
|
||||||
/// cost is that the last `Lead` characters cannot be taken back rather than the
|
|
||||||
/// last one.
|
|
||||||
///
|
///
|
||||||
/// `Lead` characters at the front are timed off the clock at the baud rate,
|
/// `Pending` is the rest: what has not been given to the engine and can still
|
||||||
/// which is an estimate of how far the engine has got. It is corrected by
|
/// be rewritten. `EngineHolds` is the engine's own answer to the same question
|
||||||
/// `EngineIdle`: an engine that has stopped transmitting has an empty buffer,
|
/// as `Outstanding`, in Baudot symbols rather than characters.
|
||||||
/// whatever the estimate says.
|
///
|
||||||
|
/// Nothing goes out until `Transmit`, which is the TX button, Ctrl+Enter or a
|
||||||
|
/// function key. From then on everything in `Pending` goes out, and so does
|
||||||
|
/// anything added to it: the operator can go on typing and can press a function
|
||||||
|
/// key, and both follow what is already going. `Ended` shuts the gate again
|
||||||
|
/// without dropping what is waiting, so text typed ahead survives the end of a
|
||||||
|
/// message.
|
||||||
|
///
|
||||||
|
/// The pace is the clock at the baud rate: one character every
|
||||||
|
/// `SymbolTime`, with the engine kept `Ahead` characters ahead so it never
|
||||||
|
/// runs dry and transmits the idle tone in the middle of a word.
|
||||||
|
/// RTTY runs at a fixed speed, so the clock is right, and the
|
||||||
|
/// engine's own count of what it has left cannot replace it. The engine probe
|
||||||
|
/// shows why: `TxBufLen` read 0 for the first 150 ms after twenty-one
|
||||||
|
/// characters were pushed, and read 0 again for three seconds while the engine
|
||||||
|
/// was still holding `ABCD` on Word out. The count is only to be believed while
|
||||||
|
/// it is large and going down, and the feeder keeps the engine nearly empty,
|
||||||
|
/// so a feeder that paced on the count fed on a reading of 0 and ran ahead of
|
||||||
|
/// the air.
|
||||||
|
///
|
||||||
|
/// The count is a brake instead. `Slack` is how many Baudot symbols the engine
|
||||||
|
/// may be behind — MMTTY answers in symbols, which is more than the characters
|
||||||
|
/// it was given, because a digit costs a shift to figures and the letter after
|
||||||
|
/// it a shift back. An exchange full of digits is slower than the clock thinks,
|
||||||
|
/// and this is what stops the clock running away on it.
|
||||||
|
///
|
||||||
|
/// A count that is not going down means the engine is holding what it has:
|
||||||
|
/// MMTTY on Word out keeps a word until the space after it arrives, and that
|
||||||
|
/// space is the character the brake would hold back. So the brake lets go after
|
||||||
|
/// `HoldingPatience`.
|
||||||
public sealed class TypeAhead : IDisposable
|
public sealed class TypeAhead : IDisposable
|
||||||
{
|
{
|
||||||
/// A RTTY character is a start bit, five data bits and a stop bit and a
|
/// A Baudot symbol is a start bit, five data bits and a stop bit and a
|
||||||
/// half.
|
/// half. A character is one symbol, or two when it needs a shift first.
|
||||||
public const double BitsPerCharacter = 7.5;
|
public const double BitsPerSymbol = 7.5;
|
||||||
|
|
||||||
/// MMTTY's own default, and the speed nearly every RTTY contest runs at.
|
/// MMTTY's own default, and the speed nearly every RTTY contest runs at.
|
||||||
public const double DefaultBaud = 45.45;
|
public const double DefaultBaud = 45.45;
|
||||||
|
|
||||||
/// How many characters may sit in the engine. Two is the smallest number
|
/// How many characters the engine is kept ahead by. Two is the smallest
|
||||||
/// that keeps the engine transmitting without a gap: one on the air and one
|
/// number that keeps it transmitting without a gap: one on the air and one
|
||||||
/// behind it. At 45.45 baud that puts the last third of a second of the
|
/// in hand for when that one finishes. An engine left with an empty buffer
|
||||||
/// message beyond reach.
|
/// transmits the idle tone instead, which is audible between the characters
|
||||||
public const int DefaultLead = 2;
|
/// of a long word. At 45.45 baud this puts the last third of a second of
|
||||||
|
/// the message beyond reach.
|
||||||
|
public const int DefaultAhead = 2;
|
||||||
|
|
||||||
|
/// How many Baudot symbols the engine may be behind the clock before the
|
||||||
|
/// feeder waits for it. Six is about a second at 45.45 baud, and well above
|
||||||
|
/// what `Ahead` characters of ordinary text come to, so the brake only
|
||||||
|
/// bites on a run of digits, which costs more symbols than the clock
|
||||||
|
/// thinks.
|
||||||
|
public const int DefaultSlack = 6;
|
||||||
|
|
||||||
/// How much of the text that has gone out is kept. It is there to be read
|
/// How much of the text that has gone out is kept. It is there to be read
|
||||||
/// back, not to be a log.
|
/// back, not to be a log.
|
||||||
public const int KeptSent = 2000;
|
public const int KeptSent = 2000;
|
||||||
|
|
||||||
/// `Cursor` set to this lets everything pending go out, which is where it
|
/// How far behind the engine's count runs. The probe pushed twenty-one
|
||||||
/// stands while the operator is not typing into the pane.
|
/// characters and read 0 for three answers, 100 to 150 ms, before the count
|
||||||
public const int NoCursor = int.MaxValue;
|
/// caught up with them. A 0 newer than this says nothing.
|
||||||
|
public static readonly TimeSpan CountLag = TimeSpan.FromMilliseconds(250);
|
||||||
|
|
||||||
/// The longest the pump sleeps between looks at the buffer. It is what
|
/// How often the engine is asked how much it has left, and how closely the
|
||||||
/// stands between the operator moving the cursor on and the next character
|
/// feeder follows its own clock. It is well under a character time at any
|
||||||
/// going out, so it is short against a character time.
|
/// speed RTTY is worked at.
|
||||||
private static readonly TimeSpan LongestTick = TimeSpan.FromMilliseconds(10);
|
public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
|
||||||
|
|
||||||
private readonly Func<char, CancellationToken, Task> send;
|
private readonly Func<char, CancellationToken, Task> send;
|
||||||
|
private readonly EngineBuffer? counter;
|
||||||
private readonly Lock gate = new();
|
private readonly Lock gate = new();
|
||||||
|
|
||||||
|
/// Held while a character or a flush is on its way to the engine, so the
|
||||||
|
/// two cannot cross. Without it a flush overtook the character the feeder
|
||||||
|
/// had already taken and was still sending, and that character went out
|
||||||
|
/// behind the rest of the message.
|
||||||
|
private readonly SemaphoreSlim handing = new(1, 1);
|
||||||
private readonly StringBuilder pending = new();
|
private readonly StringBuilder pending = new();
|
||||||
private readonly StringBuilder sent = new();
|
private readonly StringBuilder sent = new();
|
||||||
|
|
||||||
private CancellationTokenSource? stopping;
|
private CancellationTokenSource? stopping;
|
||||||
private Task pump = Task.CompletedTask;
|
|
||||||
private int cursor = NoCursor;
|
/// True once `Transmit` has been called and until the message ends: the
|
||||||
private int inEngine;
|
/// gate between what the operator has typed and the engine.
|
||||||
private DateTime nextOut = DateTime.MinValue;
|
private bool open;
|
||||||
|
|
||||||
|
/// True while no feeder is running, so anything waiting for the end of the
|
||||||
|
/// message runs at once rather than waiting for a feeder that never starts.
|
||||||
|
private bool idle = true;
|
||||||
|
|
||||||
|
/// True once there has been nothing left to hand to the engine, so the end
|
||||||
|
/// of a message is announced once rather than at every poll after it.
|
||||||
|
private bool given = true;
|
||||||
|
|
||||||
|
/// The engine's last answer: how many symbols it still had to transmit, or
|
||||||
|
/// -1 before it has answered at all.
|
||||||
|
private int counted = -1;
|
||||||
|
|
||||||
|
/// How many characters at the front of `sent` the engine has transmitted.
|
||||||
|
private int aired;
|
||||||
|
|
||||||
|
/// The count the engine last answered, and how many symbols it has been
|
||||||
|
/// seen to drop that have not been charged to a character yet. Together
|
||||||
|
/// they are the air's own rate: a count that falls by four means four
|
||||||
|
/// symbols left the engine.
|
||||||
|
private int lastCount = -1;
|
||||||
|
private int budget;
|
||||||
|
|
||||||
|
/// When the engine was last given a character. Its count reads 0 for the
|
||||||
|
/// first 150 ms after a push, so a 0 within `CountLag` of one is the answer
|
||||||
|
/// not having caught up rather than an empty engine.
|
||||||
|
private DateTime gaveAt = DateTime.MinValue;
|
||||||
|
|
||||||
|
/// How many characters the engine holds that have not gone out yet, and
|
||||||
|
/// when the one it is transmitting now is finished. The engine transmits at
|
||||||
|
/// the baud rate, so what it holds goes out one character time apart. The
|
||||||
|
/// feeder keeps these, and a flush adds to them in one go.
|
||||||
|
///
|
||||||
|
/// They outlive the feeder, which starts and stops with the text: a feeder
|
||||||
|
/// that started after a flush and reset them forgot a whole message the
|
||||||
|
/// engine was still holding, and the key went down in the middle of it.
|
||||||
|
private int outstanding;
|
||||||
|
private DateTime nextOut = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// Whether the engine is in the figures shift, on the air and at the front
|
||||||
|
/// of the feeder. The two run apart: the feeder is `Ahead` characters in
|
||||||
|
/// front of what is being transmitted.
|
||||||
|
private bool airShift;
|
||||||
|
private bool feedShift;
|
||||||
|
|
||||||
public TypeAhead(Func<char, CancellationToken, Task> send, double baud = DefaultBaud)
|
public TypeAhead(Func<char, CancellationToken, Task> send, double baud = DefaultBaud)
|
||||||
{
|
{
|
||||||
@@ -75,15 +160,36 @@ public sealed class TypeAhead : IDisposable
|
|||||||
Baud = baud;
|
Baud = baud;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The speed the engine transmits at, which is what the estimate of its
|
/// An engine that holds a buffer of its own and can say how much of it is
|
||||||
/// buffer is paced by.
|
/// left, which is what paces the feeder.
|
||||||
|
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 is what paces the feeder.
|
||||||
public double Baud { get; set; }
|
public double Baud { get; set; }
|
||||||
|
|
||||||
/// How many characters may sit in the engine at once.
|
/// How many characters the engine is kept ahead by.
|
||||||
public int Lead { get; set; } = DefaultLead;
|
public int Ahead { get; set; } = DefaultAhead;
|
||||||
|
|
||||||
public TimeSpan CharacterTime =>
|
/// How many symbols the engine may be behind before the feeder waits.
|
||||||
TimeSpan.FromSeconds(BitsPerCharacter / (Baud > 0 ? Baud : DefaultBaud));
|
public int Slack { get; set; } = DefaultSlack;
|
||||||
|
|
||||||
|
/// True once the engine has said how much it holds.
|
||||||
|
public bool Counts { get; private set; }
|
||||||
|
|
||||||
|
/// How long one Baudot symbol takes on the air. A character takes one of
|
||||||
|
/// these, or two when the engine has to shift to figures or back first.
|
||||||
|
public TimeSpan SymbolTime =>
|
||||||
|
TimeSpan.FromSeconds(BitsPerSymbol / (Baud > 0 ? Baud : DefaultBaud));
|
||||||
|
|
||||||
|
/// How long the brake may hold before it lets go. Three character times is
|
||||||
|
/// longer than any gap between transmitted characters and short enough that
|
||||||
|
/// a word the engine is holding goes out at once.
|
||||||
|
public TimeSpan HoldingPatience => SymbolTime * 3;
|
||||||
|
|
||||||
/// What has gone to the engine.
|
/// What has gone to the engine.
|
||||||
public string Sent
|
public string Sent
|
||||||
@@ -97,6 +203,30 @@ public sealed class TypeAhead : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many characters of `Sent` have gone out over the air.
|
||||||
|
///
|
||||||
|
/// The engine is kept `Ahead` characters ahead, so the last characters
|
||||||
|
/// handed to it are still in its buffer. They cannot be taken back, but
|
||||||
|
/// they have not been heard yet, so the pane draws them as text still to
|
||||||
|
/// go. Without this, a character typed after the engine has caught up went
|
||||||
|
/// red as it was typed: the feeder hands it over at once, and the buffer
|
||||||
|
/// had no other measure of the air.
|
||||||
|
///
|
||||||
|
/// It is counted on the same clock as the feeder: the engine transmits at
|
||||||
|
/// the baud rate, so a character handed to an engine that is already
|
||||||
|
/// transmitting goes out one character time after the one before it, and a
|
||||||
|
/// character handed to an empty engine one character time from now.
|
||||||
|
public int OnAir
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return Math.Min(aired, sent.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What is still to go.
|
/// What is still to go.
|
||||||
public string Pending
|
public string Pending
|
||||||
{
|
{
|
||||||
@@ -109,53 +239,50 @@ public sealed class TypeAhead : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many of the pending characters may go out. The window sets it to
|
/// True while what is waiting is being fed to the engine.
|
||||||
/// where the operator's cursor is; `NoCursor` while nobody is typing.
|
public bool IsTransmitting
|
||||||
public int Cursor
|
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
return cursor;
|
return open;
|
||||||
}
|
|
||||||
}
|
|
||||||
set
|
|
||||||
{
|
|
||||||
lock (gate)
|
|
||||||
{
|
|
||||||
cursor = Math.Max(0, value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True while there is text to send or the engine is estimated to be still
|
|
||||||
/// transmitting what it was given.
|
|
||||||
public bool IsSending
|
public bool IsSending
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
Advance(DateTime.UtcNow);
|
return pending.Length > 0;
|
||||||
return pending.Length > 0 || inEngine > 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The text moved: a character went out, or a message was added. Raised on
|
/// The text moved: a character went out, or a message was added. Raised on
|
||||||
/// the pump's thread, so a handler that touches the screen has to post.
|
/// the feeder's thread, so a handler that touches the screen has to post.
|
||||||
public event EventHandler? Changed;
|
public event EventHandler? Changed;
|
||||||
|
|
||||||
/// Everything that was waiting has gone out, and the engine is estimated to
|
/// Everything that was waiting has been handed to the engine, which still
|
||||||
/// have transmitted it. This is what tells the entry window that a message
|
/// holds the last `Ahead` characters of it. This is what tells the entry
|
||||||
/// is finished, so what stands after `{END}` runs and `{RX}` drops the
|
/// window that a message is finished, so what stands after `{END}` runs and
|
||||||
/// transmitter at the right moment.
|
/// `{RX}` reaches the engine while it has something left to send.
|
||||||
public event EventHandler? Drained;
|
public event EventHandler? Given;
|
||||||
|
|
||||||
|
/// Everything that was waiting has gone out and the engine has transmitted
|
||||||
|
/// it, which is `Ahead` characters later than `Given`.
|
||||||
|
public event EventHandler? Aired;
|
||||||
|
|
||||||
/// A message to send. It goes on the end of what is already waiting, so two
|
/// 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
|
/// function keys pressed together send one after the other rather than one
|
||||||
/// over the other.
|
/// over the other, and a function key pressed while the operator is typing
|
||||||
|
/// follows what has been typed.
|
||||||
|
///
|
||||||
|
/// A function key is the operator asking for the message, so it also opens
|
||||||
|
/// the gate: there is no second key to press.
|
||||||
public void Append(string text)
|
public void Append(string text)
|
||||||
{
|
{
|
||||||
if (text.Length == 0)
|
if (text.Length == 0)
|
||||||
@@ -165,51 +292,167 @@ public sealed class TypeAhead : IDisposable
|
|||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
pending.Append(text);
|
pending.Append(text);
|
||||||
if (cursor != NoCursor)
|
open = true;
|
||||||
{
|
given = false;
|
||||||
// text added behind the operator's cursor is still text to
|
|
||||||
// send, so the cursor moves out with it
|
|
||||||
cursor += text.Length;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
Start();
|
Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The operator rewrote what has not gone out yet.
|
/// The operator rewrote the pane. `text` is the whole pane, what has gone
|
||||||
public void Rewrite(string text, int wanted)
|
/// out and what is still to go, and `changedAt` is where in it the first
|
||||||
|
/// changed character is.
|
||||||
|
///
|
||||||
|
/// The text that has gone out cannot be changed, so as much of `text` as
|
||||||
|
/// matches it is dropped and the rest becomes the pending text. Reading the
|
||||||
|
/// whole pane rather than the pending half is what makes this safe against
|
||||||
|
/// the feeder: a character the feeder took between the window reading the
|
||||||
|
/// pane and this call is still at the front of `text`, and is dropped with
|
||||||
|
/// the rest of what has gone out.
|
||||||
|
///
|
||||||
|
/// An edit does not open or close the gate. Typing before the transmitter
|
||||||
|
/// is keyed stays off the air; typing while it is keyed goes out behind
|
||||||
|
/// what is already going.
|
||||||
|
public void Edit(string text)
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
|
int gone = Math.Min(sent.Length, text.Length);
|
||||||
pending.Clear();
|
pending.Clear();
|
||||||
pending.Append(text);
|
pending.Append(text, gone, text.Length - gone);
|
||||||
cursor = Math.Clamp(wanted, 0, text.Length);
|
given = given && pending.Length == 0;
|
||||||
}
|
}
|
||||||
Start();
|
Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The engine has stopped transmitting, so whatever it was given has gone
|
/// The TX button, Ctrl+Enter and Alt+T: what is in the pane goes out, and
|
||||||
/// out. This corrects the estimate: an engine that is faster than the
|
/// so does whatever is added to it, until the transmitter drops.
|
||||||
/// estimate would otherwise be left waiting for a character it could have
|
public void Transmit()
|
||||||
/// had.
|
|
||||||
public void EngineIdle()
|
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
inEngine = 0;
|
open = true;
|
||||||
|
given = given && pending.Length == 0;
|
||||||
|
}
|
||||||
|
Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message is over: the transmitter has dropped, so the pane starts
|
||||||
|
/// again with what has gone out cleared and whatever was typed ahead kept.
|
||||||
|
public void Ended()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
open = false;
|
||||||
|
sent.Clear();
|
||||||
|
aired = 0;
|
||||||
|
outstanding = 0;
|
||||||
|
}
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new transmission begins. What the last one sent is dropped from the
|
||||||
|
/// pane, and what the operator typed ahead is kept. The gate is left as it
|
||||||
|
/// is: the caller opens it with `Append` or `Transmit`.
|
||||||
|
///
|
||||||
|
/// `Ended` does the same at the end of a message, but only when the engine
|
||||||
|
/// reports that the transmitter dropped. Two messages sent one after the
|
||||||
|
/// other keep the transmitter up, so that report never comes, and without
|
||||||
|
/// this the pane kept every message of the run and none of it could be
|
||||||
|
/// edited: text that has gone to the engine cannot be taken back.
|
||||||
|
public void Started()
|
||||||
|
{
|
||||||
|
bool had;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
had = sent.Length > 0;
|
||||||
|
sent.Clear();
|
||||||
|
aired = 0;
|
||||||
|
}
|
||||||
|
if (had)
|
||||||
|
{
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops what has not gone out. Escape and the RX button do this: what is
|
/// The engine's own answer: how many symbols it still holds, or -1 before
|
||||||
/// already in the engine cannot be stopped from here, and the engine's own
|
/// it has answered at all. It is in symbols, which is more than the
|
||||||
/// abort takes care of that.
|
/// characters it was given, because a digit costs a shift to figures and
|
||||||
|
/// the letter after it a shift back.
|
||||||
|
public int EngineHolds => Volatile.Read(ref counted);
|
||||||
|
|
||||||
|
/// Everything still waiting goes to the engine in one piece through `push`,
|
||||||
|
/// and nothing more can be rewritten. Returns what was sent.
|
||||||
|
///
|
||||||
|
/// The feeder cannot be handing a character over at the same time: this
|
||||||
|
/// takes the same turn the feeder takes, so a character already on its way
|
||||||
|
/// arrives first and the rest follows it in order.
|
||||||
|
public async Task<string> FlushAsync(
|
||||||
|
Func<string, CancellationToken, Task> push,
|
||||||
|
CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
await handing.WaitAsync(cancellation).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string rest = TakePending();
|
||||||
|
if (rest.Length > 0)
|
||||||
|
{
|
||||||
|
await push(rest, cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
handing.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything still waiting, taken in one piece and marked as gone to the
|
||||||
|
/// engine. The caller sends it, and nothing more can be rewritten.
|
||||||
|
///
|
||||||
|
/// `{RX}` uses this. N1MM hands MMTTY the whole message with `SendString`
|
||||||
|
/// and asks it to stop 400 ms later, with the message still in the engine's
|
||||||
|
/// buffer, and MMTTY ends the transmission itself at exactly the right
|
||||||
|
/// moment. `SetMmttyPTT(1)` sent to an engine that has been fed one
|
||||||
|
/// character at a time, and is therefore nearly empty, does nothing at all.
|
||||||
|
private string TakePending()
|
||||||
|
{
|
||||||
|
string rest;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
rest = pending.ToString();
|
||||||
|
pending.Clear();
|
||||||
|
sent.Append(rest);
|
||||||
|
if (sent.Length > KeptSent)
|
||||||
|
{
|
||||||
|
int dropped = sent.Length - KeptSent;
|
||||||
|
sent.Remove(0, dropped);
|
||||||
|
aired = Math.Max(0, aired - dropped);
|
||||||
|
}
|
||||||
|
given = true;
|
||||||
|
// inside the same lock as the pending text, so the feeder cannot
|
||||||
|
// see an empty buffer and call the message over before the engine
|
||||||
|
// is counted as holding what it was just given
|
||||||
|
Gave(rest.Length, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
if (rest.Length > 0)
|
||||||
|
{
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops what has not gone out. Escape does this: what is already in the
|
||||||
|
/// engine cannot be stopped from here, and the engine's own abort takes
|
||||||
|
/// care of that.
|
||||||
public void Drop()
|
public void Drop()
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
pending.Clear();
|
pending.Clear();
|
||||||
inEngine = 0;
|
open = false;
|
||||||
cursor = NoCursor;
|
// the engine's own abort goes with this, so it holds nothing either
|
||||||
|
outstanding = 0;
|
||||||
}
|
}
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
@@ -222,111 +465,369 @@ public sealed class TypeAhead : IDisposable
|
|||||||
{
|
{
|
||||||
pending.Clear();
|
pending.Clear();
|
||||||
sent.Clear();
|
sent.Clear();
|
||||||
inEngine = 0;
|
aired = 0;
|
||||||
cursor = NoCursor;
|
open = false;
|
||||||
}
|
}
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
if (counter is not null)
|
||||||
|
{
|
||||||
|
counter.Buffered -= WhenBuffered;
|
||||||
|
}
|
||||||
stopping?.Cancel();
|
stopping?.Cancel();
|
||||||
stopping?.Dispose();
|
stopping?.Dispose();
|
||||||
stopping = null;
|
stopping = null;
|
||||||
|
handing.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Start()
|
private void Start()
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
if (!pump.IsCompleted)
|
if (!idle)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
idle = false;
|
||||||
stopping?.Dispose();
|
stopping?.Dispose();
|
||||||
stopping = new CancellationTokenSource();
|
stopping = new CancellationTokenSource();
|
||||||
pump = Task.Run(() => RunAsync(stopping.Token));
|
_ = Task.Run(() => RunAsync(stopping.Token));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hands the engine a character whenever it has room for one, until there
|
/// Hands the engine a character every character time, keeping it `Ahead`
|
||||||
/// is nothing left to send. A pump held at the cursor keeps running: the
|
/// characters ahead of the air, unless its own count says it is more than
|
||||||
/// operator is typing, and the next character is theirs to release.
|
/// `Slack` symbols behind and still moving.
|
||||||
private async Task RunAsync(CancellationToken cancellation)
|
private async Task RunAsync(CancellationToken cancellation)
|
||||||
{
|
{
|
||||||
|
// when the next character is owed. It advances by exactly one character
|
||||||
|
// time per character handed over, never from the time the poll happened:
|
||||||
|
// a poll is up to `PollInterval` late, and starting the next character
|
||||||
|
// from there made every one late by a little and the engine run dry
|
||||||
|
DateTime due = DateTime.MinValue;
|
||||||
|
DateTime moved = DateTime.UtcNow;
|
||||||
|
int last = int.MaxValue;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!cancellation.IsCancellationRequested)
|
while (!cancellation.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
if (Take() is { } next)
|
if (counter is not null)
|
||||||
{
|
{
|
||||||
|
await counter.AskBufferedAsync("", cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
DateTime now = DateTime.UtcNow;
|
||||||
|
int left = Volatile.Read(ref counted);
|
||||||
|
if (left < last)
|
||||||
|
{
|
||||||
|
moved = now;
|
||||||
|
}
|
||||||
|
last = left;
|
||||||
|
bool wentOut = Counts ? WentOutByCount(left, now) : WentOutByClock(now);
|
||||||
|
if (wentOut)
|
||||||
|
{
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
bool behind = left > Slack && now - moved < HoldingPatience;
|
||||||
|
// the feeder owes at most `Ahead` characters at any moment,
|
||||||
|
// so a start, a brake letting go or an empty pane does not turn
|
||||||
|
// into a burst that puts the whole message beyond reach
|
||||||
|
DateTime earliest = now - (SymbolTime * (Ahead - 1));
|
||||||
|
if (due < earliest)
|
||||||
|
{
|
||||||
|
due = earliest;
|
||||||
|
}
|
||||||
|
await handing.WaitAsync(cancellation).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!behind && now >= due && Take(now) is { } next)
|
||||||
|
{
|
||||||
|
due += SymbolTime * Symbols(next, ref feedShift);
|
||||||
await send(next, cancellation).ConfigureAwait(false);
|
await send(next, cancellation).ConfigureAwait(false);
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (!IsSending)
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
handing.Release();
|
||||||
|
}
|
||||||
|
if (NothingLeftToGive())
|
||||||
|
{
|
||||||
|
Given?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
// the engine is still holding what it has not transmitted, so
|
||||||
|
// the message is not over and the last characters of it have
|
||||||
|
// not been marked as gone out yet
|
||||||
|
if (left <= 0 && Outstanding == 0 && now >= due && AllAired())
|
||||||
{
|
{
|
||||||
Drained?.Invoke(this, EventArgs.Empty);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await Task.Delay(Tick, cancellation).ConfigureAwait(false);
|
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
finally
|
||||||
|
|
||||||
private TimeSpan Tick
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
TimeSpan quarter = CharacterTime / 4;
|
|
||||||
return quarter < LongestTick ? quarter : LongestTick;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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.
|
|
||||||
private char? Take()
|
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
DateTime now = DateTime.UtcNow;
|
idle = true;
|
||||||
Advance(now);
|
}
|
||||||
if (pending.Length == 0 || cursor == 0 || inEngine >= Lead)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything waiting has gone out and the engine has transmitted it, or
|
||||||
|
/// the gate has closed on what is left. `Aired` says so. False when text
|
||||||
|
/// arrived while the feeder was deciding, which is what keeps a message
|
||||||
|
/// added at the last moment from being stranded.
|
||||||
|
private bool AllAired()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (open && pending.Length > 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
idle = true;
|
||||||
|
}
|
||||||
|
Aired?.Invoke(this, EventArgs.Empty);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True the first time there is nothing left to hand to the engine, and
|
||||||
|
/// false again once more text arrives.
|
||||||
|
private bool NothingLeftToGive()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (given || (open && pending.Length > 0))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
given = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One character the engine held has gone out, on the clock. False when it
|
||||||
|
/// is still transmitting it or holds nothing.
|
||||||
|
///
|
||||||
|
/// The character that goes out is the first one that has not, which is
|
||||||
|
/// `sent[aired]`, and what it costs says when the one after it is due.
|
||||||
|
/// What the engine has transmitted, from the engine itself. Its count is in
|
||||||
|
/// symbols and falls as they go out, so what it drops between two answers
|
||||||
|
/// is what went on the air between them. That is the air's own rate, and
|
||||||
|
/// nothing here has to know what a character costs to follow it: the
|
||||||
|
/// symbols are spent on the characters at the front of what has not gone
|
||||||
|
/// out, at whatever `Symbols` says they cost.
|
||||||
|
///
|
||||||
|
/// A count of 0 is the end of it: the engine holds nothing, so everything
|
||||||
|
/// it was given has gone out, whatever the symbols added up to. That is
|
||||||
|
/// what makes a wrong guess about the shift correct itself every message
|
||||||
|
/// rather than accumulating.
|
||||||
|
///
|
||||||
|
/// The count reads 0 for the first 150 ms after a push, so a 0 that new is
|
||||||
|
/// passed over: it is the answer trailing what the engine was given, not an
|
||||||
|
/// engine that has transmitted it.
|
||||||
|
private bool WentOutByCount(int symbols, DateTime now)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (symbols < 0 || (symbols == 0 && now - gaveAt < CountLag))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (lastCount > symbols)
|
||||||
|
{
|
||||||
|
budget += lastCount - symbols;
|
||||||
|
}
|
||||||
|
lastCount = symbols;
|
||||||
|
bool moved = false;
|
||||||
|
while (aired < sent.Length && outstanding > 0 && budget >= NextSymbols())
|
||||||
|
{
|
||||||
|
budget -= NextSymbols();
|
||||||
|
OneOut();
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
if (symbols == 0)
|
||||||
|
{
|
||||||
|
budget = 0;
|
||||||
|
while (aired < sent.Length && outstanding > 0)
|
||||||
|
{
|
||||||
|
OneOut();
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One character off the front of what has not gone out. The caller holds
|
||||||
|
/// `gate`.
|
||||||
|
private void OneOut()
|
||||||
|
{
|
||||||
|
bool figures = airShift;
|
||||||
|
Symbols(sent[aired], ref figures);
|
||||||
|
airShift = figures;
|
||||||
|
aired++;
|
||||||
|
outstanding--;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the engine has transmitted, on the clock, for an engine that does
|
||||||
|
/// not count.
|
||||||
|
private bool WentOutByClock(DateTime now)
|
||||||
|
{
|
||||||
|
bool moved = false;
|
||||||
|
while (WentOut(now))
|
||||||
|
{
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool WentOut(DateTime now)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (outstanding <= 0 || now < nextOut || aired >= sent.Length)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool figures = airShift;
|
||||||
|
Symbols(sent[aired], ref figures);
|
||||||
|
airShift = figures;
|
||||||
|
outstanding--;
|
||||||
|
aired++;
|
||||||
|
nextOut += SymbolTime * NextSymbols();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks everything up to `mark` as gone out, whatever the clock had
|
||||||
|
/// reached. True when that moved. The caller holds `gate`.
|
||||||
|
private bool AirTo(int mark, DateTime now)
|
||||||
|
{
|
||||||
|
if (mark <= aired)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
while (aired < mark && aired < sent.Length)
|
||||||
|
{
|
||||||
|
bool figures = airShift;
|
||||||
|
Symbols(sent[aired], ref figures);
|
||||||
|
airShift = figures;
|
||||||
|
aired++;
|
||||||
|
outstanding = Math.Max(0, outstanding - 1);
|
||||||
|
}
|
||||||
|
nextOut = now + SymbolTime * NextSymbols();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many symbols the character now at the front of what has not gone out
|
||||||
|
/// takes. The caller holds `gate`.
|
||||||
|
private int NextSymbols()
|
||||||
|
{
|
||||||
|
bool figures = airShift;
|
||||||
|
return aired < sent.Length ? Symbols(sent[aired], ref figures) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `count` more characters are in the engine as of `now`. The caller holds
|
||||||
|
/// `gate`: what the engine holds and what is still to go are one fact and
|
||||||
|
/// are written together.
|
||||||
|
private void Gave(int count, DateTime now)
|
||||||
|
{
|
||||||
|
if (count <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (outstanding == 0)
|
||||||
|
{
|
||||||
|
nextOut = now + (SymbolTime * NextSymbols());
|
||||||
|
}
|
||||||
|
outstanding += count;
|
||||||
|
// the count reads 0 for the first 150 ms after a push, so a 0 from here
|
||||||
|
// on is the answer not having caught up rather than an empty engine.
|
||||||
|
// What it has already been seen to drop still stands: those symbols
|
||||||
|
// went out whatever is given after them.
|
||||||
|
gaveAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many characters the engine has been given that have not gone out
|
||||||
|
/// yet, counted on the clock rather than on the engine's own answer.
|
||||||
|
public int Outstanding
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return outstanding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WhenBuffered(object? sender, int left)
|
||||||
|
{
|
||||||
|
Counts = left >= 0;
|
||||||
|
Volatile.Write(ref counted, left);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many symbols `character` takes, and the shift it leaves the engine
|
||||||
|
/// in. A letter sent while the engine is in figures costs a shift symbol
|
||||||
|
/// and the character, and the same the other way.
|
||||||
|
///
|
||||||
|
/// This is why the clock alone ran ahead of the air. It paced one character
|
||||||
|
/// every symbol time, and a callsign with a digit in it takes more than
|
||||||
|
/// that: 26 characters of one CQ went out in 29 symbols, one part in nine
|
||||||
|
/// slower than the clock thought.
|
||||||
|
///
|
||||||
|
/// A space is taken to put the engine back in letters, which is
|
||||||
|
/// unshift-on-space. It is a setting — `TXUOS` in MMTTY's `UserPara.ini`,
|
||||||
|
/// per profile, and a button on its own display that the operator can press
|
||||||
|
/// mid-contest — so this cannot be read once and believed. It is assumed on
|
||||||
|
/// because MMTTY's help says that is the usual setting, and because being
|
||||||
|
/// wrong that way charges a symbol too many and leaves the pane behind the
|
||||||
|
/// air rather than in front of it. `AllowedOutstanding` is what corrects
|
||||||
|
/// the rest.
|
||||||
|
private static int Symbols(char character, ref bool figures)
|
||||||
|
{
|
||||||
|
if (character is ' ' or '\r' or '\n')
|
||||||
|
{
|
||||||
|
figures = false;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
bool wants = !char.IsAsciiLetter(character);
|
||||||
|
if (wants == figures)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
figures = wants;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next character to send, or null when there is none to send now:
|
||||||
|
/// nothing is waiting, or the gate is shut.
|
||||||
|
private char? Take(DateTime now)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (!open || pending.Length == 0)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
char next = pending[0];
|
char next = pending[0];
|
||||||
pending.Remove(0, 1);
|
pending.Remove(0, 1);
|
||||||
if (cursor != NoCursor)
|
|
||||||
{
|
|
||||||
cursor--;
|
|
||||||
}
|
|
||||||
if (inEngine == 0)
|
|
||||||
{
|
|
||||||
nextOut = now + CharacterTime;
|
|
||||||
}
|
|
||||||
inEngine++;
|
|
||||||
sent.Append(next);
|
sent.Append(next);
|
||||||
|
Gave(1, now);
|
||||||
if (sent.Length > KeptSent)
|
if (sent.Length > KeptSent)
|
||||||
{
|
{
|
||||||
sent.Remove(0, sent.Length - KeptSent);
|
int dropped = sent.Length - KeptSent;
|
||||||
|
sent.Remove(0, dropped);
|
||||||
|
aired = Math.Max(0, aired - dropped);
|
||||||
}
|
}
|
||||||
return next;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,17 +12,28 @@ public sealed class WineBridgeChannel : BridgeChannel
|
|||||||
private readonly string wine;
|
private readonly string wine;
|
||||||
private readonly string bridgePath;
|
private readonly string bridgePath;
|
||||||
private readonly string? prefix;
|
private readonly string? prefix;
|
||||||
|
private readonly string? logFolder;
|
||||||
private readonly Queue<string> lastErrors = new();
|
private readonly Queue<string> lastErrors = new();
|
||||||
private readonly SemaphoreSlim writing = new(1, 1);
|
private readonly SemaphoreSlim writing = new(1, 1);
|
||||||
|
private readonly Lock writingLog = new();
|
||||||
private Process? bridge;
|
private Process? bridge;
|
||||||
|
private StreamWriter? log;
|
||||||
|
private DateTime started;
|
||||||
|
private int lastCount = int.MinValue;
|
||||||
|
|
||||||
/// `prefix` is the WINEPREFIX to run in. Left null, Wine uses its default,
|
/// `prefix` is the WINEPREFIX to run in. Left null, Wine uses its default,
|
||||||
/// which is what a station with one prefix wants.
|
/// which is what a station with one prefix wants. `logFolder`, when it is
|
||||||
public WineBridgeChannel(string bridgePath, string? prefix = null, string wine = "wine")
|
/// given, is where the protocol log for this run is written.
|
||||||
|
public WineBridgeChannel(
|
||||||
|
string bridgePath,
|
||||||
|
string? prefix = null,
|
||||||
|
string wine = "wine",
|
||||||
|
string? logFolder = null)
|
||||||
{
|
{
|
||||||
this.bridgePath = bridgePath;
|
this.bridgePath = bridgePath;
|
||||||
this.prefix = prefix;
|
this.prefix = prefix;
|
||||||
this.wine = wine;
|
this.wine = wine;
|
||||||
|
this.logFolder = logFolder;
|
||||||
}
|
}
|
||||||
|
|
||||||
public event EventHandler<string>? LineReceived;
|
public event EventHandler<string>? LineReceived;
|
||||||
@@ -31,6 +42,7 @@ public sealed class WineBridgeChannel : BridgeChannel
|
|||||||
|
|
||||||
public Task StartAsync(CancellationToken cancellation = default)
|
public Task StartAsync(CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
|
OpenLog();
|
||||||
bridge = Start(bridgePath);
|
bridge = Start(bridgePath);
|
||||||
_ = ReadOutputAsync(bridge);
|
_ = ReadOutputAsync(bridge);
|
||||||
_ = ReadErrorsAsync(bridge);
|
_ = ReadErrorsAsync(bridge);
|
||||||
@@ -46,6 +58,7 @@ public sealed class WineBridgeChannel : BridgeChannel
|
|||||||
await running.StandardInput.WriteLineAsync(line.AsMemory(), cancellation)
|
await running.StandardInput.WriteLineAsync(line.AsMemory(), cancellation)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
await running.StandardInput.FlushAsync(cancellation).ConfigureAwait(false);
|
await running.StandardInput.FlushAsync(cancellation).ConfigureAwait(false);
|
||||||
|
Log(">", line);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -84,6 +97,71 @@ public sealed class WineBridgeChannel : BridgeChannel
|
|||||||
}
|
}
|
||||||
bridge?.Dispose();
|
bridge?.Dispose();
|
||||||
writing.Dispose();
|
writing.Dispose();
|
||||||
|
lock (writingLog)
|
||||||
|
{
|
||||||
|
log?.Dispose();
|
||||||
|
log = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One file per run of the engine, named for when it started. It is the
|
||||||
|
/// whole protocol with a millisecond stamp on every line, which is what
|
||||||
|
/// says who keyed the transmitter and when.
|
||||||
|
private void OpenLog()
|
||||||
|
{
|
||||||
|
if (logFolder is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(logFolder);
|
||||||
|
started = DateTime.Now;
|
||||||
|
log = new StreamWriter(
|
||||||
|
Path.Combine(logFolder, $"digital-{started:yyyyMMdd-HHmmss}.log"),
|
||||||
|
append: false)
|
||||||
|
{
|
||||||
|
AutoFlush = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// a log that cannot be written stops nothing
|
||||||
|
log = null;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
log = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The buffer question goes out every 50 ms and is answered just as often,
|
||||||
|
/// which would bury everything else. The question is left out and the
|
||||||
|
/// answer is written only when the count has changed.
|
||||||
|
private void Log(string direction, string line)
|
||||||
|
{
|
||||||
|
if (log is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(string verb, string[] fields) = BridgeLine.Read(line);
|
||||||
|
if (verb == "buffer")
|
||||||
|
{
|
||||||
|
if (direction == ">" || fields.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!int.TryParse(fields[0], out int count) || count == lastCount)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastCount = count;
|
||||||
|
}
|
||||||
|
lock (writingLog)
|
||||||
|
{
|
||||||
|
log?.WriteLine(
|
||||||
|
$"{(DateTime.Now - started).TotalMilliseconds,9:0} ms {direction} {line.Replace('\t', ' ')}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Process Start(string program, params string[] arguments)
|
private Process Start(string program, params string[] arguments)
|
||||||
@@ -113,6 +191,7 @@ public sealed class WineBridgeChannel : BridgeChannel
|
|||||||
{
|
{
|
||||||
while (await running.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line)
|
while (await running.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line)
|
||||||
{
|
{
|
||||||
|
Log("<", line);
|
||||||
LineReceived?.Invoke(this, line);
|
LineReceived?.Invoke(this, line);
|
||||||
}
|
}
|
||||||
await running.WaitForExitAsync().ConfigureAwait(false);
|
await running.WaitForExitAsync().ConfigureAwait(false);
|
||||||
|
|||||||
98
src/Nonemm.Network/NetworkedStation.cs
Normal file
98
src/Nonemm.Network/NetworkedStation.cs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Network;
|
||||||
|
|
||||||
|
/// One computer on the network, as far as this one can tell. The network status
|
||||||
|
/// window shows a row per station and these are its columns.
|
||||||
|
///
|
||||||
|
/// Everything here is what the station last said, with the time it said it.
|
||||||
|
/// Nothing is asked for on demand: the beacons and the messages carry it, and a
|
||||||
|
/// station that has gone quiet keeps the last thing it said with an older
|
||||||
|
/// `LastHeardUtc`, which is how the window shows it as missing rather than
|
||||||
|
/// blank.
|
||||||
|
public sealed class NetworkedStation
|
||||||
|
{
|
||||||
|
public NetworkedStation(string computerName, string address, int port)
|
||||||
|
{
|
||||||
|
ComputerName = computerName;
|
||||||
|
Address = address;
|
||||||
|
Port = port;
|
||||||
|
// now is when it was heard of, whether that was a beacon or the
|
||||||
|
// operator naming it. Left at nothing, `StationLink.Forget` reads a
|
||||||
|
// station as having been quiet since the beginning of time and drops
|
||||||
|
// the connection to it the moment it is opened
|
||||||
|
LastHeardUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name in every message the station sends, and what a contact from it
|
||||||
|
/// is stamped with. N1MM uses the computer's own network name.
|
||||||
|
public string ComputerName { get; }
|
||||||
|
|
||||||
|
public string Address { get; internal set; }
|
||||||
|
|
||||||
|
public int Port { get; internal set; }
|
||||||
|
|
||||||
|
/// Which station of the entry this is. N1MM numbers them so a contact can
|
||||||
|
/// say which position made it; the number arrives in `IAM`.
|
||||||
|
public int StationNumber { get; internal set; }
|
||||||
|
|
||||||
|
/// The version the station broadcast. N1MM refuses to talk to a station
|
||||||
|
/// whose version is not its own, so a mismatch here is why nothing works.
|
||||||
|
public string Version { get; internal set; } = "";
|
||||||
|
|
||||||
|
public string Operator { get; internal set; } = "";
|
||||||
|
|
||||||
|
public string ContestName { get; internal set; } = "";
|
||||||
|
|
||||||
|
/// Where the station is, from the last `BANDMAP` it sent.
|
||||||
|
public Frequency Frequency { get; internal set; }
|
||||||
|
|
||||||
|
public Mode? Mode { get; internal set; }
|
||||||
|
|
||||||
|
public Band? Band => Bands.ForFrequency(Frequency);
|
||||||
|
|
||||||
|
/// True while the station is running rather than searching. In a
|
||||||
|
/// multi-single entry this is the run station.
|
||||||
|
public bool IsRunning { get; internal set; }
|
||||||
|
|
||||||
|
public bool IsTransmitting { get; internal set; }
|
||||||
|
|
||||||
|
public int RadioNumber { get; internal set; } = 1;
|
||||||
|
|
||||||
|
/// A frequency this station has passed, from `PASSFREQ`, and who is on it.
|
||||||
|
public Frequency PassFrequency { get; internal set; }
|
||||||
|
|
||||||
|
public string PassCall { get; internal set; } = "";
|
||||||
|
|
||||||
|
/// When anything last arrived from the station, and what it was. The window
|
||||||
|
/// shows both: a station whose last message is minutes old is not there any
|
||||||
|
/// more, whatever its connection says.
|
||||||
|
public DateTime LastHeardUtc { get; internal set; }
|
||||||
|
|
||||||
|
public string LastMessage { get; internal set; } = "";
|
||||||
|
|
||||||
|
/// How many messages have gone each way since the program started. N1MM
|
||||||
|
/// shows the same two numbers, and a Send that climbs while Read stands
|
||||||
|
/// still is a station that is not listening.
|
||||||
|
public int Sent { get; internal set; }
|
||||||
|
|
||||||
|
public int Read { get; internal set; }
|
||||||
|
|
||||||
|
/// True while a connection to the station is open. It is not the same as
|
||||||
|
/// the station being there: the connection can stand for a while after the
|
||||||
|
/// other program has stopped.
|
||||||
|
public bool IsConnected { get; internal set; }
|
||||||
|
|
||||||
|
/// How long the last echo took to come back, or null when none has.
|
||||||
|
public TimeSpan? EchoTime { get; internal set; }
|
||||||
|
|
||||||
|
/// This computer, which is in the list as well. N1MM shows it too, so an
|
||||||
|
/// operator can read its own station number and version off the same
|
||||||
|
/// window.
|
||||||
|
public bool IsMine { get; internal init; }
|
||||||
|
|
||||||
|
/// Set when the station broadcast a version that is not ours. N1MM turns
|
||||||
|
/// such a station away, so this program says why rather than failing to
|
||||||
|
/// connect for no visible reason.
|
||||||
|
public string Refused { get; internal set; } = "";
|
||||||
|
}
|
||||||
142
src/Nonemm.Network/QsoRecord.cs
Normal file
142
src/Nonemm.Network/QsoRecord.cs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Network;
|
||||||
|
|
||||||
|
/// A contact as it travels between two logging computers: the thirty-five
|
||||||
|
/// fields of N1MM's `QSOString`, in N1MM's order.
|
||||||
|
///
|
||||||
|
/// The order is what matters and it cannot be changed, because the other end
|
||||||
|
/// reads the fields by position. It is taken from `MultiOpManager.cs:1007`.
|
||||||
|
///
|
||||||
|
/// | At | Field | At | Field |
|
||||||
|
/// |---|---|---|---|
|
||||||
|
/// | 0 | timestamp | 18 | sent serial |
|
||||||
|
/// | 1 | callsign | 19 | points |
|
||||||
|
/// | 2 | frequency, kHz | 20 | multiplier 1 |
|
||||||
|
/// | 3 | transmit frequency | 21 | multiplier 2 |
|
||||||
|
/// | 4 | mode | 22 | power |
|
||||||
|
/// | 5 | contest name | 23 | band, MHz |
|
||||||
|
/// | 6 | sent report | 24 | WPX prefix |
|
||||||
|
/// | 7 | received report | 25 | exchange 1 |
|
||||||
|
/// | 8 | country prefix | 26 | radio number |
|
||||||
|
/// | 9 | station prefix | 27 | operator |
|
||||||
|
/// | 10 | QTH | 28 | grid square |
|
||||||
|
/// | 11 | name | 29 | contest number |
|
||||||
|
/// | 12 | comment | 30 | multiplier 3 |
|
||||||
|
/// | 13 | received serial | 31 | misc text |
|
||||||
|
/// | 14 | section | 32 | contact type |
|
||||||
|
/// | 15 | precedence | 33 | run 1 or run 2 |
|
||||||
|
/// | 16 | check | 34 | continent |
|
||||||
|
/// | 17 | zone | | |
|
||||||
|
///
|
||||||
|
/// The contact carries its points and its multiplier flags, and they are read
|
||||||
|
/// rather than trusted: the log works them out again from the contest rules, so
|
||||||
|
/// two stations cannot disagree about a score because one of them was running
|
||||||
|
/// an older set of rules.
|
||||||
|
public static class QsoRecord
|
||||||
|
{
|
||||||
|
/// How many fields there are. A message with fewer is read as far as it
|
||||||
|
/// goes, because a station on another version sends a shorter one.
|
||||||
|
public const int FieldCount = 35;
|
||||||
|
|
||||||
|
public static List<string> Write(Qso qso) =>
|
||||||
|
[
|
||||||
|
StationRecord.Written(qso.TimestampUtc),
|
||||||
|
qso.Call.Text,
|
||||||
|
StationRecord.Written(qso.Frequency.Kilohertz),
|
||||||
|
StationRecord.Written(
|
||||||
|
(qso.QsxFrequency.Hertz == 0 ? qso.Frequency : qso.QsxFrequency).Kilohertz),
|
||||||
|
qso.Mode.Name,
|
||||||
|
qso.ContestName,
|
||||||
|
qso.SentReport,
|
||||||
|
qso.ReceivedReport,
|
||||||
|
qso.CountryPrefix,
|
||||||
|
qso.StationPrefix,
|
||||||
|
qso.Qth,
|
||||||
|
qso.Name,
|
||||||
|
qso.Comment,
|
||||||
|
Text(qso.ReceivedNumber),
|
||||||
|
qso.Section,
|
||||||
|
qso.Precedence,
|
||||||
|
Text(qso.Check),
|
||||||
|
Text(qso.Zone),
|
||||||
|
Text(qso.SentNumber),
|
||||||
|
Text(qso.Points),
|
||||||
|
StationRecord.Written(qso.IsMultiplier1),
|
||||||
|
StationRecord.Written(qso.IsMultiplier2),
|
||||||
|
qso.Power,
|
||||||
|
StationRecord.Written(qso.Band?.MegahertzLabel ?? 0),
|
||||||
|
qso.WpxPrefix,
|
||||||
|
qso.Exchange1,
|
||||||
|
Text(qso.RadioNumber),
|
||||||
|
qso.Operator,
|
||||||
|
qso.GridSquare,
|
||||||
|
Text(qso.ContestNumber),
|
||||||
|
StationRecord.Written(qso.IsMultiplier3),
|
||||||
|
qso.MiscText,
|
||||||
|
qso.ContactType,
|
||||||
|
Text(qso.RunPosition),
|
||||||
|
qso.Continent,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The contact the fields describe. `at` is where the fields start, which
|
||||||
|
/// is not always 0: an edit puts the old timestamp in front of them and a
|
||||||
|
/// replace the old callsign as well.
|
||||||
|
///
|
||||||
|
/// `stationName` is the computer that sent it, kept on the contact so the
|
||||||
|
/// log window can say where each row came from.
|
||||||
|
///
|
||||||
|
/// The band, field 23, is not read back: this program works the band out
|
||||||
|
/// from the frequency, and reading it would believe a station that
|
||||||
|
/// disagreed with the band plan about where 14.2 MHz is.
|
||||||
|
public static Qso Read(StationRecord record, int at, string stationName)
|
||||||
|
{
|
||||||
|
Frequency frequency = Frequency.FromKilohertz(record.Decimal(at + 2));
|
||||||
|
Frequency transmit = Frequency.FromKilohertz(record.Decimal(at + 3));
|
||||||
|
return new Qso
|
||||||
|
{
|
||||||
|
Id = Qso.NewId(),
|
||||||
|
TimestampUtc = record.Time(at),
|
||||||
|
Call = Callsign.Parse(record.Field(at + 1)),
|
||||||
|
Frequency = frequency,
|
||||||
|
QsxFrequency = transmit == frequency ? Frequency.Zero : transmit,
|
||||||
|
Mode = Modes.Parse(record.Field(at + 4)) ?? Modes.Cw,
|
||||||
|
ContestName = record.Field(at + 5),
|
||||||
|
SentReport = record.Field(at + 6),
|
||||||
|
ReceivedReport = record.Field(at + 7),
|
||||||
|
CountryPrefix = record.Field(at + 8),
|
||||||
|
StationPrefix = record.Field(at + 9),
|
||||||
|
Qth = record.Field(at + 10),
|
||||||
|
Name = record.Field(at + 11),
|
||||||
|
Comment = record.Field(at + 12),
|
||||||
|
ReceivedNumber = record.Number(at + 13),
|
||||||
|
Section = record.Field(at + 14),
|
||||||
|
Precedence = record.Field(at + 15),
|
||||||
|
Check = record.Number(at + 16),
|
||||||
|
Zone = record.Number(at + 17),
|
||||||
|
SentNumber = record.Number(at + 18),
|
||||||
|
Points = record.Number(at + 19),
|
||||||
|
IsMultiplier1 = record.Flag(at + 20),
|
||||||
|
IsMultiplier2 = record.Flag(at + 21),
|
||||||
|
Power = record.Field(at + 22),
|
||||||
|
WpxPrefix = record.Field(at + 24),
|
||||||
|
Exchange1 = record.Field(at + 25),
|
||||||
|
RadioNumber = Math.Max(1, record.Number(at + 26)),
|
||||||
|
Operator = record.Field(at + 27),
|
||||||
|
GridSquare = record.Field(at + 28),
|
||||||
|
ContestNumber = record.Number(at + 29),
|
||||||
|
IsMultiplier3 = record.Flag(at + 30),
|
||||||
|
MiscText = record.Field(at + 31),
|
||||||
|
ContactType = record.Field(at + 32),
|
||||||
|
RunPosition = record.Number(at + 33),
|
||||||
|
Continent = record.Field(at + 34),
|
||||||
|
StationName = stationName,
|
||||||
|
NetworkedComputerNumber = record.StationNumber,
|
||||||
|
// it was made at another radio, on another computer
|
||||||
|
IsOriginal = false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Text(int number) => number.ToString(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
71
src/Nonemm.Network/StationBeacon.cs
Normal file
71
src/Nonemm.Network/StationBeacon.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace Nonemm.Network;
|
||||||
|
|
||||||
|
/// The message every computer broadcasts to say it is on the network, which is
|
||||||
|
/// how the others find it without anybody typing in an address.
|
||||||
|
///
|
||||||
|
/// It goes out as a UDP broadcast to port 12070 and carries six fields:
|
||||||
|
///
|
||||||
|
/// | Field | What it is |
|
||||||
|
/// |---|---|
|
||||||
|
/// | `ComputerName` | the name the station is known by, and the name in every message it sends |
|
||||||
|
/// | `Address` | the address the others open a connection to |
|
||||||
|
/// | `Port` | the port they open it on, 12070 unless the operator moved it |
|
||||||
|
/// | `Version` | the program version, which has to match — see below |
|
||||||
|
/// | `Operator` | who is at that radio |
|
||||||
|
/// | `VpnAdapter` | the adapter the beacon went out of, or empty on an ordinary network |
|
||||||
|
///
|
||||||
|
/// **N1MM will not talk to a station whose version is not its own.** It
|
||||||
|
/// compares the fourth field with its own version and, when they differ, puts
|
||||||
|
/// up "Software versions must match. Update N1MM+." and drops the station. So
|
||||||
|
/// the version this program broadcasts is a setting rather than its own
|
||||||
|
/// version: to work alongside N1MM it has to say what that copy of N1MM says.
|
||||||
|
/// A beacon with the wrong number of fields is refused the same way.
|
||||||
|
public sealed record StationBeacon(
|
||||||
|
string ComputerName,
|
||||||
|
string Address,
|
||||||
|
int Port,
|
||||||
|
string Version,
|
||||||
|
string Operator,
|
||||||
|
string VpnAdapter = "")
|
||||||
|
{
|
||||||
|
/// N1MM's port for talking to another copy of itself. It listens on both
|
||||||
|
/// UDP and TCP here: the beacons arrive on the first and the contacts on
|
||||||
|
/// the second.
|
||||||
|
public const int DefaultPort = 12070;
|
||||||
|
|
||||||
|
/// How many fields N1MM requires. It splits on `%` and counts, and the
|
||||||
|
/// trailing separator leaves an empty seventh.
|
||||||
|
public const int FieldCount = 7;
|
||||||
|
|
||||||
|
public string ToWire() => string.Join(
|
||||||
|
StationRecord.FieldSeparator,
|
||||||
|
[ComputerName, Address, Port.ToString(CultureInfo.InvariantCulture), Version, Operator, VpnAdapter, ""]);
|
||||||
|
|
||||||
|
/// Null for anything that is not a beacon. N1MM's own reader says so out
|
||||||
|
/// loud — it tells the operator that the other station is on an old
|
||||||
|
/// version — but a beacon is broadcast, so anything on the network can land
|
||||||
|
/// here and most of it is not worth a message.
|
||||||
|
public static StationBeacon? Read(string text)
|
||||||
|
{
|
||||||
|
// N1MM's own check: XML on this port is the port-12060 contact
|
||||||
|
// broadcast pointed at the wrong place
|
||||||
|
if (text.Contains("xml version", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
string[] parts = text.Split(StationRecord.FieldSeparator);
|
||||||
|
if (parts.Length != FieldCount)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new StationBeacon(
|
||||||
|
parts[0],
|
||||||
|
parts[1],
|
||||||
|
int.TryParse(parts[2], CultureInfo.InvariantCulture, out int port) ? port : DefaultPort,
|
||||||
|
parts[3],
|
||||||
|
parts[4],
|
||||||
|
parts[5]);
|
||||||
|
}
|
||||||
|
}
|
||||||
555
src/Nonemm.Network/StationLink.cs
Normal file
555
src/Nonemm.Network/StationLink.cs
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Network;
|
||||||
|
|
||||||
|
/// The link between the computers of one multi-operator entry, on N1MM's own
|
||||||
|
/// port 12070.
|
||||||
|
///
|
||||||
|
/// Two sockets, which is how N1MM does it:
|
||||||
|
///
|
||||||
|
/// | Socket | What goes over it |
|
||||||
|
/// |---|---|
|
||||||
|
/// | UDP, broadcast | the beacon that says a computer is here, every `BeaconInterval` |
|
||||||
|
/// | TCP, one per station | contacts, edits, deletes, chat, everything else |
|
||||||
|
///
|
||||||
|
/// A station is found rather than configured: the beacon carries the address
|
||||||
|
/// and the port to open a connection to, so an operator plugs a laptop in and
|
||||||
|
/// the others see it. A connection is opened to every station whose beacon
|
||||||
|
/// arrives, and both ends do it, so there are two connections per pair of
|
||||||
|
/// stations — one each way. That is N1MM's arrangement: a station writes to the
|
||||||
|
/// connection it opened and reads from the one that was opened to it.
|
||||||
|
///
|
||||||
|
/// **The version must match.** N1MM compares the version in the beacon with
|
||||||
|
/// its own and turns away anything else, so `version` is what this program
|
||||||
|
/// claims to be and has to be the version of the N1MM copies it is running
|
||||||
|
/// beside. A station that says something else is kept in the list with
|
||||||
|
/// `Refused` set, so the operator can see why it is not talking.
|
||||||
|
///
|
||||||
|
/// This does not score anything or touch the log. It hands what arrives to
|
||||||
|
/// whoever owns the log through `UpdateArrived`, the same as `StationNetwork`
|
||||||
|
/// does with the XML broadcasts on port 12060. The two run side by side: 12060
|
||||||
|
/// is for other programs, 12070 is for other logging computers.
|
||||||
|
public sealed class StationLink : IDisposable
|
||||||
|
{
|
||||||
|
/// How often the beacon goes out. N1MM broadcasts on startup, when the
|
||||||
|
/// network status window asks, and on a timer; ten seconds is short enough
|
||||||
|
/// that a station that joins is seen at once and long enough to be nothing
|
||||||
|
/// on a network carrying contest traffic.
|
||||||
|
public static readonly TimeSpan BeaconInterval = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
/// How long a station may say nothing before the window shows it as gone.
|
||||||
|
/// Three beacons.
|
||||||
|
public static readonly TimeSpan Patience = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
private readonly int port;
|
||||||
|
private readonly string computerName;
|
||||||
|
private readonly string version;
|
||||||
|
private readonly CancellationTokenSource stopping = new();
|
||||||
|
private readonly ConcurrentDictionary<string, NetworkedStation> stations = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly ConcurrentDictionary<string, TcpClient> writers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly NetworkedStation mine;
|
||||||
|
/// When the last echo request went out, which is what the round trip is
|
||||||
|
/// measured against.
|
||||||
|
private DateTime echoSentAt;
|
||||||
|
private UdpClient? beacons;
|
||||||
|
private TcpListener? listener;
|
||||||
|
private readonly List<Task> loops = [];
|
||||||
|
|
||||||
|
public StationLink(string computerName, string version, int stationNumber = 1, int port = StationBeacon.DefaultPort)
|
||||||
|
{
|
||||||
|
this.computerName = computerName.ToUpperInvariant();
|
||||||
|
this.version = version;
|
||||||
|
this.port = port;
|
||||||
|
StationNumber = stationNumber;
|
||||||
|
mine = new NetworkedStation(this.computerName, "", port)
|
||||||
|
{
|
||||||
|
IsMine = true,
|
||||||
|
};
|
||||||
|
mine.StationNumber = stationNumber;
|
||||||
|
mine.Version = version;
|
||||||
|
stations[this.computerName] = mine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which station of the entry this computer is. It goes into every message
|
||||||
|
/// and into every contact this computer logs.
|
||||||
|
public int StationNumber { get; }
|
||||||
|
|
||||||
|
public string ComputerName => computerName;
|
||||||
|
|
||||||
|
/// Who is at this radio, sent in the beacon so the other stations can show
|
||||||
|
/// it.
|
||||||
|
public string Operator
|
||||||
|
{
|
||||||
|
get => mine.Operator;
|
||||||
|
set => mine.Operator = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every station, this computer included, in the order they were first
|
||||||
|
/// heard.
|
||||||
|
public IReadOnlyList<NetworkedStation> Stations => stations.Values.ToList();
|
||||||
|
|
||||||
|
/// A contact another station logged, edited or deleted.
|
||||||
|
public event EventHandler<ContactUpdate>? UpdateArrived;
|
||||||
|
|
||||||
|
/// A line of chat from another operator.
|
||||||
|
public event EventHandler<string>? TalkArrived;
|
||||||
|
|
||||||
|
/// Anything about the list of stations changed: one joined, one went quiet,
|
||||||
|
/// one moved band. The status window redraws on this.
|
||||||
|
public event EventHandler? StationsChanged;
|
||||||
|
|
||||||
|
public event EventHandler<string>? Failed;
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (loops.Count > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
beacons = new UdpClient();
|
||||||
|
beacons.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||||
|
beacons.Client.Bind(new IPEndPoint(IPAddress.Any, port));
|
||||||
|
beacons.EnableBroadcast = true;
|
||||||
|
listener = new TcpListener(new IPEndPoint(IPAddress.Any, port));
|
||||||
|
listener.Start();
|
||||||
|
}
|
||||||
|
catch (SocketException e)
|
||||||
|
{
|
||||||
|
Failed?.Invoke(this, $"could not take port {port}: {e.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loops.Add(Task.Run(() => BeaconLoopAsync(stopping.Token)));
|
||||||
|
loops.Add(Task.Run(() => ListenForBeaconsAsync(stopping.Token)));
|
||||||
|
loops.Add(Task.Run(() => AcceptAsync(stopping.Token)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A station named by hand rather than found by a beacon, and a connection
|
||||||
|
/// opened to it. N1MM offers the same thing for a network where a broadcast
|
||||||
|
/// does not reach every computer — its predefined stations — and it is also
|
||||||
|
/// what a station on the other side of a VPN needs.
|
||||||
|
public NetworkedStation AddStation(string name, string address, int stationPort)
|
||||||
|
{
|
||||||
|
NetworkedStation station = stations.GetOrAdd(
|
||||||
|
name.ToUpperInvariant(),
|
||||||
|
known => new NetworkedStation(known, address, stationPort));
|
||||||
|
station.Address = address;
|
||||||
|
station.Port = stationPort;
|
||||||
|
station.LastHeardUtc = DateTime.UtcNow;
|
||||||
|
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
if (!writers.ContainsKey(station.ComputerName))
|
||||||
|
{
|
||||||
|
_ = ConnectAsync(station, stopping.Token);
|
||||||
|
}
|
||||||
|
return station;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message to every station that is connected. Returns how many it
|
||||||
|
/// reached, so a caller that has to know whether anybody heard can say so.
|
||||||
|
public async Task<int> SendAsync(StationRecord record, CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
byte[] message = Encoding.UTF8.GetBytes(record.ToWire());
|
||||||
|
int reached = 0;
|
||||||
|
foreach ((string name, TcpClient writer) in writers)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await writer.GetStream().WriteAsync(message, cancellation).ConfigureAwait(false);
|
||||||
|
reached++;
|
||||||
|
if (stations.TryGetValue(name, out NetworkedStation? station))
|
||||||
|
{
|
||||||
|
station.Sent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is IOException or SocketException or ObjectDisposedException)
|
||||||
|
{
|
||||||
|
Drop(name, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reached > 0)
|
||||||
|
{
|
||||||
|
mine.Sent += reached;
|
||||||
|
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
return reached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message to one station. Returns false when it could not be written,
|
||||||
|
/// which drops the connection: the next beacon opens a new one.
|
||||||
|
public async Task<bool> SendToAsync(
|
||||||
|
NetworkedStation station,
|
||||||
|
StationRecord record,
|
||||||
|
CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
if (!writers.TryGetValue(station.ComputerName, out TcpClient? writer))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await writer
|
||||||
|
.GetStream()
|
||||||
|
.WriteAsync(Encoding.UTF8.GetBytes(record.ToWire()), cancellation)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is IOException or SocketException or ObjectDisposedException)
|
||||||
|
{
|
||||||
|
Drop(station.ComputerName, e.Message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
station.Sent++;
|
||||||
|
mine.Sent++;
|
||||||
|
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> SendLoggedAsync(Qso qso, CancellationToken cancellation = default) =>
|
||||||
|
SendAsync(StationMessages.Logged(qso, StationNumber, computerName), cancellation);
|
||||||
|
|
||||||
|
public Task<int> SendEditedAsync(
|
||||||
|
Qso qso,
|
||||||
|
string oldCall,
|
||||||
|
DateTime oldTimestampUtc,
|
||||||
|
CancellationToken cancellation = default) =>
|
||||||
|
SendAsync(
|
||||||
|
StationMessages.Edited(qso, oldCall, oldTimestampUtc, StationNumber, computerName),
|
||||||
|
cancellation);
|
||||||
|
|
||||||
|
public Task<int> SendDeletedAsync(Qso qso, CancellationToken cancellation = default) =>
|
||||||
|
SendAsync(StationMessages.Deleted(qso, StationNumber, computerName), cancellation);
|
||||||
|
|
||||||
|
public Task<int> SendTalkAsync(string text, CancellationToken cancellation = default) =>
|
||||||
|
SendAsync(StationMessages.Talk(text, StationNumber, computerName), cancellation);
|
||||||
|
|
||||||
|
/// Says where this station is. It goes out whenever the radio moves or the
|
||||||
|
/// operator turns run on or off, and it is what the other stations show and
|
||||||
|
/// what the band-change rule counts.
|
||||||
|
public Task<int> SendBandAsync(
|
||||||
|
Frequency frequency,
|
||||||
|
Mode mode,
|
||||||
|
bool running,
|
||||||
|
int radioNumber,
|
||||||
|
CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
mine.Frequency = frequency;
|
||||||
|
mine.Mode = mode;
|
||||||
|
mine.IsRunning = running;
|
||||||
|
mine.RadioNumber = radioNumber;
|
||||||
|
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
return SendAsync(
|
||||||
|
StationMessages.OnBand(frequency, mode, running, radioNumber, StationNumber, computerName),
|
||||||
|
cancellation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> SendTransmittingAsync(
|
||||||
|
bool transmitting,
|
||||||
|
int radioNumber,
|
||||||
|
CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
mine.IsTransmitting = transmitting;
|
||||||
|
return SendAsync(
|
||||||
|
StationMessages.Transmitting(transmitting, radioNumber, StationNumber, computerName),
|
||||||
|
cancellation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks every station whether it is there. The answer sets `EchoTime`.
|
||||||
|
public Task<int> SendEchoRequestAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
echoSentAt = DateTime.UtcNow;
|
||||||
|
return SendAsync(StationMessages.EchoRequest(StationNumber, computerName, DateTime.UtcNow), cancellation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
stopping.Cancel();
|
||||||
|
foreach (TcpClient writer in writers.Values)
|
||||||
|
{
|
||||||
|
writer.Dispose();
|
||||||
|
}
|
||||||
|
writers.Clear();
|
||||||
|
listener?.Stop();
|
||||||
|
beacons?.Dispose();
|
||||||
|
stopping.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The beacon, on every interface that can broadcast. It carries this
|
||||||
|
/// computer's name, address and port, so a station that hears it knows
|
||||||
|
/// where to open a connection.
|
||||||
|
private async Task BeaconLoopAsync(CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
while (!cancellation.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
StationBeacon beacon = new(computerName, Address(), port, version, mine.Operator);
|
||||||
|
byte[] message = Encoding.UTF8.GetBytes(beacon.ToWire());
|
||||||
|
await beacons!
|
||||||
|
.SendAsync(message, new IPEndPoint(IPAddress.Broadcast, port), cancellation)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
mine.Address = beacon.Address;
|
||||||
|
mine.LastHeardUtc = DateTime.UtcNow;
|
||||||
|
Forget();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (SocketException e)
|
||||||
|
{
|
||||||
|
Failed?.Invoke(this, $"could not broadcast: {e.Message}");
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(BeaconInterval, cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ListenForBeaconsAsync(CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
while (!cancellation.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
UdpReceiveResult received = await beacons!.ReceiveAsync(cancellation).ConfigureAwait(false);
|
||||||
|
if (StationBeacon.Read(Encoding.UTF8.GetString(received.Buffer)) is not { } beacon)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (beacon.ComputerName.Equals(computerName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Heard(beacon, received.RemoteEndPoint.Address.ToString());
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (SocketException e)
|
||||||
|
{
|
||||||
|
Failed?.Invoke(this, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A station said it is here. Its own address is believed only as far as
|
||||||
|
/// the packet: a station behind a router announces an address nothing can
|
||||||
|
/// reach, so the address the packet came from is what a connection is
|
||||||
|
/// opened to.
|
||||||
|
private void Heard(StationBeacon beacon, string from)
|
||||||
|
{
|
||||||
|
NetworkedStation station = stations.GetOrAdd(
|
||||||
|
beacon.ComputerName,
|
||||||
|
name => new NetworkedStation(name, from, beacon.Port));
|
||||||
|
station.Address = from;
|
||||||
|
station.Port = beacon.Port;
|
||||||
|
station.Version = beacon.Version;
|
||||||
|
station.Operator = beacon.Operator;
|
||||||
|
station.LastHeardUtc = DateTime.UtcNow;
|
||||||
|
if (beacon.Version != version)
|
||||||
|
{
|
||||||
|
station.Refused = $"version {beacon.Version}, this station is {version}";
|
||||||
|
station.IsConnected = false;
|
||||||
|
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
station.Refused = "";
|
||||||
|
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
if (!writers.ContainsKey(station.ComputerName))
|
||||||
|
{
|
||||||
|
_ = ConnectAsync(station, stopping.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConnectAsync(NetworkedStation station, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
TcpClient writer = new();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await writer.ConnectAsync(station.Address, station.Port, cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is SocketException or OperationCanceledException)
|
||||||
|
{
|
||||||
|
writer.Dispose();
|
||||||
|
station.Refused = e.Message;
|
||||||
|
Failed?.Invoke(this, $"could not open a connection to {station.ComputerName} at {station.Address}:{station.Port}: {e.Message}");
|
||||||
|
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!writers.TryAdd(station.ComputerName, writer))
|
||||||
|
{
|
||||||
|
writer.Dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
station.IsConnected = true;
|
||||||
|
station.LastHeardUtc = DateTime.UtcNow;
|
||||||
|
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
// N1MM's first message on a new connection, which tells the other end
|
||||||
|
// which station of the entry this is. It goes to that station alone:
|
||||||
|
// the others were told when their own connection opened
|
||||||
|
await SendToAsync(station, StationMessages.IAm(StationNumber, computerName), cancellation)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AcceptAsync(CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
while (!cancellation.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TcpClient reader = await listener!.AcceptTcpClientAsync(cancellation).ConfigureAwait(false);
|
||||||
|
_ = Task.Run(() => ReadAsync(reader, cancellation), cancellation);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (SocketException e)
|
||||||
|
{
|
||||||
|
Failed?.Invoke(this, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One connection that was opened to this station. Whatever arrives is
|
||||||
|
/// added to what has not been read yet, and every whole message in it is
|
||||||
|
/// handed on: TCP gives no promise about where a read ends.
|
||||||
|
private async Task ReadAsync(TcpClient reader, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
string held = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (reader)
|
||||||
|
{
|
||||||
|
NetworkStream stream = reader.GetStream();
|
||||||
|
while (!cancellation.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
int count = await stream.ReadAsync(buffer, cancellation).ConfigureAwait(false);
|
||||||
|
if (count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
held += Encoding.UTF8.GetString(buffer, 0, count);
|
||||||
|
while (StationRecord.Read(ref held) is { } record)
|
||||||
|
{
|
||||||
|
Arrived(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is IOException or SocketException or OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one message means. The station it came from is credited with it
|
||||||
|
/// whatever the type is, so the window's Read count and Last heard are
|
||||||
|
/// right even for the messages this program does nothing with.
|
||||||
|
private void Arrived(StationRecord record)
|
||||||
|
{
|
||||||
|
NetworkedStation station = stations.GetOrAdd(
|
||||||
|
record.ComputerName,
|
||||||
|
name => new NetworkedStation(name, "", port));
|
||||||
|
station.LastHeardUtc = DateTime.UtcNow;
|
||||||
|
station.LastMessage = record.Type;
|
||||||
|
station.Read++;
|
||||||
|
mine.Read++;
|
||||||
|
switch (record.Type)
|
||||||
|
{
|
||||||
|
case "IAM":
|
||||||
|
station.StationNumber = record.Number(0);
|
||||||
|
break;
|
||||||
|
case "BANDMAP":
|
||||||
|
station.Frequency = Frequency.FromKilohertz(record.Decimal(0));
|
||||||
|
station.Mode = Modes.Parse(record.Field(1));
|
||||||
|
station.IsRunning = record.Flag(2);
|
||||||
|
station.RadioNumber = Math.Max(1, record.Number(3));
|
||||||
|
break;
|
||||||
|
case "XMIT":
|
||||||
|
station.IsTransmitting = record.Flag(0);
|
||||||
|
station.RadioNumber = Math.Max(1, record.Number(1));
|
||||||
|
break;
|
||||||
|
case "PASSFREQ":
|
||||||
|
station.PassFrequency = Frequency.FromKilohertz(record.Decimal(0));
|
||||||
|
station.PassCall = record.Field(1);
|
||||||
|
break;
|
||||||
|
case "ECHOREQ":
|
||||||
|
_ = SendAsync(StationMessages.Echo(record, StationNumber, computerName));
|
||||||
|
break;
|
||||||
|
case "ECHO":
|
||||||
|
station.EchoTime = DateTime.UtcNow - echoSentAt;
|
||||||
|
break;
|
||||||
|
case "TALK":
|
||||||
|
TalkArrived?.Invoke(this, record.Field(0));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (StationMessages.Read(record) is { } update)
|
||||||
|
{
|
||||||
|
UpdateArrived?.Invoke(this, update);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes a connection that has failed or gone quiet. The station stays in
|
||||||
|
/// the list: it
|
||||||
|
/// is still part of the entry, and the window showing it as not connected
|
||||||
|
/// is the point.
|
||||||
|
private void Drop(string name, string why)
|
||||||
|
{
|
||||||
|
if (writers.TryRemove(name, out TcpClient? writer))
|
||||||
|
{
|
||||||
|
writer.Dispose();
|
||||||
|
}
|
||||||
|
if (stations.TryGetValue(name, out NetworkedStation? station))
|
||||||
|
{
|
||||||
|
station.IsConnected = false;
|
||||||
|
station.Refused = why;
|
||||||
|
}
|
||||||
|
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks as not connected any station that has said nothing for
|
||||||
|
/// `Patience`, and lets go of its connection so the next beacon opens a
|
||||||
|
/// new one.
|
||||||
|
private void Forget()
|
||||||
|
{
|
||||||
|
DateTime cutoff = DateTime.UtcNow - Patience;
|
||||||
|
foreach (NetworkedStation station in stations.Values)
|
||||||
|
{
|
||||||
|
if (!station.IsMine && station.IsConnected && station.LastHeardUtc < cutoff)
|
||||||
|
{
|
||||||
|
Drop(station.ComputerName, "said nothing for half a minute");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This computer's address on the network it can reach the others on. It is
|
||||||
|
/// only for the beacon: the other end uses the address the packet came
|
||||||
|
/// from, so a wrong answer here costs nothing.
|
||||||
|
private static string Address()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using Socket probe = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||||
|
probe.Connect("8.8.8.8", 65530);
|
||||||
|
return probe.LocalEndPoint is IPEndPoint local ? local.Address.ToString() : "";
|
||||||
|
}
|
||||||
|
catch (SocketException)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/Nonemm.Network/StationMessages.cs
Normal file
169
src/Nonemm.Network/StationMessages.cs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Network;
|
||||||
|
|
||||||
|
/// The messages two logging computers send each other, and what to do with one
|
||||||
|
/// that arrives.
|
||||||
|
///
|
||||||
|
/// N1MM has around forty of these. The ones here are the ones a multi-operator
|
||||||
|
/// entry cannot run without:
|
||||||
|
///
|
||||||
|
/// | Type | What it says | Read | Sent |
|
||||||
|
/// |---|---|---|---|
|
||||||
|
/// | `QSO` | a contact was logged | yes | yes |
|
||||||
|
/// | `ReEditQSO` | a contact was edited | yes | yes |
|
||||||
|
/// | `QSODELETE` | a contact was removed | yes | yes |
|
||||||
|
/// | `ReSyncQSO` | a contact again, in answer to a resync | yes | yes |
|
||||||
|
/// | `IAM` | which station number the sender has taken | yes | yes |
|
||||||
|
/// | `ECHOREQ` | are you there | yes | yes |
|
||||||
|
/// | `ECHO` | yes | yes | yes |
|
||||||
|
/// | `TALK` | a line of chat between operators | yes | yes |
|
||||||
|
/// | `PASSFREQ` | a frequency being passed to another radio | yes | yes |
|
||||||
|
/// | `XMIT` | a station started or stopped transmitting | yes | yes |
|
||||||
|
/// | `BANDMAP` | which band and mode a station is on | yes | yes |
|
||||||
|
///
|
||||||
|
/// The rest — the score and sked windows, the log check, the spot lists, the
|
||||||
|
/// serial-number pool — are passed over. An unknown type is not an error: N1MM
|
||||||
|
/// adds them between versions, and a station that cannot read one is no worse
|
||||||
|
/// off than a station that has not been told.
|
||||||
|
public static class StationMessages
|
||||||
|
{
|
||||||
|
/// A logged contact. Every field is N1MM's `QSOString`, with the timestamp
|
||||||
|
/// the contact had before this message in front of them; for a new contact
|
||||||
|
/// that is the same timestamp.
|
||||||
|
public static StationRecord Logged(Qso qso, int stationNumber, string computerName) =>
|
||||||
|
Record("QSO", stationNumber, computerName, [StationRecord.Written(qso.TimestampUtc), .. QsoRecord.Write(qso)]);
|
||||||
|
|
||||||
|
/// A contact that has been edited. The old timestamp and the old callsign
|
||||||
|
/// say which row to replace, because that pair is what N1MM keys a contact
|
||||||
|
/// on rather than an identifier of its own.
|
||||||
|
public static StationRecord Edited(
|
||||||
|
Qso qso,
|
||||||
|
string oldCall,
|
||||||
|
DateTime oldTimestampUtc,
|
||||||
|
int stationNumber,
|
||||||
|
string computerName) =>
|
||||||
|
Record(
|
||||||
|
"ReEditQSO",
|
||||||
|
stationNumber,
|
||||||
|
computerName,
|
||||||
|
[StationRecord.Written(oldTimestampUtc), oldCall, .. QsoRecord.Write(qso)]);
|
||||||
|
|
||||||
|
/// The same contact sent again because another station asked for it. It is
|
||||||
|
/// a separate type so the other end can tell a resync from a contact just
|
||||||
|
/// made and not count it twice in the rate window.
|
||||||
|
public static StationRecord Resynced(Qso qso, int stationNumber, string computerName) =>
|
||||||
|
Record(
|
||||||
|
"ReSyncQSO",
|
||||||
|
stationNumber,
|
||||||
|
computerName,
|
||||||
|
[StationRecord.Written(qso.TimestampUtc), .. QsoRecord.Write(qso)]);
|
||||||
|
|
||||||
|
public static StationRecord Deleted(Qso qso, int stationNumber, string computerName) =>
|
||||||
|
Record(
|
||||||
|
"QSODELETE",
|
||||||
|
stationNumber,
|
||||||
|
computerName,
|
||||||
|
[StationRecord.Written(qso.TimestampUtc), qso.Call.Text, Text(qso.ContestNumber), qso.Id]);
|
||||||
|
|
||||||
|
/// Which station number this computer has taken. N1MM numbers the stations
|
||||||
|
/// of an entry so a contact can say which position made it.
|
||||||
|
public static StationRecord IAm(int stationNumber, string computerName) =>
|
||||||
|
Record("IAM", stationNumber, computerName, [Text(stationNumber)]);
|
||||||
|
|
||||||
|
/// Are you there. N1MM sends the date and the time of day, and uses the
|
||||||
|
/// answer to show how long the round trip took.
|
||||||
|
public static StationRecord EchoRequest(int stationNumber, string computerName, DateTime now) =>
|
||||||
|
Record(
|
||||||
|
"ECHOREQ",
|
||||||
|
stationNumber,
|
||||||
|
computerName,
|
||||||
|
[StationRecord.WrittenDate(now), now.ToString("HH:mm:ss", CultureInfo.InvariantCulture)]);
|
||||||
|
|
||||||
|
/// The answer, carrying back what the request said so the asker can work
|
||||||
|
/// out the round trip without keeping anything.
|
||||||
|
public static StationRecord Echo(StationRecord request, int stationNumber, string computerName) =>
|
||||||
|
Record("ECHO", stationNumber, computerName, [request.Field(0), request.Field(1)]);
|
||||||
|
|
||||||
|
public static StationRecord Talk(string text, int stationNumber, string computerName) =>
|
||||||
|
Record("TALK", stationNumber, computerName, [$"[{computerName}] {text}"]);
|
||||||
|
|
||||||
|
/// A frequency handed to another radio, which is N1MM's pass. The
|
||||||
|
/// callsign is who is on it.
|
||||||
|
public static StationRecord PassFrequency(
|
||||||
|
Frequency frequency,
|
||||||
|
string call,
|
||||||
|
int stationNumber,
|
||||||
|
string computerName) =>
|
||||||
|
Record(
|
||||||
|
"PASSFREQ",
|
||||||
|
stationNumber,
|
||||||
|
computerName,
|
||||||
|
[StationRecord.Written(frequency.Kilohertz), call]);
|
||||||
|
|
||||||
|
/// A station started or stopped transmitting. The other stations of a
|
||||||
|
/// multi-single entry need this: two of them keying at once is one signal
|
||||||
|
/// too many.
|
||||||
|
public static StationRecord Transmitting(
|
||||||
|
bool transmitting,
|
||||||
|
int radioNumber,
|
||||||
|
int stationNumber,
|
||||||
|
string computerName) =>
|
||||||
|
Record(
|
||||||
|
"XMIT",
|
||||||
|
stationNumber,
|
||||||
|
computerName,
|
||||||
|
[StationRecord.Written(transmitting), Text(radioNumber)]);
|
||||||
|
|
||||||
|
/// Where a station is: the band and the mode it is on, and whether it is
|
||||||
|
/// running. This is what fills the band and Running columns of the network
|
||||||
|
/// status window, and what the band-change rule counts.
|
||||||
|
public static StationRecord OnBand(
|
||||||
|
Frequency frequency,
|
||||||
|
Mode mode,
|
||||||
|
bool running,
|
||||||
|
int radioNumber,
|
||||||
|
int stationNumber,
|
||||||
|
string computerName) =>
|
||||||
|
Record(
|
||||||
|
"BANDMAP",
|
||||||
|
stationNumber,
|
||||||
|
computerName,
|
||||||
|
[
|
||||||
|
StationRecord.Written(frequency.Kilohertz),
|
||||||
|
mode.Name,
|
||||||
|
StationRecord.Written(running),
|
||||||
|
Text(radioNumber),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/// What a message that has arrived means, or null for one this program does
|
||||||
|
/// nothing with.
|
||||||
|
public static ContactUpdate? Read(StationRecord record) => record.Type switch
|
||||||
|
{
|
||||||
|
"QSO" or "RESYNCQSO" => new ContactLogged(
|
||||||
|
QsoRecord.Read(record, 1, record.ComputerName),
|
||||||
|
record.ComputerName),
|
||||||
|
"REEDITQSO" => new ContactReplaced(
|
||||||
|
QsoRecord.Read(record, 2, record.ComputerName),
|
||||||
|
record.Field(1),
|
||||||
|
record.Time(0),
|
||||||
|
record.ComputerName),
|
||||||
|
"QSODELETE" => new ContactDeleted(
|
||||||
|
record.Field(3),
|
||||||
|
record.Field(1),
|
||||||
|
record.Time(0),
|
||||||
|
record.Number(2),
|
||||||
|
record.ComputerName),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static StationRecord Record(
|
||||||
|
string type,
|
||||||
|
int stationNumber,
|
||||||
|
string computerName,
|
||||||
|
IReadOnlyList<string> fields) =>
|
||||||
|
new(stationNumber, computerName, type, fields);
|
||||||
|
|
||||||
|
private static string Text(int number) => number.ToString(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
149
src/Nonemm.Network/StationRecord.cs
Normal file
149
src/Nonemm.Network/StationRecord.cs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Nonemm.Network;
|
||||||
|
|
||||||
|
/// One message on the wire between two logging computers, and the frame around
|
||||||
|
/// it.
|
||||||
|
///
|
||||||
|
/// This is not the XML on port 12060 that `ContactMessage` writes. That one is
|
||||||
|
/// N1MM talking to other programs — a spotting tool, a score poster. This is
|
||||||
|
/// N1MM talking to another copy of itself on port 12070, and the format is
|
||||||
|
/// different: fields separated by `%`, the whole message ended with `~`, and
|
||||||
|
/// the lot wrapped in `DATA__` and `__DATA`.
|
||||||
|
///
|
||||||
|
/// A frame reads
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// DATA__07%SHACK-PC%QSO%2026-09-03 12:34:56%DL1ABC%…~__DATA
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// which is the sending station's number in two digits, its computer name, the
|
||||||
|
/// message type, and then as many fields as the type has. `%` and `~` cannot
|
||||||
|
/// appear in a field, so N1MM writes `!` in their place; this does the same, so
|
||||||
|
/// a comment with a per-cent sign in it arrives as N1MM would have sent it
|
||||||
|
/// rather than splitting the message in half.
|
||||||
|
///
|
||||||
|
/// Both delimiters are needed. TCP hands over whatever has arrived, which is
|
||||||
|
/// half a message as often as two of them, so `~` says where a message ends;
|
||||||
|
/// `DATA__` and `__DATA` are N1MM's own and are kept because N1MM looks for
|
||||||
|
/// them.
|
||||||
|
public sealed record StationRecord(int StationNumber, string ComputerName, string Type, IReadOnlyList<string> Fields)
|
||||||
|
{
|
||||||
|
public const string FramePrefix = "DATA__";
|
||||||
|
|
||||||
|
public const string FrameSuffix = "__DATA";
|
||||||
|
|
||||||
|
/// What separates the fields, and what ends a message.
|
||||||
|
public const char FieldSeparator = '%';
|
||||||
|
|
||||||
|
public const char MessageEnd = '~';
|
||||||
|
|
||||||
|
/// What N1MM puts in place of a delimiter that turns up inside a field.
|
||||||
|
public const char Escape = '!';
|
||||||
|
|
||||||
|
private const string TimeFormat = "yyyy-MM-dd HH:mm:ss";
|
||||||
|
|
||||||
|
private const string DateFormat = "yyyy-MM-dd";
|
||||||
|
|
||||||
|
/// The message as it goes on the wire, frame and all.
|
||||||
|
public string ToWire()
|
||||||
|
{
|
||||||
|
StringBuilder text = new();
|
||||||
|
text.Append(FramePrefix);
|
||||||
|
text.Append(StationNumber.ToString("00", CultureInfo.InvariantCulture));
|
||||||
|
text.Append(FieldSeparator);
|
||||||
|
text.Append(Clean(ComputerName.ToUpperInvariant()));
|
||||||
|
text.Append(FieldSeparator);
|
||||||
|
text.Append(Clean(Type));
|
||||||
|
text.Append(FieldSeparator);
|
||||||
|
foreach (string field in Fields)
|
||||||
|
{
|
||||||
|
text.Append(Clean(field));
|
||||||
|
text.Append(FieldSeparator);
|
||||||
|
}
|
||||||
|
text.Append(MessageEnd);
|
||||||
|
text.Append(FrameSuffix);
|
||||||
|
return text.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One message read out of `text`, which is whatever has arrived so far.
|
||||||
|
/// Null while no whole message is there yet; the caller keeps the rest and
|
||||||
|
/// adds what arrives next to it.
|
||||||
|
///
|
||||||
|
/// The frame markers are taken off wherever they stand, because a reader
|
||||||
|
/// that has fallen behind holds several frames at once and the `__DATA`
|
||||||
|
/// that ends one sits in front of the `DATA__` that starts the next.
|
||||||
|
public static StationRecord? Read(ref string text)
|
||||||
|
{
|
||||||
|
int end = text.IndexOf(MessageEnd, StringComparison.Ordinal);
|
||||||
|
if (end < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
string message = text[..end];
|
||||||
|
text = text[(end + 1)..];
|
||||||
|
message = message
|
||||||
|
.Replace(FramePrefix, "", StringComparison.Ordinal)
|
||||||
|
.Replace(FrameSuffix, "", StringComparison.Ordinal);
|
||||||
|
string[] parts = message.Split(FieldSeparator);
|
||||||
|
// the station number, the computer name and the type, and then the
|
||||||
|
// trailing separator leaves one empty field on the end
|
||||||
|
if (parts.Length < 4)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new StationRecord(
|
||||||
|
int.TryParse(parts[0], CultureInfo.InvariantCulture, out int number) ? number : 0,
|
||||||
|
parts[1],
|
||||||
|
parts[2].ToUpperInvariant(),
|
||||||
|
parts[3..^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The field at `at`, or empty when the message is shorter than that. A
|
||||||
|
/// station running another version sends fewer fields than this one reads,
|
||||||
|
/// and a short message is worth more than no message.
|
||||||
|
public string Field(int at) => at >= 0 && at < Fields.Count ? Fields[at] : "";
|
||||||
|
|
||||||
|
public int Number(int at) =>
|
||||||
|
int.TryParse(Field(at), CultureInfo.InvariantCulture, out int value) ? value : 0;
|
||||||
|
|
||||||
|
/// A field written as N1MM's `uNum`, which is two decimal places with a
|
||||||
|
/// dot whatever the machine's own separator is.
|
||||||
|
public double Decimal(int at) =>
|
||||||
|
double.TryParse(Field(at), NumberStyles.Float, CultureInfo.InvariantCulture, out double value)
|
||||||
|
? value
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
/// N1MM writes a boolean as Visual Basic prints one, which is `True` or
|
||||||
|
/// `False`. A number is read as well, because its own log holds -1 and 0
|
||||||
|
/// for the same thing.
|
||||||
|
public bool Flag(int at) =>
|
||||||
|
Field(at).Trim() is { Length: > 0 } text
|
||||||
|
&& (text.Equals("True", StringComparison.OrdinalIgnoreCase) || Number(at) != 0);
|
||||||
|
|
||||||
|
public DateTime Time(int at) =>
|
||||||
|
DateTime.TryParseExact(
|
||||||
|
Field(at),
|
||||||
|
TimeFormat,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||||
|
out DateTime value)
|
||||||
|
? value
|
||||||
|
: default;
|
||||||
|
|
||||||
|
public static string Written(DateTime time) =>
|
||||||
|
time.ToString(TimeFormat, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
public static string WrittenDate(DateTime time) =>
|
||||||
|
time.ToString(DateFormat, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
public static string Written(double number) =>
|
||||||
|
number.ToString("0.00", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
public static string Written(bool flag) => flag ? "True" : "False";
|
||||||
|
|
||||||
|
/// A field with the delimiters taken out of it, which is what N1MM sends.
|
||||||
|
private static string Clean(string field) =>
|
||||||
|
field.Replace(FieldSeparator, Escape).Replace(MessageEnd, Escape);
|
||||||
|
}
|
||||||
@@ -113,6 +113,9 @@ public static class MessageExpander
|
|||||||
// the digital window's carriage return, which starts a new line on
|
// the digital window's carriage return, which starts a new line on
|
||||||
// the other station's screen
|
// the other station's screen
|
||||||
"ENTER" => "\r",
|
"ENTER" => "\r",
|
||||||
|
// the same with a line feed behind it, which is what N1MM's own
|
||||||
|
// RTTY message defaults are written with
|
||||||
|
"ENTERLF" => "\r\n",
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ namespace Nonemm.Session;
|
|||||||
/// N1MM's rule, which this follows: an action macro runs before the message is
|
/// N1MM's rule, which this follows: an action macro runs before the message is
|
||||||
/// sent, unless it stands after `{END}`, and then it runs when the message has
|
/// sent, unless it stands after `{END}`, and then it runs when the message has
|
||||||
/// finished. Text after `{END}` is dropped, because the message is over.
|
/// finished. Text after `{END}` is dropped, because the message is over.
|
||||||
|
///
|
||||||
|
/// `{RX}` is the exception. It drops the transmitter, which cannot happen
|
||||||
|
/// before the message it stands in has gone out, so it always runs with the
|
||||||
|
/// actions that follow the message. N1MM does the same: it takes `{RX}` out of
|
||||||
|
/// the text wherever it stands, sends the text, and stops the transmitter
|
||||||
|
/// after it.
|
||||||
public sealed record MessagePlan(
|
public sealed record MessagePlan(
|
||||||
IReadOnlyList<MessageAction> Before,
|
IReadOnlyList<MessageAction> Before,
|
||||||
string Text,
|
string Text,
|
||||||
@@ -45,7 +51,8 @@ public sealed record MessagePlan(
|
|||||||
}
|
}
|
||||||
else if (Action(name) is { } action)
|
else if (Action(name) is { } action)
|
||||||
{
|
{
|
||||||
(ended ? after : before).Add(action);
|
(ended || action.Command == MessageCommand.ReturnToReceive ? after : before)
|
||||||
|
.Add(action);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace Nonemm.Session;
|
namespace Nonemm.Session;
|
||||||
|
|
||||||
/// What a station sends on CW while exchanging QTC traffic, and N1MM's
|
/// What a station sends while exchanging QTC traffic, and N1MM's defaults for
|
||||||
/// defaults for the parts an operator can change.
|
/// the parts an operator can change.
|
||||||
///
|
///
|
||||||
/// CW only. N1MM sends recorded voice messages on SSB and its digital window's
|
/// CW and RTTY. The two are laid out differently: on CW a line is the three
|
||||||
/// templates on RTTY, and this program has neither.
|
/// fields with the operator's spacing between them and each one is keyed by
|
||||||
|
/// hand, and on RTTY the fields are joined with hyphens and the whole series
|
||||||
|
/// goes out as one message. N1MM plays recordings on SSB, and there is no voice
|
||||||
|
/// keyer here.
|
||||||
public static class QtcMessages
|
public static class QtcMessages
|
||||||
{
|
{
|
||||||
/// The gap between the fields of a QTC line, written the way N1MM writes
|
/// The gap between the fields of a QTC line, written the way N1MM writes
|
||||||
@@ -28,6 +33,59 @@ public static class QtcMessages
|
|||||||
/// What the station reading traffic out asks before it starts.
|
/// What the station reading traffic out asks before it starts.
|
||||||
public const string AreYouReady = "QRV?";
|
public const string AreYouReady = "QRV?";
|
||||||
|
|
||||||
|
/// What goes between the QTC lines on RTTY. N1MM keeps it as a macro rather
|
||||||
|
/// than as characters, so an operator can put a carriage return there,
|
||||||
|
/// which is what its default does.
|
||||||
|
public const string DefaultRttySpacing = "{ENTER}";
|
||||||
|
|
||||||
|
/// What stands in front of the series and what closes it. `{QTC}` stands
|
||||||
|
/// for the header, so the default reads the series out twice for a station
|
||||||
|
/// that missed it the first time.
|
||||||
|
public const string DefaultSendAllHeading = "{ENTERLF}{QTC} {QTC}";
|
||||||
|
|
||||||
|
public const string DefaultSendAllEnding = "{ENTERLF}QSL?? BK DE {MYCALL} K";
|
||||||
|
|
||||||
|
/// One line of traffic on RTTY: the three fields joined with hyphens. N1MM
|
||||||
|
/// writes them this way and offers no setting for it, so neither does this.
|
||||||
|
public static string RttyLine(string time, string call, string number) =>
|
||||||
|
$"{time.Trim()}-{call.Trim()}-{number.Trim()}";
|
||||||
|
|
||||||
|
/// A whole series as one message, which is N1MM's Send All: the heading,
|
||||||
|
/// then every line, then the ending, with `spacing` between them. It keys
|
||||||
|
/// the transmitter itself and drops it at the end, so the operator presses
|
||||||
|
/// one button and the series goes out.
|
||||||
|
///
|
||||||
|
/// The text macros in it are left as they stand — `{ENTER}`, `{MYCALL}` and
|
||||||
|
/// the rest are filled in by the message expander, the same as in a
|
||||||
|
/// function key. `{QTC}` is filled in here, because only this window knows
|
||||||
|
/// which series is going out.
|
||||||
|
///
|
||||||
|
/// A series with no lines in it sends nothing.
|
||||||
|
public static string SendAll(
|
||||||
|
string heading,
|
||||||
|
string ending,
|
||||||
|
string spacing,
|
||||||
|
string header,
|
||||||
|
IReadOnlyList<string> lines)
|
||||||
|
{
|
||||||
|
if (lines.Count == 0)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
StringBuilder text = new();
|
||||||
|
text.Append("{TX}").Append(WithHeader(heading, header)).Append(spacing);
|
||||||
|
foreach (string line in lines)
|
||||||
|
{
|
||||||
|
text.Append(line).Append(' ').Append(spacing);
|
||||||
|
}
|
||||||
|
return text.Append(WithHeader(ending, header)).Append("{RX}").ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line of a series on its own, for a station that asks for it again.
|
||||||
|
/// It keys and drops the transmitter the same way Send All does.
|
||||||
|
public static string SendOne(string spacing, string line) =>
|
||||||
|
$"{{TX}}{spacing}{line}{spacing}{{RX}}";
|
||||||
|
|
||||||
public static string Spacing(string setting) =>
|
public static string Spacing(string setting) =>
|
||||||
setting.Replace("S", " ", StringComparison.Ordinal);
|
setting.Replace("S", " ", StringComparison.Ordinal);
|
||||||
|
|
||||||
@@ -43,6 +101,11 @@ public static class QtcMessages
|
|||||||
/// The message that ends the exchange, sent whichever way the traffic went.
|
/// The message that ends the exchange, sent whichever way the traffic went.
|
||||||
/// `{QTC}` in it stands for the header of the series, so `TU {QTC} 73`
|
/// `{QTC}` in it stands for the header of the series, so `TU {QTC} 73`
|
||||||
/// goes out as `TU QTC 3/10 73`.
|
/// goes out as `TU QTC 3/10 73`.
|
||||||
public static string Tu(string template, string header) =>
|
public static string Tu(string template, string header) => WithHeader(template, header);
|
||||||
|
|
||||||
|
/// `{QTC}` filled in with the header of the series. It is filled in here
|
||||||
|
/// rather than by the message expander because only the QTC window knows
|
||||||
|
/// which series is going out.
|
||||||
|
private static string WithHeader(string template, string header) =>
|
||||||
template.Replace("{QTC}", header.Trim().ToUpperInvariant(), StringComparison.OrdinalIgnoreCase);
|
template.Replace("{QTC}", header.Trim().ToUpperInvariant(), StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,58 @@ public class BandChangeRulesTests
|
|||||||
TimestampUtc = Start.AddMinutes(minutes).AddSeconds(seconds),
|
TimestampUtc = Start.AddMinutes(minutes).AddSeconds(seconds),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// N1MM's fallback, which is the ten-minute rule: a multi-operator entry
|
||||||
|
/// with one transmitter has to stay ten minutes on a band, and nothing caps
|
||||||
|
/// how many times it may move.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("MULTI-ONE", "ONE", 10)]
|
||||||
|
[InlineData("MULTI-OP", "ONE", 10)]
|
||||||
|
[InlineData("MULTI-TWO", "TWO", 10)]
|
||||||
|
[InlineData("MULTI-OP", "TWO", 10)]
|
||||||
|
[InlineData("MULTI-OP", "UNLIMITED", 0)]
|
||||||
|
[InlineData("SINGLE-OP", "ONE", 0)]
|
||||||
|
public void TheCategoryAloneSaysHowLongAStationStaysOnABand(
|
||||||
|
string operatorCategory,
|
||||||
|
string transmitters,
|
||||||
|
int minutes)
|
||||||
|
{
|
||||||
|
BandChangeRules rules = BandChangeRules.ForCategory(new ContestEntry
|
||||||
|
{
|
||||||
|
OperatorCategory = operatorCategory,
|
||||||
|
TransmitterCategory = transmitters,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(TimeSpan.FromMinutes(minutes), rules.MinimumStay);
|
||||||
|
Assert.False(rules.IsCounted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A contest that says nothing about band changes still gets the rule for
|
||||||
|
/// the category, which is what N1MM does.
|
||||||
|
[Fact]
|
||||||
|
public void AContestThatSaysNothingStillGetsTheTenMinuteRule()
|
||||||
|
{
|
||||||
|
Contest contest = new Rules.CqWorldWide(ModeCategory.Cw);
|
||||||
|
|
||||||
|
BandChangeRules rules = contest.BandChangesFor(new ContestEntry
|
||||||
|
{
|
||||||
|
OperatorCategory = "MULTI-ONE",
|
||||||
|
TransmitterCategory = "ONE",
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(rules.HasStayTimer);
|
||||||
|
Assert.Equal(TimeSpan.FromMinutes(10), rules.MinimumStay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ASingleOperatorEntryHasNoBandChangeRule()
|
||||||
|
{
|
||||||
|
Contest contest = new Rules.CqWorldWide(ModeCategory.Cw);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
BandChangeRules.None,
|
||||||
|
contest.BandChangesFor(new ContestEntry { OperatorCategory = "SINGLE-OP" }));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EachMoveToAnotherBandIsOneChange() =>
|
public void EachMoveToAnotherBandIsOneChange() =>
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
|
|||||||
404
tests/Nonemm.Digital.Tests/DigitalEngineSenderTests.cs
Normal file
404
tests/Nonemm.Digital.Tests/DigitalEngineSenderTests.cs
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Nonemm.Digital;
|
||||||
|
|
||||||
|
namespace Nonemm.Digital.Tests;
|
||||||
|
|
||||||
|
/// The message sender over a digital engine. The engine here reports what it
|
||||||
|
/// was given and when it was told to stop; the pace is a baud rate no radio
|
||||||
|
/// uses so the tests do not wait for RTTY.
|
||||||
|
public class DigitalEngineSenderTests
|
||||||
|
{
|
||||||
|
/// Long enough for the stop, which waits for the engine to empty and then
|
||||||
|
/// one character time on top of it.
|
||||||
|
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
|
private const double Fast = 1500;
|
||||||
|
|
||||||
|
/// A quarter of a second per character, so a test can catch the engine
|
||||||
|
/// still holding what it was handed.
|
||||||
|
private const double Slow = 30;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheEngineDroppingTheTransmitterOnItsOwnDoesNotEndTheMessage()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Fast);
|
||||||
|
|
||||||
|
await sender.SendAsync("CQ TEST DE OM5M");
|
||||||
|
await WaitForAsync(() => engine.Sent.Length >= 2);
|
||||||
|
engine.Drop();
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Sent == "CQ TEST DE OM5M");
|
||||||
|
Assert.Equal("CQ TEST DE OM5M", engine.Sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheDropAfterReturnToReceiveEndsTheMessage()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Fast);
|
||||||
|
bool finished = false;
|
||||||
|
sender.Finished += (_, _) => finished = true;
|
||||||
|
|
||||||
|
await sender.SendAsync("CQ TEST");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
engine.Drop();
|
||||||
|
|
||||||
|
await WaitForAsync(() => finished && sender.Buffer.Sent.Length == 0);
|
||||||
|
Assert.True(finished);
|
||||||
|
Assert.Equal("", sender.Buffer.Sent);
|
||||||
|
Assert.Equal("CQ TEST", engine.Sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheEngineDroppingInTheMiddleOfAMessagePutsTheTransmitterBackUp()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Fast);
|
||||||
|
|
||||||
|
sender.Transmit();
|
||||||
|
await sender.SendAsync("CQ TEST DE OM5M");
|
||||||
|
await WaitForAsync(() => engine.Sent.Length >= 2);
|
||||||
|
int keyed = engine.Keyed;
|
||||||
|
engine.Drop();
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Keyed > keyed);
|
||||||
|
Assert.Equal(keyed + 1, engine.Keyed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MMTTY's stop does nothing when its buffer is empty already, so the
|
||||||
|
/// stop has to reach the engine while it still holds the last characters.
|
||||||
|
[Fact]
|
||||||
|
public async Task ReturnToReceiveReachesTheEngineWhileItStillHoldsTheLastCharacters()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Slow);
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync("AB");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
Assert.True(engine.Stopped);
|
||||||
|
Assert.True(sender.Buffer.OnAir < 2, "the engine had already transmitted everything");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MMTTY's stop leaves the transmitter up, so `{RX}` puts the key down
|
||||||
|
/// itself once the engine has transmitted what it holds.
|
||||||
|
[Fact]
|
||||||
|
public async Task ReturnToReceivePutsTheKeyDownAfterTheEngineHasEmptied()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Slow);
|
||||||
|
bool finished = false;
|
||||||
|
sender.Finished += (_, _) => finished = true;
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync("AB");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Released);
|
||||||
|
Assert.True(engine.Released);
|
||||||
|
Assert.Equal("AB", engine.Sent);
|
||||||
|
Assert.False(engine.Aborted, "the message was cut off instead of being let finish");
|
||||||
|
await WaitForAsync(() => finished);
|
||||||
|
Assert.True(finished);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N1MM's ending: what is left of the message goes to the engine in one
|
||||||
|
/// piece and the engine is asked to stop with its buffer full, because
|
||||||
|
/// `SetMmttyPTT(1)` does nothing at an engine that has been fed one
|
||||||
|
/// character at a time and is therefore nearly empty.
|
||||||
|
[Fact]
|
||||||
|
public async Task ReturnToReceiveHandsTheRestOfTheMessageOverInOnePiece()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Slow);
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync("CQ TEST DE OM5M");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
Assert.Equal("CQ TEST DE OM5M", engine.Sent);
|
||||||
|
Assert.Equal("", sender.Buffer.Pending);
|
||||||
|
Assert.Contains(engine.Pushes, push => push.Length > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stop reaches the engine while the message is still in it, which at
|
||||||
|
/// 30 baud is most of the four seconds the message takes.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheStopReachesTheEngineWhileItStillHoldsTheMessage()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Slow);
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync("CQ TEST DE OM5M");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
Assert.True(sender.Buffer.OnAir < "CQ TEST DE OM5M".Length,
|
||||||
|
"the engine had transmitted the whole message before it was told to stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message takes longer to transmit than the engine is given to make
|
||||||
|
/// progress, and the two are not the same thing. Measured as one, the key
|
||||||
|
/// went down partway through a CQ.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheKeyWaitsForTheWholeMessageAndNotForTheStopPatience()
|
||||||
|
{
|
||||||
|
const string message = "CQ CQ DE OM5M OM5M K";
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Slow);
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync(message);
|
||||||
|
DateTime from = DateTime.UtcNow;
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Released);
|
||||||
|
TimeSpan waited = DateTime.UtcNow - from;
|
||||||
|
Assert.Equal(message, engine.Sent);
|
||||||
|
Assert.True(
|
||||||
|
waited > DigitalEngineSender.StopPatience,
|
||||||
|
$"the key went down after {waited.TotalMilliseconds:0} ms, before the message was out");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transmitting again while the last message is still ending. The ending
|
||||||
|
/// takes as long as the engine takes to transmit what it holds, and the
|
||||||
|
/// operator can key inside that time; the old ending must not put the key
|
||||||
|
/// down in the middle of the new message.
|
||||||
|
[Fact]
|
||||||
|
public async Task TransmittingAgainAbandonsTheEndingOfTheMessageBeforeIt()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Slow);
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync("CQ");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
|
||||||
|
sender.Transmit();
|
||||||
|
await sender.SendAsync("TU");
|
||||||
|
await WaitForAsync(() => engine.Sent == "CQTU");
|
||||||
|
|
||||||
|
Assert.Equal("CQTU", engine.Sent);
|
||||||
|
Assert.False(engine.Released, "the old ending put the key down during the new message");
|
||||||
|
Assert.True(engine.IsTransmitting);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same through a message alone, with no `{TX}` in front of it: text is
|
||||||
|
/// the operator asking for the transmitter.
|
||||||
|
[Fact]
|
||||||
|
public async Task AMessageDuringAnEndingKeysAgainAndGoesOut()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Slow);
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync("CQ");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
int keyed = engine.Keyed;
|
||||||
|
|
||||||
|
await sender.SendAsync("TU");
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Sent == "CQTU");
|
||||||
|
Assert.Equal("CQTU", engine.Sent);
|
||||||
|
Assert.True(engine.Keyed > keyed, "the transmitter was not keyed for the new message");
|
||||||
|
Assert.False(engine.Released);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine dropping ends the message it was ending, and nothing else.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheKeyStaysDownAfterAMessageHasEnded()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Fast);
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync("CQ TEST");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
engine.Drop();
|
||||||
|
|
||||||
|
await WaitForAsync(() => !sender.Buffer.IsTransmitting);
|
||||||
|
int keyed = engine.Keyed;
|
||||||
|
await Task.Delay(500);
|
||||||
|
Assert.Equal(keyed, engine.Keyed);
|
||||||
|
Assert.False(engine.IsTransmitting);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A macro pressed just as the last one finishes. The stop for that message
|
||||||
|
/// is inside the engine, waiting for its buffer to empty, and feeding an
|
||||||
|
/// engine with that standing left MMTTY keyed and transmitting nothing. The
|
||||||
|
/// stop is cleared before the new message is fed.
|
||||||
|
[Fact]
|
||||||
|
public async Task AMessageStartedWhileTheLastOneIsEndingClearsTheStopFirst()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Fast);
|
||||||
|
sender.Transmit();
|
||||||
|
await sender.SendAsync("CQ");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
|
||||||
|
await sender.SendAsync("TU");
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Sent == "CQTU");
|
||||||
|
Assert.Equal("CQTU", engine.Sent);
|
||||||
|
Assert.True(engine.Aborted, "the engine was fed with its stop still pending");
|
||||||
|
Assert.True(engine.IsTransmitting);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two macros pressed one after the other keep the transmitter up, so the
|
||||||
|
/// engine never reports the drop that ends a message. The pane still starts
|
||||||
|
/// again on each of them. Text that has gone to the engine cannot be
|
||||||
|
/// edited, and keeping the whole run in the pane left none of it editable.
|
||||||
|
[Fact]
|
||||||
|
public async Task AMessageStartedAfterTheLastOneLeavesOnlyItsOwnTextInThePane()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Fast);
|
||||||
|
sender.Transmit();
|
||||||
|
await sender.SendAsync("CQ");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
|
||||||
|
await sender.SendAsync("TU");
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Sent == "CQTU");
|
||||||
|
Assert.Equal("TU", sender.Buffer.Sent + sender.Buffer.Pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<bool> ready)
|
||||||
|
{
|
||||||
|
DateTime giveUp = DateTime.UtcNow + Patience;
|
||||||
|
while (!ready() && DateTime.UtcNow < giveUp)
|
||||||
|
{
|
||||||
|
await Task.Delay(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An engine with no buffer of its own: it takes what it is given, says
|
||||||
|
/// when it was told to stop, and drops the transmitter when the test says
|
||||||
|
/// so.
|
||||||
|
private sealed class FakeEngine : DigitalEngine
|
||||||
|
{
|
||||||
|
private readonly StringBuilder sent = new();
|
||||||
|
private readonly List<string> pushes = [];
|
||||||
|
private readonly Lock gate = new();
|
||||||
|
|
||||||
|
public bool IsConnected => true;
|
||||||
|
|
||||||
|
public bool IsTransmitting { get; private set; } = true;
|
||||||
|
|
||||||
|
/// True once `{RX}` told the engine to stop.
|
||||||
|
public bool Stopped { get; private set; }
|
||||||
|
|
||||||
|
/// True once the engine was stopped the hard way.
|
||||||
|
public bool Aborted { get; private set; }
|
||||||
|
|
||||||
|
/// True once the key was put down, which is what ends a message on
|
||||||
|
/// MMTTY.
|
||||||
|
public bool Released { get; private set; }
|
||||||
|
|
||||||
|
/// How many times the transmitter has been keyed.
|
||||||
|
public int Keyed { get; private set; }
|
||||||
|
|
||||||
|
public string Sent
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return sent.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every call, so a test can tell a message pushed in one piece from
|
||||||
|
/// the same text fed one character at a time.
|
||||||
|
public IReadOnlyList<string> Pushes
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return [.. pushes];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nothing here decodes or disconnects, so these two are declared to
|
||||||
|
// satisfy the interface and never raised
|
||||||
|
public event EventHandler<string>? Received
|
||||||
|
{
|
||||||
|
add { }
|
||||||
|
remove { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<bool>? TransmitChanged;
|
||||||
|
|
||||||
|
public event EventHandler<bool>? ConnectionChanged
|
||||||
|
{
|
||||||
|
add { }
|
||||||
|
remove { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine reporting the transmitter down.
|
||||||
|
public void Drop()
|
||||||
|
{
|
||||||
|
IsTransmitting = false;
|
||||||
|
TransmitChanged?.Invoke(this, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellation = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task KeyAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
Keyed++;
|
||||||
|
Stopped = false;
|
||||||
|
IsTransmitting = true;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SendAsync(string text, CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
sent.Append(text);
|
||||||
|
pushes.Add(text);
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task AbortAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
Aborted = true;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ReturnToReceiveAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
Stopped = true;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MMTTY drops the transmitter as soon as the key goes down, and this
|
||||||
|
/// does the same.
|
||||||
|
public Task ReleaseKeyAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
Released = true;
|
||||||
|
Drop();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,20 +3,20 @@ using Nonemm.Digital;
|
|||||||
|
|
||||||
namespace Nonemm.Digital.Tests;
|
namespace Nonemm.Digital.Tests;
|
||||||
|
|
||||||
/// The text waiting to go out, fed to the engine a few characters at a time.
|
/// The text waiting to go out, fed to the engine one character per character
|
||||||
/// The pump is run at a baud rate no radio uses so the tests do not wait for
|
/// time. The feeder is run at a baud rate no radio uses so the tests do not
|
||||||
/// RTTY.
|
/// wait for RTTY.
|
||||||
public class TypeAheadTests
|
public class TypeAheadTests
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(5);
|
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
/// Fast enough that a message goes out in milliseconds, slow enough that a
|
/// Fast enough that a message goes out in milliseconds, slow enough that a
|
||||||
/// test can still catch the pump partway through.
|
/// test can still catch the feeder partway through.
|
||||||
private const double Fast = 7500;
|
private const double Fast = 1500;
|
||||||
|
|
||||||
/// A character time of 100 ms, so a test can tell the characters sent ahead
|
/// A quarter of a second per character, which is long enough to read what
|
||||||
/// from the ones that wait for the engine.
|
/// the engine is holding before it has transmitted it.
|
||||||
private const double Slow = 75;
|
private const double Slow = 30;
|
||||||
|
|
||||||
private readonly StringBuilder went = new();
|
private readonly StringBuilder went = new();
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ public class TypeAheadTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An engine with no count of its own, so the clock alone paces it.
|
||||||
private TypeAhead Buffer(double baud = Fast) =>
|
private TypeAhead Buffer(double baud = Fast) =>
|
||||||
new(
|
new(
|
||||||
(character, _) =>
|
(character, _) =>
|
||||||
@@ -68,7 +69,7 @@ public class TypeAheadTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task WhatHasGoneOutIsNotPendingAnyMore()
|
public async Task WhatHasGoneOutIsNotPendingAnyMore()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer(baud: 40);
|
using TypeAhead buffer = Buffer();
|
||||||
|
|
||||||
buffer.Append("CQ TEST DE OM5M");
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
|
||||||
@@ -77,52 +78,72 @@ public class TypeAheadTests
|
|||||||
Assert.Equal("CQ TEST DE OM5M", buffer.Sent + buffer.Pending);
|
Assert.Equal("CQ TEST DE OM5M", buffer.Sent + buffer.Pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pane is edited as a whole: what has gone out and what is still to
|
||||||
|
/// go. What has gone out is dropped, and the rest replaces what was
|
||||||
|
/// waiting.
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheOperatorRewritesWhatHasNotGoneOut()
|
public async Task TheOperatorRewritesWhatHasNotGoneOut()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer(baud: 60);
|
using TypeAhead buffer = Buffer();
|
||||||
buffer.Append("OM5X 599 001");
|
buffer.Append("OM5X 599 001");
|
||||||
|
|
||||||
await WaitForAsync(() => buffer.Sent.Length >= 5);
|
await WaitForAsync(() => buffer.Sent.Length >= 5);
|
||||||
buffer.Rewrite("599 002", TypeAhead.NoCursor);
|
|
||||||
|
buffer.Edit(buffer.Sent + "599 002");
|
||||||
|
|
||||||
await WaitForAsync(() => buffer.Pending.Length == 0);
|
await WaitForAsync(() => buffer.Pending.Length == 0);
|
||||||
Assert.EndsWith("599 002", Sent, StringComparison.Ordinal);
|
Assert.EndsWith("599 002", Sent, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("001", Sent, StringComparison.Ordinal);
|
Assert.DoesNotContain("001", Sent, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What is typed stays off the air until the transmitter is keyed.
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task NothingGoesOutFromBehindTheCursor()
|
public async Task NothingTypedGoesOutBeforeTheTransmitterIsKeyed()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer();
|
using TypeAhead buffer = Buffer();
|
||||||
|
|
||||||
buffer.Rewrite("CQ TEST", 2);
|
buffer.Edit("CQ TEST");
|
||||||
|
|
||||||
await WaitForAsync(() => Sent.Length == 2);
|
|
||||||
await Task.Delay(50);
|
await Task.Delay(50);
|
||||||
Assert.Equal("CQ", Sent);
|
Assert.Equal("", Sent);
|
||||||
Assert.Equal(" TEST", buffer.Pending);
|
Assert.Equal("CQ TEST", buffer.Pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheRestGoesOutWhenTheCursorMovesOn()
|
public async Task TheWholePaneGoesOutOnTransmit()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer();
|
using TypeAhead buffer = Buffer();
|
||||||
buffer.Rewrite("CQ TEST", 2);
|
buffer.Edit("CQ TEST");
|
||||||
await WaitForAsync(() => Sent.Length == 2);
|
await Task.Delay(20);
|
||||||
|
|
||||||
buffer.Cursor = TypeAhead.NoCursor;
|
buffer.Transmit();
|
||||||
|
|
||||||
await WaitForAsync(() => Sent == "CQ TEST");
|
await WaitForAsync(() => Sent == "CQ TEST");
|
||||||
Assert.Equal("CQ TEST", Sent);
|
Assert.Equal("CQ TEST", Sent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The workflow the pane is for: keyed up, what is typed goes out behind
|
||||||
|
/// what is already going, with no second key to press.
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TextAddedBehindTheCursorStillGoesOut()
|
public async Task TextTypedWhileTransmittingGoesOutBehindIt()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer();
|
using TypeAhead buffer = Buffer();
|
||||||
buffer.Rewrite("CQ", 2);
|
buffer.Edit("CQ ");
|
||||||
await WaitForAsync(() => Sent == "CQ");
|
buffer.Transmit();
|
||||||
|
await WaitForAsync(() => Sent == "CQ ");
|
||||||
|
|
||||||
|
buffer.Edit(buffer.Sent + "DE OM5M");
|
||||||
|
|
||||||
|
await WaitForAsync(() => Sent == "CQ DE OM5M");
|
||||||
|
Assert.Equal("CQ DE OM5M", Sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A function key pressed while the operator is typing goes on the end of
|
||||||
|
/// what has been typed, and keys the transmitter itself.
|
||||||
|
[Fact]
|
||||||
|
public async Task AMacroFollowsWhatWasTypedAndKeysTheTransmitter()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer();
|
||||||
|
buffer.Edit("CQ");
|
||||||
|
|
||||||
buffer.Append(" TEST");
|
buffer.Append(" TEST");
|
||||||
|
|
||||||
@@ -130,12 +151,200 @@ public class TypeAheadTests
|
|||||||
Assert.Equal("CQ TEST", Sent);
|
Assert.Equal("CQ TEST", Sent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Text taken out of the pane before it goes out is never transmitted.
|
||||||
|
[Fact]
|
||||||
|
public async Task TextDeletedBeforeItGoesOutIsNotSent()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer();
|
||||||
|
buffer.Edit("CQ TEST DE OM5X");
|
||||||
|
buffer.Transmit();
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length >= 3);
|
||||||
|
|
||||||
|
buffer.Edit(buffer.Sent + "DE OM5M");
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.Pending.Length == 0);
|
||||||
|
Assert.EndsWith("DE OM5M", Sent, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("OM5X", Sent, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The transmitter dropping ends the message: what went out is cleared off
|
||||||
|
/// the pane and nothing goes out again until the transmitter is keyed.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheEndOfAMessageClearsWhatWentOutAndShutsTheGate()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer();
|
||||||
|
buffer.Append("TU");
|
||||||
|
await WaitForAsync(() => Sent == "TU");
|
||||||
|
|
||||||
|
buffer.Ended();
|
||||||
|
buffer.Edit(" NEXT");
|
||||||
|
|
||||||
|
Assert.Equal("", buffer.Sent);
|
||||||
|
Assert.Equal(" NEXT", buffer.Pending);
|
||||||
|
await Task.Delay(50);
|
||||||
|
Assert.Equal("TU", Sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Aired` is the end of the message: the engine has been given
|
||||||
|
/// everything and has transmitted it too.
|
||||||
|
[Fact]
|
||||||
|
public async Task DrainedComesWhenTheMessageHasGoneOut()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer();
|
||||||
|
int drained = 0;
|
||||||
|
buffer.Aired += (_, _) => Interlocked.Increment(ref drained);
|
||||||
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
|
||||||
|
Assert.Equal(0, Volatile.Read(ref drained));
|
||||||
|
await WaitForAsync(() => Volatile.Read(ref drained) == 1);
|
||||||
|
Assert.Equal("CQ TEST DE OM5M", Sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The red text in the pane is `OnAir`, and it ran ahead of the
|
||||||
|
/// transmission because it advanced one character per symbol time. A digit
|
||||||
|
/// in a callsign costs a shift to figures and the letter after it a shift
|
||||||
|
/// back, so `OM5M` is six symbols and not four.
|
||||||
|
[Fact]
|
||||||
|
public async Task WhatIsOnTheAirIsPricedInSymbolsAndNotInCharacters()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer();
|
||||||
|
buffer.Append("OM5M");
|
||||||
|
|
||||||
|
// four symbol times is what the old clock allowed the whole callsign
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length == 4);
|
||||||
|
await Task.Delay(buffer.SymbolTime * 4.5);
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
buffer.OnAir < 4,
|
||||||
|
$"{buffer.OnAir} of 4 characters were called transmitted in four symbol times");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Letters with nothing to shift for cost one symbol each, so the two
|
||||||
|
/// agree there.
|
||||||
|
[Fact]
|
||||||
|
public async Task PlainLettersGoOutAtOneSymbolEach()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer();
|
||||||
|
buffer.Append("CQ TEST");
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.OnAir >= 4);
|
||||||
|
Assert.True(buffer.OnAir <= 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine's count is what says how much is left, whatever the clock
|
||||||
|
/// thinks. An engine that says it is still holding the whole message keeps
|
||||||
|
/// the pane from marking any of it as gone out.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheEnginesOwnCountHoldsThePaneBack()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new() { Answer = 30 };
|
||||||
|
using TypeAhead buffer = new(engine, Fast);
|
||||||
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length == 15);
|
||||||
|
await Task.Delay(buffer.SymbolTime * 20);
|
||||||
|
|
||||||
|
Assert.Equal(0, buffer.OnAir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// And it lets go as the count falls.
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePaneCatchesUpAsTheCountFalls()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new() { Answer = 30 };
|
||||||
|
using TypeAhead buffer = new(engine, Fast);
|
||||||
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length == 15);
|
||||||
|
|
||||||
|
engine.Answer = 0;
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.OnAir == 15);
|
||||||
|
Assert.Equal(15, buffer.OnAir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine's count is what says how much has gone out. It answers in
|
||||||
|
/// symbols and they fall as they are transmitted, so what it drops between
|
||||||
|
/// two answers is what went on the air between them.
|
||||||
|
[Fact]
|
||||||
|
public async Task WhatTheEngineHasTransmittedComesFromItsOwnCount()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new() { Answer = 20 };
|
||||||
|
using TypeAhead buffer = new(engine, Fast);
|
||||||
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length == 15);
|
||||||
|
await WaitForAsync(() => buffer.EngineHolds == 20);
|
||||||
|
|
||||||
|
// four symbols out of the engine, which is the first four characters
|
||||||
|
engine.Answer = 16;
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.OnAir == 4);
|
||||||
|
Assert.Equal(4, buffer.OnAir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A count that has not moved leaves the pane where it is, however long
|
||||||
|
/// the clock runs.
|
||||||
|
[Fact]
|
||||||
|
public async Task ACountThatDoesNotMoveHoldsThePaneStill()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new() { Answer = 20 };
|
||||||
|
using TypeAhead buffer = new(engine, Fast);
|
||||||
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length == 15);
|
||||||
|
|
||||||
|
await Task.Delay(buffer.SymbolTime * 20);
|
||||||
|
|
||||||
|
Assert.Equal(0, buffer.OnAir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty engine has transmitted everything it was given, whatever the
|
||||||
|
/// symbols added up to along the way. A wrong guess about the shift
|
||||||
|
/// corrects itself at the end of every message rather than accumulating.
|
||||||
|
[Fact]
|
||||||
|
public async Task AnEmptyEngineHasTransmittedEverything()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new() { Answer = 20 };
|
||||||
|
using TypeAhead buffer = new(engine, Fast);
|
||||||
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length == 15);
|
||||||
|
await WaitForAsync(() => buffer.EngineHolds == 20);
|
||||||
|
|
||||||
|
engine.Answer = 0;
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.OnAir == 15);
|
||||||
|
Assert.Equal(15, buffer.OnAir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The count reads 0 for the first 150 ms after a push, before the engine
|
||||||
|
/// has caught up with what it was given. That 0 does not mean the message
|
||||||
|
/// is over.
|
||||||
|
[Fact]
|
||||||
|
public async Task ACountThatHasNotCaughtUpIsNotAnEmptyEngine()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new() { Answer = 0 };
|
||||||
|
using TypeAhead buffer = new(engine, Fast);
|
||||||
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length == 15);
|
||||||
|
await Task.Delay(buffer.SymbolTime * 8);
|
||||||
|
|
||||||
|
Assert.Equal(0, buffer.OnAir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nothing has been given to the engine, so nothing is outstanding.
|
||||||
|
[Fact]
|
||||||
|
public void NothingIsOutstandingWhenNothingIsGoingOut()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer();
|
||||||
|
|
||||||
|
Assert.Equal(0, buffer.Outstanding);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheBufferSaysWhenTheMessageHasGoneOut()
|
public async Task TheBufferSaysWhenTheMessageHasGoneOut()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer();
|
using TypeAhead buffer = Buffer();
|
||||||
int drained = 0;
|
int drained = 0;
|
||||||
buffer.Drained += (_, _) => Interlocked.Increment(ref drained);
|
buffer.Aired += (_, _) => Interlocked.Increment(ref drained);
|
||||||
|
|
||||||
buffer.Append("TU");
|
buffer.Append("TU");
|
||||||
|
|
||||||
@@ -146,7 +355,7 @@ public class TypeAheadTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AMessageAddedWhileOneIsGoingOutFollowsIt()
|
public async Task AMessageAddedWhileOneIsGoingOutFollowsIt()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer(baud: 400);
|
using TypeAhead buffer = Buffer();
|
||||||
buffer.Append("CQ ");
|
buffer.Append("CQ ");
|
||||||
|
|
||||||
buffer.Append("DE OM5M");
|
buffer.Append("DE OM5M");
|
||||||
@@ -158,7 +367,7 @@ public class TypeAheadTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task DroppingLeavesWhatHasAlreadyGone()
|
public async Task DroppingLeavesWhatHasAlreadyGone()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer(baud: 60);
|
using TypeAhead buffer = Buffer();
|
||||||
buffer.Append("CQ TEST DE OM5M");
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
await WaitForAsync(() => buffer.Sent.Length >= 3);
|
await WaitForAsync(() => buffer.Sent.Length >= 3);
|
||||||
|
|
||||||
@@ -174,7 +383,7 @@ public class TypeAheadTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ClearingEmptiesBothHalves()
|
public void ClearingEmptiesBothHalves()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer(baud: 1);
|
using TypeAhead buffer = Buffer();
|
||||||
buffer.Append("CQ TEST");
|
buffer.Append("CQ TEST");
|
||||||
|
|
||||||
buffer.Clear();
|
buffer.Clear();
|
||||||
@@ -183,57 +392,178 @@ public class TypeAheadTests
|
|||||||
Assert.Equal("", buffer.Sent);
|
Assert.Equal("", buffer.Sent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The engine is given the second character before it has transmitted the
|
/// MMTTY answers 0 while it is still transmitting: for the first 150 ms
|
||||||
/// first, so it has one in hand when the first is done. An engine left with
|
/// after a push, and for as long as it holds a word on Word out. A feeder
|
||||||
/// an empty buffer transmits idle instead, and that idle is added to how
|
/// that read that as room fed on it and ran ahead of the air, so the clock
|
||||||
/// long the message takes.
|
/// is the pace and the count only ever holds it back. This one runs at the
|
||||||
|
/// real RTTY speed, because the number that matters is how many characters
|
||||||
|
/// go out in a second at 45.45 baud.
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheEngineIsKeptOneCharacterAhead()
|
public async Task AnEngineAnsweringZeroDoesNotPullThePumpForward()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer(baud: Slow);
|
FakeEngine engine = new() { Answer = 0 };
|
||||||
|
using TypeAhead buffer = new(engine, baud: TypeAhead.DefaultBaud);
|
||||||
|
|
||||||
|
buffer.Append("CQ TEST DE OM5M OM5M K");
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||||
|
// 45.45 baud is 6.06 characters a second, plus the `Ahead` characters
|
||||||
|
// the engine is primed with and one for the poll granularity
|
||||||
|
Assert.InRange(engine.Waiting.Length, 7, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine is given the next character before it has transmitted the one
|
||||||
|
/// on the air, so it has one in hand when that one finishes. An engine left
|
||||||
|
/// with an empty buffer transmits the idle tone instead, which is audible
|
||||||
|
/// between the characters of a long word.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheEngineIsNeverLeftWithNothingInHand()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using TypeAhead buffer = new(engine, baud: TypeAhead.DefaultBaud);
|
||||||
|
// transmits one character every character time, as a real engine does
|
||||||
|
using Timer transmitting = new(_ => engine.Transmit(1), null, 165, 165);
|
||||||
|
|
||||||
|
buffer.Append(new string('N', 30));
|
||||||
|
|
||||||
|
int emptied = 0;
|
||||||
|
for (int look = 0; look < 60; look++)
|
||||||
|
{
|
||||||
|
await Task.Delay(30);
|
||||||
|
if (engine.Waiting.Length == 0 && buffer.Pending.Length > 0)
|
||||||
|
{
|
||||||
|
emptied++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert.Equal(0, emptied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine transmitting makes room, and the feeder fills it. Nothing
|
||||||
|
/// here guesses how fast the engine is going: it says, and it is believed.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheEngineIsFedAsItMakesRoom()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using TypeAhead buffer = new(engine);
|
||||||
|
using Timer transmitting = new(_ => engine.Transmit(1), null, 0, 10);
|
||||||
|
|
||||||
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Transmitted == "CQ TEST DE OM5M");
|
||||||
|
Assert.Equal("CQ TEST DE OM5M", engine.Transmitted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An engine that will not say how much it holds is fed anyway: there is
|
||||||
|
/// nothing to pace against, so it gets the message.
|
||||||
|
[Fact]
|
||||||
|
public async Task AnEngineThatWillNotCountIsFedAnyway()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new() { Counts = false };
|
||||||
|
using TypeAhead buffer = new(engine, baud: Fast);
|
||||||
|
|
||||||
buffer.Append("CQ TEST");
|
buffer.Append("CQ TEST");
|
||||||
|
|
||||||
await WaitForAsync(() => Sent.Length >= 2);
|
await WaitForAsync(() => engine.Waiting == "CQ TEST");
|
||||||
Assert.Equal("CQ", Sent);
|
Assert.False(buffer.Counts);
|
||||||
}
|
Assert.Equal("CQ TEST", engine.Waiting);
|
||||||
|
|
||||||
/// Only the lead goes out ahead. The rest waits, which is what leaves it
|
|
||||||
/// where the operator can still change it.
|
|
||||||
[Fact]
|
|
||||||
public async Task NoMoreThanTheLeadGoesToTheEngineAtOnce()
|
|
||||||
{
|
|
||||||
using TypeAhead buffer = Buffer(baud: Slow);
|
|
||||||
|
|
||||||
buffer.Append("CQ TEST");
|
|
||||||
|
|
||||||
await WaitForAsync(() => Sent.Length >= 2);
|
|
||||||
await Task.Delay(20);
|
|
||||||
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.
|
|
||||||
[Fact]
|
|
||||||
public async Task AnIdleEngineIsFedWithoutWaiting()
|
|
||||||
{
|
|
||||||
using TypeAhead buffer = Buffer(baud: Slow);
|
|
||||||
buffer.Append("CQ TEST");
|
|
||||||
await WaitForAsync(() => Sent.Length >= 2);
|
|
||||||
|
|
||||||
buffer.EngineIdle();
|
|
||||||
|
|
||||||
await WaitForAsync(() => Sent.Length >= 4);
|
|
||||||
Assert.Equal("CQ T", Sent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ACharacterTakesAsLongAsTheBaudRateSays()
|
public async Task WhatTheEngineIsStillHoldingHasNotGoneOutYet()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer(baud: TypeAhead.DefaultBaud);
|
using TypeAhead buffer = Buffer(Slow);
|
||||||
|
|
||||||
Assert.Equal(165, buffer.CharacterTime.TotalMilliseconds, 0.5);
|
buffer.Append("AB");
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.Sent == "AB");
|
||||||
|
Assert.Equal(0, buffer.OnAir);
|
||||||
|
await WaitForAsync(() => buffer.OnAir == 2);
|
||||||
|
Assert.Equal(2, buffer.OnAir);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheMessageIsNotOverUntilTheEngineHasTransmittedWhatItHolds()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer(Slow);
|
||||||
|
int atTheEnd = -1;
|
||||||
|
buffer.Aired += (_, _) => atTheEnd = buffer.OnAir;
|
||||||
|
|
||||||
|
buffer.Append("AB");
|
||||||
|
|
||||||
|
await WaitForAsync(() => atTheEnd >= 0);
|
||||||
|
Assert.Equal(2, atTheEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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, and `Answer` is
|
||||||
|
/// the engine that answers a number of its own rather than what it holds:
|
||||||
|
/// MMTTY reads 0 while it is still transmitting, and reads high while it is
|
||||||
|
/// holding a word.
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// What to answer instead of what is really waiting, or -1 to answer
|
||||||
|
/// what is waiting. A test can move it while the buffer is running, the
|
||||||
|
/// way a real engine's count falls as it transmits.
|
||||||
|
public int Answer { get; set; } = -1;
|
||||||
|
|
||||||
|
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 ? (Answer >= 0 ? Answer : 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
188
tests/Nonemm.Network.Tests/StationLinkTests.cs
Normal file
188
tests/Nonemm.Network.Tests/StationLinkTests.cs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Network.Tests;
|
||||||
|
|
||||||
|
/// Two links talking to each other over loopback. The stations are named by
|
||||||
|
/// hand rather than found by a beacon: a broadcast test would depend on the
|
||||||
|
/// machine's interfaces, and what is being tested here is what happens after
|
||||||
|
/// two stations have found each other.
|
||||||
|
public class StationLinkTests
|
||||||
|
{
|
||||||
|
private const string Version = "1.0.11364";
|
||||||
|
|
||||||
|
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AContactReachesTheOtherStation()
|
||||||
|
{
|
||||||
|
(int onePort, int twoPort) = SparePorts();
|
||||||
|
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||||
|
using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort);
|
||||||
|
one.Start();
|
||||||
|
two.Start();
|
||||||
|
ContactUpdate? arrived = null;
|
||||||
|
two.UpdateArrived += (_, update) => arrived = update;
|
||||||
|
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||||
|
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
|
||||||
|
|
||||||
|
await one.SendLoggedAsync(Contact());
|
||||||
|
|
||||||
|
await WaitForAsync(() => arrived is not null);
|
||||||
|
ContactLogged logged = Assert.IsType<ContactLogged>(arrived);
|
||||||
|
Assert.Equal("G3XYZ", logged.Qso.Call.Text);
|
||||||
|
Assert.Equal("RUN-PC", logged.Qso.StationName);
|
||||||
|
Assert.Equal(1, logged.Qso.NetworkedComputerNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first message on a new connection says which station of the entry
|
||||||
|
/// the sender is, so the other end can show its number.
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectingSaysWhichStationNumberThisIs()
|
||||||
|
{
|
||||||
|
(int onePort, int twoPort) = SparePorts();
|
||||||
|
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||||
|
using StationLink two = new("MULT-PC", Version, stationNumber: 7, port: twoPort);
|
||||||
|
one.Start();
|
||||||
|
two.Start();
|
||||||
|
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||||
|
await WaitForAsync(() =>
|
||||||
|
two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.StationNumber == 1);
|
||||||
|
|
||||||
|
NetworkedStation? seen = two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC");
|
||||||
|
Assert.Equal(1, seen?.StationNumber);
|
||||||
|
Assert.Equal("IAM", seen?.LastMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The band a station is on is what the status window shows and what the
|
||||||
|
/// band-change rule counts.
|
||||||
|
[Fact]
|
||||||
|
public async Task WhereAStationIsReachesTheOthers()
|
||||||
|
{
|
||||||
|
(int onePort, int twoPort) = SparePorts();
|
||||||
|
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||||
|
using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort);
|
||||||
|
one.Start();
|
||||||
|
two.Start();
|
||||||
|
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||||
|
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
|
||||||
|
|
||||||
|
await one.SendBandAsync(Frequency.FromKilohertz(21_025), Modes.Cw, running: true, radioNumber: 1);
|
||||||
|
|
||||||
|
await WaitForAsync(() =>
|
||||||
|
two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.Band?.Name == "15M");
|
||||||
|
NetworkedStation? seen = two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC");
|
||||||
|
Assert.Equal("15M", seen?.Band?.Name);
|
||||||
|
Assert.Equal("CW", seen?.Mode?.Name);
|
||||||
|
Assert.True(seen?.IsRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChatReachesTheOtherOperator()
|
||||||
|
{
|
||||||
|
(int onePort, int twoPort) = SparePorts();
|
||||||
|
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||||
|
using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort);
|
||||||
|
one.Start();
|
||||||
|
two.Start();
|
||||||
|
string? said = null;
|
||||||
|
two.TalkArrived += (_, text) => said = text;
|
||||||
|
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||||
|
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
|
||||||
|
|
||||||
|
await one.SendTalkAsync("qsy 20");
|
||||||
|
|
||||||
|
await WaitForAsync(() => said is not null);
|
||||||
|
Assert.Equal("[RUN-PC] qsy 20", said);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An echo request is answered without anybody asking, which is how N1MM
|
||||||
|
/// measures the round trip to each station.
|
||||||
|
[Fact]
|
||||||
|
public async Task AnEchoRequestIsAnswered()
|
||||||
|
{
|
||||||
|
(int onePort, int twoPort) = SparePorts();
|
||||||
|
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||||
|
using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort);
|
||||||
|
one.Start();
|
||||||
|
two.Start();
|
||||||
|
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||||
|
two.AddStation("RUN-PC", "127.0.0.1", onePort);
|
||||||
|
await WaitForAsync(() =>
|
||||||
|
one.Stations.Any(s => s is { IsMine: false, IsConnected: true })
|
||||||
|
&& two.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
|
||||||
|
|
||||||
|
await one.SendEchoRequestAsync();
|
||||||
|
|
||||||
|
await WaitForAsync(() =>
|
||||||
|
one.Stations.FirstOrDefault(s => s.ComputerName == "MULT-PC")?.EchoTime is not null);
|
||||||
|
Assert.NotNull(one.Stations.FirstOrDefault(s => s.ComputerName == "MULT-PC")?.EchoTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This computer is in its own list, the way N1MM shows it, so an operator
|
||||||
|
/// can read its station number and version off the same window.
|
||||||
|
[Fact]
|
||||||
|
public void ThisComputerIsInItsOwnStationList()
|
||||||
|
{
|
||||||
|
using StationLink link = new("RUN-PC", Version, stationNumber: 3, port: SparePorts().One);
|
||||||
|
|
||||||
|
NetworkedStation mine = Assert.Single(link.Stations);
|
||||||
|
|
||||||
|
Assert.True(mine.IsMine);
|
||||||
|
Assert.Equal("RUN-PC", mine.ComputerName);
|
||||||
|
Assert.Equal(3, mine.StationNumber);
|
||||||
|
Assert.Equal(Version, mine.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message that reached nobody says so, rather than looking as though it
|
||||||
|
/// went out.
|
||||||
|
[Fact]
|
||||||
|
public async Task SendingWithNoStationsConnectedReachesNobody()
|
||||||
|
{
|
||||||
|
using StationLink link = new("RUN-PC", Version, port: SparePorts().One);
|
||||||
|
link.Start();
|
||||||
|
|
||||||
|
Assert.Equal(0, await link.SendLoggedAsync(Contact()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<bool> ready)
|
||||||
|
{
|
||||||
|
DateTime giveUp = DateTime.UtcNow + Patience;
|
||||||
|
while (!ready() && DateTime.UtcNow < giveUp)
|
||||||
|
{
|
||||||
|
await Task.Delay(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two ports nothing else is on, so the tests do not fight each other or
|
||||||
|
/// the N1MM the machine may be running.
|
||||||
|
///
|
||||||
|
/// Both are taken before either is let go. Asking twice in a row does not
|
||||||
|
/// work: the second ask often gets the port the first one has just given
|
||||||
|
/// back, and two links on one port leaves the second without a listener.
|
||||||
|
private static (int One, int Two) SparePorts()
|
||||||
|
{
|
||||||
|
TcpListener first = new(IPAddress.Loopback, 0);
|
||||||
|
TcpListener second = new(IPAddress.Loopback, 0);
|
||||||
|
first.Start();
|
||||||
|
second.Start();
|
||||||
|
int one = ((IPEndPoint)first.LocalEndpoint).Port;
|
||||||
|
int two = ((IPEndPoint)second.LocalEndpoint).Port;
|
||||||
|
first.Stop();
|
||||||
|
second.Stop();
|
||||||
|
return (one, two);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Qso Contact() => new()
|
||||||
|
{
|
||||||
|
Id = Qso.NewId(),
|
||||||
|
TimestampUtc = new DateTime(2026, 9, 3, 12, 34, 56, DateTimeKind.Utc),
|
||||||
|
Call = Callsign.Parse("G3XYZ"),
|
||||||
|
Frequency = Frequency.FromKilohertz(14_025),
|
||||||
|
Mode = Modes.Cw,
|
||||||
|
ContestName = "CQWW",
|
||||||
|
ContestNumber = 2,
|
||||||
|
SentNumber = 41,
|
||||||
|
};
|
||||||
|
}
|
||||||
271
tests/Nonemm.Network.Tests/StationRecordTests.cs
Normal file
271
tests/Nonemm.Network.Tests/StationRecordTests.cs
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Network.Tests;
|
||||||
|
|
||||||
|
/// The computer-to-computer protocol on port 12070. It is N1MM's own, so the
|
||||||
|
/// tests are about the bytes: a field in the wrong place is a contact with the
|
||||||
|
/// callsign in the comment.
|
||||||
|
public class StationRecordTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AMessageIsFramedTheWayN1MmFramesIt()
|
||||||
|
{
|
||||||
|
StationRecord record = new(7, "shack-pc", "IAM", ["7"]);
|
||||||
|
|
||||||
|
Assert.Equal("DATA__07%SHACK-PC%IAM%7%~__DATA", record.ToWire());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AMessageReadsBackTheWayItWasWritten()
|
||||||
|
{
|
||||||
|
string wire = new StationRecord(3, "SHACK-PC", "TALK", ["hello", "there"]).ToWire();
|
||||||
|
|
||||||
|
StationRecord? read = StationRecord.Read(ref wire);
|
||||||
|
|
||||||
|
Assert.NotNull(read);
|
||||||
|
Assert.Equal(3, read.StationNumber);
|
||||||
|
Assert.Equal("SHACK-PC", read.ComputerName);
|
||||||
|
Assert.Equal("TALK", read.Type);
|
||||||
|
Assert.Equal(["hello", "there"], read.Fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TCP hands over whatever has arrived, which is half a message as often as
|
||||||
|
/// a whole one.
|
||||||
|
[Fact]
|
||||||
|
public void HalfAMessageIsHeldUntilTheRestArrives()
|
||||||
|
{
|
||||||
|
string whole = new StationRecord(1, "PC", "IAM", ["1"]).ToWire();
|
||||||
|
string text = whole[..10];
|
||||||
|
|
||||||
|
Assert.Null(StationRecord.Read(ref text));
|
||||||
|
|
||||||
|
text += whole[10..];
|
||||||
|
|
||||||
|
Assert.NotNull(StationRecord.Read(ref text));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TwoMessagesInOneReadAreBothFound()
|
||||||
|
{
|
||||||
|
string text = new StationRecord(1, "PC", "IAM", ["1"]).ToWire()
|
||||||
|
+ new StationRecord(2, "OTHER", "IAM", ["2"]).ToWire();
|
||||||
|
|
||||||
|
StationRecord? first = StationRecord.Read(ref text);
|
||||||
|
StationRecord? second = StationRecord.Read(ref text);
|
||||||
|
|
||||||
|
Assert.Equal("PC", first?.ComputerName);
|
||||||
|
Assert.Equal("OTHER", second?.ComputerName);
|
||||||
|
Assert.Null(StationRecord.Read(ref text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N1MM writes `!` in place of a delimiter that turns up in a field, so a
|
||||||
|
/// comment with a per-cent sign in it does not split the message.
|
||||||
|
[Fact]
|
||||||
|
public void ADelimiterInsideAFieldIsReplaced()
|
||||||
|
{
|
||||||
|
StationRecord record = new(1, "PC", "TALK", ["100% sure~ok"]);
|
||||||
|
|
||||||
|
string wire = record.ToWire();
|
||||||
|
StationRecord? read = StationRecord.Read(ref wire);
|
||||||
|
|
||||||
|
Assert.Equal("100! sure!ok", read?.Field(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AFieldPastTheEndOfTheMessageIsEmpty()
|
||||||
|
{
|
||||||
|
StationRecord record = new(1, "PC", "IAM", ["1"]);
|
||||||
|
|
||||||
|
Assert.Equal("", record.Field(9));
|
||||||
|
Assert.Equal(0, record.Number(9));
|
||||||
|
Assert.False(record.Flag(9));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N1MM writes a boolean as Visual Basic prints one. Its own log holds -1
|
||||||
|
/// and 0 for the same thing, so both are read.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("True", true)]
|
||||||
|
[InlineData("False", false)]
|
||||||
|
[InlineData("-1", true)]
|
||||||
|
[InlineData("1", true)]
|
||||||
|
[InlineData("0", false)]
|
||||||
|
[InlineData("", false)]
|
||||||
|
public void BooleansAreReadTheWayN1MmWritesThem(string field, bool expected)
|
||||||
|
{
|
||||||
|
StationRecord record = new(1, "PC", "XMIT", [field]);
|
||||||
|
|
||||||
|
Assert.Equal(expected, record.Flag(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ABeaconCarriesTheSixFieldsN1MmCounts()
|
||||||
|
{
|
||||||
|
StationBeacon beacon = new("SHACK-PC", "192.168.1.5", 12070, "1.0.11364", "OM3KFF");
|
||||||
|
|
||||||
|
Assert.Equal("SHACK-PC%192.168.1.5%12070%1.0.11364%OM3KFF%%", beacon.ToWire());
|
||||||
|
Assert.Equal(beacon, StationBeacon.Read(beacon.ToWire()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N1MM splits the beacon on `%` and refuses anything that is not seven
|
||||||
|
/// long, which is how it turns away a station on an older version.
|
||||||
|
[Fact]
|
||||||
|
public void ABeaconWithTheWrongNumberOfFieldsIsNotABeacon()
|
||||||
|
{
|
||||||
|
Assert.Null(StationBeacon.Read("SHACK-PC%192.168.1.5%12070"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The port-12060 contact broadcast pointed at the wrong port. N1MM says
|
||||||
|
/// so out loud; this drops it.
|
||||||
|
[Fact]
|
||||||
|
public void XmlOnTheBeaconPortIsNotABeacon()
|
||||||
|
{
|
||||||
|
Assert.Null(StationBeacon.Read("<?xml version=\"1.0\"?><contactinfo />"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ALoggedContactRoundTripsThroughTheWire()
|
||||||
|
{
|
||||||
|
Qso qso = Contact();
|
||||||
|
|
||||||
|
string wire = StationMessages.Logged(qso, 4, "SHACK-PC").ToWire();
|
||||||
|
StationRecord? record = StationRecord.Read(ref wire);
|
||||||
|
ContactUpdate? update = StationMessages.Read(record!);
|
||||||
|
|
||||||
|
ContactLogged logged = Assert.IsType<ContactLogged>(update);
|
||||||
|
Assert.Equal("SHACK-PC", logged.StationName);
|
||||||
|
Assert.Equal(qso.Call.Text, logged.Qso.Call.Text);
|
||||||
|
Assert.Equal(qso.TimestampUtc, logged.Qso.TimestampUtc);
|
||||||
|
Assert.Equal(qso.Frequency.Hertz, logged.Qso.Frequency.Hertz);
|
||||||
|
Assert.Equal(qso.Mode.Name, logged.Qso.Mode.Name);
|
||||||
|
Assert.Equal(qso.ContestName, logged.Qso.ContestName);
|
||||||
|
Assert.Equal(qso.SentNumber, logged.Qso.SentNumber);
|
||||||
|
Assert.Equal(qso.ReceivedNumber, logged.Qso.ReceivedNumber);
|
||||||
|
Assert.Equal(qso.Zone, logged.Qso.Zone);
|
||||||
|
Assert.Equal(qso.Points, logged.Qso.Points);
|
||||||
|
Assert.True(logged.Qso.IsMultiplier1);
|
||||||
|
Assert.False(logged.Qso.IsMultiplier2);
|
||||||
|
Assert.Equal(qso.Operator, logged.Qso.Operator);
|
||||||
|
Assert.Equal(qso.ContestNumber, logged.Qso.ContestNumber);
|
||||||
|
Assert.Equal(qso.Continent, logged.Qso.Continent);
|
||||||
|
Assert.Equal(4, logged.Qso.NetworkedComputerNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A contact that arrived over the network was made somewhere else, which
|
||||||
|
/// is what the log window colours a row on.
|
||||||
|
[Fact]
|
||||||
|
public void AContactFromAnotherStationIsNotOriginal()
|
||||||
|
{
|
||||||
|
string wire = StationMessages.Logged(Contact(), 4, "SHACK-PC").ToWire();
|
||||||
|
StationRecord? record = StationRecord.Read(ref wire);
|
||||||
|
|
||||||
|
ContactLogged logged = Assert.IsType<ContactLogged>(StationMessages.Read(record!));
|
||||||
|
|
||||||
|
Assert.False(logged.Qso.IsOriginal);
|
||||||
|
Assert.Equal("SHACK-PC", logged.Qso.StationName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An edit carries the old callsign and the old time in front of the
|
||||||
|
/// contact, because that pair is what N1MM keys a contact on.
|
||||||
|
[Fact]
|
||||||
|
public void AnEditSaysWhichRowToReplace()
|
||||||
|
{
|
||||||
|
Qso qso = Contact() with { Call = Callsign.Parse("DL1ABC") };
|
||||||
|
DateTime was = qso.TimestampUtc.AddMinutes(-3);
|
||||||
|
|
||||||
|
string wire = StationMessages.Edited(qso, "DL1AB", was, 4, "SHACK-PC").ToWire();
|
||||||
|
StationRecord? record = StationRecord.Read(ref wire);
|
||||||
|
|
||||||
|
ContactReplaced replaced = Assert.IsType<ContactReplaced>(StationMessages.Read(record!));
|
||||||
|
|
||||||
|
Assert.Equal("DL1AB", replaced.OldCall);
|
||||||
|
Assert.Equal(was, replaced.OldTimestampUtc);
|
||||||
|
Assert.Equal("DL1ABC", replaced.Qso.Call.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ADeleteNamesTheContactAndTheContest()
|
||||||
|
{
|
||||||
|
Qso qso = Contact();
|
||||||
|
|
||||||
|
string wire = StationMessages.Deleted(qso, 4, "SHACK-PC").ToWire();
|
||||||
|
StationRecord? record = StationRecord.Read(ref wire);
|
||||||
|
|
||||||
|
ContactDeleted deleted = Assert.IsType<ContactDeleted>(StationMessages.Read(record!));
|
||||||
|
|
||||||
|
Assert.Equal(qso.Id, deleted.Id);
|
||||||
|
Assert.Equal(qso.Call.Text, deleted.Call);
|
||||||
|
Assert.Equal(qso.TimestampUtc, deleted.TimestampUtc);
|
||||||
|
Assert.Equal(qso.ContestNumber, deleted.ContestNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resync is the same contact sent again, so it means the same thing.
|
||||||
|
[Fact]
|
||||||
|
public void AResyncedContactIsReadAsALoggedOne()
|
||||||
|
{
|
||||||
|
string wire = StationMessages.Resynced(Contact(), 4, "SHACK-PC").ToWire();
|
||||||
|
StationRecord? record = StationRecord.Read(ref wire);
|
||||||
|
|
||||||
|
Assert.IsType<ContactLogged>(StationMessages.Read(record!));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N1MM adds message types between versions. One this program does not
|
||||||
|
/// know is passed over rather than treated as a fault.
|
||||||
|
[Fact]
|
||||||
|
public void AMessageTypeThisProgramDoesNotKnowIsPassedOver()
|
||||||
|
{
|
||||||
|
Assert.Null(StationMessages.Read(new StationRecord(1, "PC", "SKEDD", ["something"])));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frequency the other station transmits on is only kept when it is
|
||||||
|
/// not the one it listens on: a contact in split is the exception, not the
|
||||||
|
/// rule.
|
||||||
|
[Fact]
|
||||||
|
public void OneFrequencyForBothMeansNoSplit()
|
||||||
|
{
|
||||||
|
string wire = StationMessages.Logged(Contact(), 1, "PC").ToWire();
|
||||||
|
StationRecord? record = StationRecord.Read(ref wire);
|
||||||
|
|
||||||
|
ContactLogged logged = Assert.IsType<ContactLogged>(StationMessages.Read(record!));
|
||||||
|
|
||||||
|
Assert.Equal(0, logged.Qso.QsxFrequency.Hertz);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ASplitContactKeepsBothFrequencies()
|
||||||
|
{
|
||||||
|
Qso qso = Contact() with { QsxFrequency = Frequency.FromKilohertz(14_205) };
|
||||||
|
|
||||||
|
string wire = StationMessages.Logged(qso, 1, "PC").ToWire();
|
||||||
|
StationRecord? record = StationRecord.Read(ref wire);
|
||||||
|
|
||||||
|
ContactLogged logged = Assert.IsType<ContactLogged>(StationMessages.Read(record!));
|
||||||
|
|
||||||
|
Assert.Equal(14_205_000, logged.Qso.QsxFrequency.Hertz);
|
||||||
|
Assert.Equal(14_025_000, logged.Qso.Frequency.Hertz);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Qso Contact() => new()
|
||||||
|
{
|
||||||
|
Id = Qso.NewId(),
|
||||||
|
TimestampUtc = new DateTime(2026, 9, 3, 12, 34, 56, DateTimeKind.Utc),
|
||||||
|
Call = Callsign.Parse("G3XYZ"),
|
||||||
|
Frequency = Frequency.FromKilohertz(14_025),
|
||||||
|
Mode = Modes.Cw,
|
||||||
|
ContestName = "CQWW",
|
||||||
|
ContestNumber = 2,
|
||||||
|
SentReport = "599",
|
||||||
|
ReceivedReport = "599",
|
||||||
|
SentNumber = 41,
|
||||||
|
ReceivedNumber = 17,
|
||||||
|
Zone = 14,
|
||||||
|
Points = 3,
|
||||||
|
IsMultiplier1 = true,
|
||||||
|
Operator = "OM3KFF",
|
||||||
|
RadioNumber = 2,
|
||||||
|
Continent = "EU",
|
||||||
|
CountryPrefix = "G",
|
||||||
|
StationPrefix = "G",
|
||||||
|
WpxPrefix = "G3",
|
||||||
|
Comment = "good sig",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -100,6 +100,14 @@ public class MessageExpanderTests
|
|||||||
public void WithOneRadioTheOtherMacrosStandForNothing() =>
|
public void WithOneRadioTheOtherMacrosStandForNothing() =>
|
||||||
Assert.Equal("", MessageExpander.Expand("{OTHERFREQ}{OTHERMHZ}{OTHERBAND}", Session()));
|
Assert.Equal("", MessageExpander.Expand("{OTHERFREQ}{OTHERMHZ}{OTHERBAND}", Session()));
|
||||||
|
|
||||||
|
/// The two carriage return macros a digital message is written with. N1MM's
|
||||||
|
/// own RTTY message defaults use `{ENTERLF}`.
|
||||||
|
[Fact]
|
||||||
|
public void TheCarriageReturnMacrosAreTheCharactersTheyStandFor()
|
||||||
|
{
|
||||||
|
Assert.Equal("A\rB\r\nC", MessageExpander.Expand("A{ENTER}B{ENTERLF}C", Session()));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TheNameComesFromTheCallHistoryWhenTheContestHasNoNameBox()
|
public void TheNameComesFromTheCallHistoryWhenTheContestHasNoNameBox()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -117,8 +117,21 @@ public class MessagePlanTests
|
|||||||
{
|
{
|
||||||
MessagePlan plan = MessagePlan.Read("{TX}CQ DE {MYCALL}{ENTER}{RX}", Session());
|
MessagePlan plan = MessagePlan.Read("{TX}CQ DE {MYCALL}{ENTER}{RX}", Session());
|
||||||
|
|
||||||
Assert.Equal([MessageCommand.StartTransmit, MessageCommand.ReturnToReceive],
|
Assert.Equal([MessageCommand.StartTransmit], plan.Before.Select(a => a.Command));
|
||||||
plan.Before.Select(a => a.Command));
|
|
||||||
Assert.Equal("CQ DE DL1ABC\r", plan.Text);
|
Assert.Equal("CQ DE DL1ABC\r", plan.Text);
|
||||||
|
Assert.Equal([MessageCommand.ReturnToReceive], plan.After.Select(a => a.Command));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `{RX}` waits for the message even when it stands in front of it, because
|
||||||
|
/// the transmitter cannot drop before the text has gone out. Everything
|
||||||
|
/// else in front of the text still runs first.
|
||||||
|
[Fact]
|
||||||
|
public void ReturnToReceiveRunsAfterTheMessageWhereverItStands()
|
||||||
|
{
|
||||||
|
MessagePlan plan = MessagePlan.Read("{TX}{RX}CQ TEST{WIPE}", Session());
|
||||||
|
|
||||||
|
Assert.Equal([MessageCommand.StartTransmit, MessageCommand.Wipe],
|
||||||
|
plan.Before.Select(a => a.Command));
|
||||||
|
Assert.Equal([MessageCommand.ReturnToReceive], plan.After.Select(a => a.Command));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,57 @@ public class QtcMessagesTests
|
|||||||
Assert.Equal("TU 73", QtcMessages.Tu("TU 73", "QTC 3/10"));
|
Assert.Equal("TU 73", QtcMessages.Tu("TU 73", "QTC 3/10"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// N1MM joins the three fields with hyphens on RTTY and offers no setting
|
||||||
|
/// for it.
|
||||||
|
[Fact]
|
||||||
|
public void ARttyLineIsTheThreeFieldsJoinedWithHyphens()
|
||||||
|
{
|
||||||
|
Assert.Equal("1234-DL1ABC-123", QtcMessages.RttyLine(" 1234 ", " DL1ABC ", " 123 "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shape is N1MM's, from `QTCWindow.cs:3609`: the heading, the spacing,
|
||||||
|
/// then every line with a space and the spacing behind it, then the ending.
|
||||||
|
/// The macros are left for the expander; `{QTC}` is the one filled in here.
|
||||||
|
[Fact]
|
||||||
|
public void SendAllReadsTheWholeSeriesOutInOneMessage()
|
||||||
|
{
|
||||||
|
string message = QtcMessages.SendAll(
|
||||||
|
QtcMessages.DefaultSendAllHeading,
|
||||||
|
QtcMessages.DefaultSendAllEnding,
|
||||||
|
QtcMessages.DefaultRttySpacing,
|
||||||
|
"QTC 3/10",
|
||||||
|
["1234-DL1ABC-123", "1240-G3XYZ-124"]);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"{TX}{ENTERLF}QTC 3/10 QTC 3/10{ENTER}"
|
||||||
|
+ "1234-DL1ABC-123 {ENTER}"
|
||||||
|
+ "1240-G3XYZ-124 {ENTER}"
|
||||||
|
+ "{ENTERLF}QSL?? BK DE {MYCALL} K{RX}",
|
||||||
|
message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SendAllWithNoLinesSendsNothing()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
"",
|
||||||
|
QtcMessages.SendAll(
|
||||||
|
QtcMessages.DefaultSendAllHeading,
|
||||||
|
QtcMessages.DefaultSendAllEnding,
|
||||||
|
QtcMessages.DefaultRttySpacing,
|
||||||
|
"QTC 3/10",
|
||||||
|
[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line asked for again, which N1MM wraps in the spacing on both sides.
|
||||||
|
[Fact]
|
||||||
|
public void OneLineKeysAndDropsTheTransmitterOfItsOwn()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
"{TX}{ENTER}1234-DL1ABC-123{ENTER}{RX}",
|
||||||
|
QtcMessages.SendOne(QtcMessages.DefaultRttySpacing, "1234-DL1ABC-123"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WhatIsInTheBoxesIsTrimmed()
|
public void WhatIsInTheBoxesIsTrimmed()
|
||||||
{
|
{
|
||||||
|
|||||||
506
tools/Nonemm.EngineProbe/EngineProbe.cs
Normal file
506
tools/Nonemm.EngineProbe/EngineProbe.cs
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Nonemm.Digital;
|
||||||
|
|
||||||
|
namespace Nonemm.EngineProbe;
|
||||||
|
|
||||||
|
/// 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 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 `{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.
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// A count that has not moved for this long is not going to move.
|
||||||
|
private static readonly TimeSpan Still = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
/// How long after a push the count is written down every time it is read,
|
||||||
|
/// and an empty answer is not believed. The count runs behind the engine,
|
||||||
|
/// and this is where the report says by how much.
|
||||||
|
private static readonly TimeSpan Settling = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
/// How long the engine is given to key up and to drop again.
|
||||||
|
private static readonly TimeSpan Keying = TimeSpan.FromSeconds(3);
|
||||||
|
|
||||||
|
/// One character at 45.45 baud, which is the rate the window feeds at.
|
||||||
|
private static readonly TimeSpan CharacterTime = TimeSpan.FromMilliseconds(165);
|
||||||
|
|
||||||
|
private const string Message = "CQ TEST DE OM5M OM5M ";
|
||||||
|
private const string Word = "ABCD";
|
||||||
|
|
||||||
|
private readonly MmttyEngine engine;
|
||||||
|
private readonly ProbeLog log;
|
||||||
|
private readonly StringBuilder received = new();
|
||||||
|
private readonly Lock gate = new();
|
||||||
|
private TaskCompletionSource<int>? asking;
|
||||||
|
private TaskCompletionSource<bool>? keying;
|
||||||
|
|
||||||
|
public EngineProbe(MmttyEngine engine, ProbeLog log)
|
||||||
|
{
|
||||||
|
this.engine = engine;
|
||||||
|
this.log = log;
|
||||||
|
engine.Reported += (_, what) => log.Write($"bridge: {what}");
|
||||||
|
engine.TransmitChanged += WhenTransmitChanged;
|
||||||
|
engine.Buffered += WhenBuffered;
|
||||||
|
engine.Received += WhenReceived;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RunAsync(CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
await NamesAsync(cancellation).ConfigureAwait(false);
|
||||||
|
await IdleAsync(cancellation).ConfigureAwait(false);
|
||||||
|
if (!await KeyingAsync(cancellation).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await MessageAsync(cancellation).ConfigureAwait(false);
|
||||||
|
await WordAsync(cancellation).ConfigureAwait(false);
|
||||||
|
await UnfinishedWordAsync(cancellation).ConfigureAwait(false);
|
||||||
|
await FedSlowlyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
await PoliteStopAsync(cancellation).ConfigureAwait(false);
|
||||||
|
await StopCharacterAsync('\\', cancellation).ConfigureAwait(false);
|
||||||
|
await StopCharacterAsync('~', cancellation).ConfigureAwait(false);
|
||||||
|
await SentWholeAsync(cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Question 11. N1MM hands MMTTY the whole message with `SendString` and
|
||||||
|
/// calls `SetMmttyPTT(1)` 400 ms later, and MMTTY ends the transmission
|
||||||
|
/// itself. This program hands the message over one character at a time with
|
||||||
|
/// `PostMmttyMessage(4, ...)` and the same stop does nothing. Is it the way
|
||||||
|
/// the text arrives that makes the difference?
|
||||||
|
private async Task SentWholeAsync(CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
log.Step("11. the whole message with SendString, then SetMmttyPTT(1) as N1MM sends it");
|
||||||
|
TakeReceived();
|
||||||
|
await KeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
await engine.SendAsync(Message, cancellation).ConfigureAwait(false);
|
||||||
|
log.Write($"pushed {Message.Length} characters in one call");
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(400), cancellation).ConfigureAwait(false);
|
||||||
|
await engine.SetPttAsync(false, cancellation).ConfigureAwait(false);
|
||||||
|
log.Write("SetMmttyPTT(1) sent 400 ms after the push, which is N1MM's wait");
|
||||||
|
TimeSpan waited = await WatchAsync(TimeSpan.FromSeconds(10), cancellation).ConfigureAwait(false);
|
||||||
|
log.Write(waited >= TimeSpan.Zero
|
||||||
|
? $"the transmitter dropped {waited.TotalMilliseconds:0} ms after the stop"
|
||||||
|
: "the transmitter stayed up");
|
||||||
|
log.Write($"received: \"{TakeReceived()}\"");
|
||||||
|
await UnkeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Question 10. MMTTY's macro language ends a transmission with `\` at the
|
||||||
|
/// end of a macro, and `~` stops the carrier. The only way into the engine
|
||||||
|
/// from here is `PostMmttyMessage(4, ...)`, one typed character, so the
|
||||||
|
/// question is whether a character typed that way is read as a command or
|
||||||
|
/// transmitted as text. A stop that travels with the text is worth far more
|
||||||
|
/// than one timed from outside: it lands exactly at the end of the message
|
||||||
|
/// with nothing held on after it.
|
||||||
|
private async Task StopCharacterAsync(char candidate, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
log.Step($"10. \"{Word}\" and then '{candidate}' typed as a character");
|
||||||
|
TakeReceived();
|
||||||
|
await KeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
await TypeAsync(Word, cancellation).ConfigureAwait(false);
|
||||||
|
await engine.TypeAsync(candidate, cancellation).ConfigureAwait(false);
|
||||||
|
TimeSpan waited = await WatchAsync(TimeSpan.FromSeconds(5), cancellation).ConfigureAwait(false);
|
||||||
|
log.Write(waited >= TimeSpan.Zero
|
||||||
|
? $"'{candidate}' dropped the transmitter after {waited.TotalMilliseconds:0} ms"
|
||||||
|
: $"'{candidate}' did not drop the transmitter");
|
||||||
|
log.Write($"received: \"{TakeReceived()}\"");
|
||||||
|
await engine.ReleaseKeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
await StoppedAsync(cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Question 9. Does `SetMmttyPTT(1)` drop the transmitter at all? The
|
||||||
|
/// window sends it at the end of every message and the engine went on
|
||||||
|
/// transmitting, so the abort that follows it a second and a half later is
|
||||||
|
/// what unkeys, and it cut the last character off a message once. Nothing
|
||||||
|
/// is aborted here until the question is answered, and if the polite stop
|
||||||
|
/// does nothing the `PTT` property is put back to false to see whether that
|
||||||
|
/// does.
|
||||||
|
private async Task PoliteStopAsync(CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
log.Step($"9. \"{Message}\" fed slowly, then SetMmttyPTT(1) and nothing else");
|
||||||
|
TakeReceived();
|
||||||
|
await KeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
foreach (char character in Message)
|
||||||
|
{
|
||||||
|
await engine.TypeAsync(character, cancellation).ConfigureAwait(false);
|
||||||
|
await Task.Delay(CharacterTime, cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
await engine.SetPttAsync(false, cancellation).ConfigureAwait(false);
|
||||||
|
log.Write("SetMmttyPTT(1) sent with the message fed");
|
||||||
|
TimeSpan waited = await WatchAsync(TimeSpan.FromSeconds(8), cancellation).ConfigureAwait(false);
|
||||||
|
if (waited >= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
log.Write($"the polite stop dropped the transmitter after {waited.TotalMilliseconds:0} ms");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.Write("the polite stop did not drop the transmitter");
|
||||||
|
await engine.ReleaseKeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
log.Write("PTT property put back to false");
|
||||||
|
waited = await WatchAsync(TimeSpan.FromSeconds(3), cancellation).ConfigureAwait(false);
|
||||||
|
log.Write(waited >= TimeSpan.Zero
|
||||||
|
? $"the property dropped the transmitter after {waited.TotalMilliseconds:0} ms"
|
||||||
|
: "the property did not drop the transmitter either");
|
||||||
|
log.Write($"received: \"{TakeReceived()}\"");
|
||||||
|
await UnkeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watches the transmit state and the count until the engine says it has
|
||||||
|
/// stopped. Returns how long that took, or -1 when it never did.
|
||||||
|
private async Task<TimeSpan> WatchAsync(TimeSpan patience, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
DateTime from = DateTime.UtcNow;
|
||||||
|
DateTime giveUp = from + patience;
|
||||||
|
while (DateTime.UtcNow < giveUp)
|
||||||
|
{
|
||||||
|
int left = await AskAsync("", cancellation).ConfigureAwait(false);
|
||||||
|
if (!engine.IsTransmitting)
|
||||||
|
{
|
||||||
|
return DateTime.UtcNow - from;
|
||||||
|
}
|
||||||
|
log.Write($"{(DateTime.UtcNow - from).TotalMilliseconds,6:0} ms transmitting, TxBufLen: {Answer(left)}");
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return TimeSpan.FromMilliseconds(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Question 6. The window feeds the engine one character every character
|
||||||
|
/// time and keeps it nearly empty, so the engine sits with an empty buffer
|
||||||
|
/// between characters. Does it drop the transmitter there, and does it
|
||||||
|
/// hold it when the feeding stops altogether? The pane is drawn on the
|
||||||
|
/// answer: a transmitter that drops by itself is not the end of a message.
|
||||||
|
private async Task FedSlowlyAsync(CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
log.Step($"7. \"{Message}\" fed one character every {CharacterTime.TotalMilliseconds:0} ms");
|
||||||
|
TakeReceived();
|
||||||
|
await KeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
foreach (char character in Message)
|
||||||
|
{
|
||||||
|
await engine.TypeAsync(character, cancellation).ConfigureAwait(false);
|
||||||
|
await Task.Delay(CharacterTime, cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
log.Write($"fed {Message.Length} characters; transmitting: {engine.IsTransmitting}");
|
||||||
|
log.Write("an unkey above this line is the engine dropping between two characters");
|
||||||
|
log.Step("8. keyed with nothing more to feed");
|
||||||
|
for (int look = 0; look < 12; look++)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellation).ConfigureAwait(false);
|
||||||
|
log.Write($"transmitting: {engine.IsTransmitting}, TxBufLen: {Answer(await AskAsync("", cancellation).ConfigureAwait(false))}");
|
||||||
|
}
|
||||||
|
log.Write($"received: \"{TakeReceived()}\"");
|
||||||
|
log.Write("still transmitting after three idle seconds means the engine holds the transmitter itself");
|
||||||
|
await UnkeyAsync(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nothing else in the report means anything until the engine keys, so this
|
||||||
|
/// stops the run rather than letting the rest measure an engine that is
|
||||||
|
/// sitting still.
|
||||||
|
private async Task<bool> KeyingAsync(CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
log.Step("3. does the engine key up");
|
||||||
|
if (!await KeyAsync(cancellation).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
log.Write("the engine did not key: nothing below this would mean anything, so stopping");
|
||||||
|
log.Write("keying is the control's PTT property; SetMmttyPTT only stops a transmission");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
log.Write($"TxBufLen while keyed and idle: {Answer(await AskAsync("", cancellation).ConfigureAwait(false))}");
|
||||||
|
await UnkeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Questions 2 and 5. The whole message is pushed as fast as the bridge
|
||||||
|
/// takes it, so what the count does afterwards is the engine transmitting
|
||||||
|
/// rather than the probe feeding.
|
||||||
|
private async Task MessageAsync(CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
log.Step($"4. \"{Message}\" pushed in one go, then TxBufLen every {PollInterval.TotalMilliseconds} ms");
|
||||||
|
TakeReceived();
|
||||||
|
await KeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
await TypeAsync(Message, cancellation).ConfigureAwait(false);
|
||||||
|
log.Write($"pushed {Message.Length} characters");
|
||||||
|
log.Write("every reading for the next two seconds: the first one that is not 0 says how far behind the count is");
|
||||||
|
await DrainAsync(cancellation).ConfigureAwait(false);
|
||||||
|
log.Write($"received while transmitting: \"{TakeReceived()}\"");
|
||||||
|
await UnkeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Question 3. A word with no space after it is what MMTTY holds back when
|
||||||
|
/// it is set to Word out, and that changes what "still in the buffer"
|
||||||
|
/// means.
|
||||||
|
private async Task WordAsync(CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
log.Step($"5. \"{Word}\" with no space after it, then the space");
|
||||||
|
TakeReceived();
|
||||||
|
await KeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
await TypeAsync(Word, cancellation).ConfigureAwait(false);
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(3), cancellation).ConfigureAwait(false);
|
||||||
|
log.Write($"TxBufLen three seconds after the word: {Answer(await AskAsync("", cancellation).ConfigureAwait(false))}");
|
||||||
|
log.Write($"received so far: \"{TakeReceived()}\"");
|
||||||
|
log.Write("nothing received here means the engine is set to Word out and is holding it");
|
||||||
|
await TypeAsync(" ", cancellation).ConfigureAwait(false);
|
||||||
|
await DrainAsync(cancellation).ConfigureAwait(false);
|
||||||
|
log.Write($"received after the space: \"{TakeReceived()}\"");
|
||||||
|
await UnkeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Question 4. The 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. \"{Word}\" with no space after it, then {{RX}}");
|
||||||
|
TakeReceived();
|
||||||
|
await KeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
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 started = DateTime.UtcNow;
|
||||||
|
DateTime giveUp = started + Patience;
|
||||||
|
DateTime moved = started;
|
||||||
|
int last = int.MinValue;
|
||||||
|
bool wasEmpty = false;
|
||||||
|
while (DateTime.UtcNow < giveUp)
|
||||||
|
{
|
||||||
|
int left = await AskAsync("", cancellation).ConfigureAwait(false);
|
||||||
|
bool settling = DateTime.UtcNow - started < Settling;
|
||||||
|
if (left != last || settling)
|
||||||
|
{
|
||||||
|
log.Write($"TxBufLen: {Answer(left)}");
|
||||||
|
moved = left != last ? DateTime.UtcNow : moved;
|
||||||
|
last = left;
|
||||||
|
}
|
||||||
|
if (left == 0 && wasEmpty && !settling)
|
||||||
|
{
|
||||||
|
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");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
log.Write($"gave up waiting after {Patience.TotalSeconds} s");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits until the engine holds no more than `wanted` characters, so the
|
||||||
|
/// next thing the probe does lands at a known point in the message.
|
||||||
|
private async Task<int> WaitForAsync(int wanted, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
DateTime giveUp = DateTime.UtcNow + Still;
|
||||||
|
while (DateTime.UtcNow < giveUp)
|
||||||
|
{
|
||||||
|
int left = await AskAsync("", cancellation).ConfigureAwait(false);
|
||||||
|
if (left <= wanted)
|
||||||
|
{
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
log.Write($"the count never came down to {wanted}, so the backspaces go in wherever it is");
|
||||||
|
return await AskAsync("", cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N1MM's `{TX}`: the control's PTT property, then wait for the engine to
|
||||||
|
/// say it is transmitting.
|
||||||
|
private async Task<bool> KeyAsync(CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
if (engine.IsTransmitting)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
TaskCompletionSource<bool> keyed = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
keying = keyed;
|
||||||
|
}
|
||||||
|
await engine.SetPttAsync(true, cancellation).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await keyed.Task.WaitAsync(Keying, cancellation).ConfigureAwait(false);
|
||||||
|
log.Write("keyed");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
log.Write($"no transmit report {Keying.TotalSeconds} s after keying");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N1MM's `{RX}`: stop once the buffer is empty. 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);
|
||||||
|
}
|
||||||
|
return !engine.IsTransmitting;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(Keying, 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 WhenTransmitChanged(object? sender, bool transmitting)
|
||||||
|
{
|
||||||
|
log.Write(transmitting ? "engine keyed" : "engine unkeyed");
|
||||||
|
if (!transmitting)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
keying?.TrySetResult(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// While the engine is not transmitting this is the demodulator hearing
|
||||||
|
/// whatever is on the band, which is noise on a quiet frequency. Only what
|
||||||
|
/// arrives while transmitting is about the transmission.
|
||||||
|
private void WhenReceived(object? sender, string text)
|
||||||
|
{
|
||||||
|
foreach (char character in text)
|
||||||
|
{
|
||||||
|
log.Write($"{(engine.IsTransmitting ? "rx" : "noise")} {Printable(character)}");
|
||||||
|
}
|
||||||
|
if (!engine.IsTransmitting)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
125
tools/Nonemm.EngineProbe/Program.cs
Normal file
125
tools/Nonemm.EngineProbe/Program.cs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
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)
|
||||||
|
--log <folder> where to write the protocol log, if it is wanted
|
||||||
|
|
||||||
|
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"),
|
||||||
|
logFolder: options.GetValueOrDefault("log")),
|
||||||
|
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 the
|
||||||
|
/// transmission is aborted before the engine is shut down, whatever
|
||||||
|
/// happened.
|
||||||
|
private static async Task Shutdown(MmttyEngine engine, ProbeLog log)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await engine.AbortAsync().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