Compare commits

...

10 Commits

Author SHA1 Message Date
6f2f39768c Show the other computers, and apply the ten-minute rule
Three pieces on top of the link:

**The network status window**, on the entry window's Window menu. A row
per station, this computer included the way N1MM shows it: number,
address, operator, band, mode, run, transmit, pass frequency, the last
message type, how long ago it arrived, the counts each way and the echo
round trip. A station that broadcast the wrong version is named under the
table in red — that is the one fault where everything looks connected
and nothing arrives. The box at the bottom sends a line of chat, and
Echo asks every station whether it is there.

**Where this station is** goes out once a second when it has changed.
The frequency, the mode and run have a dozen places they can change from
— the radio moving, a band button, a QSY typed into the callsign box —
so it is read and compared rather than announced from each of them.

**The ten-minute rule.** `BandChangeRules` already counted changes and
the stay on a band, and the entry window already showed the countdown,
but only a user-defined contest carried a rule, so every built-in
contest allowed anything. `ForCategory` gives a multi-operator entry
with one or two transmitters a ten-minute stay, which is N1MM's fallback
in `ContestInstance.BandChangeTimerDuration` for every contest that does
not name its own. Its per-contest table is a few hundred cases in a
decompiled hash switch and is not repeated.

Config ▸ Edit Networked-Computer Names now covers both networks: the
12060 broadcast to other programs and the 12070 link, with the station
number, the port, the version to claim and stations named by address for
a network where a broadcast does not reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
2026-09-03 15:26:42 +00:00
c0e1bc1e1b Talk to the other logging computers on N1MM's own port
N1MM has two networks and this program had only one of them. Port 12060
carries XML to other programs — a spot tool, a score poster — and
`StationNetwork` already writes that. Computer to computer, N1MM uses
port 12070 and a different protocol, which is what this adds.

The wire format, from `MultiOpManager` and `MultiStation`:

    DATA__07%SHACK-PC%QSO%2026-09-03 12:34:56%DL1ABC%…~__DATA

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. `StationRecord` reads and writes that, and
holds what has arrived until a `~` says a message is whole: TCP hands
over half a message as often as two.

Discovery is a UDP broadcast to the same port, six fields wide.
**N1MM refuses a station whose version is not its own** — it says
"Software versions must match" and drops it — so the version this
program broadcasts is a setting rather than its own version, and a
station that says something else is kept in the list with `Refused` set
so the operator can see why nothing is arriving.

`StationLink` runs both sockets, opens a connection to every station it
hears, and reports what arrives. `QsoRecord` is the thirty-five fields
of a contact in N1MM's order, from `MultiOpManager.cs:1007`; points and
multiplier flags are read but the log works them out again from the
rules, so two stations cannot disagree about a score. `AddStation` names
a station by hand, for a network where a broadcast does not reach.

A station can also be named rather than heard, and one that is connected
may say nothing for a while, so `NetworkedStation` is heard-from as soon
as it exists. Left at nothing, the sweep that drops quiet stations read
every new station as quiet since the beginning of time and closed the
connection the moment it opened.

Tested as bytes and over loopback. Nothing has talked to a real N1MM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
2026-09-03 15:19:03 +00:00
9f635550bd Read a QTC series out on RTTY
N1MM's Send All: the whole series as one message — the heading, every
line with the operator's spacing between them, then the ending, with
`{TX}` and `{RX}` around it so it keys the transmitter and drops it
again. The shape is N1MM's, from `QTCWindow.cs:3609`. A line is the three
fields joined with hyphens, which is how N1MM writes them, and Snd n
sends one line again for a station that missed it.

One message rather than a button per line because a series is close to a
minute of transmission at 45 baud: the type-ahead buffer holds it and
paces the engine.

Three settings with N1MM's names and defaults — `WAESendAllHeadingText`,
`WAESendAllEndingText` and `WAESQTCSpacing` — on the QTC window's setup
dialog. `{ENTERLF}` is new to the expander and stands for a carriage
return and a line feed; `{QTC}` is filled in by the window, which is the
only place that knows which series is going out.

The messages are tested as text. Nothing has gone on the air.

Left out: N1MM's four SSB recordings, which need a voice keyer, and its
RTTY messages for the station taking traffic down, which are typed into
the digital window's transmit pane here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
2026-09-03 15:02:08 +00:00
0fc8d7e405 Write down what starts the transmit pane again
Two things do, not one: the engine reporting the drop, and a new message
being keyed. The second was missing from the text, which is the fault the
last commit fixed.

