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
|
||||
/mmtty/
|
||||
/bridge/XMMT.ocx
|
||||
engine-probe.log
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<Project Path="src/Nonemm.Session/Nonemm.Session.csproj" />
|
||||
<Project Path="src/Nonemm.App/Nonemm.App.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tools/">
|
||||
<Project Path="tools/Nonemm.EngineProbe/Nonemm.EngineProbe.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Nonemm.Core.Tests/Nonemm.Core.Tests.csproj" />
|
||||
<Project Path="tests/Nonemm.Contests.Tests/Nonemm.Contests.Tests.csproj" />
|
||||
|
||||
@@ -178,15 +178,25 @@ HRESULT XmmrControl::callLong(const wchar_t* name, long first, long second) {
|
||||
}
|
||||
|
||||
long XmmrControl::getLong(const wchar_t* name) {
|
||||
VARIANT value;
|
||||
VariantInit(&value);
|
||||
long value = 0;
|
||||
tryGetLong(name, &value);
|
||||
return value;
|
||||
}
|
||||
|
||||
HRESULT XmmrControl::tryGetLong(const wchar_t* name, long* value) {
|
||||
*value = 0;
|
||||
VARIANT answer;
|
||||
VariantInit(&answer);
|
||||
DISPPARAMS none = {nullptr, nullptr, 0, 0};
|
||||
if (FAILED(invoke(name, DISPATCH_PROPERTYGET, &none, &value))) {
|
||||
return 0;
|
||||
HRESULT result = invoke(name, DISPATCH_PROPERTYGET, &none, &answer);
|
||||
if (SUCCEEDED(result)) {
|
||||
result = VariantChangeType(&answer, &answer, 0, VT_I4);
|
||||
if (SUCCEEDED(result)) {
|
||||
*value = answer.lVal;
|
||||
}
|
||||
long found = SUCCEEDED(VariantChangeType(&value, &value, 0, VT_I4)) ? value.lVal : 0;
|
||||
VariantClear(&value);
|
||||
return found;
|
||||
}
|
||||
VariantClear(&answer);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string XmmrControl::getString(const wchar_t* name) {
|
||||
|
||||
@@ -32,6 +32,11 @@ public:
|
||||
HRESULT callLong(const wchar_t* name, long first, long second);
|
||||
|
||||
long getLong(const wchar_t* name);
|
||||
|
||||
// The same, saying whether the control answered. A property that is not
|
||||
// there returns DISP_E_UNKNOWNNAME, which is how a name can be checked.
|
||||
HRESULT tryGetLong(const wchar_t* name, long* value);
|
||||
|
||||
std::string getString(const wchar_t* name);
|
||||
|
||||
private:
|
||||
|
||||
@@ -145,6 +145,14 @@ void send(const std::string& text) {
|
||||
VariantClear(&argument);
|
||||
}
|
||||
|
||||
// N1MM's {TX}: the control's own PTT property, which is what starts a
|
||||
// transmission. SetMmttyPTT is the other direction, and only that.
|
||||
void key(bool on) {
|
||||
report("PTT", control->putBool(L"PTT", on));
|
||||
}
|
||||
|
||||
// 1 stops when the buffer is empty, which is N1MM's XmitOff; 0 stops now,
|
||||
// which is its AbortXmit.
|
||||
void setPtt(long on) {
|
||||
VARIANT argument;
|
||||
VariantInit(&argument);
|
||||
@@ -153,14 +161,36 @@ void setPtt(long on) {
|
||||
control->call(L"SetMmttyPTT", &argument, 1);
|
||||
}
|
||||
|
||||
// How many characters MMTTY still has to transmit. It is asked for rather than
|
||||
// reported: the control has no event for it.
|
||||
void askBuffer(const std::string& name) {
|
||||
const std::wstring wanted = toWide(name.empty() ? "TxBufLen" : name);
|
||||
long left = 0;
|
||||
HRESULT result = control->tryGetLong(wanted.c_str(), &left);
|
||||
if (FAILED(result)) {
|
||||
char message[128];
|
||||
std::snprintf(message, sizeof(message), "%s failed: 0x%08lx", wanted.empty() ? "" : name.c_str(),
|
||||
static_cast<unsigned long>(result));
|
||||
// not an "error": a property the control will not answer stops nothing
|
||||
protocol::write("log", {message});
|
||||
protocol::write("buffer", -1);
|
||||
return;
|
||||
}
|
||||
protocol::write("buffer", left);
|
||||
}
|
||||
|
||||
void act(const std::string& text) {
|
||||
protocol::Line line = protocol::read(text);
|
||||
if (line.verb == "open") {
|
||||
open(line);
|
||||
} else if (line.verb == "send") {
|
||||
send(line.field(0));
|
||||
} else if (line.verb == "key") {
|
||||
key(line.number(0) != 0);
|
||||
} else if (line.verb == "ptt") {
|
||||
setPtt(line.number(0));
|
||||
} else if (line.verb == "buffer") {
|
||||
askBuffer(line.field(0));
|
||||
} else if (line.verb == "post") {
|
||||
control->callLong(L"PostMmttyMessage", line.number(0), line.number(1));
|
||||
} 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 |
|
||||
| `send <text>` | `SendString` |
|
||||
| `ptt <0\|1>` | `SetMmttyPTT` |
|
||||
| `key <0\|1>` | sets the control's `PTT` property, which is what starts a transmission |
|
||||
| `ptt <0\|1>` | `SetMmttyPTT`: 0 stops now, 1 stops once the buffer is empty |
|
||||
| `post <message> <parameter>` | `PostMmttyMessage`, for everything in `MmttyMessage` |
|
||||
| `buffer [property]` | reads `TxBufLen`, or the property named instead, and answers `buffer` |
|
||||
| `close` | shuts the engine down and leaves the bridge running |
|
||||
| `quit` | shuts the engine down and exits |
|
||||
|
||||
@@ -46,9 +48,17 @@ From the bridge:
|
||||
| `tx <0\|1>` | the engine started or stopped transmitting |
|
||||
| `mark <hz>`, `space <hz>` | the tone pair moved |
|
||||
| `switch <bits>`, `view <bits>` | AFC (4), net (8) and reverse (256), and the engine's view state |
|
||||
| `buffer <n>` | how many characters the engine still has to transmit, or -1 when the control would not say |
|
||||
| `log <text>`, `error <text>` | anything the bridge has to say |
|
||||
|
||||
Each of those comes from an event of the control's own: `OnCharRcvd`,
|
||||
`buffer` is the one thing that is asked for rather than reported: the control
|
||||
has no event for the length of the transmit buffer, so it is polled. A property
|
||||
name in the request is only for finding out what the control answers to; a name
|
||||
it does not know fails with `DISP_E_UNKNOWNNAME`, which comes back as a `log`
|
||||
line and `buffer -1` rather than an `error`, because a property the control will
|
||||
not answer stops nothing.
|
||||
|
||||
Each of the others comes from an event of the control's own: `OnCharRcvd`,
|
||||
`OnPttEvent`, `OnFreqChanged` (mark and space in one event), `OnSwitchChanged`
|
||||
and `OnViewChanged`. The control raises `OnTranslateMessage` only for the
|
||||
MMTTY messages it has no event for — width, resolution, thread and the like —
|
||||
|
||||
@@ -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`. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
|
||||
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
|
||||
MMTTY does not start on it.
|
||||
|
||||
**What paces the type-ahead pump.** The pump keeps two characters in the
|
||||
engine and counts character times off the clock at the baud rate in the digital
|
||||
settings to work out when the engine has room for the next one. Two characters
|
||||
is what stops the engine running dry between characters, which would make it
|
||||
transmit idle and add that idle to the time the message takes; the price is that
|
||||
the last two characters cannot be taken back. The estimate is corrected when the
|
||||
engine reports that it has stopped transmitting, which means its buffer is
|
||||
empty.
|
||||
**The entry window's function keys on a digital mode.** They are the digital
|
||||
macros, not the CW file: N1MM loads its send buttons from the RTTYBTN set on a
|
||||
digital mode, and the CW messages carry no `{TX}`, so a key pressed there fed
|
||||
the engine without keying the transmitter and nothing went out. The first ten
|
||||
digital macros are F1 to F10; F11 and F12 stay Spot and Wipe, as in every other
|
||||
mode, and the macros past the tenth are on the digital window's own buttons.
|
||||
Right-clicking a key still opens the CW or phone messages, so the digital macros
|
||||
are edited from the digital window.
|
||||
|
||||
The engine can be asked instead of estimated: `XMMT.ocx` has a `TxBufLen`
|
||||
property, and a probe against the control shows it answers, though what it
|
||||
answers while a message is going out could not be measured here because the
|
||||
engine will not start. Reporting it from the bridge would make the red text
|
||||
exact and would take out the one error the estimate can still make: a baud rate
|
||||
that does not match what the engine transmits at leaves a small gap between
|
||||
characters near the end of a long message.
|
||||
**How the transmit pane works.** The pane is one editable box holding the whole
|
||||
message: what the engine has transmitted, coloured red, then what is still to
|
||||
go. The red stops where the air is, not where the feeder is: the last characters
|
||||
handed over are still in the engine, and colouring those too turned every
|
||||
character red as it was typed once the transmission had caught up. An edit that
|
||||
reaches into the red is undone, and so is one into the characters the engine
|
||||
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
|
||||
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
|
||||
recording has finished would drive it as it stands.
|
||||
|
||||
**The QTC window transmits on CW only.** The header, the lines, the QRV, the TU
|
||||
and the again messages go out through the keyer, with N1MM's messages and
|
||||
N1MM's defaults. Nothing goes out on SSB or RTTY: N1MM plays four recordings on
|
||||
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
|
||||
keys a whole series at once, belongs to that RTTY window and is not here
|
||||
either.
|
||||
**The QTC window sends on CW and RTTY, not on SSB.** On CW the header, the
|
||||
lines, the QRV, the TU and the again messages go out through the keyer, with
|
||||
N1MM's messages and N1MM's defaults.
|
||||
|
||||
On RTTY the station reading traffic out has Send All, which is N1MM's: the whole
|
||||
series as one message, the heading, then every line with the operator's spacing
|
||||
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
|
||||
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
|
||||
`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
|
||||
the contest rules score it, but there is no digital window: no decoding, no
|
||||
transmitting, no interface to fldigi, MMTTY or similar.
|
||||
**Digital interfaces other than MMTTY.** A contact can be logged as RTTY or
|
||||
another digital mode and the contest rules score it, and the digital window
|
||||
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
|
||||
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 ClusterClient? cluster;
|
||||
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 AlternatingCq? alternating;
|
||||
private MmttyEngine? digital;
|
||||
@@ -124,6 +130,10 @@ public sealed class AppSession : IDisposable
|
||||
|
||||
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;
|
||||
|
||||
/// 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(
|
||||
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
|
||||
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);
|
||||
Available = new AvailableStations(positions[0], Bandmap);
|
||||
Save(Settings with { ContestNumber = contestNumber });
|
||||
@@ -234,7 +250,9 @@ public sealed class AppSession : IDisposable
|
||||
/// multi-operator entry, following what the settings now say.
|
||||
public void ApplyNetworkSettings()
|
||||
{
|
||||
announcing?.Stop();
|
||||
network?.Dispose();
|
||||
link?.Dispose();
|
||||
network = null;
|
||||
if (!Settings.NetworkEnabled)
|
||||
{
|
||||
@@ -253,6 +271,87 @@ public sealed class AppSession : IDisposable
|
||||
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
|
||||
/// out to the network, and the contact is scored here from the rules
|
||||
/// instead of trusting what the sender put in the message.
|
||||
@@ -329,7 +428,8 @@ public sealed class AppSession : IDisposable
|
||||
WineBridgeChannel channel = new(
|
||||
Settings.DigitalBridgePath,
|
||||
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);
|
||||
await started.StartAsync();
|
||||
digital = started;
|
||||
@@ -594,7 +694,9 @@ public sealed class AppSession : IDisposable
|
||||
spotFlush.Dispose();
|
||||
DisposeRadios();
|
||||
cluster?.Dispose();
|
||||
announcing?.Stop();
|
||||
network?.Dispose();
|
||||
link?.Dispose();
|
||||
alternating?.Dispose();
|
||||
keyer?.Dispose();
|
||||
box?.Dispose();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Network;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Configuration;
|
||||
@@ -118,6 +119,29 @@ public sealed record Settings
|
||||
|
||||
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
|
||||
/// published per contest, so this is not a fixed name.
|
||||
public string CallHistoryFile { get; init; } = "";
|
||||
@@ -169,6 +193,16 @@ public sealed record Settings
|
||||
|
||||
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
|
||||
/// in `BandPlan.Default`; an entry replaces one band's boundaries.
|
||||
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>
|
||||
<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" />
|
||||
<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">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||
|
||||
@@ -18,6 +18,11 @@ public sealed partial class NetworkDialog : Window
|
||||
PortBox.Text = settings.NetworkPort.ToString();
|
||||
PeersBox.Text = string.Join("\n", settings.NetworkPeers);
|
||||
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)
|
||||
.ToList(),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -33,8 +33,19 @@
|
||||
</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."
|
||||
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"
|
||||
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">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||
|
||||
@@ -24,6 +24,9 @@ public sealed partial class QtcSetupDialog : Window
|
||||
CallAgainBox.Text = settings.QtcCwCallAgain;
|
||||
NumberAgainBox.Text = settings.QtcCwNumberAgain;
|
||||
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
|
||||
@@ -40,6 +43,9 @@ public sealed partial class QtcSetupDialog : Window
|
||||
QtcCwCallAgain = CallAgainBox.Text ?? "",
|
||||
QtcCwNumberAgain = NumberAgainBox.Text ?? "",
|
||||
QtcCwTu = TuBox.Text ?? "",
|
||||
QtcRttySpacing = RttySpacingBox.Text ?? "",
|
||||
QtcRttySendAllHeading = SendAllHeadingBox.Text ?? "",
|
||||
QtcRttySendAllEnding = SendAllEndingBox.Text ?? "",
|
||||
});
|
||||
|
||||
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,
|
||||
/// which feeds the engine. A carriage return is what the engine takes as a
|
||||
/// new line, so Enter puts one in rather than the newline the box would.
|
||||
/// What is typed in the transmit pane goes into the type-ahead buffer.
|
||||
/// Nothing goes on the air until the transmitter is keyed, which is the TX
|
||||
/// 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)
|
||||
{
|
||||
if (e.Key == Key.Enter && e.KeyModifiers == KeyModifiers.Control)
|
||||
{
|
||||
e.Handled = true;
|
||||
StartTransmit();
|
||||
return;
|
||||
}
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
e.Handled = true;
|
||||
int at = Math.Clamp(TransmitBox.CaretIndex, 0, TransmitBox.Text?.Length ?? 0);
|
||||
TransmitBox.Text = (TransmitBox.Text ?? "").Insert(at, "\r");
|
||||
TransmitBox.CaretIndex = at + 1;
|
||||
Type("\r");
|
||||
return;
|
||||
}
|
||||
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;
|
||||
/// what is already in the engine is in the label beside it and cannot be
|
||||
/// reached from here.
|
||||
/// Puts text in at the caret, never before the text that has gone out.
|
||||
private void Type(string text)
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (showingBuffer || session.DigitalKeyer is not { } keyer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
keyer.TypeAhead.Rewrite(TransmitBox.Text ?? "", CursorInBox());
|
||||
}
|
||||
|
||||
/// 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()
|
||||
string now = TransmitBox.Text ?? "";
|
||||
if (FirstDifference(pane, now) < locked)
|
||||
{
|
||||
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() =>
|
||||
Math.Clamp(TransmitBox.CaretIndex, 0, TransmitBox.Text?.Length ?? 0);
|
||||
/// Where two versions of the pane first differ, which is the length of both
|
||||
/// 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
|
||||
/// the box. The pump takes characters off the front, so the cursor moves
|
||||
/// back with them and the operator can go on typing while it does.
|
||||
/// Draws the buffer: what has gone out and what is still to go, as one
|
||||
/// text, with the length of the first half telling the pane how much of it
|
||||
/// 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()
|
||||
{
|
||||
if (session.DigitalKeyer is not { } keyer)
|
||||
@@ -188,20 +221,29 @@ public sealed partial class DigitalWindow
|
||||
showingBuffer = true;
|
||||
try
|
||||
{
|
||||
SentText.Text = keyer.TypeAhead.Sent;
|
||||
string pending = keyer.TypeAhead.Pending;
|
||||
string was = TransmitBox.Text ?? "";
|
||||
if (was == pending)
|
||||
string sent = keyer.Buffer.Sent;
|
||||
string now = sent + keyer.Buffer.Pending;
|
||||
locked = sent.Length;
|
||||
// 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;
|
||||
}
|
||||
int taken = was.Length > pending.Length
|
||||
&& was.EndsWith(pending, StringComparison.Ordinal)
|
||||
? was.Length - pending.Length
|
||||
// the end of a message drops what has gone out off the front of
|
||||
// the pane, so the caret moves back with the text it is in
|
||||
string was = TransmitBox.Text ?? "";
|
||||
int dropped = was.Length > now.Length && was.EndsWith(now, StringComparison.Ordinal)
|
||||
? was.Length - now.Length
|
||||
: 0;
|
||||
int caret = TransmitBox.CaretIndex;
|
||||
TransmitBox.Text = pending;
|
||||
TransmitBox.CaretIndex = Math.Clamp(caret - taken, 0, pending.Length);
|
||||
int caret = TransmitBox.CaretIndex - dropped;
|
||||
TransmitBox.Text = now;
|
||||
TransmitBox.CaretIndex = Math.Clamp(caret, locked, now.Length);
|
||||
pane = now;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -211,7 +253,8 @@ public sealed partial class DigitalWindow
|
||||
|
||||
/// 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
|
||||
/// 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)
|
||||
{
|
||||
if (e.KeyModifiers == KeyModifiers.Alt && e.Key == Key.T)
|
||||
@@ -235,14 +278,15 @@ public sealed partial class DigitalWindow
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
e.Handled = true;
|
||||
_ = session.DigitalKeyer?.AbortAsync();
|
||||
_ = Running()?.AbortAsync();
|
||||
return;
|
||||
}
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
/// Alt+T: on to transmit with the cursor in the transmit pane, off back to
|
||||
/// receive.
|
||||
/// Alt+T: on to transmit with the cursor in the transmit pane, off to drop
|
||||
/// the transmitter at the end of what is waiting.
|
||||
private void ToggleTransmit()
|
||||
{
|
||||
if (Running() is not { } running)
|
||||
@@ -251,31 +295,56 @@ public sealed partial class DigitalWindow
|
||||
}
|
||||
if (running.IsTransmitting)
|
||||
{
|
||||
_ = session.DigitalKeyer?.AbortAsync();
|
||||
_ = running.ReturnToReceiveAsync();
|
||||
ReturnToReceive();
|
||||
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);
|
||||
}
|
||||
TransmitBox.Focus();
|
||||
}
|
||||
|
||||
private void OnTransmit(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = Running()?.SetPttAsync(true);
|
||||
TransmitBox.Focus();
|
||||
}
|
||||
/// 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
|
||||
/// stops now.
|
||||
private void OnReceive(object? sender, RoutedEventArgs e) => ReturnToReceive();
|
||||
|
||||
/// The RX button stops where it is: what has not gone to the engine is
|
||||
/// dropped, and the engine drops what it holds.
|
||||
private void OnReceive(object? sender, RoutedEventArgs e)
|
||||
private void ReturnToReceive()
|
||||
{
|
||||
_ = session.DigitalKeyer?.AbortAsync();
|
||||
_ = Running()?.AbortAsync();
|
||||
if (Running() is not { } running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (session.DigitalKeyer is { } keyer)
|
||||
{
|
||||
keyer.ReturnToReceiveWhenSent();
|
||||
return;
|
||||
}
|
||||
_ = running.ReturnToReceiveAsync();
|
||||
}
|
||||
|
||||
private void OnClearTransmit(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
session.DigitalKeyer?.TypeAhead.Clear();
|
||||
session.DigitalKeyer?.Buffer.Clear();
|
||||
ShowBuffer();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Nonemm.App.Windows"
|
||||
xmlns:controls="using:Nonemm.App.Controls"
|
||||
x:Class="Nonemm.App.Windows.DigitalWindow"
|
||||
Title="Digital Interface" Width="900" Height="640">
|
||||
<Window.Styles>
|
||||
@@ -33,6 +34,7 @@
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Margin" Value="4,2" />
|
||||
</Style>
|
||||
<StyleInclude Source="avares://Nonemm.App/Controls/TransmitPane.axaml" />
|
||||
</Window.Styles>
|
||||
|
||||
<DockPanel>
|
||||
@@ -166,17 +168,14 @@
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="*,Auto,150">
|
||||
<Border BorderThickness="1" BorderBrush="#40808080">
|
||||
<!-- what has gone out, then what is still to go: the first is a
|
||||
label so it cannot be edited, the second the box the operator
|
||||
types in -->
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Name="SentText" FontFamily="monospace" Margin="3,3,0,0"
|
||||
VerticalAlignment="Top" />
|
||||
<TextBox Grid.Column="1" Name="TransmitBox" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
FontFamily="monospace" BorderThickness="0"
|
||||
KeyDown="OnTransmitKeyDown" TextChanged="OnTransmitTextChanged"
|
||||
GotFocus="OnTransmitFocus" LostFocus="OnTransmitFocus" />
|
||||
</Grid>
|
||||
<!-- one box for the whole message: what has gone out is coloured and
|
||||
cannot be edited, what is still to go is typed into the same
|
||||
text, so the two wrap together and neither takes width from the
|
||||
other -->
|
||||
<controls:TransmitPane Name="TransmitBox" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
FontFamily="monospace"
|
||||
KeyDown="OnTransmitKeyDown"
|
||||
TextChanged="OnTransmitTextChanged" />
|
||||
</Border>
|
||||
<Grid Grid.Column="2" RowDefinitions="*,Auto">
|
||||
<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.
|
||||
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.
|
||||
private TypeAhead? buffer;
|
||||
|
||||
@@ -82,9 +91,6 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
entry.Activated += WhenEntryActivated;
|
||||
BuildMacros();
|
||||
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);
|
||||
Refresh();
|
||||
Closed += (_, _) =>
|
||||
@@ -104,8 +110,8 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
{
|
||||
ReceiveScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||
GrabScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||
SentText.Foreground = Transmitted;
|
||||
SentText.FontSize = Settings.DigitalFontSize;
|
||||
TransmitBox.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||
TransmitBox.SentBrush = Transmitted;
|
||||
TransmitBox.FontSize = Settings.DigitalFontSize;
|
||||
if (buffer is not null)
|
||||
{
|
||||
@@ -147,7 +153,7 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
}
|
||||
Detach();
|
||||
engine = started;
|
||||
buffer = session.DigitalKeyer?.TypeAhead;
|
||||
buffer = session.DigitalKeyer?.Buffer;
|
||||
if (buffer is not null)
|
||||
{
|
||||
buffer.Baud = Settings.DigitalBaud;
|
||||
@@ -188,21 +194,16 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
private void WhenBufferChanged(object? sender, EventArgs e) =>
|
||||
Dispatcher.UIThread.Post(ShowBuffer);
|
||||
|
||||
/// The pane starts empty for the next message: the transmitter dropping
|
||||
/// with nothing left to send is the end of this one. An engine that keys
|
||||
/// itself drops between two characters as well, and that text is left
|
||||
/// where it is.
|
||||
/// The transmitter dropping ends the message. The buffer clears what has
|
||||
/// gone out and keeps what the operator typed ahead, so the pane is left
|
||||
/// with the next message in it rather than empty.
|
||||
private void WhenTransmitChanged(object? sender, bool transmitting) =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
TransmitDot.Background = transmitting
|
||||
? Themes.Brush(Themes.Current.TransmitLight)
|
||||
: Brushes.Transparent;
|
||||
if (!transmitting && buffer is { IsSending: false })
|
||||
{
|
||||
buffer.Clear();
|
||||
ShowBuffer();
|
||||
}
|
||||
});
|
||||
|
||||
private void WhenConnectionChanged(object? sender, bool connected) =>
|
||||
@@ -568,18 +569,3 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
_ => 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
|
||||
/// 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() =>
|
||||
Messages
|
||||
Logging?.Mode.Category == ModeCategory.Digital
|
||||
? DigitalKeys()
|
||||
: Messages
|
||||
.For(
|
||||
Logging?.Mode.Category ?? Core.ModeCategory.Cw,
|
||||
session.Settings.CwMessageFile,
|
||||
session.Settings.PhoneMessageFile)
|
||||
.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
|
||||
/// radio is in, which is where import and export live too.
|
||||
private Task EditMessages() => EditMessages(Logging?.Mode.Category ?? ModeCategory.Cw);
|
||||
@@ -140,7 +160,12 @@ public sealed partial class EntryWindow
|
||||
}
|
||||
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;
|
||||
}
|
||||
if ((through ?? Sender) is not { } keyer)
|
||||
@@ -170,7 +195,22 @@ public sealed partial class EntryWindow
|
||||
Status(e.Message);
|
||||
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
|
||||
@@ -292,14 +332,30 @@ public sealed partial class EntryWindow
|
||||
Logging.Stack.Clear();
|
||||
ShowCallStack();
|
||||
break;
|
||||
// the digital engine keys itself while it has text to send, so
|
||||
// {TX} and {RX} only matter when a macro wants the transmitter
|
||||
// held open around what it sends
|
||||
// {TX} keys the transmitter and {RX} drops it. {RX} waits until
|
||||
// everything waiting has gone out, so it ends the transmission
|
||||
// rather than cutting it off
|
||||
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);
|
||||
}
|
||||
break;
|
||||
case MessageCommand.ReturnToReceive:
|
||||
if (session.DigitalKeyer is { } digital)
|
||||
{
|
||||
digital.ReturnToReceiveWhenSent();
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = session.Digital?.ReturnToReceiveAsync();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,6 +431,9 @@ public sealed partial class EntryWindow
|
||||
|
||||
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.
|
||||
private void OnShowGrayline(object? sender, RoutedEventArgs e) =>
|
||||
Show(() => new GraylineWindow(session));
|
||||
@@ -518,10 +521,21 @@ public sealed partial class EntryWindow
|
||||
{
|
||||
session.Save(updated);
|
||||
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)
|
||||
{
|
||||
KeyerDialog dialog = new(session.Settings);
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
<MenuItem Header="Call Stack" Click="OnShowCallStack" />
|
||||
<MenuItem Header="Check" Click="OnShowCheck" />
|
||||
<MenuItem Header="Log" Click="OnShowLog" InputGesture="Ctrl+L" />
|
||||
<MenuItem Header="Network Status" Click="OnShowNetworkStatus" />
|
||||
<MenuItem Header="Grey Line" Click="OnShowGrayline" />
|
||||
<MenuItem Header="Score Summary" Click="OnShowScore" />
|
||||
<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="HeaderAgainButton" Content="Hdr Agn" Click="OnHeaderAgain" />
|
||||
<Button Name="HeaderCfmButton" Content="Cfm" Click="OnHeaderConfirm" />
|
||||
<Button Name="SendAllButton" Content="Send All" Click="OnSendAll" />
|
||||
</StackPanel>
|
||||
<Grid Name="Lines" 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.
|
||||
///
|
||||
/// 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
|
||||
/// nothing is sent: one needs a voice keyer and the other a digital window.
|
||||
/// way N1MM's QTC window sends through its own entry window. On RTTY it sends
|
||||
/// 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
|
||||
{
|
||||
private static IBrush Saved => Themes.Brush(Themes.Current.GoodBackground);
|
||||
@@ -83,7 +85,9 @@ public sealed partial class QtcWindow : Window
|
||||
if (!IsCw)
|
||||
{
|
||||
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.";
|
||||
}
|
||||
return isSending
|
||||
@@ -91,10 +95,17 @@ public sealed partial class QtcWindow : Window
|
||||
: "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
|
||||
/// what the radio happens to be on, which is how N1MM decides too.
|
||||
/// Which mode the traffic goes out on. The contest says, not what the radio
|
||||
/// happens to be on, which is how N1MM decides too.
|
||||
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)
|
||||
{
|
||||
if (sending == isSending)
|
||||
@@ -118,6 +129,9 @@ public sealed partial class QtcWindow : Window
|
||||
HeaderAgainButton.Content = isSending && IsCw ? "Snd Hdr" : "Hdr Agn";
|
||||
HeaderAgainButton.IsVisible = !isSending || IsCw;
|
||||
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;
|
||||
CloseButton.Content = IsCw ? "Exit" : "Close";
|
||||
HeaderBox.IsReadOnly = isSending;
|
||||
@@ -144,7 +158,7 @@ public sealed partial class QtcWindow : Window
|
||||
TextBox number = Box(at, 2);
|
||||
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),
|
||||
};
|
||||
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;
|
||||
}
|
||||
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
|
||||
? ""
|
||||
: rtty
|
||||
? $"QTC {ready[0].SeriesText} - {station.Text}"
|
||||
: $"QTC {ready[0].SeriesText}";
|
||||
: IsRtty
|
||||
? $"{SeriesHeader} - {station.Text}"
|
||||
: SeriesHeader;
|
||||
for (int at = 0; at < rows.Count; at++)
|
||||
{
|
||||
bool has = at < ready.Count;
|
||||
@@ -353,7 +365,7 @@ public sealed partial class QtcWindow : Window
|
||||
{
|
||||
if (isSending)
|
||||
{
|
||||
if (IsCw)
|
||||
if (IsCw || IsRtty)
|
||||
{
|
||||
SendLine(at);
|
||||
return;
|
||||
@@ -375,7 +387,9 @@ public sealed partial class QtcWindow : Window
|
||||
private void SendLine(int at)
|
||||
{
|
||||
lastSent = at;
|
||||
_ = send(QtcMessages.Line(
|
||||
_ = send(IsRtty
|
||||
? QtcMessages.SendOne(session.Settings.QtcRttySpacing, RttyLine(at))
|
||||
: QtcMessages.Line(
|
||||
rows[at].Time.Text ?? "",
|
||||
rows[at].Call.Text ?? "",
|
||||
rows[at].Number.Text ?? "",
|
||||
@@ -391,6 +405,43 @@ public sealed partial class QtcWindow : Window
|
||||
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
|
||||
/// number and the header.
|
||||
private static int FieldOf(Key key) =>
|
||||
|
||||
@@ -22,6 +22,48 @@ public sealed record BandChangeRules(
|
||||
/// A contest that does not limit band changes at all.
|
||||
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;
|
||||
|
||||
/// 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
|
||||
/// 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.
|
||||
/// 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.
|
||||
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
|
||||
/// CW message does.
|
||||
///
|
||||
/// A message is not handed to the engine whole. It goes into the type-ahead
|
||||
/// buffer, which keeps the engine a couple of characters ahead of the operator,
|
||||
/// so the rest can still be rewritten. `Finished` is raised when the buffer has
|
||||
/// run dry and the engine has transmitted what it was given.
|
||||
/// A message goes into the type-ahead buffer rather than to the engine whole,
|
||||
/// so the part that has not gone out can still be rewritten. `Finished` is
|
||||
/// raised when the buffer has run dry, with the last characters of the message
|
||||
/// 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
|
||||
{
|
||||
/// 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 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)
|
||||
{
|
||||
this.engine = engine;
|
||||
TypeAhead = new TypeAhead(
|
||||
Buffer = engine is EngineBuffer counter
|
||||
? new TypeAhead(counter, baud)
|
||||
: new TypeAhead(
|
||||
(character, cancellation) => engine.SendAsync(character.ToString(), cancellation),
|
||||
baud);
|
||||
TypeAhead.Drained += WhenDrained;
|
||||
Buffer.Given += WhenGiven;
|
||||
engine.TransmitChanged += WhenTransmitChanged;
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -35,17 +96,158 @@ public sealed class DigitalEngineSender : MessageSender
|
||||
|
||||
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);
|
||||
return Task.CompletedTask;
|
||||
await StartAsync(cancellation).ConfigureAwait(false);
|
||||
Buffer.Append(text);
|
||||
}
|
||||
|
||||
/// Escape and the RX button: what has not gone to the engine is dropped,
|
||||
/// and the engine drops what it still holds.
|
||||
private async Task TransmitAsync()
|
||||
{
|
||||
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)
|
||||
{
|
||||
TypeAhead.Drop();
|
||||
lock (gate)
|
||||
{
|
||||
state = Keying.Down;
|
||||
transmission++;
|
||||
}
|
||||
Buffer.Drop();
|
||||
return engine.AbortAsync(cancellation);
|
||||
}
|
||||
|
||||
@@ -56,29 +258,134 @@ public sealed class DigitalEngineSender : MessageSender
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
TypeAhead.Drained -= WhenDrained;
|
||||
TypeAhead.Dispose();
|
||||
Buffer.Given -= WhenGiven;
|
||||
Buffer.Dispose();
|
||||
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
|
||||
/// type-ahead buffer is told so it stops waiting for characters that have
|
||||
/// already gone. It ends the message only when there is nothing left to
|
||||
/// send: an engine that keys itself off what it is given drops between two
|
||||
/// characters of a message the pump is still feeding, and that is not the
|
||||
/// end of anything.
|
||||
/// Tells the engine to stop, waits for it to transmit what it still holds,
|
||||
/// and puts the key down.
|
||||
///
|
||||
/// This is N1MM's ending, which is not the same as its keying. N1MM hands
|
||||
/// MMTTY the whole message with `SendString` and calls `SetMmttyPTT(1)`
|
||||
/// 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)
|
||||
{
|
||||
if (transmitting)
|
||||
{
|
||||
return;
|
||||
}
|
||||
TypeAhead.EngineIdle();
|
||||
if (!TypeAhead.IsSending)
|
||||
bool unfinished = Buffer.IsTransmitting && Buffer.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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// command line, and an engine that fails to initialise is started again up to
|
||||
/// 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;
|
||||
|
||||
@@ -54,6 +58,10 @@ public sealed class MmttyEngine : DigitalEngine
|
||||
/// Anything the bridge says about itself, for the status line.
|
||||
public event EventHandler<string>? Reported;
|
||||
|
||||
/// The answer to `AskBufferedAsync`: how many characters the engine still
|
||||
/// has to transmit.
|
||||
public event EventHandler<int>? Buffered;
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
settings = MmttySettings.Read(options.SettingsPath);
|
||||
@@ -75,15 +83,34 @@ public sealed class MmttyEngine : DigitalEngine
|
||||
public Task TypeAsync(char character, CancellationToken cancellation = default) =>
|
||||
PostAsync(MmttyMessage.TypeCharacter, character, cancellation);
|
||||
|
||||
/// N1MM's abort, which is what its RX button and Escape do: drop PTT and
|
||||
/// let the engine stop where it is.
|
||||
public Task AbortAsync(CancellationToken cancellation = default) =>
|
||||
SetPttAsync(false, cancellation);
|
||||
/// Asks how many characters the engine still holds. The control has no
|
||||
/// event for it, so it is polled; the answer arrives on `Buffered`. A
|
||||
/// property name other than `TxBufLen` is only for finding out what the
|
||||
/// control answers to.
|
||||
public Task AskBufferedAsync(string property = "", CancellationToken cancellation = default) =>
|
||||
bridge.SendAsync(BridgeLine.Write("buffer", property), cancellation);
|
||||
|
||||
/// N1MM's `{TX}` and `{RX}`. MMTTY sends what is in its buffer before it
|
||||
/// drops PTT, so a macro that ends with `{RX}` still goes out in full.
|
||||
/// Escape and the RX button: stop now and leave what has not gone out
|
||||
/// 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) =>
|
||||
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) =>
|
||||
SetPttAsync(false, cancellation);
|
||||
@@ -154,6 +181,9 @@ public sealed class MmttyEngine : DigitalEngine
|
||||
case "rx":
|
||||
Received?.Invoke(this, ((char)Number(fields, 0)).ToString());
|
||||
break;
|
||||
case "buffer":
|
||||
Buffered?.Invoke(this, Number(fields, 0));
|
||||
break;
|
||||
case "tx":
|
||||
IsTransmitting = Number(fields, 0) == 1;
|
||||
TransmitChanged?.Invoke(this, IsTransmitting);
|
||||
|
||||
@@ -5,69 +5,154 @@ namespace Nonemm.Digital;
|
||||
/// The text waiting to go out, held here rather than handed to the engine in
|
||||
/// one piece.
|
||||
///
|
||||
/// A digital engine takes a whole message and transmits it at the baud rate,
|
||||
/// which is slow: a callsign and a report take several seconds. Once the engine
|
||||
/// has the message nothing can be changed, so the operator who sees a wrong
|
||||
/// call go out has to stop the transmission and start again. This keeps the
|
||||
/// message here instead and feeds it to the engine a few characters at a time,
|
||||
/// so everything that has not gone out yet can still be rewritten, added to or
|
||||
/// deleted.
|
||||
/// A digital engine transmits at the baud rate, which is slow: a callsign and a
|
||||
/// report take several seconds. Once the engine has a character nothing can
|
||||
/// take it back — MMTTY treats a backspace as another character to transmit
|
||||
/// rather than as an edit, which the engine probe showed on the air. So the
|
||||
/// message is held here and the engine is kept just short of running dry, and
|
||||
/// everything behind that can still be rewritten, added to or deleted.
|
||||
///
|
||||
/// `Sent` is what has gone to the engine and cannot be taken back. `Pending` is
|
||||
/// what is still to go. `Cursor` is how much of the pending text may go out,
|
||||
/// which is where the operator is typing: the pump stops when it reaches the
|
||||
/// cursor, because the operator has not finished the word yet.
|
||||
/// Two things happen to a character and they are not the same: it is **given**
|
||||
/// to the engine, and later it **goes out** on the air. Everything here is one
|
||||
/// or the other.
|
||||
///
|
||||
/// The pump keeps `Lead` characters in the engine rather than one. An engine
|
||||
/// that runs out of characters partway through a message does not wait: it
|
||||
/// transmits idle until the next one arrives, so every gap the pump leaves is
|
||||
/// added to the time the message takes. Keeping a second character queued
|
||||
/// behind the one being transmitted means the engine never runs dry, and the
|
||||
/// cost is that the last `Lead` characters cannot be taken back rather than the
|
||||
/// last one.
|
||||
/// | Given to the engine | Gone out on the air |
|
||||
/// |---|---|
|
||||
/// | `Sent`, and it cannot be taken back | `OnAir`, how much of `Sent` has been transmitted |
|
||||
/// | `Outstanding`, given but not out yet | |
|
||||
/// | `Given` is raised when there is nothing left to give | `Aired` is raised when the engine has transmitted it too |
|
||||
///
|
||||
/// `Lead` characters at the front are timed off the clock at the baud rate,
|
||||
/// which is an estimate of how far the engine has got. It is corrected by
|
||||
/// `EngineIdle`: an engine that has stopped transmitting has an empty buffer,
|
||||
/// whatever the estimate says.
|
||||
/// `Pending` is the rest: what has not been given to the engine and can still
|
||||
/// be rewritten. `EngineHolds` is the engine's own answer to the same question
|
||||
/// as `Outstanding`, in Baudot symbols rather than characters.
|
||||
///
|
||||
/// 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
|
||||
{
|
||||
/// A RTTY character is a start bit, five data bits and a stop bit and a
|
||||
/// half.
|
||||
public const double BitsPerCharacter = 7.5;
|
||||
/// A Baudot symbol is a start bit, five data bits and a stop bit and a
|
||||
/// half. A character is one symbol, or two when it needs a shift first.
|
||||
public const double BitsPerSymbol = 7.5;
|
||||
|
||||
/// MMTTY's own default, and the speed nearly every RTTY contest runs at.
|
||||
public const double DefaultBaud = 45.45;
|
||||
|
||||
/// How many characters may sit in the engine. Two is the smallest number
|
||||
/// that keeps the engine 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
|
||||
/// message beyond reach.
|
||||
public const int DefaultLead = 2;
|
||||
/// How many characters the engine is kept ahead by. Two is the smallest
|
||||
/// number that keeps it transmitting without a gap: one on the air and one
|
||||
/// in hand for 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. 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
|
||||
/// back, not to be a log.
|
||||
public const int KeptSent = 2000;
|
||||
|
||||
/// `Cursor` set to this lets everything pending go out, which is where it
|
||||
/// stands while the operator is not typing into the pane.
|
||||
public const int NoCursor = int.MaxValue;
|
||||
/// How far behind the engine's count runs. The probe pushed twenty-one
|
||||
/// characters and read 0 for three answers, 100 to 150 ms, before the count
|
||||
/// 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
|
||||
/// stands between the operator moving the cursor on and the next character
|
||||
/// going out, so it is short against a character time.
|
||||
private static readonly TimeSpan LongestTick = TimeSpan.FromMilliseconds(10);
|
||||
/// How often the engine is asked how much it has left, and how closely the
|
||||
/// feeder follows its own clock. It is well under a character time at any
|
||||
/// speed RTTY is worked at.
|
||||
public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
private readonly Func<char, CancellationToken, Task> send;
|
||||
private readonly EngineBuffer? counter;
|
||||
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 sent = new();
|
||||
|
||||
private CancellationTokenSource? stopping;
|
||||
private Task pump = Task.CompletedTask;
|
||||
private int cursor = NoCursor;
|
||||
private int inEngine;
|
||||
private DateTime nextOut = DateTime.MinValue;
|
||||
|
||||
/// True once `Transmit` has been called and until the message ends: the
|
||||
/// gate between what the operator has typed and the engine.
|
||||
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)
|
||||
{
|
||||
@@ -75,15 +160,36 @@ public sealed class TypeAhead : IDisposable
|
||||
Baud = baud;
|
||||
}
|
||||
|
||||
/// The speed the engine transmits at, which is what the estimate of its
|
||||
/// buffer is paced by.
|
||||
/// An engine that holds a buffer of its own and can say how much of it is
|
||||
/// left, which is what paces the 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; }
|
||||
|
||||
/// How many characters may sit in the engine at once.
|
||||
public int Lead { get; set; } = DefaultLead;
|
||||
/// How many characters the engine is kept ahead by.
|
||||
public int Ahead { get; set; } = DefaultAhead;
|
||||
|
||||
public TimeSpan CharacterTime =>
|
||||
TimeSpan.FromSeconds(BitsPerCharacter / (Baud > 0 ? Baud : DefaultBaud));
|
||||
/// How many symbols the engine may be behind before the feeder waits.
|
||||
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.
|
||||
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.
|
||||
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
|
||||
/// where the operator's cursor is; `NoCursor` while nobody is typing.
|
||||
public int Cursor
|
||||
/// True while what is waiting is being fed to the engine.
|
||||
public bool IsTransmitting
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return cursor;
|
||||
}
|
||||
}
|
||||
set
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
cursor = Math.Max(0, value);
|
||||
return open;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True while there is text to send or the engine is estimated to be still
|
||||
/// transmitting what it was given.
|
||||
public bool IsSending
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
Advance(DateTime.UtcNow);
|
||||
return pending.Length > 0 || inEngine > 0;
|
||||
return pending.Length > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Everything that was waiting has gone out, and the engine is estimated to
|
||||
/// have transmitted it. This is what tells the entry window that a message
|
||||
/// is finished, so what stands after `{END}` runs and `{RX}` drops the
|
||||
/// transmitter at the right moment.
|
||||
public event EventHandler? Drained;
|
||||
/// Everything that was waiting has been handed to the engine, which still
|
||||
/// holds the last `Ahead` characters of it. This is what tells the entry
|
||||
/// window that a message is finished, so what stands after `{END}` runs and
|
||||
/// `{RX}` reaches the engine while it has something left to send.
|
||||
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
|
||||
/// 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)
|
||||
{
|
||||
if (text.Length == 0)
|
||||
@@ -165,51 +292,167 @@ public sealed class TypeAhead : IDisposable
|
||||
lock (gate)
|
||||
{
|
||||
pending.Append(text);
|
||||
if (cursor != NoCursor)
|
||||
{
|
||||
// text added behind the operator's cursor is still text to
|
||||
// send, so the cursor moves out with it
|
||||
cursor += text.Length;
|
||||
}
|
||||
open = true;
|
||||
given = false;
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
Start();
|
||||
}
|
||||
|
||||
/// The operator rewrote what has not gone out yet.
|
||||
public void Rewrite(string text, int wanted)
|
||||
/// The operator rewrote the pane. `text` is the whole pane, what has gone
|
||||
/// 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)
|
||||
{
|
||||
int gone = Math.Min(sent.Length, text.Length);
|
||||
pending.Clear();
|
||||
pending.Append(text);
|
||||
cursor = Math.Clamp(wanted, 0, text.Length);
|
||||
pending.Append(text, gone, text.Length - gone);
|
||||
given = given && pending.Length == 0;
|
||||
}
|
||||
Start();
|
||||
}
|
||||
|
||||
/// The engine has stopped transmitting, so whatever it was given has gone
|
||||
/// out. This corrects the estimate: an engine that is faster than the
|
||||
/// estimate would otherwise be left waiting for a character it could have
|
||||
/// had.
|
||||
public void EngineIdle()
|
||||
/// The TX button, Ctrl+Enter and Alt+T: what is in the pane goes out, and
|
||||
/// so does whatever is added to it, until the transmitter drops.
|
||||
public void Transmit()
|
||||
{
|
||||
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
|
||||
/// already in the engine cannot be stopped from here, and the engine's own
|
||||
/// abort takes care of that.
|
||||
/// The engine's own answer: how many symbols it still holds, or -1 before
|
||||
/// it has answered at all. It is 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.
|
||||
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()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
pending.Clear();
|
||||
inEngine = 0;
|
||||
cursor = NoCursor;
|
||||
open = false;
|
||||
// the engine's own abort goes with this, so it holds nothing either
|
||||
outstanding = 0;
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -222,111 +465,369 @@ public sealed class TypeAhead : IDisposable
|
||||
{
|
||||
pending.Clear();
|
||||
sent.Clear();
|
||||
inEngine = 0;
|
||||
cursor = NoCursor;
|
||||
aired = 0;
|
||||
open = false;
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (counter is not null)
|
||||
{
|
||||
counter.Buffered -= WhenBuffered;
|
||||
}
|
||||
stopping?.Cancel();
|
||||
stopping?.Dispose();
|
||||
stopping = null;
|
||||
handing.Dispose();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!pump.IsCompleted)
|
||||
if (!idle)
|
||||
{
|
||||
return;
|
||||
}
|
||||
idle = false;
|
||||
stopping?.Dispose();
|
||||
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
|
||||
/// is nothing left to send. A pump held at the cursor keeps running: the
|
||||
/// operator is typing, and the next character is theirs to release.
|
||||
/// Hands the engine a character every character time, keeping it `Ahead`
|
||||
/// characters ahead of the air, unless its own count says it is more than
|
||||
/// `Slack` symbols behind and still moving.
|
||||
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
|
||||
{
|
||||
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);
|
||||
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;
|
||||
}
|
||||
await Task.Delay(Tick, cancellation).ConfigureAwait(false);
|
||||
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
finally
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
Advance(now);
|
||||
if (pending.Length == 0 || cursor == 0 || inEngine >= Lead)
|
||||
idle = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
char next = pending[0];
|
||||
pending.Remove(0, 1);
|
||||
if (cursor != NoCursor)
|
||||
{
|
||||
cursor--;
|
||||
}
|
||||
if (inEngine == 0)
|
||||
{
|
||||
nextOut = now + CharacterTime;
|
||||
}
|
||||
inEngine++;
|
||||
sent.Append(next);
|
||||
Gave(1, now);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 bridgePath;
|
||||
private readonly string? prefix;
|
||||
private readonly string? logFolder;
|
||||
private readonly Queue<string> lastErrors = new();
|
||||
private readonly SemaphoreSlim writing = new(1, 1);
|
||||
private readonly Lock writingLog = new();
|
||||
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,
|
||||
/// which is what a station with one prefix wants.
|
||||
public WineBridgeChannel(string bridgePath, string? prefix = null, string wine = "wine")
|
||||
/// which is what a station with one prefix wants. `logFolder`, when it is
|
||||
/// 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.prefix = prefix;
|
||||
this.wine = wine;
|
||||
this.logFolder = logFolder;
|
||||
}
|
||||
|
||||
public event EventHandler<string>? LineReceived;
|
||||
@@ -31,6 +42,7 @@ public sealed class WineBridgeChannel : BridgeChannel
|
||||
|
||||
public Task StartAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
OpenLog();
|
||||
bridge = Start(bridgePath);
|
||||
_ = ReadOutputAsync(bridge);
|
||||
_ = ReadErrorsAsync(bridge);
|
||||
@@ -46,6 +58,7 @@ public sealed class WineBridgeChannel : BridgeChannel
|
||||
await running.StandardInput.WriteLineAsync(line.AsMemory(), cancellation)
|
||||
.ConfigureAwait(false);
|
||||
await running.StandardInput.FlushAsync(cancellation).ConfigureAwait(false);
|
||||
Log(">", line);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -84,6 +97,71 @@ public sealed class WineBridgeChannel : BridgeChannel
|
||||
}
|
||||
bridge?.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)
|
||||
@@ -113,6 +191,7 @@ public sealed class WineBridgeChannel : BridgeChannel
|
||||
{
|
||||
while (await running.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line)
|
||||
{
|
||||
Log("<", line);
|
||||
LineReceived?.Invoke(this, line);
|
||||
}
|
||||
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 other station's screen
|
||||
"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,
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@ namespace Nonemm.Session;
|
||||
/// 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
|
||||
/// 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(
|
||||
IReadOnlyList<MessageAction> Before,
|
||||
string Text,
|
||||
@@ -45,7 +51,8 @@ public sealed record MessagePlan(
|
||||
}
|
||||
else if (Action(name) is { } action)
|
||||
{
|
||||
(ended ? after : before).Add(action);
|
||||
(ended || action.Command == MessageCommand.ReturnToReceive ? after : before)
|
||||
.Add(action);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// What a station sends on CW while exchanging QTC traffic, and N1MM's
|
||||
/// defaults for the parts an operator can change.
|
||||
/// What a station sends while exchanging QTC traffic, and N1MM's defaults for
|
||||
/// the parts an operator can change.
|
||||
///
|
||||
/// CW only. N1MM sends recorded voice messages on SSB and its digital window's
|
||||
/// templates on RTTY, and this program has neither.
|
||||
/// CW and RTTY. The two are laid out differently: on CW a line is the three
|
||||
/// 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
|
||||
{
|
||||
/// 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.
|
||||
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) =>
|
||||
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.
|
||||
/// `{QTC}` in it stands for the header of the series, so `TU {QTC} 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);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,58 @@ public class BandChangeRulesTests
|
||||
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]
|
||||
public void EachMoveToAnotherBandIsOneChange() =>
|
||||
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;
|
||||
|
||||
/// The text waiting to go out, fed to the engine a few characters at a time.
|
||||
/// The pump is run at a baud rate no radio uses so the tests do not wait for
|
||||
/// RTTY.
|
||||
/// The text waiting to go out, fed to the engine one character per character
|
||||
/// time. The feeder is run at a baud rate no radio uses so the tests do not
|
||||
/// wait for RTTY.
|
||||
public class TypeAheadTests
|
||||
{
|
||||
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// Fast enough that a message goes out in milliseconds, slow enough that a
|
||||
/// test can still catch the pump partway through.
|
||||
private const double Fast = 7500;
|
||||
/// test can still catch the feeder partway through.
|
||||
private const double Fast = 1500;
|
||||
|
||||
/// A character time of 100 ms, so a test can tell the characters sent ahead
|
||||
/// from the ones that wait for the engine.
|
||||
private const double Slow = 75;
|
||||
/// A quarter of a second per character, which is long enough to read what
|
||||
/// the engine is holding before it has transmitted it.
|
||||
private const double Slow = 30;
|
||||
|
||||
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) =>
|
||||
new(
|
||||
(character, _) =>
|
||||
@@ -68,7 +69,7 @@ public class TypeAheadTests
|
||||
[Fact]
|
||||
public async Task WhatHasGoneOutIsNotPendingAnyMore()
|
||||
{
|
||||
using TypeAhead buffer = Buffer(baud: 40);
|
||||
using TypeAhead buffer = Buffer();
|
||||
|
||||
buffer.Append("CQ TEST DE OM5M");
|
||||
|
||||
@@ -77,52 +78,72 @@ public class TypeAheadTests
|
||||
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]
|
||||
public async Task TheOperatorRewritesWhatHasNotGoneOut()
|
||||
{
|
||||
using TypeAhead buffer = Buffer(baud: 60);
|
||||
using TypeAhead buffer = Buffer();
|
||||
buffer.Append("OM5X 599 001");
|
||||
|
||||
await WaitForAsync(() => buffer.Sent.Length >= 5);
|
||||
buffer.Rewrite("599 002", TypeAhead.NoCursor);
|
||||
|
||||
buffer.Edit(buffer.Sent + "599 002");
|
||||
|
||||
await WaitForAsync(() => buffer.Pending.Length == 0);
|
||||
Assert.EndsWith("599 002", Sent, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("001", Sent, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// What is typed stays off the air until the transmitter is keyed.
|
||||
[Fact]
|
||||
public async Task NothingGoesOutFromBehindTheCursor()
|
||||
public async Task NothingTypedGoesOutBeforeTheTransmitterIsKeyed()
|
||||
{
|
||||
using TypeAhead buffer = Buffer();
|
||||
|
||||
buffer.Rewrite("CQ TEST", 2);
|
||||
buffer.Edit("CQ TEST");
|
||||
|
||||
await WaitForAsync(() => Sent.Length == 2);
|
||||
await Task.Delay(50);
|
||||
Assert.Equal("CQ", Sent);
|
||||
Assert.Equal(" TEST", buffer.Pending);
|
||||
Assert.Equal("", Sent);
|
||||
Assert.Equal("CQ TEST", buffer.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheRestGoesOutWhenTheCursorMovesOn()
|
||||
public async Task TheWholePaneGoesOutOnTransmit()
|
||||
{
|
||||
using TypeAhead buffer = Buffer();
|
||||
buffer.Rewrite("CQ TEST", 2);
|
||||
await WaitForAsync(() => Sent.Length == 2);
|
||||
buffer.Edit("CQ TEST");
|
||||
await Task.Delay(20);
|
||||
|
||||
buffer.Cursor = TypeAhead.NoCursor;
|
||||
buffer.Transmit();
|
||||
|
||||
await WaitForAsync(() => Sent == "CQ TEST");
|
||||
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]
|
||||
public async Task TextAddedBehindTheCursorStillGoesOut()
|
||||
public async Task TextTypedWhileTransmittingGoesOutBehindIt()
|
||||
{
|
||||
using TypeAhead buffer = Buffer();
|
||||
buffer.Rewrite("CQ", 2);
|
||||
await WaitForAsync(() => Sent == "CQ");
|
||||
buffer.Edit("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");
|
||||
|
||||
@@ -130,12 +151,200 @@ public class TypeAheadTests
|
||||
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]
|
||||
public async Task TheBufferSaysWhenTheMessageHasGoneOut()
|
||||
{
|
||||
using TypeAhead buffer = Buffer();
|
||||
int drained = 0;
|
||||
buffer.Drained += (_, _) => Interlocked.Increment(ref drained);
|
||||
buffer.Aired += (_, _) => Interlocked.Increment(ref drained);
|
||||
|
||||
buffer.Append("TU");
|
||||
|
||||
@@ -146,7 +355,7 @@ public class TypeAheadTests
|
||||
[Fact]
|
||||
public async Task AMessageAddedWhileOneIsGoingOutFollowsIt()
|
||||
{
|
||||
using TypeAhead buffer = Buffer(baud: 400);
|
||||
using TypeAhead buffer = Buffer();
|
||||
buffer.Append("CQ ");
|
||||
|
||||
buffer.Append("DE OM5M");
|
||||
@@ -158,7 +367,7 @@ public class TypeAheadTests
|
||||
[Fact]
|
||||
public async Task DroppingLeavesWhatHasAlreadyGone()
|
||||
{
|
||||
using TypeAhead buffer = Buffer(baud: 60);
|
||||
using TypeAhead buffer = Buffer();
|
||||
buffer.Append("CQ TEST DE OM5M");
|
||||
await WaitForAsync(() => buffer.Sent.Length >= 3);
|
||||
|
||||
@@ -174,7 +383,7 @@ public class TypeAheadTests
|
||||
[Fact]
|
||||
public void ClearingEmptiesBothHalves()
|
||||
{
|
||||
using TypeAhead buffer = Buffer(baud: 1);
|
||||
using TypeAhead buffer = Buffer();
|
||||
buffer.Append("CQ TEST");
|
||||
|
||||
buffer.Clear();
|
||||
@@ -183,57 +392,178 @@ public class TypeAheadTests
|
||||
Assert.Equal("", buffer.Sent);
|
||||
}
|
||||
|
||||
/// The engine is given the second character before it has transmitted the
|
||||
/// first, so it has one in hand when the first is done. An engine left with
|
||||
/// an empty buffer transmits idle instead, and that idle is added to how
|
||||
/// long the message takes.
|
||||
/// MMTTY answers 0 while it is still transmitting: for the first 150 ms
|
||||
/// after a push, and for as long as it holds a word on Word out. A feeder
|
||||
/// that read that as room fed on it and ran ahead of the air, so the clock
|
||||
/// 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]
|
||||
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");
|
||||
|
||||
await WaitForAsync(() => Sent.Length >= 2);
|
||||
Assert.Equal("CQ", Sent);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
await WaitForAsync(() => engine.Waiting == "CQ TEST");
|
||||
Assert.False(buffer.Counts);
|
||||
Assert.Equal("CQ TEST", engine.Waiting);
|
||||
}
|
||||
|
||||
[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() =>
|
||||
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]
|
||||
public void TheNameComesFromTheCallHistoryWhenTheContestHasNoNameBox()
|
||||
{
|
||||
|
||||
@@ -117,8 +117,21 @@ public class MessagePlanTests
|
||||
{
|
||||
MessagePlan plan = MessagePlan.Read("{TX}CQ DE {MYCALL}{ENTER}{RX}", Session());
|
||||
|
||||
Assert.Equal([MessageCommand.StartTransmit, MessageCommand.ReturnToReceive],
|
||||
plan.Before.Select(a => a.Command));
|
||||
Assert.Equal([MessageCommand.StartTransmit], plan.Before.Select(a => a.Command));
|
||||
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"));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
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