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
This commit is contained in:
@@ -44,37 +44,218 @@ type-ahead transmit pane were checked. Nothing has been decoded from a real
|
|||||||
signal and nothing has gone on the air: this machine has no sound card, and
|
signal and nothing has gone on the air: this machine has no sound card, and
|
||||||
MMTTY does not start on it.
|
MMTTY does not start on it.
|
||||||
|
|
||||||
**What paces the type-ahead pump.** The text is held in `TypeAhead` and the
|
**The entry window's function keys on a digital mode.** They are the digital
|
||||||
engine is kept `Lead` characters ahead: one being transmitted and one behind it,
|
macros, not the CW file: N1MM loads its send buttons from the RTTYBTN set on a
|
||||||
so it never runs dry and never transmits idle in the middle of a message. Those
|
digital mode, and the CW messages carry no `{TX}`, so a key pressed there fed
|
||||||
two characters cannot be taken back; everything behind them can still be
|
the engine without keying the transmitter and nothing went out. The first ten
|
||||||
rewritten.
|
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 pace comes from the clock at the baud rate in the digital settings. The
|
**How the transmit pane works.** The pane is one editable box holding the whole
|
||||||
engine's own count of what it has left is a check on that rather than the pace
|
message: what the engine has transmitted, coloured red, then what is still to
|
||||||
itself, for the reason in the table below: it is used as a cap, so the engine is
|
go. The red stops where the air is, not where the feeder is: the last characters
|
||||||
never given more than `Lead + Slack` characters however fast the clock says to
|
handed over are still in the engine, and colouring those too turned every
|
||||||
feed, and as the end of a message, together with the clock estimate. A count
|
character red as it was typed once the transmission had caught up. An edit that
|
||||||
that neither goes down nor is being added to means the engine is holding what it
|
reaches into the red is undone, and so is one into the characters the engine
|
||||||
has, and the pump feeds a character every `HoldingPatience` until it moves.
|
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.
|
||||||
|
|
||||||
|
`{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
|
What the engine probe established, with MMTTY 1.70 under Wine on a machine with
|
||||||
a sound card:
|
a sound card:
|
||||||
|
|
||||||
| What | Answer |
|
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.
|
||||||
| Does the control answer `TxBufLen`? | Yes, and a name it does not know fails with `DISP_E_UNKNOWNNAME`, so the two are distinguishable. |
|
|
||||||
| What does it count? | Characters left to transmit. Seventeen were pushed; a second later, with five decoded back off the air, it read 12, and it counted down to 0 as the message went out. |
|
|
||||||
| How current is that number? | It 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. That is why the count cannot be the pace: a pump that fed on a fresh 0 would hand over a whole message in half a second and put all of it beyond reach. |
|
|
||||||
| What is it counting? | Baudot symbols, not the characters handed over. Twenty-one characters read as 26, and the message was `CQ TEST DE OM5M OM5M `: two digits, each needing a shift to figures and a shift back, plus one shift at the start. It also only moves once per symbol transmitted, so four readings 50 ms apart are the same number. |
|
|
||||||
| Does a backspace take back a character the engine has not transmitted? | No. Pushed in as a character it made the count go **up** by four, and the text went out unchanged. MMTTY's help describes its backspace key doing this in its own window; through `PostMmttyMessage(4, 8)` it is just another character. So text in the engine cannot be retracted, only aborted. |
|
|
||||||
| Way to send | This engine is set to Character out: `ABCD` with no space after it went out at once. MMTTY's help says Word out is the usual setting, which would hold that word, so the pump copes with both. |
|
|
||||||
| Does the count reaching 0 cut the end of a message off? | No. It reached 0 about 350 ms before the last character was decoded back, and the engine held the transmitter up for another 900 ms after that, so `{RX}` straight after the count empties does not truncate anything. |
|
|
||||||
|
|
||||||
Counting symbols rather than characters is what makes the cap earn its place. A
|
|
||||||
contest exchange is full of digits, so the engine has more to transmit than the
|
|
||||||
pump's clock thinks: `599 001` is seven characters and eleven symbols. The clock
|
|
||||||
would run ahead of the engine on every exchange; the cap is what stops it.
|
|
||||||
|
|
||||||
The report is worth reading for the receive side as well. With MMTTY's sound
|
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,
|
loopback off, its help says the receive window is fed from the transmit window,
|
||||||
@@ -82,6 +263,14 @@ 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
|
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.
|
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
|
```sh
|
||||||
./build.sh run --project tools/Nonemm.EngineProbe -- \
|
./build.sh run --project tools/Nonemm.EngineProbe -- \
|
||||||
--engine ~/mmtty/MMTTY.EXE --prefix ~/.wine-nonemm --out engine-probe.log
|
--engine ~/mmtty/MMTTY.EXE --prefix ~/.wine-nonemm --out engine-probe.log
|
||||||
@@ -90,6 +279,51 @@ arrives about 420 ms after the character goes out and matches what was sent.
|
|||||||
It transmits on the sound card for about twenty seconds and keys no serial port
|
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.
|
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. N1MM does not read that report at all: it binds an
|
||||||
|
empty handler to `OnPttEvent` and takes the transmit state from MMTTY's window
|
||||||
|
message 32772 instead, which the bridge does not forward yet.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
**Voice keying.** `MessageSender` was written to cover a voice keyer playing a
|
**Voice keying.** `MessageSender` was written to cover a voice keyer playing a
|
||||||
recording, and nothing implements it. Each operator's recordings folder is
|
recording, and nothing implements it. Each operator's recordings folder is
|
||||||
stored with the rest of his settings and waits on the same thing. No DVK support either, so the second radio
|
stored with the rest of his settings and waits on the same thing. No DVK support either, so the second radio
|
||||||
|
|||||||
@@ -329,7 +329,8 @@ public sealed class AppSession : IDisposable
|
|||||||
WineBridgeChannel channel = new(
|
WineBridgeChannel channel = new(
|
||||||
Settings.DigitalBridgePath,
|
Settings.DigitalBridgePath,
|
||||||
Settings.DigitalWinePrefix.Trim().Length > 0 ? Settings.DigitalWinePrefix : null,
|
Settings.DigitalWinePrefix.Trim().Length > 0 ? Settings.DigitalWinePrefix : null,
|
||||||
Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine");
|
Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine",
|
||||||
|
Paths.Diagnostics);
|
||||||
MmttyEngine started = new(channel, options);
|
MmttyEngine started = new(channel, options);
|
||||||
await started.StartAsync();
|
await started.StartAsync();
|
||||||
digital = started;
|
digital = started;
|
||||||
|
|||||||
40
src/Nonemm.App/Controls/TransmitPane.axaml
Normal file
40
src/Nonemm.App/Controls/TransmitPane.axaml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:controls="using:Nonemm.App.Controls">
|
||||||
|
<Style Selector="controls|TransmitPane">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource FieldBackground}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource FieldForeground}" />
|
||||||
|
<Setter Property="CaretBrush" Value="{DynamicResource FieldForeground}" />
|
||||||
|
<Setter Property="SelectionBrush" Value="#3399FF" />
|
||||||
|
<Setter Property="SelectionForegroundBrush" Value="#FFFFFF" />
|
||||||
|
<Setter Property="Padding" Value="3,2" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<!-- a TextBox subclass gets no theme of its own, so the pane carries
|
||||||
|
its own template. It is the Fluent one cut down to what the pane
|
||||||
|
uses: no watermark, no clear button, no border of its own -->
|
||||||
|
<ControlTemplate>
|
||||||
|
<ScrollViewer Name="PART_ScrollViewer"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
Padding="{TemplateBinding Padding}"
|
||||||
|
HorizontalScrollBarVisibility="Disabled"
|
||||||
|
VerticalScrollBarVisibility="Auto">
|
||||||
|
<controls:TransmitPresenter Name="PART_TextPresenter"
|
||||||
|
Text="{TemplateBinding Text}"
|
||||||
|
CaretIndex="{TemplateBinding CaretIndex}"
|
||||||
|
SelectionStart="{TemplateBinding SelectionStart}"
|
||||||
|
SelectionEnd="{TemplateBinding SelectionEnd}"
|
||||||
|
SelectionBrush="{TemplateBinding SelectionBrush}"
|
||||||
|
SelectionForegroundBrush="{TemplateBinding SelectionForegroundBrush}"
|
||||||
|
CaretBrush="{TemplateBinding CaretBrush}"
|
||||||
|
TextAlignment="{TemplateBinding TextAlignment}"
|
||||||
|
TextWrapping="{TemplateBinding TextWrapping}"
|
||||||
|
LineHeight="{TemplateBinding LineHeight}"
|
||||||
|
LetterSpacing="{TemplateBinding LetterSpacing}"
|
||||||
|
SentLength="{TemplateBinding SentLength}"
|
||||||
|
SentBrush="{TemplateBinding SentBrush}"
|
||||||
|
VerticalAlignment="Top" />
|
||||||
|
</ScrollViewer>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
37
src/Nonemm.App/Controls/TransmitPane.cs
Normal file
37
src/Nonemm.App/Controls/TransmitPane.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace Nonemm.App.Controls;
|
||||||
|
|
||||||
|
/// The digital transmit pane: one editable box in which the first `SentLength`
|
||||||
|
/// characters — what has already gone to the engine — are drawn in `SentBrush`.
|
||||||
|
///
|
||||||
|
/// Avalonia's TextBox draws all of its text in one brush, so this was a label
|
||||||
|
/// beside a box before. That took width from the box as the label grew and the
|
||||||
|
/// label did not wrap. A TextBox builds its text through a TextPresenter, and
|
||||||
|
/// the TextLayout under it does take a brush per run, so the pane is a TextBox
|
||||||
|
/// with `TransmitPresenter` in place of the plain presenter. The template in
|
||||||
|
/// `DigitalWindow.axaml` is what puts it there.
|
||||||
|
public class TransmitPane : TextBox
|
||||||
|
{
|
||||||
|
public static readonly StyledProperty<int> SentLengthProperty =
|
||||||
|
AvaloniaProperty.Register<TransmitPane, int>(nameof(SentLength));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<IBrush?> SentBrushProperty =
|
||||||
|
AvaloniaProperty.Register<TransmitPane, IBrush?>(nameof(SentBrush));
|
||||||
|
|
||||||
|
/// How many characters at the front of the text have gone out.
|
||||||
|
public int SentLength
|
||||||
|
{
|
||||||
|
get => GetValue(SentLengthProperty);
|
||||||
|
set => SetValue(SentLengthProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What those characters are drawn in.
|
||||||
|
public IBrush? SentBrush
|
||||||
|
{
|
||||||
|
get => GetValue(SentBrushProperty);
|
||||||
|
set => SetValue(SentBrushProperty, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/Nonemm.App/Controls/TransmitPresenter.cs
Normal file
133
src/Nonemm.App/Controls/TransmitPresenter.cs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls.Presenters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.TextFormatting;
|
||||||
|
using Avalonia.Utilities;
|
||||||
|
|
||||||
|
namespace Nonemm.App.Controls;
|
||||||
|
|
||||||
|
/// The presenter behind `TransmitPane`: the same text layout Avalonia builds
|
||||||
|
/// for a TextBox, with the first `SentLength` characters given `SentBrush`.
|
||||||
|
///
|
||||||
|
/// TextPresenter builds its layout in `CreateTextLayout` and already passes
|
||||||
|
/// per-run overrides for the selection, so this adds one more run to that list.
|
||||||
|
/// The selection has to keep its own colour on top, which is why the coloured
|
||||||
|
/// run is cut around it rather than laid over it: overlapping runs are not
|
||||||
|
/// defined.
|
||||||
|
public class TransmitPresenter : TextPresenter
|
||||||
|
{
|
||||||
|
public static readonly StyledProperty<int> SentLengthProperty =
|
||||||
|
AvaloniaProperty.Register<TransmitPresenter, int>(nameof(SentLength));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<IBrush?> SentBrushProperty =
|
||||||
|
AvaloniaProperty.Register<TransmitPresenter, IBrush?>(nameof(SentBrush));
|
||||||
|
|
||||||
|
/// The width the layout is built to. `TextPresenter` keeps its own copy and
|
||||||
|
/// does not hand it out, so it is read off the measure pass here.
|
||||||
|
private Size constraint;
|
||||||
|
|
||||||
|
public int SentLength
|
||||||
|
{
|
||||||
|
get => GetValue(SentLengthProperty);
|
||||||
|
set => SetValue(SentLengthProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IBrush? SentBrush
|
||||||
|
{
|
||||||
|
get => GetValue(SentBrushProperty);
|
||||||
|
set => SetValue(SentBrushProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Size MeasureOverride(Size availableSize)
|
||||||
|
{
|
||||||
|
constraint = availableSize;
|
||||||
|
return base.MeasureOverride(availableSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||||
|
{
|
||||||
|
base.OnPropertyChanged(change);
|
||||||
|
if (change.Property == SentLengthProperty || change.Property == SentBrushProperty)
|
||||||
|
{
|
||||||
|
InvalidateTextLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override TextLayout CreateTextLayout()
|
||||||
|
{
|
||||||
|
string text = Text ?? "";
|
||||||
|
int sent = Math.Clamp(SentLength, 0, text.Length);
|
||||||
|
// nothing to colour, or a case the base class handles on its own: the
|
||||||
|
// password character replaces the text, and a preedit run is the input
|
||||||
|
// method's, not ours
|
||||||
|
if (sent == 0 || SentBrush is null || PasswordChar != '\0'
|
||||||
|
|| !string.IsNullOrEmpty(PreeditText))
|
||||||
|
{
|
||||||
|
return base.CreateTextLayout();
|
||||||
|
}
|
||||||
|
Typeface typeface = new(FontFamily, FontStyle, FontWeight, FontStretch);
|
||||||
|
// a zero constraint is a measure with no bound, which is infinity to
|
||||||
|
// the layout
|
||||||
|
double width = constraint.Width > 0 ? constraint.Width : double.PositiveInfinity;
|
||||||
|
double height = constraint.Height > 0 ? constraint.Height : double.PositiveInfinity;
|
||||||
|
return new TextLayout(
|
||||||
|
text,
|
||||||
|
typeface,
|
||||||
|
FontSize,
|
||||||
|
Foreground,
|
||||||
|
TextAlignment,
|
||||||
|
TextWrapping,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
FlowDirection,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
LineHeight,
|
||||||
|
LetterSpacing,
|
||||||
|
0,
|
||||||
|
FontFeatures,
|
||||||
|
Runs(typeface, sent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The coloured runs, in order and not overlapping. The selection, when
|
||||||
|
/// there is one with a colour of its own, cuts the coloured run in two.
|
||||||
|
private List<ValueSpan<TextRunProperties>> Runs(Typeface typeface, int sent)
|
||||||
|
{
|
||||||
|
int from = Math.Min(SelectionStart, SelectionEnd);
|
||||||
|
int to = Math.Max(SelectionStart, SelectionEnd);
|
||||||
|
bool selected = ShowSelectionHighlight && to > from && SelectionForegroundBrush is not null;
|
||||||
|
List<ValueSpan<TextRunProperties>> runs = [];
|
||||||
|
Add(runs, 0, selected ? Math.Min(sent, from) : sent, SentBrush, typeface);
|
||||||
|
if (selected)
|
||||||
|
{
|
||||||
|
Add(runs, from, to, SelectionForegroundBrush, typeface);
|
||||||
|
Add(runs, to, sent, SentBrush, typeface);
|
||||||
|
}
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Add(
|
||||||
|
List<ValueSpan<TextRunProperties>> runs,
|
||||||
|
int start,
|
||||||
|
int end,
|
||||||
|
IBrush? brush,
|
||||||
|
Typeface typeface)
|
||||||
|
{
|
||||||
|
if (end <= start)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runs.Add(new ValueSpan<TextRunProperties>(
|
||||||
|
start,
|
||||||
|
end - start,
|
||||||
|
new GenericTextRunProperties(
|
||||||
|
typeface,
|
||||||
|
FontSize,
|
||||||
|
null,
|
||||||
|
brush,
|
||||||
|
null,
|
||||||
|
BaselineAlignment.Baseline,
|
||||||
|
null,
|
||||||
|
FontFeatures)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,17 +127,26 @@ public sealed partial class DigitalWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What is typed in the transmit pane goes into the type-ahead buffer,
|
/// What is typed in the transmit pane goes into the type-ahead buffer.
|
||||||
/// which feeds the engine. A carriage return is what the engine takes as a
|
/// Nothing goes on the air until the transmitter is keyed, which is the TX
|
||||||
/// new line, so Enter puts one in rather than the newline the box would.
|
/// button, Ctrl+Enter or Alt+T; from then on what is typed goes out as it
|
||||||
|
/// is typed. Enter is a new line in the message, which the engine takes as
|
||||||
|
/// a carriage return.
|
||||||
|
///
|
||||||
|
/// Escape stops now: what has not gone to the engine is dropped and the
|
||||||
|
/// engine drops what it holds.
|
||||||
private void OnTransmitKeyDown(object? sender, KeyEventArgs e)
|
private void OnTransmitKeyDown(object? sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (e.Key == Key.Enter && e.KeyModifiers == KeyModifiers.Control)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
StartTransmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.Key == Key.Enter)
|
if (e.Key == Key.Enter)
|
||||||
{
|
{
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
int at = Math.Clamp(TransmitBox.CaretIndex, 0, TransmitBox.Text?.Length ?? 0);
|
Type("\r");
|
||||||
TransmitBox.Text = (TransmitBox.Text ?? "").Insert(at, "\r");
|
|
||||||
TransmitBox.CaretIndex = at + 1;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.Key == Key.Escape)
|
if (e.Key == Key.Escape)
|
||||||
@@ -148,37 +157,61 @@ public sealed partial class DigitalWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The operator rewrote what has not gone out yet. Only the box holds it;
|
/// Puts text in at the caret, never before the text that has gone out.
|
||||||
/// what is already in the engine is in the label beside it and cannot be
|
private void Type(string text)
|
||||||
/// reached from here.
|
{
|
||||||
|
string was = TransmitBox.Text ?? "";
|
||||||
|
int at = Math.Clamp(TransmitBox.CaretIndex, locked, was.Length);
|
||||||
|
TransmitBox.Text = was.Insert(at, text);
|
||||||
|
TransmitBox.CaretIndex = at + text.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The operator rewrote the pane. What has already gone to the engine
|
||||||
|
/// cannot be taken back, so an edit that reaches into it is undone; the
|
||||||
|
/// rest goes to the buffer, which sends it if the transmitter is up and
|
||||||
|
/// holds it if it is not.
|
||||||
private void OnTransmitTextChanged(object? sender, TextChangedEventArgs e)
|
private void OnTransmitTextChanged(object? sender, TextChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (showingBuffer || session.DigitalKeyer is not { } keyer)
|
if (showingBuffer || session.DigitalKeyer is not { } keyer)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
keyer.Buffer.Rewrite(TransmitBox.Text ?? "", CursorInBox());
|
string now = TransmitBox.Text ?? "";
|
||||||
}
|
if (FirstDifference(pane, now) < locked)
|
||||||
|
|
||||||
/// The cursor holds the pump back: nothing behind it goes out, so the
|
|
||||||
/// engine idles rather than transmitting text the operator is still
|
|
||||||
/// typing. With the box out of focus there is no cursor to hold anything.
|
|
||||||
private void OnTransmitFocus(object? sender, RoutedEventArgs e) => ShowCursor();
|
|
||||||
|
|
||||||
private void ShowCursor()
|
|
||||||
{
|
{
|
||||||
if (session.DigitalKeyer is { } keyer)
|
showingBuffer = true;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
keyer.Buffer.Cursor = TransmitBox.IsFocused ? CursorInBox() : TypeAhead.NoCursor;
|
TransmitBox.Text = pane;
|
||||||
|
TransmitBox.CaretIndex = locked;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
showingBuffer = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pane = now;
|
||||||
|
keyer.Buffer.Edit(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
private int CursorInBox() =>
|
/// Where two versions of the pane first differ, which is the length of both
|
||||||
Math.Clamp(TransmitBox.CaretIndex, 0, TransmitBox.Text?.Length ?? 0);
|
/// when one is the other with text added or taken off the end.
|
||||||
|
private static int FirstDifference(string was, string now)
|
||||||
|
{
|
||||||
|
int most = Math.Min(was.Length, now.Length);
|
||||||
|
int at = 0;
|
||||||
|
while (at < most && was[at] == now[at])
|
||||||
|
{
|
||||||
|
at++;
|
||||||
|
}
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
|
||||||
/// Draws the buffer: what has gone out in the label, what is still to go in
|
/// Draws the buffer: what has gone out and what is still to go, as one
|
||||||
/// the box. The pump takes characters off the front, so the cursor moves
|
/// text, with the length of the first half telling the pane how much of it
|
||||||
/// back with them and the operator can go on typing while it does.
|
/// to colour. A character moving from one half to the other leaves the text
|
||||||
|
/// the same, so the caret and what the operator is typing do not move.
|
||||||
private void ShowBuffer()
|
private void ShowBuffer()
|
||||||
{
|
{
|
||||||
if (session.DigitalKeyer is not { } keyer)
|
if (session.DigitalKeyer is not { } keyer)
|
||||||
@@ -188,20 +221,29 @@ public sealed partial class DigitalWindow
|
|||||||
showingBuffer = true;
|
showingBuffer = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
SentText.Text = keyer.Buffer.Sent;
|
string sent = keyer.Buffer.Sent;
|
||||||
string pending = keyer.Buffer.Pending;
|
string now = sent + keyer.Buffer.Pending;
|
||||||
string was = TransmitBox.Text ?? "";
|
locked = sent.Length;
|
||||||
if (was == pending)
|
// only what the engine has transmitted is coloured. What it is
|
||||||
|
// still holding cannot be taken back either, but the operator has
|
||||||
|
// not heard it go yet, and marking it as gone turned every
|
||||||
|
// character red as it was typed once the transmission caught up
|
||||||
|
TransmitBox.SentLength = keyer.Buffer.OnAir;
|
||||||
|
if ((TransmitBox.Text ?? "") == now)
|
||||||
{
|
{
|
||||||
|
pane = now;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
int taken = was.Length > pending.Length
|
// the end of a message drops what has gone out off the front of
|
||||||
&& was.EndsWith(pending, StringComparison.Ordinal)
|
// the pane, so the caret moves back with the text it is in
|
||||||
? was.Length - pending.Length
|
string was = TransmitBox.Text ?? "";
|
||||||
|
int dropped = was.Length > now.Length && was.EndsWith(now, StringComparison.Ordinal)
|
||||||
|
? was.Length - now.Length
|
||||||
: 0;
|
: 0;
|
||||||
int caret = TransmitBox.CaretIndex;
|
int caret = TransmitBox.CaretIndex - dropped;
|
||||||
TransmitBox.Text = pending;
|
TransmitBox.Text = now;
|
||||||
TransmitBox.CaretIndex = Math.Clamp(caret - taken, 0, pending.Length);
|
TransmitBox.CaretIndex = Math.Clamp(caret, locked, now.Length);
|
||||||
|
pane = now;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -211,7 +253,8 @@ public sealed partial class DigitalWindow
|
|||||||
|
|
||||||
/// N1MM's keys for the digital window: Alt+T turns the transmitter on and
|
/// N1MM's keys for the digital window: Alt+T turns the transmitter on and
|
||||||
/// puts the cursor where what is typed goes out, Ctrl+K does the same, and
|
/// puts the cursor where what is typed goes out, Ctrl+K does the same, and
|
||||||
/// Alt+G takes the next call off the grab list.
|
/// Alt+G takes the next call off the grab list. Escape stops now, wherever
|
||||||
|
/// the focus is.
|
||||||
protected override void OnKeyDown(KeyEventArgs e)
|
protected override void OnKeyDown(KeyEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.KeyModifiers == KeyModifiers.Alt && e.Key == Key.T)
|
if (e.KeyModifiers == KeyModifiers.Alt && e.Key == Key.T)
|
||||||
@@ -235,14 +278,15 @@ public sealed partial class DigitalWindow
|
|||||||
if (e.Key == Key.Escape)
|
if (e.Key == Key.Escape)
|
||||||
{
|
{
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
|
_ = session.DigitalKeyer?.AbortAsync();
|
||||||
_ = Running()?.AbortAsync();
|
_ = Running()?.AbortAsync();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
base.OnKeyDown(e);
|
base.OnKeyDown(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alt+T: on to transmit with the cursor in the transmit pane, off back to
|
/// Alt+T: on to transmit with the cursor in the transmit pane, off to drop
|
||||||
/// receive.
|
/// the transmitter at the end of what is waiting.
|
||||||
private void ToggleTransmit()
|
private void ToggleTransmit()
|
||||||
{
|
{
|
||||||
if (Running() is not { } running)
|
if (Running() is not { } running)
|
||||||
@@ -251,26 +295,51 @@ public sealed partial class DigitalWindow
|
|||||||
}
|
}
|
||||||
if (running.IsTransmitting)
|
if (running.IsTransmitting)
|
||||||
{
|
{
|
||||||
_ = session.DigitalKeyer?.AbortAsync();
|
ReturnToReceive();
|
||||||
_ = running.ReturnToReceiveAsync();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
StartTransmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTransmit(object? sender, RoutedEventArgs e) => StartTransmit();
|
||||||
|
|
||||||
|
/// Keys the transmitter and opens the gate, so what is in the pane goes out
|
||||||
|
/// and so does whatever is typed into it after this. The keyer does the
|
||||||
|
/// keying, so the engine is never fed before it is keyed.
|
||||||
|
private void StartTransmit()
|
||||||
|
{
|
||||||
|
if (Running() is not { } running)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.DigitalKeyer is { } keyer)
|
||||||
|
{
|
||||||
|
keyer.Transmit();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
_ = running.SetPttAsync(true);
|
_ = running.SetPttAsync(true);
|
||||||
|
}
|
||||||
TransmitBox.Focus();
|
TransmitBox.Focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnTransmit(object? sender, RoutedEventArgs e)
|
/// The RX button is the `{RX}` macro by hand: the transmitter drops at the
|
||||||
{
|
/// end of what is waiting rather than in the middle of it. Escape is what
|
||||||
_ = Running()?.SetPttAsync(true);
|
/// stops now.
|
||||||
TransmitBox.Focus();
|
private void OnReceive(object? sender, RoutedEventArgs e) => ReturnToReceive();
|
||||||
}
|
|
||||||
|
|
||||||
/// The RX button stops where it is: what has not gone to the engine is
|
private void ReturnToReceive()
|
||||||
/// dropped, and the engine drops what it holds.
|
|
||||||
private void OnReceive(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
{
|
||||||
_ = session.DigitalKeyer?.AbortAsync();
|
if (Running() is not { } running)
|
||||||
_ = Running()?.AbortAsync();
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.DigitalKeyer is { } keyer)
|
||||||
|
{
|
||||||
|
keyer.ReturnToReceiveWhenSent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ = running.ReturnToReceiveAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnClearTransmit(object? sender, RoutedEventArgs e)
|
private void OnClearTransmit(object? sender, RoutedEventArgs e)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:local="using:Nonemm.App.Windows"
|
xmlns:local="using:Nonemm.App.Windows"
|
||||||
|
xmlns:controls="using:Nonemm.App.Controls"
|
||||||
x:Class="Nonemm.App.Windows.DigitalWindow"
|
x:Class="Nonemm.App.Windows.DigitalWindow"
|
||||||
Title="Digital Interface" Width="900" Height="640">
|
Title="Digital Interface" Width="900" Height="640">
|
||||||
<Window.Styles>
|
<Window.Styles>
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
<Setter Property="Margin" Value="4,2" />
|
<Setter Property="Margin" Value="4,2" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<StyleInclude Source="avares://Nonemm.App/Controls/TransmitPane.axaml" />
|
||||||
</Window.Styles>
|
</Window.Styles>
|
||||||
|
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
@@ -166,17 +168,14 @@
|
|||||||
|
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="*,Auto,150">
|
<Grid Grid.Row="2" ColumnDefinitions="*,Auto,150">
|
||||||
<Border BorderThickness="1" BorderBrush="#40808080">
|
<Border BorderThickness="1" BorderBrush="#40808080">
|
||||||
<!-- what has gone out, then what is still to go: the first is a
|
<!-- one box for the whole message: what has gone out is coloured and
|
||||||
label so it cannot be edited, the second the box the operator
|
cannot be edited, what is still to go is typed into the same
|
||||||
types in -->
|
text, so the two wrap together and neither takes width from the
|
||||||
<Grid ColumnDefinitions="Auto,*">
|
other -->
|
||||||
<TextBlock Name="SentText" FontFamily="monospace" Margin="3,3,0,0"
|
<controls:TransmitPane Name="TransmitBox" AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
VerticalAlignment="Top" />
|
FontFamily="monospace"
|
||||||
<TextBox Grid.Column="1" Name="TransmitBox" AcceptsReturn="True" TextWrapping="Wrap"
|
KeyDown="OnTransmitKeyDown"
|
||||||
FontFamily="monospace" BorderThickness="0"
|
TextChanged="OnTransmitTextChanged" />
|
||||||
KeyDown="OnTransmitKeyDown" TextChanged="OnTransmitTextChanged"
|
|
||||||
GotFocus="OnTransmitFocus" LostFocus="OnTransmitFocus" />
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
</Border>
|
||||||
<Grid Grid.Column="2" RowDefinitions="*,Auto">
|
<Grid Grid.Column="2" RowDefinitions="*,Auto">
|
||||||
<Border BorderThickness="1" BorderBrush="#40808080">
|
<Border BorderThickness="1" BorderBrush="#40808080">
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
|||||||
/// redraw is not read back as an edit by the operator.
|
/// redraw is not read back as an edit by the operator.
|
||||||
private bool showingBuffer;
|
private bool showingBuffer;
|
||||||
|
|
||||||
|
/// The transmit pane as it was last drawn, so an edit can be told from a
|
||||||
|
/// redraw and the changed character found.
|
||||||
|
private string pane = "";
|
||||||
|
|
||||||
|
/// How much of the pane is in the engine's hands and cannot be edited. It
|
||||||
|
/// is more than what is coloured, which is only what has gone out over the
|
||||||
|
/// air.
|
||||||
|
private int locked;
|
||||||
|
|
||||||
/// The buffer this window is drawing, or null while no engine is running.
|
/// The buffer this window is drawing, or null while no engine is running.
|
||||||
private TypeAhead? buffer;
|
private TypeAhead? buffer;
|
||||||
|
|
||||||
@@ -82,9 +91,6 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
|||||||
entry.Activated += WhenEntryActivated;
|
entry.Activated += WhenEntryActivated;
|
||||||
BuildMacros();
|
BuildMacros();
|
||||||
ApplySettings();
|
ApplySettings();
|
||||||
// the cursor holds the pump back, so it has to follow the caret as it
|
|
||||||
// moves, not only as the text changes
|
|
||||||
TransmitBox.GetObservable(TextBox.CaretIndexProperty).Subscribe(new Watcher(ShowCursor));
|
|
||||||
Attach(session.Digital);
|
Attach(session.Digital);
|
||||||
Refresh();
|
Refresh();
|
||||||
Closed += (_, _) =>
|
Closed += (_, _) =>
|
||||||
@@ -104,8 +110,8 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
|||||||
{
|
{
|
||||||
ReceiveScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
ReceiveScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||||
GrabScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
GrabScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||||
SentText.Foreground = Transmitted;
|
TransmitBox.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||||
SentText.FontSize = Settings.DigitalFontSize;
|
TransmitBox.SentBrush = Transmitted;
|
||||||
TransmitBox.FontSize = Settings.DigitalFontSize;
|
TransmitBox.FontSize = Settings.DigitalFontSize;
|
||||||
if (buffer is not null)
|
if (buffer is not null)
|
||||||
{
|
{
|
||||||
@@ -188,21 +194,16 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
|||||||
private void WhenBufferChanged(object? sender, EventArgs e) =>
|
private void WhenBufferChanged(object? sender, EventArgs e) =>
|
||||||
Dispatcher.UIThread.Post(ShowBuffer);
|
Dispatcher.UIThread.Post(ShowBuffer);
|
||||||
|
|
||||||
/// The pane starts empty for the next message: the transmitter dropping
|
/// The transmitter dropping ends the message. The buffer clears what has
|
||||||
/// with nothing left to send is the end of this one. An engine that keys
|
/// gone out and keeps what the operator typed ahead, so the pane is left
|
||||||
/// itself drops between two characters as well, and that text is left
|
/// with the next message in it rather than empty.
|
||||||
/// where it is.
|
|
||||||
private void WhenTransmitChanged(object? sender, bool transmitting) =>
|
private void WhenTransmitChanged(object? sender, bool transmitting) =>
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
TransmitDot.Background = transmitting
|
TransmitDot.Background = transmitting
|
||||||
? Themes.Brush(Themes.Current.TransmitLight)
|
? Themes.Brush(Themes.Current.TransmitLight)
|
||||||
: Brushes.Transparent;
|
: Brushes.Transparent;
|
||||||
if (!transmitting && buffer is { IsSending: false })
|
|
||||||
{
|
|
||||||
buffer.Clear();
|
|
||||||
ShowBuffer();
|
ShowBuffer();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
private void WhenConnectionChanged(object? sender, bool connected) =>
|
private void WhenConnectionChanged(object? sender, bool connected) =>
|
||||||
@@ -568,18 +569,3 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
|||||||
_ => StackOrder.Disabled,
|
_ => StackOrder.Disabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Watches one property. Avalonia hands out observables and this program has no
|
|
||||||
/// other use for Rx, so a handler that takes no value is enough.
|
|
||||||
internal sealed class Watcher(Action changed) : IObserver<int>
|
|
||||||
{
|
|
||||||
public void OnCompleted()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnError(Exception error)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnNext(int value) => changed();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,14 +35,34 @@ public sealed partial class EntryWindow
|
|||||||
|
|
||||||
/// The twelve keys for this radio, which are a different twelve while
|
/// The twelve keys for this radio, which are a different twelve while
|
||||||
/// running and while searching, as N1MM's file holds them.
|
/// running and while searching, as N1MM's file holds them.
|
||||||
|
///
|
||||||
|
/// On a digital mode they come from the digital macros instead, which is
|
||||||
|
/// where N1MM reads them from as well: its entry window loads the RTTYBTN
|
||||||
|
/// set there rather than the CW file. Without this the keys held CW text
|
||||||
|
/// with no `{TX}` in it, so pressing one fed the engine without keying the
|
||||||
|
/// transmitter and nothing went on the air.
|
||||||
private IReadOnlyList<FunctionKey> Keys() =>
|
private IReadOnlyList<FunctionKey> Keys() =>
|
||||||
Messages
|
Logging?.Mode.Category == ModeCategory.Digital
|
||||||
|
? DigitalKeys()
|
||||||
|
: Messages
|
||||||
.For(
|
.For(
|
||||||
Logging?.Mode.Category ?? Core.ModeCategory.Cw,
|
Logging?.Mode.Category ?? Core.ModeCategory.Cw,
|
||||||
session.Settings.CwMessageFile,
|
session.Settings.CwMessageFile,
|
||||||
session.Settings.PhoneMessageFile)
|
session.Settings.PhoneMessageFile)
|
||||||
.Keys(Logging?.IsRunning ?? false);
|
.Keys(Logging?.IsRunning ?? false);
|
||||||
|
|
||||||
|
/// The first ten digital macros, then Spot and Wipe. F11 and F12 are what
|
||||||
|
/// they are in every other mode, so the labels say what the buttons do;
|
||||||
|
/// the macros past the tenth are on the digital window's own buttons.
|
||||||
|
private IReadOnlyList<FunctionKey> DigitalKeys()
|
||||||
|
{
|
||||||
|
IReadOnlyList<FunctionKey> macros = Messages.Digital(session.Settings.DigitalMessageFile).Buttons;
|
||||||
|
List<FunctionKey> keys = [.. macros.Take(MessageFile.KeyCount - 2)];
|
||||||
|
keys.Add(new FunctionKey("Spot", ""));
|
||||||
|
keys.Add(new FunctionKey("Wipe", ""));
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
/// Right-clicking a function key button opens the messages for the mode the
|
/// Right-clicking a function key button opens the messages for the mode the
|
||||||
/// radio is in, which is where import and export live too.
|
/// radio is in, which is where import and export live too.
|
||||||
private Task EditMessages() => EditMessages(Logging?.Mode.Category ?? ModeCategory.Cw);
|
private Task EditMessages() => EditMessages(Logging?.Mode.Category ?? ModeCategory.Cw);
|
||||||
@@ -140,7 +160,12 @@ public sealed partial class EntryWindow
|
|||||||
}
|
}
|
||||||
if (plan.Text.Length == 0)
|
if (plan.Text.Length == 0)
|
||||||
{
|
{
|
||||||
// a button that only acts: {WIPE}, {LOG}, {RUN}
|
// a button that only acts: {WIPE}, {LOG}, {RUN}, or an {RX} on its
|
||||||
|
// own, which has nothing to wait for
|
||||||
|
foreach (MessageAction action in plan.After)
|
||||||
|
{
|
||||||
|
Run(action);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if ((through ?? Sender) is not { } keyer)
|
if ((through ?? Sender) is not { } keyer)
|
||||||
@@ -170,7 +195,22 @@ public sealed partial class EntryWindow
|
|||||||
Status(e.Message);
|
Status(e.Message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_ = RunWhenSentAsync(keyer, after);
|
// `{RX}` goes to the keyer as soon as the message is in it, which is
|
||||||
|
// N1MM's ending: the text in one piece, the stop 400 ms behind it, and
|
||||||
|
// the engine unkeys itself at the last character. Waiting for the
|
||||||
|
// message to go out first leaves the engine empty, and a stop that
|
||||||
|
// arrives there does nothing. Everything else after `{END}` still
|
||||||
|
// waits.
|
||||||
|
foreach (MessageAction action in after)
|
||||||
|
{
|
||||||
|
if (action.Command == MessageCommand.ReturnToReceive)
|
||||||
|
{
|
||||||
|
Run(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = RunWhenSentAsync(
|
||||||
|
keyer,
|
||||||
|
[.. after.Where(action => action.Command != MessageCommand.ReturnToReceive)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Waits for the keyer, turns the transmit light off, and runs whatever
|
/// Waits for the keyer, turns the transmit light off, and runs whatever
|
||||||
@@ -292,14 +332,30 @@ public sealed partial class EntryWindow
|
|||||||
Logging.Stack.Clear();
|
Logging.Stack.Clear();
|
||||||
ShowCallStack();
|
ShowCallStack();
|
||||||
break;
|
break;
|
||||||
// the digital engine keys itself while it has text to send, so
|
// {TX} keys the transmitter and {RX} drops it. {RX} waits until
|
||||||
// {TX} and {RX} only matter when a macro wants the transmitter
|
// everything waiting has gone out, so it ends the transmission
|
||||||
// held open around what it sends
|
// rather than cutting it off
|
||||||
case MessageCommand.StartTransmit:
|
case MessageCommand.StartTransmit:
|
||||||
|
// through the keyer, which keys the engine before it feeds it
|
||||||
|
// the message
|
||||||
|
if (session.DigitalKeyer is { } keying)
|
||||||
|
{
|
||||||
|
keying.Transmit();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
_ = session.Digital?.SetPttAsync(true);
|
_ = session.Digital?.SetPttAsync(true);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case MessageCommand.ReturnToReceive:
|
case MessageCommand.ReturnToReceive:
|
||||||
|
if (session.DigitalKeyer is { } digital)
|
||||||
|
{
|
||||||
|
digital.ReturnToReceiveWhenSent();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
_ = session.Digital?.ReturnToReceiveAsync();
|
_ = session.Digital?.ReturnToReceiveAsync();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,4 +28,18 @@ public interface DigitalEngine : IDisposable
|
|||||||
|
|
||||||
/// Drops whatever has not gone out yet, which is what Escape does.
|
/// Drops whatever has not gone out yet, which is what Escape does.
|
||||||
Task AbortAsync(CancellationToken cancellation = default);
|
Task AbortAsync(CancellationToken cancellation = default);
|
||||||
|
|
||||||
|
/// N1MM's `{TX}`: the transmitter comes up now, before there is anything
|
||||||
|
/// to send.
|
||||||
|
Task KeyAsync(CancellationToken cancellation = default);
|
||||||
|
|
||||||
|
/// N1MM's `{RX}`: the transmitter drops once the modem has transmitted what
|
||||||
|
/// it still holds.
|
||||||
|
Task ReturnToReceiveAsync(CancellationToken cancellation = default);
|
||||||
|
|
||||||
|
/// The key back down, without waiting for anything. MMTTY needs this: its
|
||||||
|
/// `SetMmttyPTT(1)` leaves the transmitter up whatever N1MM's source says,
|
||||||
|
/// so `{RX}` waits for the modem to say its buffer is empty and then puts
|
||||||
|
/// the key down here.
|
||||||
|
Task ReleaseKeyAsync(CancellationToken cancellation = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,71 @@ namespace Nonemm.Digital;
|
|||||||
///
|
///
|
||||||
/// A message goes into the type-ahead buffer rather than to the engine whole,
|
/// 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
|
/// so the part that has not gone out can still be rewritten. `Finished` is
|
||||||
/// raised when the buffer has run dry and the engine has transmitted what it
|
/// raised when the buffer has run dry, with the last characters of the message
|
||||||
/// was given.
|
/// in the engine and about a third of a second of it still to go out.
|
||||||
///
|
///
|
||||||
/// An engine that can say how much it still has to transmit paces the buffer;
|
/// The buffer is paced by the clock at `baud`, and the engine's own count of
|
||||||
/// the rest are paced by the clock at `baud`.
|
/// what it has left is a brake on it.
|
||||||
|
///
|
||||||
|
/// Three states, which is what the transmitter can be doing:
|
||||||
|
///
|
||||||
|
/// | State | Key | Gate | Engine |
|
||||||
|
/// |---|---|---|---|
|
||||||
|
/// | `Down` | down | shut | holds nothing |
|
||||||
|
/// | `Keyed` | up | open, the feeder paces text over | holds `Ahead` characters |
|
||||||
|
/// | `Ending` | up | shut, everything left was flushed in one piece | holds the rest of the message |
|
||||||
|
///
|
||||||
|
/// | From | Event | To |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | `Down` | `Transmit`, or a message to send | `Keyed` |
|
||||||
|
/// | `Keyed` | more text | `Keyed` |
|
||||||
|
/// | `Keyed` | `{RX}` | `Ending` |
|
||||||
|
/// | `Keyed` | the engine drops with the message unfinished | `Keyed`, keyed again |
|
||||||
|
/// | `Ending` | the engine drops | `Down` |
|
||||||
|
/// | `Ending` | the engine never drops | `Down`, the key put down here |
|
||||||
|
/// | `Ending` | `Transmit`, or a message to send | `Keyed`, once the engine is empty and its stop is cleared |
|
||||||
|
/// | any | `AbortAsync` | `Down` |
|
||||||
|
///
|
||||||
|
/// The last two rows are why there is a state at all rather than a flag.
|
||||||
|
/// Ending a message takes as long as the engine takes to transmit what it
|
||||||
|
/// holds, and the operator can key again inside that time. An ending that went
|
||||||
|
/// on running put the key down in the middle of the message after it.
|
||||||
|
///
|
||||||
|
/// Coming back out of `Ending` is not free either. The stop is inside the
|
||||||
|
/// engine by then, waiting for its buffer to empty, and MMTTY fed while that
|
||||||
|
/// stands stayed keyed and transmitted nothing: the characters went in and
|
||||||
|
/// never came out. So a new message waits for what the engine holds to go out,
|
||||||
|
/// clears the stop with an abort, and keys again.
|
||||||
public sealed class DigitalEngineSender : MessageSender
|
public sealed class DigitalEngineSender : MessageSender
|
||||||
{
|
{
|
||||||
|
/// How long the engine may make no progress at all, after it has been told
|
||||||
|
/// to stop, before the key goes down anyway. It is not a limit on the whole
|
||||||
|
/// wait: a flushed message is seconds of transmission and the engine is
|
||||||
|
/// entitled to all of it. Measured against the whole wait instead, it cut
|
||||||
|
/// a CQ off with 21 symbols still in the engine.
|
||||||
|
public static readonly TimeSpan StopPatience = TimeSpan.FromSeconds(1.5);
|
||||||
|
|
||||||
|
/// The longest the stop waits for the engine to say it has the message.
|
||||||
|
/// The stop does nothing at all if it arrives at an engine with an empty
|
||||||
|
/// buffer, so it goes out as soon as the count says there is something to
|
||||||
|
/// stop, and after this long whether the count says so or not. It is
|
||||||
|
/// N1MM's number, which N1MM sleeps outright.
|
||||||
|
public static readonly TimeSpan StopDelay = TimeSpan.FromMilliseconds(400);
|
||||||
|
|
||||||
private readonly DigitalEngine engine;
|
private readonly DigitalEngine engine;
|
||||||
|
|
||||||
|
private readonly Lock gate = new();
|
||||||
|
|
||||||
|
/// Where the transmitter is, as far as this program knows.
|
||||||
|
private Keying state = Keying.Down;
|
||||||
|
|
||||||
|
/// Which transmission this is. It goes up whenever one starts or is
|
||||||
|
/// abandoned, and the ending of a message checks it after every step: an
|
||||||
|
/// ending belongs to one transmission and must not act on the next one.
|
||||||
|
/// Without it the ending of one message put the key down in the middle of
|
||||||
|
/// the message after it.
|
||||||
|
private int transmission;
|
||||||
|
|
||||||
public DigitalEngineSender(DigitalEngine engine, double baud = TypeAhead.DefaultBaud)
|
public DigitalEngineSender(DigitalEngine engine, double baud = TypeAhead.DefaultBaud)
|
||||||
{
|
{
|
||||||
this.engine = engine;
|
this.engine = engine;
|
||||||
@@ -25,7 +81,7 @@ public sealed class DigitalEngineSender : MessageSender
|
|||||||
: new TypeAhead(
|
: new TypeAhead(
|
||||||
(character, cancellation) => engine.SendAsync(character.ToString(), cancellation),
|
(character, cancellation) => engine.SendAsync(character.ToString(), cancellation),
|
||||||
baud);
|
baud);
|
||||||
Buffer.Drained += WhenDrained;
|
Buffer.Given += WhenGiven;
|
||||||
engine.TransmitChanged += WhenTransmitChanged;
|
engine.TransmitChanged += WhenTransmitChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,16 +96,157 @@ public sealed class DigitalEngineSender : MessageSender
|
|||||||
|
|
||||||
public event EventHandler? Finished;
|
public event EventHandler? Finished;
|
||||||
|
|
||||||
public Task SendAsync(string text, CancellationToken cancellation = default)
|
/// A message to send. Text is the operator asking for the transmitter, so
|
||||||
|
/// it keys as well: text arriving while the last message was ending
|
||||||
|
/// abandons that ending, and text arriving with the transmitter down brings
|
||||||
|
/// it up rather than letting MMTTY key itself off the first character.
|
||||||
|
public async Task SendAsync(string text, CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
|
await StartAsync(cancellation).ConfigureAwait(false);
|
||||||
Buffer.Append(text);
|
Buffer.Append(text);
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Escape and the RX button: what has not gone to the engine is dropped,
|
private async Task TransmitAsync()
|
||||||
/// and the engine drops what it still holds.
|
{
|
||||||
|
await StartAsync().ConfigureAwait(false);
|
||||||
|
Buffer.Transmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The TX button, Ctrl+Enter and `{TX}`: the transmitter comes up and what
|
||||||
|
/// is in the pane goes out, and so does whatever is typed into it from now
|
||||||
|
/// on.
|
||||||
|
///
|
||||||
|
/// Keying belongs here rather than in the window so that it is always the
|
||||||
|
/// first thing the engine is told. MMTTY keys itself off a character it is
|
||||||
|
/// given while the transmitter is down, sends it and drops again, so a key
|
||||||
|
/// that arrives behind the text puts a keyed-up gap in the middle of a
|
||||||
|
/// message.
|
||||||
|
public void Transmit() => _ = TransmitAsync();
|
||||||
|
|
||||||
|
/// N1MM's `{RX}`: everything still waiting goes to the engine in one piece
|
||||||
|
/// and the engine is then asked to stop. A macro that ends with it goes out
|
||||||
|
/// in full, and so does anything the operator has typed ahead of it.
|
||||||
|
///
|
||||||
|
/// It does not wait for the feeder to hand the message over first. Waiting
|
||||||
|
/// is what made the stop useless: by the time the last character had gone
|
||||||
|
/// over, the engine was empty again, and MMTTY ignores a stop that reaches
|
||||||
|
/// it empty.
|
||||||
|
public void ReturnToReceiveWhenSent() => _ = StopAsync();
|
||||||
|
|
||||||
|
/// A transmission begins, or the one that was ending goes on. True when the
|
||||||
|
/// engine has to be keyed, which is every time but one already keyed and
|
||||||
|
/// still going.
|
||||||
|
private async Task StartAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
bool ending;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
ending = state == Keying.Ending;
|
||||||
|
}
|
||||||
|
if (ending)
|
||||||
|
{
|
||||||
|
// the last message is ending and its stop is inside the engine,
|
||||||
|
// waiting for the buffer to empty. Feeding an engine with that
|
||||||
|
// standing left MMTTY keyed with nothing going out and the
|
||||||
|
// characters swallowed, so what it still holds is let out and the
|
||||||
|
// stop is cleared with an abort before it is keyed again
|
||||||
|
await WaitUntilAiredAsync(StopPatience).ConfigureAwait(false);
|
||||||
|
await engine.AbortAsync(cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
bool key;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
key = state != Keying.Keyed;
|
||||||
|
if (key)
|
||||||
|
{
|
||||||
|
transmission++;
|
||||||
|
}
|
||||||
|
state = Keying.Keyed;
|
||||||
|
}
|
||||||
|
if (key)
|
||||||
|
{
|
||||||
|
// the pane starts again on the message that has gone out. The
|
||||||
|
// engine reporting the drop does this too, but it never reports one
|
||||||
|
// when a message follows the last close enough to keep the
|
||||||
|
// transmitter up, and the pane then held the whole run with none of
|
||||||
|
// it editable
|
||||||
|
Buffer.Started();
|
||||||
|
await engine.KeyAsync(cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits until the engine says it holds something, or `patience` runs out.
|
||||||
|
/// The stop does nothing at an engine with an empty buffer, so it goes out
|
||||||
|
/// as soon as there is something to stop. N1MM sleeps 400 ms here instead,
|
||||||
|
/// which is the same wait without the question: it never reads the count.
|
||||||
|
/// A sleep is also wrong on a short message — `TU` is 330 ms of air at
|
||||||
|
/// 45.45 baud, so 400 ms of it puts the stop back where it does nothing.
|
||||||
|
private async Task WaitUntilHoldingAsync(TimeSpan patience)
|
||||||
|
{
|
||||||
|
DateTime giveUp = DateTime.UtcNow + patience;
|
||||||
|
while (Buffer.EngineHolds <= 0 && DateTime.UtcNow < giveUp)
|
||||||
|
{
|
||||||
|
await Task.Delay(TypeAhead.PollInterval).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits until the engine has transmitted everything it was given, or until
|
||||||
|
/// it has made no progress for `patience`.
|
||||||
|
///
|
||||||
|
/// `patience` is not a limit on the whole wait: a flushed message is
|
||||||
|
/// seconds of transmission and the engine is entitled to all of it. What it
|
||||||
|
/// catches is an engine that has stopped moving. Measured against the whole
|
||||||
|
/// wait, it cut a CQ off with 21 symbols still in the engine.
|
||||||
|
private async Task WaitUntilAiredAsync(TimeSpan patience)
|
||||||
|
{
|
||||||
|
if (Buffer.Outstanding <= 0 && Buffer.EngineHolds <= 0 && !Buffer.IsSending)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TaskCompletionSource aired = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
void WhenAired(object? sender, EventArgs e) => aired.TrySetResult();
|
||||||
|
Buffer.Aired += WhenAired;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int holds = Buffer.EngineHolds;
|
||||||
|
int outstanding = Buffer.Outstanding;
|
||||||
|
DateTime giveUp = DateTime.UtcNow + patience;
|
||||||
|
while (!aired.Task.IsCompleted && DateTime.UtcNow < giveUp)
|
||||||
|
{
|
||||||
|
await Task.Delay(TypeAhead.PollInterval).ConfigureAwait(false);
|
||||||
|
if (Buffer.EngineHolds < holds || Buffer.Outstanding < outstanding)
|
||||||
|
{
|
||||||
|
holds = Buffer.EngineHolds;
|
||||||
|
outstanding = Buffer.Outstanding;
|
||||||
|
giveUp = DateTime.UtcNow + patience;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Buffer.Aired -= WhenAired;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True while `mine` is still the transmission being ended. False once
|
||||||
|
/// something has started another one, ended this one, or aborted.
|
||||||
|
private bool Ending(int mine)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return state == Keying.Ending && transmission == mine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape: what has not gone to the engine is dropped, and the engine drops
|
||||||
|
/// what it still holds.
|
||||||
public Task AbortAsync(CancellationToken cancellation = default)
|
public Task AbortAsync(CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
state = Keying.Down;
|
||||||
|
transmission++;
|
||||||
|
}
|
||||||
Buffer.Drop();
|
Buffer.Drop();
|
||||||
return engine.AbortAsync(cancellation);
|
return engine.AbortAsync(cancellation);
|
||||||
}
|
}
|
||||||
@@ -61,26 +258,134 @@ public sealed class DigitalEngineSender : MessageSender
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
Buffer.Drained -= WhenDrained;
|
Buffer.Given -= WhenGiven;
|
||||||
Buffer.Dispose();
|
Buffer.Dispose();
|
||||||
engine.TransmitChanged -= WhenTransmitChanged;
|
engine.TransmitChanged -= WhenTransmitChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WhenDrained(object? sender, EventArgs e) => Finished?.Invoke(this, EventArgs.Empty);
|
/// The message is finished when the last character has been handed to the
|
||||||
|
/// engine, not when the engine has transmitted it. What stands after
|
||||||
|
/// `{END}` runs there, `{RX}` among it, and `{RX}` has to reach the engine
|
||||||
|
/// while the engine still holds something to send.
|
||||||
|
private void WhenGiven(object? sender, EventArgs e) => Finished?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
/// The engine dropping the transmitter ends the message, but only when
|
/// Tells the engine to stop, waits for it to transmit what it still holds,
|
||||||
/// there is nothing left to send: an engine that keys itself off what it is
|
/// and puts the key down.
|
||||||
/// given drops between two characters of a message the pump is still
|
///
|
||||||
/// feeding, and that is not the end of anything.
|
/// 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)
|
private void WhenTransmitChanged(object? sender, bool transmitting)
|
||||||
{
|
{
|
||||||
if (transmitting)
|
if (transmitting)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
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)
|
if (!Buffer.IsSending)
|
||||||
{
|
{
|
||||||
Finished?.Invoke(this, EventArgs.Empty);
|
Finished?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the transmitter is. `Ending` is one message: everything left of it
|
||||||
|
/// has gone to the engine and nothing more is fed, and the engine is
|
||||||
|
/// transmitting what it holds.
|
||||||
|
private enum Keying
|
||||||
|
{
|
||||||
|
Down,
|
||||||
|
Keyed,
|
||||||
|
Ending,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ namespace Nonemm.Digital;
|
|||||||
/// N1MM's: the engine is given a title, the PTT port out of Mmtty.INI and a
|
/// N1MM's: the engine is given a title, the PTT port out of Mmtty.INI and a
|
||||||
/// command line, and an engine that fails to initialise is started again up to
|
/// command line, and an engine that fails to initialise is started again up to
|
||||||
/// ten times, which is what N1MM's retry does.
|
/// ten times, which is what N1MM's retry does.
|
||||||
public sealed class MmttyEngine : DigitalEngine
|
///
|
||||||
|
/// It holds a buffer of its own and says how much of it is left, so the
|
||||||
|
/// type-ahead feeds it one character at a time the way N1MM does and paces on
|
||||||
|
/// the count.
|
||||||
|
public sealed class MmttyEngine : DigitalEngine, EngineBuffer
|
||||||
{
|
{
|
||||||
private const int StartAttempts = 10;
|
private const int StartAttempts = 10;
|
||||||
|
|
||||||
@@ -91,6 +95,12 @@ public sealed class MmttyEngine : DigitalEngine
|
|||||||
public Task AbortAsync(CancellationToken cancellation = default) =>
|
public Task AbortAsync(CancellationToken cancellation = default) =>
|
||||||
bridge.SendAsync(BridgeLine.Write("ptt", "0"), cancellation);
|
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;
|
/// N1MM's `{TX}` and `{RX}`. Keying is the control's `PTT` property;
|
||||||
/// unkeying is `SetMmttyPTT(1)`, which waits for the buffer to empty first,
|
/// unkeying is `SetMmttyPTT(1)`, which waits for the buffer to empty first,
|
||||||
/// so a macro that ends with `{RX}` still goes out in full.
|
/// so a macro that ends with `{RX}` still goes out in full.
|
||||||
@@ -99,6 +109,9 @@ public sealed class MmttyEngine : DigitalEngine
|
|||||||
on ? BridgeLine.Write("key", "1") : BridgeLine.Write("ptt", "1"),
|
on ? BridgeLine.Write("key", "1") : BridgeLine.Write("ptt", "1"),
|
||||||
cancellation);
|
cancellation);
|
||||||
|
|
||||||
|
public Task KeyAsync(CancellationToken cancellation = default) =>
|
||||||
|
SetPttAsync(true, cancellation);
|
||||||
|
|
||||||
public Task ReturnToReceiveAsync(CancellationToken cancellation = default) =>
|
public Task ReturnToReceiveAsync(CancellationToken cancellation = default) =>
|
||||||
SetPttAsync(false, cancellation);
|
SetPttAsync(false, cancellation);
|
||||||
|
|
||||||
|
|||||||
@@ -5,90 +5,154 @@ namespace Nonemm.Digital;
|
|||||||
/// The text waiting to go out, held here rather than handed to the engine in
|
/// The text waiting to go out, held here rather than handed to the engine in
|
||||||
/// one piece.
|
/// one piece.
|
||||||
///
|
///
|
||||||
/// A digital engine takes a whole message and transmits it at the baud rate,
|
/// A digital engine transmits at the baud rate, which is slow: a callsign and a
|
||||||
/// which is slow: a callsign and a report take several seconds. Once the engine
|
/// report take several seconds. Once the engine has a character nothing can
|
||||||
/// has a character nothing can take it back — MMTTY treats a backspace as
|
/// take it back — MMTTY treats a backspace as another character to transmit
|
||||||
/// another character to transmit rather than as an edit, which the engine probe
|
/// rather than as an edit, which the engine probe showed on the air. So the
|
||||||
/// showed on the air. So the message is held here and the engine is given
|
/// message is held here and the engine is kept just short of running dry, and
|
||||||
/// `Lead` characters at a time: one being transmitted and one behind it, so it
|
/// everything behind that can still be rewritten, added to or deleted.
|
||||||
/// never runs dry and never transmits idle in the middle of a message, while
|
|
||||||
/// everything further back can still be rewritten, added to or deleted.
|
|
||||||
///
|
///
|
||||||
/// `Sent` is what has gone to the engine and cannot be taken back. `Pending` is
|
/// Two things happen to a character and they are not the same: it is **given**
|
||||||
/// what is still to go. `Cursor` is how much of the pending text may go out,
|
/// to the engine, and later it **goes out** on the air. Everything here is one
|
||||||
/// which is where the operator is typing: the pump stops when it reaches the
|
/// or the other.
|
||||||
/// cursor, because the operator has not finished the word yet.
|
|
||||||
///
|
///
|
||||||
/// The pump is paced by the clock at the baud rate, and the engine's own count
|
/// | Given to the engine | Gone out on the air |
|
||||||
/// of what it has left is a check on it rather than the pace. MMTTY answers
|
/// |---|---|
|
||||||
/// `TxBufLen` with the number of Baudot symbols it still has to transmit, which
|
/// | `Sent`, and it cannot be taken back | `OnAir`, how much of `Sent` has been transmitted |
|
||||||
/// is more than the characters handed to it: a digit costs a shift to figures
|
/// | `Outstanding`, given but not out yet | |
|
||||||
/// and a shift back, so `599 001` is seven characters and eleven symbols. The
|
/// | `Given` is raised when there is nothing left to give | `Aired` is raised when the engine has transmitted it too |
|
||||||
/// answer also lags the engine by about a character time. A pump that believed
|
|
||||||
/// a fresh 0 would hand over a whole message in half a second and put all of it
|
|
||||||
/// beyond reach, so the count is used two ways only:
|
|
||||||
///
|
///
|
||||||
/// - as a cap. The engine holding more than `Lead + Slack` characters stops the
|
/// `Pending` is the rest: what has not been given to the engine and can still
|
||||||
/// pump until it comes down, whatever the clock thinks.
|
/// be rewritten. `EngineHolds` is the engine's own answer to the same question
|
||||||
/// - as the end of a message, together with the clock estimate.
|
/// as `Outstanding`, in Baudot symbols rather than characters.
|
||||||
///
|
///
|
||||||
/// A count that stops going down means the engine is holding what it has:
|
/// Nothing goes out until `Transmit`, which is the TX button, Ctrl+Enter or a
|
||||||
/// MMTTY set to Word out keeps a word until the space after it arrives. The
|
/// function key. From then on everything in `Pending` goes out, and so does
|
||||||
/// pump feeds a character anyway while that lasts, so the space gets there and
|
/// anything added to it: the operator can go on typing and can press a function
|
||||||
/// the word goes out.
|
/// key, and both follow what is already going. `Ended` shuts the gate again
|
||||||
|
/// without dropping what is waiting, so text typed ahead survives the end of a
|
||||||
|
/// message.
|
||||||
|
///
|
||||||
|
/// The pace is the clock at the baud rate: one character every
|
||||||
|
/// `SymbolTime`, with the engine kept `Ahead` characters ahead so it never
|
||||||
|
/// runs dry and transmits the idle tone in the middle of a word.
|
||||||
|
/// RTTY runs at a fixed speed, so the clock is right, and the
|
||||||
|
/// engine's own count of what it has left cannot replace it. The engine probe
|
||||||
|
/// shows why: `TxBufLen` read 0 for the first 150 ms after twenty-one
|
||||||
|
/// characters were pushed, and read 0 again for three seconds while the engine
|
||||||
|
/// was still holding `ABCD` on Word out. The count is only to be believed while
|
||||||
|
/// it is large and going down, and the feeder keeps the engine nearly empty,
|
||||||
|
/// so a feeder that paced on the count fed on a reading of 0 and ran ahead of
|
||||||
|
/// the air.
|
||||||
|
///
|
||||||
|
/// The count is a brake instead. `Slack` is how many Baudot symbols the engine
|
||||||
|
/// may be behind — MMTTY answers in symbols, which is more than the characters
|
||||||
|
/// it was given, because a digit costs a shift to figures and the letter after
|
||||||
|
/// it a shift back. An exchange full of digits is slower than the clock thinks,
|
||||||
|
/// and this is what stops the clock running away on it.
|
||||||
|
///
|
||||||
|
/// A count that is not going down means the engine is holding what it has:
|
||||||
|
/// MMTTY on Word out keeps a word until the space after it arrives, and that
|
||||||
|
/// space is the character the brake would hold back. So the brake lets go after
|
||||||
|
/// `HoldingPatience`.
|
||||||
public sealed class TypeAhead : IDisposable
|
public sealed class TypeAhead : IDisposable
|
||||||
{
|
{
|
||||||
/// A RTTY character is a start bit, five data bits and a stop bit and a
|
/// A Baudot symbol is a start bit, five data bits and a stop bit and a
|
||||||
/// half.
|
/// half. A character is one symbol, or two when it needs a shift first.
|
||||||
public const double BitsPerCharacter = 7.5;
|
public const double BitsPerSymbol = 7.5;
|
||||||
|
|
||||||
/// MMTTY's own default, and the speed nearly every RTTY contest runs at.
|
/// MMTTY's own default, and the speed nearly every RTTY contest runs at.
|
||||||
public const double DefaultBaud = 45.45;
|
public const double DefaultBaud = 45.45;
|
||||||
|
|
||||||
/// How many characters may sit in the engine. Two is the smallest number
|
/// How many characters the engine is kept ahead by. Two is the smallest
|
||||||
/// that keeps the engine transmitting without a gap: one on the air and one
|
/// number that keeps it transmitting without a gap: one on the air and one
|
||||||
/// behind it. At 45.45 baud that puts the last third of a second of the
|
/// in hand for when that one finishes. An engine left with an empty buffer
|
||||||
/// message beyond reach.
|
/// transmits the idle tone instead, which is audible between the characters
|
||||||
public const int DefaultLead = 2;
|
/// of a long word. At 45.45 baud this puts the last third of a second of
|
||||||
|
/// the message beyond reach.
|
||||||
|
public const int DefaultAhead = 2;
|
||||||
|
|
||||||
|
/// How many Baudot symbols the engine may be behind the clock before the
|
||||||
|
/// feeder waits for it. Six is about a second at 45.45 baud, and well above
|
||||||
|
/// what `Ahead` characters of ordinary text come to, so the brake only
|
||||||
|
/// bites on a run of digits, which costs more symbols than the clock
|
||||||
|
/// thinks.
|
||||||
|
public const int DefaultSlack = 6;
|
||||||
|
|
||||||
/// How much of the text that has gone out is kept. It is there to be read
|
/// How much of the text that has gone out is kept. It is there to be read
|
||||||
/// back, not to be a log.
|
/// back, not to be a log.
|
||||||
public const int KeptSent = 2000;
|
public const int KeptSent = 2000;
|
||||||
|
|
||||||
/// `Cursor` set to this lets everything pending go out, which is where it
|
/// How far behind the engine's count runs. The probe pushed twenty-one
|
||||||
/// stands while the operator is not typing into the pane.
|
/// characters and read 0 for three answers, 100 to 150 ms, before the count
|
||||||
public const int NoCursor = int.MaxValue;
|
/// caught up with them. A 0 newer than this says nothing.
|
||||||
|
public static readonly TimeSpan CountLag = TimeSpan.FromMilliseconds(250);
|
||||||
|
|
||||||
/// How much the engine may hold above `Lead` before the pump stops feeding.
|
/// How often the engine is asked how much it has left, and how closely the
|
||||||
/// It covers the count's lag, measured at 100 to 150 ms, and the shift
|
/// feeder follows its own clock. It is well under a character time at any
|
||||||
/// symbols the engine has to transmit that the clock does not know about.
|
/// speed RTTY is worked at.
|
||||||
public const int Slack = 3;
|
|
||||||
|
|
||||||
/// How often the engine is asked how much it has left.
|
|
||||||
public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
|
public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
|
||||||
|
|
||||||
/// The longest the clock-paced pump sleeps between looks at the buffer. It
|
|
||||||
/// is what stands between the operator moving the cursor on and the next
|
|
||||||
/// character going out, so it is short against a character time.
|
|
||||||
private static readonly TimeSpan LongestTick = TimeSpan.FromMilliseconds(10);
|
|
||||||
|
|
||||||
private readonly Func<char, CancellationToken, Task> send;
|
private readonly Func<char, CancellationToken, Task> send;
|
||||||
private readonly EngineBuffer? counter;
|
private readonly EngineBuffer? counter;
|
||||||
private readonly Lock gate = new();
|
private readonly Lock gate = new();
|
||||||
|
|
||||||
|
/// Held while a character or a flush is on its way to the engine, so the
|
||||||
|
/// two cannot cross. Without it a flush overtook the character the feeder
|
||||||
|
/// had already taken and was still sending, and that character went out
|
||||||
|
/// behind the rest of the message.
|
||||||
|
private readonly SemaphoreSlim handing = new(1, 1);
|
||||||
private readonly StringBuilder pending = new();
|
private readonly StringBuilder pending = new();
|
||||||
private readonly StringBuilder sent = new();
|
private readonly StringBuilder sent = new();
|
||||||
|
|
||||||
private CancellationTokenSource? stopping;
|
private CancellationTokenSource? stopping;
|
||||||
private Task pump = Task.CompletedTask;
|
|
||||||
private int cursor = NoCursor;
|
|
||||||
|
|
||||||
/// The engine's last answer: how many characters it still had to transmit,
|
/// True once `Transmit` has been called and until the message ends: the
|
||||||
/// or -1 before it has answered at all.
|
/// 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;
|
private int counted = -1;
|
||||||
|
|
||||||
/// How many answers have arrived, so the pump can tell a new one from the
|
/// How many characters at the front of `sent` the engine has transmitted.
|
||||||
/// one it has already added its own characters to.
|
private int aired;
|
||||||
private int answers;
|
|
||||||
|
/// The count the engine last answered, and how many symbols it has been
|
||||||
|
/// seen to drop that have not been charged to a character yet. Together
|
||||||
|
/// they are the air's own rate: a count that falls by four means four
|
||||||
|
/// symbols left the engine.
|
||||||
|
private int lastCount = -1;
|
||||||
|
private int budget;
|
||||||
|
|
||||||
|
/// When the engine was last given a character. Its count reads 0 for the
|
||||||
|
/// first 150 ms after a push, so a 0 within `CountLag` of one is the answer
|
||||||
|
/// not having caught up rather than an empty engine.
|
||||||
|
private DateTime gaveAt = DateTime.MinValue;
|
||||||
|
|
||||||
|
/// How many characters the engine holds that have not gone out yet, and
|
||||||
|
/// when the one it is transmitting now is finished. The engine transmits at
|
||||||
|
/// the baud rate, so what it holds goes out one character time apart. The
|
||||||
|
/// feeder keeps these, and a flush adds to them in one go.
|
||||||
|
///
|
||||||
|
/// They outlive the feeder, which starts and stops with the text: a feeder
|
||||||
|
/// that started after a flush and reset them forgot a whole message the
|
||||||
|
/// engine was still holding, and the key went down in the middle of it.
|
||||||
|
private int outstanding;
|
||||||
|
private DateTime nextOut = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// Whether the engine is in the figures shift, on the air and at the front
|
||||||
|
/// of the feeder. The two run apart: the feeder is `Ahead` characters in
|
||||||
|
/// front of what is being transmitted.
|
||||||
|
private bool airShift;
|
||||||
|
private bool feedShift;
|
||||||
|
|
||||||
public TypeAhead(Func<char, CancellationToken, Task> send, double baud = DefaultBaud)
|
public TypeAhead(Func<char, CancellationToken, Task> send, double baud = DefaultBaud)
|
||||||
{
|
{
|
||||||
@@ -97,7 +161,7 @@ public sealed class TypeAhead : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// An engine that holds a buffer of its own and can say how much of it is
|
/// An engine that holds a buffer of its own and can say how much of it is
|
||||||
/// left, which is what the pump checks the clock against.
|
/// left, which is what paces the feeder.
|
||||||
public TypeAhead(EngineBuffer engine, double baud = DefaultBaud)
|
public TypeAhead(EngineBuffer engine, double baud = DefaultBaud)
|
||||||
: this((character, cancellation) => engine.TypeAsync(character, cancellation), baud)
|
: this((character, cancellation) => engine.TypeAsync(character, cancellation), baud)
|
||||||
{
|
{
|
||||||
@@ -105,23 +169,27 @@ public sealed class TypeAhead : IDisposable
|
|||||||
engine.Buffered += WhenBuffered;
|
engine.Buffered += WhenBuffered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The speed the engine transmits at, which is what paces the pump.
|
/// The speed the engine transmits at, which is what paces the feeder.
|
||||||
public double Baud { get; set; }
|
public double Baud { get; set; }
|
||||||
|
|
||||||
/// How many characters may sit in the engine at once.
|
/// How many characters the engine is kept ahead by.
|
||||||
public int Lead { get; set; } = DefaultLead;
|
public int Ahead { get; set; } = DefaultAhead;
|
||||||
|
|
||||||
/// True once the engine has said how much it holds. An engine that will not
|
/// How many symbols the engine may be behind before the feeder waits.
|
||||||
/// say is paced by the clock alone.
|
public int Slack { get; set; } = DefaultSlack;
|
||||||
|
|
||||||
|
/// True once the engine has said how much it holds.
|
||||||
public bool Counts { get; private set; }
|
public bool Counts { get; private set; }
|
||||||
|
|
||||||
public TimeSpan CharacterTime =>
|
/// How long one Baudot symbol takes on the air. A character takes one of
|
||||||
TimeSpan.FromSeconds(BitsPerCharacter / (Baud > 0 ? Baud : DefaultBaud));
|
/// 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 engine may hold what it has before the pump feeds it
|
/// How long the brake may hold before it lets go. Three character times is
|
||||||
/// anyway. Three characters is longer than any gap between transmitted
|
/// longer than any gap between transmitted characters and short enough that
|
||||||
/// characters and short enough that a held word goes out at once.
|
/// a word the engine is holding goes out at once.
|
||||||
public TimeSpan HoldingPatience => CharacterTime * 3;
|
public TimeSpan HoldingPatience => SymbolTime * 3;
|
||||||
|
|
||||||
/// What has gone to the engine.
|
/// What has gone to the engine.
|
||||||
public string Sent
|
public string Sent
|
||||||
@@ -135,6 +203,30 @@ public sealed class TypeAhead : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many characters of `Sent` have gone out over the air.
|
||||||
|
///
|
||||||
|
/// The engine is kept `Ahead` characters ahead, so the last characters
|
||||||
|
/// handed to it are still in its buffer. They cannot be taken back, but
|
||||||
|
/// they have not been heard yet, so the pane draws them as text still to
|
||||||
|
/// go. Without this, a character typed after the engine has caught up went
|
||||||
|
/// red as it was typed: the feeder hands it over at once, and the buffer
|
||||||
|
/// had no other measure of the air.
|
||||||
|
///
|
||||||
|
/// It is counted on the same clock as the feeder: the engine transmits at
|
||||||
|
/// the baud rate, so a character handed to an engine that is already
|
||||||
|
/// transmitting goes out one character time after the one before it, and a
|
||||||
|
/// character handed to an empty engine one character time from now.
|
||||||
|
public int OnAir
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return Math.Min(aired, sent.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What is still to go.
|
/// What is still to go.
|
||||||
public string Pending
|
public string Pending
|
||||||
{
|
{
|
||||||
@@ -147,22 +239,14 @@ public sealed class TypeAhead : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many of the pending characters may go out. The window sets it to
|
/// True while what is waiting is being fed to the engine.
|
||||||
/// where the operator's cursor is; `NoCursor` while nobody is typing.
|
public bool IsTransmitting
|
||||||
public int Cursor
|
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
return cursor;
|
return open;
|
||||||
}
|
|
||||||
}
|
|
||||||
set
|
|
||||||
{
|
|
||||||
lock (gate)
|
|
||||||
{
|
|
||||||
cursor = Math.Max(0, value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,18 +263,26 @@ public sealed class TypeAhead : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The text moved: a character went out, or a message was added. Raised on
|
/// The text moved: a character went out, or a message was added. Raised on
|
||||||
/// the pump's thread, so a handler that touches the screen has to post.
|
/// the feeder's thread, so a handler that touches the screen has to post.
|
||||||
public event EventHandler? Changed;
|
public event EventHandler? Changed;
|
||||||
|
|
||||||
|
/// Everything that was waiting has 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
|
/// Everything that was waiting has gone out and the engine has transmitted
|
||||||
/// it. This is what tells the entry window that a message is finished, so
|
/// it, which is `Ahead` characters later than `Given`.
|
||||||
/// what stands after `{END}` runs and `{RX}` drops the transmitter at the
|
public event EventHandler? Aired;
|
||||||
/// right moment.
|
|
||||||
public event EventHandler? Drained;
|
|
||||||
|
|
||||||
/// A message to send. It goes on the end of what is already waiting, so two
|
/// A message to send. It goes on the end of what is already waiting, so two
|
||||||
/// function keys pressed together send one after the other rather than one
|
/// function keys pressed together send one after the other rather than one
|
||||||
/// over the other.
|
/// over the other, and a function key pressed while the operator is typing
|
||||||
|
/// follows what has been typed.
|
||||||
|
///
|
||||||
|
/// A function key is the operator asking for the message, so it also opens
|
||||||
|
/// the gate: there is no second key to press.
|
||||||
public void Append(string text)
|
public void Append(string text)
|
||||||
{
|
{
|
||||||
if (text.Length == 0)
|
if (text.Length == 0)
|
||||||
@@ -200,38 +292,167 @@ public sealed class TypeAhead : IDisposable
|
|||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
pending.Append(text);
|
pending.Append(text);
|
||||||
if (cursor != NoCursor)
|
open = true;
|
||||||
{
|
given = false;
|
||||||
// text added behind the operator's cursor is still text to
|
|
||||||
// send, so the cursor moves out with it
|
|
||||||
cursor += text.Length;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
Start();
|
Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The operator rewrote what has not gone out yet.
|
/// The operator rewrote the pane. `text` is the whole pane, what has gone
|
||||||
public void Rewrite(string text, int wanted)
|
/// out and what is still to go, and `changedAt` is where in it the first
|
||||||
|
/// changed character is.
|
||||||
|
///
|
||||||
|
/// The text that has gone out cannot be changed, so as much of `text` as
|
||||||
|
/// matches it is dropped and the rest becomes the pending text. Reading the
|
||||||
|
/// whole pane rather than the pending half is what makes this safe against
|
||||||
|
/// the feeder: a character the feeder took between the window reading the
|
||||||
|
/// pane and this call is still at the front of `text`, and is dropped with
|
||||||
|
/// the rest of what has gone out.
|
||||||
|
///
|
||||||
|
/// An edit does not open or close the gate. Typing before the transmitter
|
||||||
|
/// is keyed stays off the air; typing while it is keyed goes out behind
|
||||||
|
/// what is already going.
|
||||||
|
public void Edit(string text)
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
|
int gone = Math.Min(sent.Length, text.Length);
|
||||||
pending.Clear();
|
pending.Clear();
|
||||||
pending.Append(text);
|
pending.Append(text, gone, text.Length - gone);
|
||||||
cursor = Math.Clamp(wanted, 0, text.Length);
|
given = given && pending.Length == 0;
|
||||||
}
|
}
|
||||||
Start();
|
Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops what has not gone out. Escape and the RX button do this: what is
|
/// The TX button, Ctrl+Enter and Alt+T: what is in the pane goes out, and
|
||||||
/// already in the engine cannot be stopped from here, and the engine's own
|
/// so does whatever is added to it, until the transmitter drops.
|
||||||
/// abort takes care of that.
|
public void Transmit()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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()
|
public void Drop()
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
pending.Clear();
|
pending.Clear();
|
||||||
cursor = NoCursor;
|
open = false;
|
||||||
|
// the engine's own abort goes with this, so it holds nothing either
|
||||||
|
outstanding = 0;
|
||||||
}
|
}
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
@@ -244,7 +465,8 @@ public sealed class TypeAhead : IDisposable
|
|||||||
{
|
{
|
||||||
pending.Clear();
|
pending.Clear();
|
||||||
sent.Clear();
|
sent.Clear();
|
||||||
cursor = NoCursor;
|
aired = 0;
|
||||||
|
open = false;
|
||||||
}
|
}
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
@@ -258,101 +480,291 @@ public sealed class TypeAhead : IDisposable
|
|||||||
stopping?.Cancel();
|
stopping?.Cancel();
|
||||||
stopping?.Dispose();
|
stopping?.Dispose();
|
||||||
stopping = null;
|
stopping = null;
|
||||||
|
handing.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Start()
|
private void Start()
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
if (!pump.IsCompleted)
|
if (!idle)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
idle = false;
|
||||||
stopping?.Dispose();
|
stopping?.Dispose();
|
||||||
stopping = new CancellationTokenSource();
|
stopping = new CancellationTokenSource();
|
||||||
pump = Task.Run(() => RunAsync(stopping.Token));
|
_ = Task.Run(() => RunAsync(stopping.Token));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Feeds the engine on the clock, holds off when its own count says it is
|
/// Hands the engine a character every character time, keeping it `Ahead`
|
||||||
/// further behind than that, and stops when both agree there is nothing
|
/// characters ahead of the air, unless its own count says it is more than
|
||||||
/// left.
|
/// `Slack` symbols behind and still moving.
|
||||||
private async Task RunAsync(CancellationToken cancellation)
|
private async Task RunAsync(CancellationToken cancellation)
|
||||||
{
|
{
|
||||||
int inEngine = 0;
|
// when the next character is owed. It advances by exactly one character
|
||||||
int last = int.MaxValue;
|
// time per character handed over, never from the time the poll happened:
|
||||||
int fedSince = 0;
|
// a poll is up to `PollInterval` late, and starting the next character
|
||||||
int seen = -1;
|
// from there made every one late by a little and the engine run dry
|
||||||
DateTime nextOut = DateTime.MinValue;
|
DateTime due = DateTime.MinValue;
|
||||||
DateTime lastAsked = DateTime.MinValue;
|
|
||||||
DateTime moved = DateTime.UtcNow;
|
DateTime moved = DateTime.UtcNow;
|
||||||
|
int last = int.MaxValue;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!cancellation.IsCancellationRequested)
|
while (!cancellation.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
DateTime now = DateTime.UtcNow;
|
if (counter is not null)
|
||||||
while (inEngine > 0 && now >= nextOut)
|
|
||||||
{
|
{
|
||||||
inEngine--;
|
|
||||||
nextOut += CharacterTime;
|
|
||||||
}
|
|
||||||
if (counter is not null && now - lastAsked >= PollInterval)
|
|
||||||
{
|
|
||||||
lastAsked = now;
|
|
||||||
await counter.AskBufferedAsync("", cancellation).ConfigureAwait(false);
|
await counter.AskBufferedAsync("", cancellation).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
int answered = Volatile.Read(ref answers);
|
DateTime now = DateTime.UtcNow;
|
||||||
if (answered != seen)
|
|
||||||
{
|
|
||||||
seen = answered;
|
|
||||||
fedSince = 0;
|
|
||||||
}
|
|
||||||
int left = Volatile.Read(ref counted);
|
int left = Volatile.Read(ref counted);
|
||||||
if (left < last)
|
if (left < last)
|
||||||
{
|
{
|
||||||
moved = now;
|
moved = now;
|
||||||
}
|
}
|
||||||
last = left;
|
last = left;
|
||||||
// the answer says nothing about the characters fed since it
|
bool wentOut = Counts ? WentOutByCount(left, now) : WentOutByClock(now);
|
||||||
int held = left < 0 ? -1 : left + fedSince;
|
if (wentOut)
|
||||||
bool holding = left > 0 && now - moved > HoldingPatience;
|
|
||||||
bool room = holding
|
|
||||||
|| (inEngine < Lead && (held < 0 || held < Lead + Slack));
|
|
||||||
if (room && Take() is { } next)
|
|
||||||
{
|
{
|
||||||
if (inEngine == 0)
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
{
|
|
||||||
nextOut = now + CharacterTime;
|
|
||||||
}
|
}
|
||||||
inEngine++;
|
bool behind = left > Slack && now - moved < HoldingPatience;
|
||||||
fedSince++;
|
// the feeder owes at most `Ahead` characters at any moment,
|
||||||
// feeding explains a count that is not going down, so it
|
// so a start, a brake letting go or an empty pane does not turn
|
||||||
// counts as movement: an engine is only holding what it has
|
// into a burst that puts the whole message beyond reach
|
||||||
// if it neither transmits nor is given anything
|
DateTime earliest = now - (SymbolTime * (Ahead - 1));
|
||||||
moved = now;
|
if (due < earliest)
|
||||||
|
{
|
||||||
|
due = earliest;
|
||||||
|
}
|
||||||
|
await handing.WaitAsync(cancellation).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!behind && now >= due && Take(now) is { } next)
|
||||||
|
{
|
||||||
|
due += SymbolTime * Symbols(next, ref feedShift);
|
||||||
await send(next, cancellation).ConfigureAwait(false);
|
await send(next, cancellation).ConfigureAwait(false);
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (inEngine == 0 && left <= 0 && !IsSending)
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
handing.Release();
|
||||||
|
}
|
||||||
|
if (NothingLeftToGive())
|
||||||
|
{
|
||||||
|
Given?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
// the engine is still holding what it has not transmitted, so
|
||||||
|
// the message is not over and the last characters of it have
|
||||||
|
// not been marked as gone out yet
|
||||||
|
if (left <= 0 && Outstanding == 0 && now >= due && AllAired())
|
||||||
{
|
{
|
||||||
Drained?.Invoke(this, EventArgs.Empty);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await Task.Delay(Tick, cancellation).ConfigureAwait(false);
|
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
idle = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private TimeSpan Tick
|
/// 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
|
get
|
||||||
{
|
{
|
||||||
TimeSpan quarter = CharacterTime / 4;
|
lock (gate)
|
||||||
return quarter < LongestTick ? quarter : LongestTick;
|
{
|
||||||
|
return outstanding;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,29 +772,60 @@ public sealed class TypeAhead : IDisposable
|
|||||||
{
|
{
|
||||||
Counts = left >= 0;
|
Counts = left >= 0;
|
||||||
Volatile.Write(ref counted, left);
|
Volatile.Write(ref counted, left);
|
||||||
Interlocked.Increment(ref answers);
|
}
|
||||||
|
|
||||||
|
/// 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:
|
/// The next character to send, or null when there is none to send now:
|
||||||
/// nothing is waiting, or what is waiting is behind the cursor.
|
/// nothing is waiting, or the gate is shut.
|
||||||
private char? Take()
|
private char? Take(DateTime now)
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
if (pending.Length == 0 || cursor == 0)
|
if (!open || pending.Length == 0)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
char next = pending[0];
|
char next = pending[0];
|
||||||
pending.Remove(0, 1);
|
pending.Remove(0, 1);
|
||||||
if (cursor != NoCursor)
|
|
||||||
{
|
|
||||||
cursor--;
|
|
||||||
}
|
|
||||||
sent.Append(next);
|
sent.Append(next);
|
||||||
|
Gave(1, now);
|
||||||
if (sent.Length > KeptSent)
|
if (sent.Length > KeptSent)
|
||||||
{
|
{
|
||||||
sent.Remove(0, sent.Length - KeptSent);
|
int dropped = sent.Length - KeptSent;
|
||||||
|
sent.Remove(0, dropped);
|
||||||
|
aired = Math.Max(0, aired - dropped);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,17 +12,28 @@ public sealed class WineBridgeChannel : BridgeChannel
|
|||||||
private readonly string wine;
|
private readonly string wine;
|
||||||
private readonly string bridgePath;
|
private readonly string bridgePath;
|
||||||
private readonly string? prefix;
|
private readonly string? prefix;
|
||||||
|
private readonly string? logFolder;
|
||||||
private readonly Queue<string> lastErrors = new();
|
private readonly Queue<string> lastErrors = new();
|
||||||
private readonly SemaphoreSlim writing = new(1, 1);
|
private readonly SemaphoreSlim writing = new(1, 1);
|
||||||
|
private readonly Lock writingLog = new();
|
||||||
private Process? bridge;
|
private Process? bridge;
|
||||||
|
private StreamWriter? log;
|
||||||
|
private DateTime started;
|
||||||
|
private int lastCount = int.MinValue;
|
||||||
|
|
||||||
/// `prefix` is the WINEPREFIX to run in. Left null, Wine uses its default,
|
/// `prefix` is the WINEPREFIX to run in. Left null, Wine uses its default,
|
||||||
/// which is what a station with one prefix wants.
|
/// which is what a station with one prefix wants. `logFolder`, when it is
|
||||||
public WineBridgeChannel(string bridgePath, string? prefix = null, string wine = "wine")
|
/// given, is where the protocol log for this run is written.
|
||||||
|
public WineBridgeChannel(
|
||||||
|
string bridgePath,
|
||||||
|
string? prefix = null,
|
||||||
|
string wine = "wine",
|
||||||
|
string? logFolder = null)
|
||||||
{
|
{
|
||||||
this.bridgePath = bridgePath;
|
this.bridgePath = bridgePath;
|
||||||
this.prefix = prefix;
|
this.prefix = prefix;
|
||||||
this.wine = wine;
|
this.wine = wine;
|
||||||
|
this.logFolder = logFolder;
|
||||||
}
|
}
|
||||||
|
|
||||||
public event EventHandler<string>? LineReceived;
|
public event EventHandler<string>? LineReceived;
|
||||||
@@ -31,6 +42,7 @@ public sealed class WineBridgeChannel : BridgeChannel
|
|||||||
|
|
||||||
public Task StartAsync(CancellationToken cancellation = default)
|
public Task StartAsync(CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
|
OpenLog();
|
||||||
bridge = Start(bridgePath);
|
bridge = Start(bridgePath);
|
||||||
_ = ReadOutputAsync(bridge);
|
_ = ReadOutputAsync(bridge);
|
||||||
_ = ReadErrorsAsync(bridge);
|
_ = ReadErrorsAsync(bridge);
|
||||||
@@ -46,6 +58,7 @@ public sealed class WineBridgeChannel : BridgeChannel
|
|||||||
await running.StandardInput.WriteLineAsync(line.AsMemory(), cancellation)
|
await running.StandardInput.WriteLineAsync(line.AsMemory(), cancellation)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
await running.StandardInput.FlushAsync(cancellation).ConfigureAwait(false);
|
await running.StandardInput.FlushAsync(cancellation).ConfigureAwait(false);
|
||||||
|
Log(">", line);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -84,6 +97,71 @@ public sealed class WineBridgeChannel : BridgeChannel
|
|||||||
}
|
}
|
||||||
bridge?.Dispose();
|
bridge?.Dispose();
|
||||||
writing.Dispose();
|
writing.Dispose();
|
||||||
|
lock (writingLog)
|
||||||
|
{
|
||||||
|
log?.Dispose();
|
||||||
|
log = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One file per run of the engine, named for when it started. It is the
|
||||||
|
/// whole protocol with a millisecond stamp on every line, which is what
|
||||||
|
/// says who keyed the transmitter and when.
|
||||||
|
private void OpenLog()
|
||||||
|
{
|
||||||
|
if (logFolder is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(logFolder);
|
||||||
|
started = DateTime.Now;
|
||||||
|
log = new StreamWriter(
|
||||||
|
Path.Combine(logFolder, $"digital-{started:yyyyMMdd-HHmmss}.log"),
|
||||||
|
append: false)
|
||||||
|
{
|
||||||
|
AutoFlush = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// a log that cannot be written stops nothing
|
||||||
|
log = null;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
log = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The buffer question goes out every 50 ms and is answered just as often,
|
||||||
|
/// which would bury everything else. The question is left out and the
|
||||||
|
/// answer is written only when the count has changed.
|
||||||
|
private void Log(string direction, string line)
|
||||||
|
{
|
||||||
|
if (log is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(string verb, string[] fields) = BridgeLine.Read(line);
|
||||||
|
if (verb == "buffer")
|
||||||
|
{
|
||||||
|
if (direction == ">" || fields.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!int.TryParse(fields[0], out int count) || count == lastCount)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastCount = count;
|
||||||
|
}
|
||||||
|
lock (writingLog)
|
||||||
|
{
|
||||||
|
log?.WriteLine(
|
||||||
|
$"{(DateTime.Now - started).TotalMilliseconds,9:0} ms {direction} {line.Replace('\t', ' ')}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Process Start(string program, params string[] arguments)
|
private Process Start(string program, params string[] arguments)
|
||||||
@@ -113,6 +191,7 @@ public sealed class WineBridgeChannel : BridgeChannel
|
|||||||
{
|
{
|
||||||
while (await running.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line)
|
while (await running.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line)
|
||||||
{
|
{
|
||||||
|
Log("<", line);
|
||||||
LineReceived?.Invoke(this, line);
|
LineReceived?.Invoke(this, line);
|
||||||
}
|
}
|
||||||
await running.WaitForExitAsync().ConfigureAwait(false);
|
await running.WaitForExitAsync().ConfigureAwait(false);
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ namespace Nonemm.Session;
|
|||||||
/// N1MM's rule, which this follows: an action macro runs before the message is
|
/// N1MM's rule, which this follows: an action macro runs before the message is
|
||||||
/// sent, unless it stands after `{END}`, and then it runs when the message has
|
/// sent, unless it stands after `{END}`, and then it runs when the message has
|
||||||
/// finished. Text after `{END}` is dropped, because the message is over.
|
/// finished. Text after `{END}` is dropped, because the message is over.
|
||||||
|
///
|
||||||
|
/// `{RX}` is the exception. It drops the transmitter, which cannot happen
|
||||||
|
/// before the message it stands in has gone out, so it always runs with the
|
||||||
|
/// actions that follow the message. N1MM does the same: it takes `{RX}` out of
|
||||||
|
/// the text wherever it stands, sends the text, and stops the transmitter
|
||||||
|
/// after it.
|
||||||
public sealed record MessagePlan(
|
public sealed record MessagePlan(
|
||||||
IReadOnlyList<MessageAction> Before,
|
IReadOnlyList<MessageAction> Before,
|
||||||
string Text,
|
string Text,
|
||||||
@@ -45,7 +51,8 @@ public sealed record MessagePlan(
|
|||||||
}
|
}
|
||||||
else if (Action(name) is { } action)
|
else if (Action(name) is { } action)
|
||||||
{
|
{
|
||||||
(ended ? after : before).Add(action);
|
(ended || action.Command == MessageCommand.ReturnToReceive ? after : before)
|
||||||
|
.Add(action);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
404
tests/Nonemm.Digital.Tests/DigitalEngineSenderTests.cs
Normal file
404
tests/Nonemm.Digital.Tests/DigitalEngineSenderTests.cs
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Nonemm.Digital;
|
||||||
|
|
||||||
|
namespace Nonemm.Digital.Tests;
|
||||||
|
|
||||||
|
/// The message sender over a digital engine. The engine here reports what it
|
||||||
|
/// was given and when it was told to stop; the pace is a baud rate no radio
|
||||||
|
/// uses so the tests do not wait for RTTY.
|
||||||
|
public class DigitalEngineSenderTests
|
||||||
|
{
|
||||||
|
/// Long enough for the stop, which waits for the engine to empty and then
|
||||||
|
/// one character time on top of it.
|
||||||
|
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
|
private const double Fast = 1500;
|
||||||
|
|
||||||
|
/// A quarter of a second per character, so a test can catch the engine
|
||||||
|
/// still holding what it was handed.
|
||||||
|
private const double Slow = 30;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheEngineDroppingTheTransmitterOnItsOwnDoesNotEndTheMessage()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Fast);
|
||||||
|
|
||||||
|
await sender.SendAsync("CQ TEST DE OM5M");
|
||||||
|
await WaitForAsync(() => engine.Sent.Length >= 2);
|
||||||
|
engine.Drop();
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Sent == "CQ TEST DE OM5M");
|
||||||
|
Assert.Equal("CQ TEST DE OM5M", engine.Sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheDropAfterReturnToReceiveEndsTheMessage()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Fast);
|
||||||
|
bool finished = false;
|
||||||
|
sender.Finished += (_, _) => finished = true;
|
||||||
|
|
||||||
|
await sender.SendAsync("CQ TEST");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
engine.Drop();
|
||||||
|
|
||||||
|
await WaitForAsync(() => finished && sender.Buffer.Sent.Length == 0);
|
||||||
|
Assert.True(finished);
|
||||||
|
Assert.Equal("", sender.Buffer.Sent);
|
||||||
|
Assert.Equal("CQ TEST", engine.Sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheEngineDroppingInTheMiddleOfAMessagePutsTheTransmitterBackUp()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Fast);
|
||||||
|
|
||||||
|
sender.Transmit();
|
||||||
|
await sender.SendAsync("CQ TEST DE OM5M");
|
||||||
|
await WaitForAsync(() => engine.Sent.Length >= 2);
|
||||||
|
int keyed = engine.Keyed;
|
||||||
|
engine.Drop();
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Keyed > keyed);
|
||||||
|
Assert.Equal(keyed + 1, engine.Keyed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MMTTY's stop does nothing when its buffer is empty already, so the
|
||||||
|
/// stop has to reach the engine while it still holds the last characters.
|
||||||
|
[Fact]
|
||||||
|
public async Task ReturnToReceiveReachesTheEngineWhileItStillHoldsTheLastCharacters()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Slow);
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync("AB");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
Assert.True(engine.Stopped);
|
||||||
|
Assert.True(sender.Buffer.OnAir < 2, "the engine had already transmitted everything");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MMTTY's stop leaves the transmitter up, so `{RX}` puts the key down
|
||||||
|
/// itself once the engine has transmitted what it holds.
|
||||||
|
[Fact]
|
||||||
|
public async Task ReturnToReceivePutsTheKeyDownAfterTheEngineHasEmptied()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Slow);
|
||||||
|
bool finished = false;
|
||||||
|
sender.Finished += (_, _) => finished = true;
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync("AB");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Released);
|
||||||
|
Assert.True(engine.Released);
|
||||||
|
Assert.Equal("AB", engine.Sent);
|
||||||
|
Assert.False(engine.Aborted, "the message was cut off instead of being let finish");
|
||||||
|
await WaitForAsync(() => finished);
|
||||||
|
Assert.True(finished);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N1MM's ending: what is left of the message goes to the engine in one
|
||||||
|
/// piece and the engine is asked to stop with its buffer full, because
|
||||||
|
/// `SetMmttyPTT(1)` does nothing at an engine that has been fed one
|
||||||
|
/// character at a time and is therefore nearly empty.
|
||||||
|
[Fact]
|
||||||
|
public async Task ReturnToReceiveHandsTheRestOfTheMessageOverInOnePiece()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Slow);
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync("CQ TEST DE OM5M");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
Assert.Equal("CQ TEST DE OM5M", engine.Sent);
|
||||||
|
Assert.Equal("", sender.Buffer.Pending);
|
||||||
|
Assert.Contains(engine.Pushes, push => push.Length > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stop reaches the engine while the message is still in it, which at
|
||||||
|
/// 30 baud is most of the four seconds the message takes.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheStopReachesTheEngineWhileItStillHoldsTheMessage()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Slow);
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync("CQ TEST DE OM5M");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
Assert.True(sender.Buffer.OnAir < "CQ TEST DE OM5M".Length,
|
||||||
|
"the engine had transmitted the whole message before it was told to stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message takes longer to transmit than the engine is given to make
|
||||||
|
/// progress, and the two are not the same thing. Measured as one, the key
|
||||||
|
/// went down partway through a CQ.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheKeyWaitsForTheWholeMessageAndNotForTheStopPatience()
|
||||||
|
{
|
||||||
|
const string message = "CQ CQ DE OM5M OM5M K";
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Slow);
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync(message);
|
||||||
|
DateTime from = DateTime.UtcNow;
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Released);
|
||||||
|
TimeSpan waited = DateTime.UtcNow - from;
|
||||||
|
Assert.Equal(message, engine.Sent);
|
||||||
|
Assert.True(
|
||||||
|
waited > DigitalEngineSender.StopPatience,
|
||||||
|
$"the key went down after {waited.TotalMilliseconds:0} ms, before the message was out");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transmitting again while the last message is still ending. The ending
|
||||||
|
/// takes as long as the engine takes to transmit what it holds, and the
|
||||||
|
/// operator can key inside that time; the old ending must not put the key
|
||||||
|
/// down in the middle of the new message.
|
||||||
|
[Fact]
|
||||||
|
public async Task TransmittingAgainAbandonsTheEndingOfTheMessageBeforeIt()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Slow);
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync("CQ");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
|
||||||
|
sender.Transmit();
|
||||||
|
await sender.SendAsync("TU");
|
||||||
|
await WaitForAsync(() => engine.Sent == "CQTU");
|
||||||
|
|
||||||
|
Assert.Equal("CQTU", engine.Sent);
|
||||||
|
Assert.False(engine.Released, "the old ending put the key down during the new message");
|
||||||
|
Assert.True(engine.IsTransmitting);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same through a message alone, with no `{TX}` in front of it: text is
|
||||||
|
/// the operator asking for the transmitter.
|
||||||
|
[Fact]
|
||||||
|
public async Task AMessageDuringAnEndingKeysAgainAndGoesOut()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Slow);
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync("CQ");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
int keyed = engine.Keyed;
|
||||||
|
|
||||||
|
await sender.SendAsync("TU");
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Sent == "CQTU");
|
||||||
|
Assert.Equal("CQTU", engine.Sent);
|
||||||
|
Assert.True(engine.Keyed > keyed, "the transmitter was not keyed for the new message");
|
||||||
|
Assert.False(engine.Released);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine dropping ends the message it was ending, and nothing else.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheKeyStaysDownAfterAMessageHasEnded()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Fast);
|
||||||
|
sender.Transmit();
|
||||||
|
|
||||||
|
await sender.SendAsync("CQ TEST");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
engine.Drop();
|
||||||
|
|
||||||
|
await WaitForAsync(() => !sender.Buffer.IsTransmitting);
|
||||||
|
int keyed = engine.Keyed;
|
||||||
|
await Task.Delay(500);
|
||||||
|
Assert.Equal(keyed, engine.Keyed);
|
||||||
|
Assert.False(engine.IsTransmitting);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A macro pressed just as the last one finishes. The stop for that message
|
||||||
|
/// is inside the engine, waiting for its buffer to empty, and feeding an
|
||||||
|
/// engine with that standing left MMTTY keyed and transmitting nothing. The
|
||||||
|
/// stop is cleared before the new message is fed.
|
||||||
|
[Fact]
|
||||||
|
public async Task AMessageStartedWhileTheLastOneIsEndingClearsTheStopFirst()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Fast);
|
||||||
|
sender.Transmit();
|
||||||
|
await sender.SendAsync("CQ");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
|
||||||
|
await sender.SendAsync("TU");
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Sent == "CQTU");
|
||||||
|
Assert.Equal("CQTU", engine.Sent);
|
||||||
|
Assert.True(engine.Aborted, "the engine was fed with its stop still pending");
|
||||||
|
Assert.True(engine.IsTransmitting);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two macros pressed one after the other keep the transmitter up, so the
|
||||||
|
/// engine never reports the drop that ends a message. The pane still starts
|
||||||
|
/// again on each of them. Text that has gone to the engine cannot be
|
||||||
|
/// edited, and keeping the whole run in the pane left none of it editable.
|
||||||
|
[Fact]
|
||||||
|
public async Task AMessageStartedAfterTheLastOneLeavesOnlyItsOwnTextInThePane()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new();
|
||||||
|
using DigitalEngineSender sender = new(engine, baud: Fast);
|
||||||
|
sender.Transmit();
|
||||||
|
await sender.SendAsync("CQ");
|
||||||
|
sender.ReturnToReceiveWhenSent();
|
||||||
|
await WaitForAsync(() => engine.Stopped);
|
||||||
|
|
||||||
|
await sender.SendAsync("TU");
|
||||||
|
|
||||||
|
await WaitForAsync(() => engine.Sent == "CQTU");
|
||||||
|
Assert.Equal("TU", sender.Buffer.Sent + sender.Buffer.Pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<bool> ready)
|
||||||
|
{
|
||||||
|
DateTime giveUp = DateTime.UtcNow + Patience;
|
||||||
|
while (!ready() && DateTime.UtcNow < giveUp)
|
||||||
|
{
|
||||||
|
await Task.Delay(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An engine with no buffer of its own: it takes what it is given, says
|
||||||
|
/// when it was told to stop, and drops the transmitter when the test says
|
||||||
|
/// so.
|
||||||
|
private sealed class FakeEngine : DigitalEngine
|
||||||
|
{
|
||||||
|
private readonly StringBuilder sent = new();
|
||||||
|
private readonly List<string> pushes = [];
|
||||||
|
private readonly Lock gate = new();
|
||||||
|
|
||||||
|
public bool IsConnected => true;
|
||||||
|
|
||||||
|
public bool IsTransmitting { get; private set; } = true;
|
||||||
|
|
||||||
|
/// True once `{RX}` told the engine to stop.
|
||||||
|
public bool Stopped { get; private set; }
|
||||||
|
|
||||||
|
/// True once the engine was stopped the hard way.
|
||||||
|
public bool Aborted { get; private set; }
|
||||||
|
|
||||||
|
/// True once the key was put down, which is what ends a message on
|
||||||
|
/// MMTTY.
|
||||||
|
public bool Released { get; private set; }
|
||||||
|
|
||||||
|
/// How many times the transmitter has been keyed.
|
||||||
|
public int Keyed { get; private set; }
|
||||||
|
|
||||||
|
public string Sent
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return sent.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every call, so a test can tell a message pushed in one piece from
|
||||||
|
/// the same text fed one character at a time.
|
||||||
|
public IReadOnlyList<string> Pushes
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return [.. pushes];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nothing here decodes or disconnects, so these two are declared to
|
||||||
|
// satisfy the interface and never raised
|
||||||
|
public event EventHandler<string>? Received
|
||||||
|
{
|
||||||
|
add { }
|
||||||
|
remove { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<bool>? TransmitChanged;
|
||||||
|
|
||||||
|
public event EventHandler<bool>? ConnectionChanged
|
||||||
|
{
|
||||||
|
add { }
|
||||||
|
remove { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine reporting the transmitter down.
|
||||||
|
public void Drop()
|
||||||
|
{
|
||||||
|
IsTransmitting = false;
|
||||||
|
TransmitChanged?.Invoke(this, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellation = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task KeyAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
Keyed++;
|
||||||
|
Stopped = false;
|
||||||
|
IsTransmitting = true;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SendAsync(string text, CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
sent.Append(text);
|
||||||
|
pushes.Add(text);
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task AbortAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
Aborted = true;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ReturnToReceiveAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
Stopped = true;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MMTTY drops the transmitter as soon as the key goes down, and this
|
||||||
|
/// does the same.
|
||||||
|
public Task ReleaseKeyAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
Released = true;
|
||||||
|
Drop();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,20 +3,20 @@ using Nonemm.Digital;
|
|||||||
|
|
||||||
namespace Nonemm.Digital.Tests;
|
namespace Nonemm.Digital.Tests;
|
||||||
|
|
||||||
/// The text waiting to go out, fed to the engine a few characters at a time.
|
/// The text waiting to go out, fed to the engine one character per character
|
||||||
/// The pump is run at a baud rate no radio uses so the tests do not wait for
|
/// time. The feeder is run at a baud rate no radio uses so the tests do not
|
||||||
/// RTTY.
|
/// wait for RTTY.
|
||||||
public class TypeAheadTests
|
public class TypeAheadTests
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(5);
|
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
/// Fast enough that a message goes out in milliseconds, slow enough that a
|
/// Fast enough that a message goes out in milliseconds, slow enough that a
|
||||||
/// test can still catch the pump partway through.
|
/// test can still catch the feeder partway through.
|
||||||
private const double Fast = 7500;
|
private const double Fast = 1500;
|
||||||
|
|
||||||
/// A character time of 100 ms, so a test can tell the characters sent ahead
|
/// A quarter of a second per character, which is long enough to read what
|
||||||
/// from the ones that wait for the engine.
|
/// the engine is holding before it has transmitted it.
|
||||||
private const double Slow = 75;
|
private const double Slow = 30;
|
||||||
|
|
||||||
private readonly StringBuilder went = new();
|
private readonly StringBuilder went = new();
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ public class TypeAheadTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An engine with no count of its own, so the clock alone paces it.
|
||||||
private TypeAhead Buffer(double baud = Fast) =>
|
private TypeAhead Buffer(double baud = Fast) =>
|
||||||
new(
|
new(
|
||||||
(character, _) =>
|
(character, _) =>
|
||||||
@@ -68,7 +69,7 @@ public class TypeAheadTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task WhatHasGoneOutIsNotPendingAnyMore()
|
public async Task WhatHasGoneOutIsNotPendingAnyMore()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer(baud: 40);
|
using TypeAhead buffer = Buffer();
|
||||||
|
|
||||||
buffer.Append("CQ TEST DE OM5M");
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
|
||||||
@@ -77,52 +78,72 @@ public class TypeAheadTests
|
|||||||
Assert.Equal("CQ TEST DE OM5M", buffer.Sent + buffer.Pending);
|
Assert.Equal("CQ TEST DE OM5M", buffer.Sent + buffer.Pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pane is edited as a whole: what has gone out and what is still to
|
||||||
|
/// go. What has gone out is dropped, and the rest replaces what was
|
||||||
|
/// waiting.
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheOperatorRewritesWhatHasNotGoneOut()
|
public async Task TheOperatorRewritesWhatHasNotGoneOut()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer(baud: 60);
|
using TypeAhead buffer = Buffer();
|
||||||
buffer.Append("OM5X 599 001");
|
buffer.Append("OM5X 599 001");
|
||||||
|
|
||||||
await WaitForAsync(() => buffer.Sent.Length >= 5);
|
await WaitForAsync(() => buffer.Sent.Length >= 5);
|
||||||
buffer.Rewrite("599 002", TypeAhead.NoCursor);
|
|
||||||
|
buffer.Edit(buffer.Sent + "599 002");
|
||||||
|
|
||||||
await WaitForAsync(() => buffer.Pending.Length == 0);
|
await WaitForAsync(() => buffer.Pending.Length == 0);
|
||||||
Assert.EndsWith("599 002", Sent, StringComparison.Ordinal);
|
Assert.EndsWith("599 002", Sent, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("001", Sent, StringComparison.Ordinal);
|
Assert.DoesNotContain("001", Sent, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What is typed stays off the air until the transmitter is keyed.
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task NothingGoesOutFromBehindTheCursor()
|
public async Task NothingTypedGoesOutBeforeTheTransmitterIsKeyed()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer();
|
using TypeAhead buffer = Buffer();
|
||||||
|
|
||||||
buffer.Rewrite("CQ TEST", 2);
|
buffer.Edit("CQ TEST");
|
||||||
|
|
||||||
await WaitForAsync(() => Sent.Length == 2);
|
|
||||||
await Task.Delay(50);
|
await Task.Delay(50);
|
||||||
Assert.Equal("CQ", Sent);
|
Assert.Equal("", Sent);
|
||||||
Assert.Equal(" TEST", buffer.Pending);
|
Assert.Equal("CQ TEST", buffer.Pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheRestGoesOutWhenTheCursorMovesOn()
|
public async Task TheWholePaneGoesOutOnTransmit()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer();
|
using TypeAhead buffer = Buffer();
|
||||||
buffer.Rewrite("CQ TEST", 2);
|
buffer.Edit("CQ TEST");
|
||||||
await WaitForAsync(() => Sent.Length == 2);
|
await Task.Delay(20);
|
||||||
|
|
||||||
buffer.Cursor = TypeAhead.NoCursor;
|
buffer.Transmit();
|
||||||
|
|
||||||
await WaitForAsync(() => Sent == "CQ TEST");
|
await WaitForAsync(() => Sent == "CQ TEST");
|
||||||
Assert.Equal("CQ TEST", Sent);
|
Assert.Equal("CQ TEST", Sent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The workflow the pane is for: keyed up, what is typed goes out behind
|
||||||
|
/// what is already going, with no second key to press.
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TextAddedBehindTheCursorStillGoesOut()
|
public async Task TextTypedWhileTransmittingGoesOutBehindIt()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer();
|
using TypeAhead buffer = Buffer();
|
||||||
buffer.Rewrite("CQ", 2);
|
buffer.Edit("CQ ");
|
||||||
await WaitForAsync(() => Sent == "CQ");
|
buffer.Transmit();
|
||||||
|
await WaitForAsync(() => Sent == "CQ ");
|
||||||
|
|
||||||
|
buffer.Edit(buffer.Sent + "DE OM5M");
|
||||||
|
|
||||||
|
await WaitForAsync(() => Sent == "CQ DE OM5M");
|
||||||
|
Assert.Equal("CQ DE OM5M", Sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A function key pressed while the operator is typing goes on the end of
|
||||||
|
/// what has been typed, and keys the transmitter itself.
|
||||||
|
[Fact]
|
||||||
|
public async Task AMacroFollowsWhatWasTypedAndKeysTheTransmitter()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer();
|
||||||
|
buffer.Edit("CQ");
|
||||||
|
|
||||||
buffer.Append(" TEST");
|
buffer.Append(" TEST");
|
||||||
|
|
||||||
@@ -130,12 +151,200 @@ public class TypeAheadTests
|
|||||||
Assert.Equal("CQ TEST", Sent);
|
Assert.Equal("CQ TEST", Sent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Text taken out of the pane before it goes out is never transmitted.
|
||||||
|
[Fact]
|
||||||
|
public async Task TextDeletedBeforeItGoesOutIsNotSent()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer();
|
||||||
|
buffer.Edit("CQ TEST DE OM5X");
|
||||||
|
buffer.Transmit();
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length >= 3);
|
||||||
|
|
||||||
|
buffer.Edit(buffer.Sent + "DE OM5M");
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.Pending.Length == 0);
|
||||||
|
Assert.EndsWith("DE OM5M", Sent, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("OM5X", Sent, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The transmitter dropping ends the message: what went out is cleared off
|
||||||
|
/// the pane and nothing goes out again until the transmitter is keyed.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheEndOfAMessageClearsWhatWentOutAndShutsTheGate()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer();
|
||||||
|
buffer.Append("TU");
|
||||||
|
await WaitForAsync(() => Sent == "TU");
|
||||||
|
|
||||||
|
buffer.Ended();
|
||||||
|
buffer.Edit(" NEXT");
|
||||||
|
|
||||||
|
Assert.Equal("", buffer.Sent);
|
||||||
|
Assert.Equal(" NEXT", buffer.Pending);
|
||||||
|
await Task.Delay(50);
|
||||||
|
Assert.Equal("TU", Sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Aired` is the end of the message: the engine has been given
|
||||||
|
/// everything and has transmitted it too.
|
||||||
|
[Fact]
|
||||||
|
public async Task DrainedComesWhenTheMessageHasGoneOut()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer();
|
||||||
|
int drained = 0;
|
||||||
|
buffer.Aired += (_, _) => Interlocked.Increment(ref drained);
|
||||||
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
|
||||||
|
Assert.Equal(0, Volatile.Read(ref drained));
|
||||||
|
await WaitForAsync(() => Volatile.Read(ref drained) == 1);
|
||||||
|
Assert.Equal("CQ TEST DE OM5M", Sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The red text in the pane is `OnAir`, and it ran ahead of the
|
||||||
|
/// transmission because it advanced one character per symbol time. A digit
|
||||||
|
/// in a callsign costs a shift to figures and the letter after it a shift
|
||||||
|
/// back, so `OM5M` is six symbols and not four.
|
||||||
|
[Fact]
|
||||||
|
public async Task WhatIsOnTheAirIsPricedInSymbolsAndNotInCharacters()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer();
|
||||||
|
buffer.Append("OM5M");
|
||||||
|
|
||||||
|
// four symbol times is what the old clock allowed the whole callsign
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length == 4);
|
||||||
|
await Task.Delay(buffer.SymbolTime * 4.5);
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
buffer.OnAir < 4,
|
||||||
|
$"{buffer.OnAir} of 4 characters were called transmitted in four symbol times");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Letters with nothing to shift for cost one symbol each, so the two
|
||||||
|
/// agree there.
|
||||||
|
[Fact]
|
||||||
|
public async Task PlainLettersGoOutAtOneSymbolEach()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer();
|
||||||
|
buffer.Append("CQ TEST");
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.OnAir >= 4);
|
||||||
|
Assert.True(buffer.OnAir <= 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine's count is what says how much is left, whatever the clock
|
||||||
|
/// thinks. An engine that says it is still holding the whole message keeps
|
||||||
|
/// the pane from marking any of it as gone out.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheEnginesOwnCountHoldsThePaneBack()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new() { Answer = 30 };
|
||||||
|
using TypeAhead buffer = new(engine, Fast);
|
||||||
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length == 15);
|
||||||
|
await Task.Delay(buffer.SymbolTime * 20);
|
||||||
|
|
||||||
|
Assert.Equal(0, buffer.OnAir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// And it lets go as the count falls.
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePaneCatchesUpAsTheCountFalls()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new() { Answer = 30 };
|
||||||
|
using TypeAhead buffer = new(engine, Fast);
|
||||||
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length == 15);
|
||||||
|
|
||||||
|
engine.Answer = 0;
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.OnAir == 15);
|
||||||
|
Assert.Equal(15, buffer.OnAir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine's count is what says how much has gone out. It answers in
|
||||||
|
/// symbols and they fall as they are transmitted, so what it drops between
|
||||||
|
/// two answers is what went on the air between them.
|
||||||
|
[Fact]
|
||||||
|
public async Task WhatTheEngineHasTransmittedComesFromItsOwnCount()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new() { Answer = 20 };
|
||||||
|
using TypeAhead buffer = new(engine, Fast);
|
||||||
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length == 15);
|
||||||
|
await WaitForAsync(() => buffer.EngineHolds == 20);
|
||||||
|
|
||||||
|
// four symbols out of the engine, which is the first four characters
|
||||||
|
engine.Answer = 16;
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.OnAir == 4);
|
||||||
|
Assert.Equal(4, buffer.OnAir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A count that has not moved leaves the pane where it is, however long
|
||||||
|
/// the clock runs.
|
||||||
|
[Fact]
|
||||||
|
public async Task ACountThatDoesNotMoveHoldsThePaneStill()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new() { Answer = 20 };
|
||||||
|
using TypeAhead buffer = new(engine, Fast);
|
||||||
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length == 15);
|
||||||
|
|
||||||
|
await Task.Delay(buffer.SymbolTime * 20);
|
||||||
|
|
||||||
|
Assert.Equal(0, buffer.OnAir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty engine has transmitted everything it was given, whatever the
|
||||||
|
/// symbols added up to along the way. A wrong guess about the shift
|
||||||
|
/// corrects itself at the end of every message rather than accumulating.
|
||||||
|
[Fact]
|
||||||
|
public async Task AnEmptyEngineHasTransmittedEverything()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new() { Answer = 20 };
|
||||||
|
using TypeAhead buffer = new(engine, Fast);
|
||||||
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length == 15);
|
||||||
|
await WaitForAsync(() => buffer.EngineHolds == 20);
|
||||||
|
|
||||||
|
engine.Answer = 0;
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.OnAir == 15);
|
||||||
|
Assert.Equal(15, buffer.OnAir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The count reads 0 for the first 150 ms after a push, before the engine
|
||||||
|
/// has caught up with what it was given. That 0 does not mean the message
|
||||||
|
/// is over.
|
||||||
|
[Fact]
|
||||||
|
public async Task ACountThatHasNotCaughtUpIsNotAnEmptyEngine()
|
||||||
|
{
|
||||||
|
FakeEngine engine = new() { Answer = 0 };
|
||||||
|
using TypeAhead buffer = new(engine, Fast);
|
||||||
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.Sent.Length == 15);
|
||||||
|
await Task.Delay(buffer.SymbolTime * 8);
|
||||||
|
|
||||||
|
Assert.Equal(0, buffer.OnAir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nothing has been given to the engine, so nothing is outstanding.
|
||||||
|
[Fact]
|
||||||
|
public void NothingIsOutstandingWhenNothingIsGoingOut()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer();
|
||||||
|
|
||||||
|
Assert.Equal(0, buffer.Outstanding);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheBufferSaysWhenTheMessageHasGoneOut()
|
public async Task TheBufferSaysWhenTheMessageHasGoneOut()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer();
|
using TypeAhead buffer = Buffer();
|
||||||
int drained = 0;
|
int drained = 0;
|
||||||
buffer.Drained += (_, _) => Interlocked.Increment(ref drained);
|
buffer.Aired += (_, _) => Interlocked.Increment(ref drained);
|
||||||
|
|
||||||
buffer.Append("TU");
|
buffer.Append("TU");
|
||||||
|
|
||||||
@@ -146,7 +355,7 @@ public class TypeAheadTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AMessageAddedWhileOneIsGoingOutFollowsIt()
|
public async Task AMessageAddedWhileOneIsGoingOutFollowsIt()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer(baud: 400);
|
using TypeAhead buffer = Buffer();
|
||||||
buffer.Append("CQ ");
|
buffer.Append("CQ ");
|
||||||
|
|
||||||
buffer.Append("DE OM5M");
|
buffer.Append("DE OM5M");
|
||||||
@@ -158,7 +367,7 @@ public class TypeAheadTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task DroppingLeavesWhatHasAlreadyGone()
|
public async Task DroppingLeavesWhatHasAlreadyGone()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer(baud: 60);
|
using TypeAhead buffer = Buffer();
|
||||||
buffer.Append("CQ TEST DE OM5M");
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
await WaitForAsync(() => buffer.Sent.Length >= 3);
|
await WaitForAsync(() => buffer.Sent.Length >= 3);
|
||||||
|
|
||||||
@@ -174,7 +383,7 @@ public class TypeAheadTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ClearingEmptiesBothHalves()
|
public void ClearingEmptiesBothHalves()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer(baud: 1);
|
using TypeAhead buffer = Buffer();
|
||||||
buffer.Append("CQ TEST");
|
buffer.Append("CQ TEST");
|
||||||
|
|
||||||
buffer.Clear();
|
buffer.Clear();
|
||||||
@@ -183,63 +392,71 @@ public class TypeAheadTests
|
|||||||
Assert.Equal("", buffer.Sent);
|
Assert.Equal("", buffer.Sent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The engine is given the second character before it has transmitted the
|
/// MMTTY answers 0 while it is still transmitting: for the first 150 ms
|
||||||
/// first, so it has one in hand when the first is done. An engine left with
|
/// after a push, and for as long as it holds a word on Word out. A feeder
|
||||||
/// an empty buffer transmits idle instead, and that idle is added to how
|
/// that read that as room fed on it and ran ahead of the air, so the clock
|
||||||
/// long the message takes.
|
/// is the pace and the count only ever holds it back. This one runs at the
|
||||||
|
/// real RTTY speed, because the number that matters is how many characters
|
||||||
|
/// go out in a second at 45.45 baud.
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheEngineIsKeptOneCharacterAhead()
|
public async Task AnEngineAnsweringZeroDoesNotPullThePumpForward()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer(baud: Slow);
|
FakeEngine engine = new() { Answer = 0 };
|
||||||
|
using TypeAhead buffer = new(engine, baud: TypeAhead.DefaultBaud);
|
||||||
|
|
||||||
buffer.Append("CQ TEST");
|
buffer.Append("CQ TEST DE OM5M OM5M K");
|
||||||
|
|
||||||
await WaitForAsync(() => Sent.Length >= 2);
|
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||||
Assert.Equal("CQ", Sent);
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Only the lead goes out ahead. The rest waits, which is what leaves it
|
/// The engine is given the next character before it has transmitted the one
|
||||||
/// where the operator can still change it.
|
/// 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]
|
[Fact]
|
||||||
public async Task NoMoreThanTheLeadGoesToTheEngineAtOnce()
|
public async Task TheEngineIsNeverLeftWithNothingInHand()
|
||||||
{
|
|
||||||
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 engine's count of what it has left runs behind what it is really
|
|
||||||
/// doing: the probe read 0 fifty milliseconds after twenty-one characters
|
|
||||||
/// had been pushed and were already going out. A pump that believed that
|
|
||||||
/// would hand over the whole message at once, so the count only ever stops
|
|
||||||
/// it, and the engine is never given more than `Lead` plus `Slack`.
|
|
||||||
[Fact]
|
|
||||||
public async Task AnEngineBehindTheClockIsNotFedPastTheCap()
|
|
||||||
{
|
{
|
||||||
FakeEngine engine = new();
|
FakeEngine engine = new();
|
||||||
using TypeAhead buffer = new(engine, baud: Slow);
|
using TypeAhead buffer = new(engine, baud: TypeAhead.DefaultBaud);
|
||||||
// half the speed of the baud rate the pump is paced by
|
// transmits one character every character time, as a real engine does
|
||||||
using Timer transmitting = new(_ => engine.Transmit(1), null, 0, 200);
|
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");
|
buffer.Append("CQ TEST DE OM5M");
|
||||||
|
|
||||||
int most = 0;
|
await WaitForAsync(() => engine.Transmitted == "CQ TEST DE OM5M");
|
||||||
for (int look = 0; look < 50; look++)
|
Assert.Equal("CQ TEST DE OM5M", engine.Transmitted);
|
||||||
{
|
|
||||||
most = Math.Max(most, engine.Waiting.Length);
|
|
||||||
await Task.Delay(20);
|
|
||||||
}
|
|
||||||
Assert.True(most <= TypeAhead.DefaultLead + TypeAhead.Slack, $"the engine was given {most}");
|
|
||||||
Assert.True(engine.Transmitted.Length > 0, "nothing was transmitted at all");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The clock is the fallback for an engine that will not answer.
|
/// 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]
|
[Fact]
|
||||||
public async Task AnEngineThatWillNotCountIsPacedByTheClock()
|
public async Task AnEngineThatWillNotCountIsFedAnyway()
|
||||||
{
|
{
|
||||||
FakeEngine engine = new() { Counts = false };
|
FakeEngine engine = new() { Counts = false };
|
||||||
using TypeAhead buffer = new(engine, baud: Fast);
|
using TypeAhead buffer = new(engine, baud: Fast);
|
||||||
@@ -252,24 +469,49 @@ public class TypeAheadTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ACharacterTakesAsLongAsTheBaudRateSays()
|
public async Task WhatTheEngineIsStillHoldingHasNotGoneOutYet()
|
||||||
{
|
{
|
||||||
using TypeAhead buffer = Buffer(baud: TypeAhead.DefaultBaud);
|
using TypeAhead buffer = Buffer(Slow);
|
||||||
|
|
||||||
Assert.Equal(165, buffer.CharacterTime.TotalMilliseconds, 0.5);
|
buffer.Append("AB");
|
||||||
|
|
||||||
|
await WaitForAsync(() => buffer.Sent == "AB");
|
||||||
|
Assert.Equal(0, buffer.OnAir);
|
||||||
|
await WaitForAsync(() => buffer.OnAir == 2);
|
||||||
|
Assert.Equal(2, buffer.OnAir);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheMessageIsNotOverUntilTheEngineHasTransmittedWhatItHolds()
|
||||||
|
{
|
||||||
|
using TypeAhead buffer = Buffer(Slow);
|
||||||
|
int atTheEnd = -1;
|
||||||
|
buffer.Aired += (_, _) => atTheEnd = buffer.OnAir;
|
||||||
|
|
||||||
|
buffer.Append("AB");
|
||||||
|
|
||||||
|
await WaitForAsync(() => atTheEnd >= 0);
|
||||||
|
Assert.Equal(2, atTheEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An engine with a buffer of its own: it takes characters, holds them
|
/// 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
|
/// until the test says they have been transmitted, and says how many it
|
||||||
/// has. `Counts` false is the engine that will not answer.
|
/// 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 sealed class FakeEngine : EngineBuffer
|
||||||
{
|
{
|
||||||
private readonly Lock gate = new();
|
private readonly Lock gate = new();
|
||||||
private readonly StringBuilder waiting = new();
|
private readonly StringBuilder waiting = new();
|
||||||
private readonly StringBuilder transmitted = new();
|
private readonly StringBuilder transmitted = new();
|
||||||
|
|
||||||
public bool Counts { get; init; } = true;
|
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 event EventHandler<int>? Buffered;
|
||||||
|
|
||||||
public string Waiting
|
public string Waiting
|
||||||
@@ -308,7 +550,7 @@ public class TypeAheadTests
|
|||||||
int left;
|
int left;
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
left = Counts ? waiting.Length : -1;
|
left = Counts ? (Answer >= 0 ? Answer : waiting.Length) : -1;
|
||||||
}
|
}
|
||||||
Buffered?.Invoke(this, left);
|
Buffered?.Invoke(this, left);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|||||||
@@ -117,8 +117,21 @@ public class MessagePlanTests
|
|||||||
{
|
{
|
||||||
MessagePlan plan = MessagePlan.Read("{TX}CQ DE {MYCALL}{ENTER}{RX}", Session());
|
MessagePlan plan = MessagePlan.Read("{TX}CQ DE {MYCALL}{ENTER}{RX}", Session());
|
||||||
|
|
||||||
Assert.Equal([MessageCommand.StartTransmit, MessageCommand.ReturnToReceive],
|
Assert.Equal([MessageCommand.StartTransmit], plan.Before.Select(a => a.Command));
|
||||||
plan.Before.Select(a => a.Command));
|
|
||||||
Assert.Equal("CQ DE DL1ABC\r", plan.Text);
|
Assert.Equal("CQ DE DL1ABC\r", plan.Text);
|
||||||
|
Assert.Equal([MessageCommand.ReturnToReceive], plan.After.Select(a => a.Command));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `{RX}` waits for the message even when it stands in front of it, because
|
||||||
|
/// the transmitter cannot drop before the text has gone out. Everything
|
||||||
|
/// else in front of the text still runs first.
|
||||||
|
[Fact]
|
||||||
|
public void ReturnToReceiveRunsAfterTheMessageWhereverItStands()
|
||||||
|
{
|
||||||
|
MessagePlan plan = MessagePlan.Read("{TX}{RX}CQ TEST{WIPE}", Session());
|
||||||
|
|
||||||
|
Assert.Equal([MessageCommand.StartTransmit, MessageCommand.Wipe],
|
||||||
|
plan.Before.Select(a => a.Command));
|
||||||
|
Assert.Equal([MessageCommand.ReturnToReceive], plan.After.Select(a => a.Command));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ public sealed class EngineProbe
|
|||||||
/// How long the engine is given to key up and to drop again.
|
/// How long the engine is given to key up and to drop again.
|
||||||
private static readonly TimeSpan Keying = TimeSpan.FromSeconds(3);
|
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 Message = "CQ TEST DE OM5M OM5M ";
|
||||||
private const string Word = "ABCD";
|
private const string Word = "ABCD";
|
||||||
|
|
||||||
@@ -80,6 +83,140 @@ public sealed class EngineProbe
|
|||||||
await MessageAsync(cancellation).ConfigureAwait(false);
|
await MessageAsync(cancellation).ConfigureAwait(false);
|
||||||
await WordAsync(cancellation).ConfigureAwait(false);
|
await WordAsync(cancellation).ConfigureAwait(false);
|
||||||
await UnfinishedWordAsync(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
|
/// Question 1. `DISP_E_UNKNOWNNAME` is 0x80020006; a name the control knows
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public static class Program
|
|||||||
--prefix <path> WINEPREFIX to run in (default: Wine's own)
|
--prefix <path> WINEPREFIX to run in (default: Wine's own)
|
||||||
--ptt <port> serial port to key (default: none, so no radio is keyed)
|
--ptt <port> serial port to key (default: none, so no radio is keyed)
|
||||||
--out <file> where to write the report (default engine-probe.log)
|
--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
|
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
|
minute. It keys no serial port unless --ptt says so, but a rig listening to
|
||||||
@@ -47,7 +48,8 @@ public static class Program
|
|||||||
MmttyEngine engine = new(
|
MmttyEngine engine = new(
|
||||||
new WineBridgeChannel(
|
new WineBridgeChannel(
|
||||||
Option(options, "bridge", Path.Combine("bridge", "nonemm-mmtty-bridge.exe")),
|
Option(options, "bridge", Path.Combine("bridge", "nonemm-mmtty-bridge.exe")),
|
||||||
options.GetValueOrDefault("prefix")),
|
options.GetValueOrDefault("prefix"),
|
||||||
|
logFolder: options.GetValueOrDefault("log")),
|
||||||
new MmttyOptions
|
new MmttyOptions
|
||||||
{
|
{
|
||||||
EnginePath = options["engine"],
|
EnginePath = options["engine"],
|
||||||
|
|||||||
Reference in New Issue
Block a user