The claim that the bridge does not forward MMTTY's window message 32772
is dropped. `docs/digital-bridge.md` argues the control cannot raise
`OnTranslateMessage` for a message it has an event for, `OnPttEvent` does
report drops here, and the two files disagreed. Says what is known and
what is not instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
2026-09-03 14:46:37 +00:00
f49a8c10fd Start the transmit pane again on every new message
The macro buttons went wrong at the end of a transmission, both faults in
DigitalEngineSender:

- The engine reports the transmitter drop on its own thread.
  WhenTransmitChanged took the state lock, decided the message had ended,
  released the lock, and only then called Buffer.Ended(), which clears
  what has gone to the engine. A macro pressed on the last character got
  through StartAsync in that gap and had already flushed its own text into
  Sent, so Ended() wiped the new text off the pane while the engine
  transmitted it. Ended() is now called inside the same lock.

- The pane only started again when the engine reported a drop. Two macros
  in a row keep the transmitter up, so that report never came and Sent
  grew with every press. Everything in Sent is locked, because it is in
  the engine and cannot be taken back, so the whole pane became
  read-only. TypeAhead.Started() drops the last message's sent text and
  keeps what was typed ahead, and StartAsync calls it whenever it keys a
  new transmission.

The rest of this commit is the digital transmit work these fixes sit on:
the pane as one coloured box, the sender's three keying states, the
type-ahead feeder paced by the clock with the engine's count as a brake,
{RX} flushing what is left in one piece, and the entry window's function
keys reading the digital macros on a digital mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
2026-09-03 14:40:30 +00:00
7ae60be4a0 Write down what the count lag and the symbol count measure
The third probe run puts numbers on both. TxBufLen lags by 100 to 150 ms, about
one character at 45.45 baud: twenty-one characters pushed at 2496 ms read 0 at
2497, 2547 and 2597, then 26 at 2647. It moves once per symbol transmitted, so
readings 50 ms apart repeat.

It read 26 for 21 characters because it counts Baudot symbols, and the message
carried two digits: a shift to figures and a shift back each time, plus one at
the start. That is the case the cap was needed for and I had not seen. A contest
exchange is mostly digits, so the engine always has more to transmit than the
clock thinks, and the clock alone would run ahead of it on every QSO.

Slack of three characters covers both, so nothing changes in the pump. The
count reaching 0 does not cut the end of a message off either: it emptied 350 ms
before the last character was decoded back and the engine held the transmitter
up for another 900 ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
2026-09-01 23:20:11 +00:00
5370ad3f6d Do not trust the engine's count as the pace
The second probe run read TxBufLen as 0 one millisecond and again fifty
milliseconds after twenty-one characters had been pushed, while those characters
were already going out: the first of them was decoded back off the air 420 ms
later. The number is right when it is a second old and wrong when it is fresh,
so it runs behind the engine.

A pump that fed on it would have handed over the whole message in half a second
and put all of it beyond reach, which is worse than the clock it replaced. So
the clock is the pace again, and the count is a check on it: the engine is never
given more than Lead + Slack characters however fast the clock says to feed, and
a message ends when the count and the clock estimate both say it has. Characters
fed since the last answer are added to it, so a stale answer cannot be spent
twice.

The engine is set to Character out: ABCD with no space after it went out at
once. Word out would have held it, so the holding case stays, but it now needs
the count to be neither going down nor being added to, since an engine kept
exactly at the cap has a count that does not move either.

The probe writes down every reading for two seconds after a push and does not
believe an empty one in that window, which is what the run needed to measure the
lag and did not do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
2026-09-01 23:13:28 +00:00
4118cd6d33 Pace the pump on what MMTTY says it has left
The engine probe answered two questions on the air, and they point opposite
ways.

TxBufLen is the number of characters left to transmit. Seventeen characters
pushed, five decoded back, and it read 12, counting down to 0 as the message
went out. So the pump no longer counts character times off the clock: it asks
the engine, feeds while the answer is under Lead, and asks again. The baud rate
is now only the fallback for an engine that will not answer.

A backspace is not an edit. Pushed in as a character it made the count go up by
four and the text went out unchanged, so text the engine has been given cannot
be taken back, only aborted. EngineTypeAhead was built on the opposite belief
and is gone, along with the setting that chose it and the interface that existed
to switch between the two.

A count that stops going down means the engine is holding what it has: MMTTY set
to Word out keeps a word until the space after it. The pump feeds one character
per look while that lasts, so the space arrives and the word goes out rather
than the message sitting there.

The probe kept two bugs of its own that this run showed: it read the count a
millisecond after pushing, saw 0 and called the message finished, and it started
a step while the previous unkey was still in flight, so that step ran with the
engine down. It now needs two empty answers in a row and waits for the engine to
say it has stopped. It also asks what {RX} does to a word the engine is holding,
which decides whether a macro without a trailing space loses its last word.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
2026-09-01 23:02:00 +00:00
917a05b898 Key MMTTY the way N1MM does, with the control's PTT property
Nothing was ever transmitted. SetMmttyPTT does not start a transmission: N1MM
calls it with 1 to stop once the buffer is empty, which is its XmitOff, and with
0 to stop now, which is its AbortXmit. A transmission starts by setting the
control's PTT property, in XmitOn.

So the bridge learns `key <0|1>` for that property, and MmttyEngine now keys
with it, ends a message with SetMmttyPTT(1) so the buffer still goes out, and
aborts with SetMmttyPTT(0). This is the digital {TX} in the entry window and the
digital window as well as the probe: none of them could key the engine before.

The probe checks that the engine keys before it measures anything, and stops
with a plain statement if it does not, rather than reporting numbers from an
engine sitting still. It also asks a new question: whether the engine holds a
word until the space after it, which is MMTTY's Way to send. Received characters
are marked as noise while the engine is not transmitting, since a machine with a
sound card decodes the band all the way through the run.

The first run on a real engine says MMTTY 1.70 connects, the control answers
TxBufLen and refuses NotAProperty with DISP_E_UNKNOWNNAME. What TxBufLen counts
is still open: read while nothing was transmitting it rose over time and rose by
four after four backspaces, which is not what characters-left would do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
2026-09-01 22:51:43 +00:00
54b9181c06 Let MMTTY hold the text waiting to go out, behind a setting
MMTTY has a type-ahead buffer of its own: characters go into it, a backspace
takes back one it has not transmitted, and TxBufLen says how many are left. Used
that way it paces itself, so there is no gap between characters to tune and no
baud rate to keep in step with the engine.

EngineTypeAhead does that, and Config > Digital picks between it and the pump
that is there now. Off is still the default: three things it rests on have never
been seen with a real engine.

tools/Nonemm.EngineProbe asks the engine those three questions and writes the
answers to a file. It starts MMTTY through the bridge, pushes a message, polls
TxBufLen while it goes out, backspaces over text that has and has not been
transmitted, and logs what came back on the receive side and when.

The bridge learns one verb for it: `buffer` reads TxBufLen and answers with the
count, or -1 when the control will not say. A property the control does not know
is a log line rather than an error, since it stops nothing.

TypeAhead and EngineTypeAhead share the TransmitBuffer interface, which is what
the digital window now works through, so the window does not know which one it
has.

docs/unfinished.md states what each buffer assumes and how to run the probe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
2026-09-01 22:41:18 +00:00
55 changed files with 5933 additions and 419 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ obj/
/bridge/nonemm-mmtty-bridge.exe
/mmtty/
/bridge/XMMT.ocx
engine-probe.log

View File

@@ -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" />

View File

@@ -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);
DISPPARAMS none = {nullptr, nullptr, 0, 0};
if (FAILED(invoke(name, DISPATCH_PROPERTYGET, &none, &value))) {
return 0;
long value = 0;
tryGetLong(name, &value);
return value;
}
long found = SUCCEEDED(VariantChangeType(&value, &value, 0, VT_I4)) ? value.lVal : 0;
VariantClear(&value);
return found;
HRESULT XmmrControl::tryGetLong(const wchar_t* name, long* value) {
*value = 0;
VARIANT answer;
VariantInit(&answer);
DISPPARAMS none = {nullptr, nullptr, 0, 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;
}
}
VariantClear(&answer);
return result;
}
std::string XmmrControl::getString(const wchar_t* name) {

View File

@@ -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:

View File

@@ -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") {

View File

@@ -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 —

View File

@@ -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

View File

@@ -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();

View File

@@ -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; } = [];

View 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>

View 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);
}
}

View 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)));
}
}

View File

@@ -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" />

View File

@@ -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);
}

View File

@@ -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" />

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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">

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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" />

View 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>

View 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);
}
}
}

View File

@@ -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" />

View File

@@ -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) =>

View File

@@ -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.

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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,
}
}

View 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);
}

View File

@@ -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);

View File

@@ -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;
}
}
}

View File

@@ -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);

View 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; } = "";
}

View 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);
}

View 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]);
}
}

View 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 "";
}
}
}

View 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);
}

View 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);
}

View File

@@ -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,
};

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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(

View 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()
{
}
}
}

View File

@@ -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,65 +78,273 @@ 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);
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");
await WaitForAsync(() => Sent == "CQ TEST");
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);
}
}
}
}

View 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,
};
}

View 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",
};
}

View File

@@ -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()
{

View File

@@ -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));
}
}

View File

@@ -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()
{

View 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(),
};
}

View 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>

View 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();
}

View 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;
}