Alternate CQ between the two radios
Ctrl+B calls CQ on one radio, and when that message has gone out, moves to the other and calls there. The keyboard, the entry window and the SO2R box follow each turn. N1MM calls this dueling CQs and puts it on the same key. The turn is taken when the keyer says the message has ended, not when a timer guesses it has. So MessageSender grew a Finished event and a ReportsCompletion flag, and both keyers fill them in: cwdaemon answers the <ESC>h reply request that now goes out in front of every message, and a WinKeyer clears the busy bit in the status bytes it sends of its own accord. The status-byte reading is in WinkeyerStatus, away from the serial port, because that is the half that can be tested without a keyer on the desk. A keyer that reports nothing refuses to start alternating CQ rather than keying the second radio over the first. AlternatingCq itself takes the keyer, a callback that calls CQ on a radio, the gap and a wait function, so the alternation is tested without sleeping. The gap is in Config ▸ Keyer and messages and will not go below 100 ms, which is N1MM's floor too: an SO2R box works relays. docs/keying.md writes down why cwdaemon does the timing and we do not. N1MM keys DTR itself with a coarse sleep, a busy-wait and a margin that grows every time the sleep overshoots, and it raises the thread to TIME_CRITICAL for the length of the message. The busy-wait ports to Linux; the priority does not, without CAP_SYS_NICE, and a garbage collection mid-element is audible. A direct serial keyer stays a reasonable third option, to be taken knowingly. Running it against a fake daemon that takes 1.5 seconds to play a message: six CQs went out back to back and the keyboard moved between the two entry windows each time. Escape stopped it, let the message in flight finish, and started nothing further. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
22
README.md
22
README.md
@@ -208,7 +208,27 @@ out, so a message cannot go out of the radio you have just left. A command that
|
|||||||
would change nothing is not sent, because the box works relays. Without a box
|
would change nothing is not sent, because the box works relays. Without a box
|
||||||
the logger just keeps track of which radio it is on and you switch by hand.
|
the logger just keeps track of which radio it is on and you switch by hand.
|
||||||
|
|
||||||
Not there yet: alternating CQ, and voice keying on the second radio.
|
Not there yet: voice keying on the second radio.
|
||||||
|
|
||||||
|
### Alternating CQ
|
||||||
|
|
||||||
|
**Ctrl+B** calls CQ on one radio, and when that message has gone out, moves to
|
||||||
|
the other radio and calls there, until Escape or another Ctrl+B. The keyboard,
|
||||||
|
the entry window and the SO2R box follow each turn. N1MM calls this dueling CQs
|
||||||
|
and puts it on the same key.
|
||||||
|
|
||||||
|
It runs off the keyer saying the message has gone out, not off a guess at how
|
||||||
|
long the text takes: `cwdaemon` answers the `<ESC>h` reply request, and a
|
||||||
|
WinKeyer clears the busy bit in its status byte. A keyer that cannot report this
|
||||||
|
cannot drive alternating CQ, and says so rather than keying the second radio
|
||||||
|
while the first is still sending. **Config → Keyer and messages** sets the gap
|
||||||
|
between the two, which is 100 ms by default because an SO2R box works relays.
|
||||||
|
|
||||||
|
CW only for now: it sends the F1 message, and there is no voice keyer.
|
||||||
|
|
||||||
|
Why the keying is `cwdaemon`'s job rather than the logger's, and what it would
|
||||||
|
take to key the port directly as N1MM does, is in
|
||||||
|
[`docs/keying.md`](docs/keying.md).
|
||||||
|
|
||||||
### The bandmap
|
### The bandmap
|
||||||
|
|
||||||
|
|||||||
87
docs/keying.md
Normal file
87
docs/keying.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# How CW gets on the air, and why
|
||||||
|
|
||||||
|
Written 2026-08-27.
|
||||||
|
|
||||||
|
Nonemm has two keying paths and neither of them times the Morse itself:
|
||||||
|
|
||||||
|
| Path | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `CwDaemonSender` | UDP to `cwdaemon`, which keys DTR or RTS on a serial port and does the element timing |
|
||||||
|
| `WinkeyerSender` | serial to a WinKeyer in host mode, which does the element timing in hardware |
|
||||||
|
|
||||||
|
Keying the serial port ourselves — writing the dots and dashes from inside the
|
||||||
|
logger — was looked at and left out. This file says what that would take and
|
||||||
|
why the answer was no for now.
|
||||||
|
|
||||||
|
## What N1MM does
|
||||||
|
|
||||||
|
N1MM has no cwdaemon. It keys the port itself, in `CWInt.cs`:
|
||||||
|
|
||||||
|
- `PortOn(n)` sets `CommPort.DtrEnable` or `RtsEnable` (or writes a parallel
|
||||||
|
port bit through `inpout32.dll`), then waits `1200000 × n ÷ wpm`
|
||||||
|
microseconds, minus the time the port write itself took. `PortOff(n)` is the
|
||||||
|
same with the line dropped. `n` is the element length in dot units.
|
||||||
|
- The wait is `waitunit`: sleep in 40, 20, 4 and 1 ms steps while there is
|
||||||
|
slack, then busy-wait on a `Stopwatch` for the rest. An element can end late
|
||||||
|
but never early.
|
||||||
|
- The spin margin measures the machine. `CntDnAmount` starts at 2000 µs and
|
||||||
|
grows every time a sleep overshoots — 200 µs for a small overshoot, up to
|
||||||
|
10 ms for a large one — and never shrinks during the run. After a few
|
||||||
|
characters it has found how sloppy this machine's timers are and starts
|
||||||
|
spinning early enough to land on time.
|
||||||
|
- Error does not accumulate: each `PortOn`/`PortOff` starts its own
|
||||||
|
`Stopwatch`, so one late element does not push the rest late.
|
||||||
|
- `sendCW` raises the thread to `THREAD_PRIORITY_TIME_CRITICAL` for the length
|
||||||
|
of the message — `SetPriority((IntPtr)32, 15)` — and drops it back to normal
|
||||||
|
afterwards. While CW is going out, that thread preempts the screen, the
|
||||||
|
database and the network.
|
||||||
|
|
||||||
|
The keying also sits behind its own UDP listener, `CWIFMain` and `UDPClass`,
|
||||||
|
which N1MM calls the CW interface. In N1MM Classic it was a separate process;
|
||||||
|
in Logger+ it is a module in the same process, still spoken to over UDP. That
|
||||||
|
is the same shape as cwdaemon.
|
||||||
|
|
||||||
|
## Why we use cwdaemon instead
|
||||||
|
|
||||||
|
Most of N1MM's recipe ports. `SerialPort.DtrEnable` works on Linux, `Stopwatch`
|
||||||
|
is the same class, and a thread can spin the same way. Two things do not:
|
||||||
|
|
||||||
|
**Thread priority.** `THREAD_PRIORITY_TIME_CRITICAL` has real teeth on Windows.
|
||||||
|
On Linux, .NET's `ThreadPriority.Highest` is a nice value, and nice does not
|
||||||
|
stop the scheduler taking the core away mid-element. The equivalent is
|
||||||
|
`SCHED_FIFO`, which needs `CAP_SYS_NICE` or root. cwdaemon can have that
|
||||||
|
privilege; a logger the operator starts from a desktop should not ask for it.
|
||||||
|
|
||||||
|
**Garbage collection.** A collection that stops the keying thread part way
|
||||||
|
through an element makes an element the wrong length, and that is audible.
|
||||||
|
N1MM has the same exposure and lives with it. cwdaemon does not have it at all,
|
||||||
|
being C.
|
||||||
|
|
||||||
|
The parallel port is not worth copying either: `inpout32` has no Linux
|
||||||
|
equivalent that works without root, and the hardware is gone.
|
||||||
|
|
||||||
|
## What was decided
|
||||||
|
|
||||||
|
Keep cwdaemon as the Linux path and the WinKeyer as the hardware path. A
|
||||||
|
`SerialCwSender` doing N1MM's coarse-sleep-then-spin is a reasonable third
|
||||||
|
keyer kind later: it would remove the install-cwdaemon step, it is the only
|
||||||
|
software path that works on Windows without a WinKeyer, and its completion
|
||||||
|
signal would be exact rather than a UDP round trip. On Windows it would be as
|
||||||
|
good as N1MM. On Linux it would be worse than cwdaemon, for the two reasons
|
||||||
|
above, and that is the trade to make knowingly rather than by accident.
|
||||||
|
|
||||||
|
## Knowing when a message has gone out
|
||||||
|
|
||||||
|
Alternating CQ needs the end of a message, and both paths report it:
|
||||||
|
|
||||||
|
- cwdaemon: `<ESC>h<text>` in front of the message asks for a reply, and the
|
||||||
|
daemon sends `h<text>` back on the same socket once it has played. The
|
||||||
|
request covers one message, so it goes out before every message.
|
||||||
|
- WinKeyer: a byte from 0xC0 to 0xDF is a status byte and 0x04 is set while the
|
||||||
|
keyer is sending, so busy going off is the end. The bit meanings are from
|
||||||
|
N1MM's `Winkey.cs`; the K1EL datasheet is a scanned PDF that does not extract
|
||||||
|
as text.
|
||||||
|
|
||||||
|
Timing the message from the length of its text was rejected. The guess runs
|
||||||
|
short exactly when the operator has turned the speed up, and a short guess keys
|
||||||
|
the second radio while the first is still sending.
|
||||||
@@ -16,22 +16,22 @@ is still sitting there undiscovered.
|
|||||||
| `OtrspBox` | a `MemoryStream`, checking the bytes | no real SO2R box. Command forms are from N1MM's `N1MMPort.cs`. |
|
| `OtrspBox` | a `MemoryStream`, checking the bytes | no real SO2R box. Command forms are from N1MM's `N1MMPort.cs`. |
|
||||||
| `ClusterClient` | a node fake over a real socket, sending the telnet negotiation, the login prompt and spot lines | no live cluster node. Which nodes send bare CR, and which send option negotiation, is guessed from N1MM's code. |
|
| `ClusterClient` | a node fake over a real socket, sending the telnet negotiation, the login prompt and spot lines | no live cluster node. Which nodes send bare CR, and which send option negotiation, is guessed from N1MM's code. |
|
||||||
| `StationNetwork` | the message format, round-tripped | no second station, and no N1MM on the same network. |
|
| `StationNetwork` | the message format, round-tripped | no second station, and no N1MM on the same network. |
|
||||||
| `CwDaemonSender` | the UDP messages | no `cwdaemon`, no radio keyed. |
|
| `CwDaemonSender` | the UDP messages, and a fake daemon that answers the `<ESC>h` reply request | no `cwdaemon`, no radio keyed. |
|
||||||
| `WinkeyerSender` | nothing | no test at all, and no WinKeyer. The host-mode open sequence is from the WinKeyer datasheet. |
|
| `WinkeyerSender` | the status-byte reader, on its own | no test of the serial side, and no WinKeyer. The host-mode open sequence is from the WinKeyer datasheet; the status bits are from N1MM's `Winkey.cs`. |
|
||||||
|
|
||||||
The Cabrillo output has not been put in front of a contest sponsor's robot.
|
The Cabrillo output has not been put in front of a contest sponsor's robot.
|
||||||
|
|
||||||
## Half-built
|
## Half-built
|
||||||
|
|
||||||
**Alternating CQ (SO2R).** Needs to know when the keyer has finished sending.
|
|
||||||
`MessageSender` is fire-and-forget: it has `SendAsync`, `AbortAsync` and
|
|
||||||
`SetSpeedAsync` and no completion signal. Both `cwdaemon` and a WinKeyer can
|
|
||||||
report completion, so the interface has to grow first. Timing it from the length
|
|
||||||
of the text would be a guess that goes wrong exactly when the contest is busy.
|
|
||||||
|
|
||||||
**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. No DVK support either, so the second radio
|
recording, and nothing implements it. No DVK support either, so the second radio
|
||||||
of an SO2R station cannot call CQ by voice.
|
of an SO2R station cannot call CQ by voice. Alternating CQ therefore works on CW
|
||||||
|
only, though nothing in it is CW-specific: a voice keyer that reports when the
|
||||||
|
recording has finished would drive it as it stands.
|
||||||
|
|
||||||
|
**Alternating CQ does not restart itself after a contact.** Escape stops it, and
|
||||||
|
it has to be started again with Ctrl+B. N1MM carries on calling after the QSO is
|
||||||
|
logged.
|
||||||
|
|
||||||
**Call history: the section-validating directives.** `!!Validate50State!!`,
|
**Call history: the section-validating directives.** `!!Validate50State!!`,
|
||||||
`!!ValidateArrlSection!!`, `!!MapOnSection!!` and `!!GTA2GH_NT2TER!!` are read
|
`!!ValidateArrlSection!!`, `!!MapOnSection!!` and `!!GTA2GH_NT2TER!!` are read
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public sealed class AppSession : IDisposable
|
|||||||
private ClusterClient? cluster;
|
private ClusterClient? cluster;
|
||||||
private StationNetwork? network;
|
private StationNetwork? network;
|
||||||
private MessageSender? keyer;
|
private MessageSender? keyer;
|
||||||
|
private AlternatingCq? alternating;
|
||||||
private So2rBox? box;
|
private So2rBox? box;
|
||||||
|
|
||||||
public AppSession(UserPaths paths, Settings settings)
|
public AppSession(UserPaths paths, Settings settings)
|
||||||
@@ -91,6 +92,10 @@ public sealed class AppSession : IDisposable
|
|||||||
|
|
||||||
public MessageSender? Keyer => keyer;
|
public MessageSender? Keyer => keyer;
|
||||||
|
|
||||||
|
/// Alternating CQ, or null while there is no keyer. It needs two radios to
|
||||||
|
/// do anything, and a keyer that reports when a message has gone out.
|
||||||
|
public AlternatingCq? Alternating => alternating;
|
||||||
|
|
||||||
/// The SO2R box, or null when there is none and the operator switches the
|
/// The SO2R box, or null when there is none and the operator switches the
|
||||||
/// transmitter and the headphones by hand.
|
/// transmitter and the headphones by hand.
|
||||||
public So2rBox? Box => box;
|
public So2rBox? Box => box;
|
||||||
@@ -245,6 +250,8 @@ public sealed class AppSession : IDisposable
|
|||||||
/// without one.
|
/// without one.
|
||||||
public void ApplyKeyerSettings()
|
public void ApplyKeyerSettings()
|
||||||
{
|
{
|
||||||
|
alternating?.Dispose();
|
||||||
|
alternating = null;
|
||||||
keyer?.Dispose();
|
keyer?.Dispose();
|
||||||
keyer = null;
|
keyer = null;
|
||||||
switch (Settings.KeyerKind.ToLowerInvariant())
|
switch (Settings.KeyerKind.ToLowerInvariant())
|
||||||
@@ -261,6 +268,10 @@ public sealed class AppSession : IDisposable
|
|||||||
if (keyer is not null)
|
if (keyer is not null)
|
||||||
{
|
{
|
||||||
_ = keyer.SetSpeedAsync(Settings.KeyerSpeed);
|
_ = keyer.SetSpeedAsync(Settings.KeyerSpeed);
|
||||||
|
alternating = new AlternatingCq(
|
||||||
|
keyer,
|
||||||
|
CallCqOnAsync,
|
||||||
|
TimeSpan.FromMilliseconds(Settings.AlternatingCqGapMs));
|
||||||
}
|
}
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
@@ -303,12 +314,47 @@ public sealed class AppSession : IDisposable
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activeRadio = (activeRadio + 1) % positions.Count;
|
MoveToRadio(positions[(activeRadio + 1) % positions.Count].RadioNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the operator to a radio by number. Alternating CQ calls this from
|
||||||
|
/// the keyer's thread, so everything it raises is posted by the windows.
|
||||||
|
public void MoveToRadio(int radioNumber)
|
||||||
|
{
|
||||||
|
int at = positions.FindIndex(p => p.RadioNumber == radioNumber);
|
||||||
|
if (at < 0 || at == activeRadio)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeRadio = at;
|
||||||
_ = FollowActiveRadioAsync();
|
_ = FollowActiveRadioAsync();
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
ActiveRadioChanged?.Invoke(this, ActiveRadioNumber);
|
ActiveRadioChanged?.Invoke(this, ActiveRadioNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Moves to the radio and sends its CQ message, which is F1. Used by
|
||||||
|
/// alternating CQ; the operator's own F1 goes through the entry window.
|
||||||
|
private async Task CallCqOnAsync(int radioNumber)
|
||||||
|
{
|
||||||
|
RadioPosition position = positions.FirstOrDefault(p => p.RadioNumber == radioNumber)
|
||||||
|
?? throw new InvalidOperationException($"there is no radio {radioNumber}");
|
||||||
|
if (keyer is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("there is no keyer");
|
||||||
|
}
|
||||||
|
string template = Messages.For(
|
||||||
|
position.Mode.Category,
|
||||||
|
Settings.CwMessages,
|
||||||
|
Settings.PhoneMessages)[0];
|
||||||
|
if (template.Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("F1 has no message — Config ▸ Keyer and messages");
|
||||||
|
}
|
||||||
|
MoveToRadio(radioNumber);
|
||||||
|
await PointTransmitAtAsync(radioNumber).ConfigureAwait(false);
|
||||||
|
await keyer.SendAsync(MessageExpander.Expand(template, position)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
/// Puts both radios in the headphones, or goes back to one. An operator
|
/// Puts both radios in the headphones, or goes back to one. An operator
|
||||||
/// listens to the second radio while the first is sending.
|
/// listens to the second radio while the first is sending.
|
||||||
public void ToggleListenToBoth()
|
public void ToggleListenToBoth()
|
||||||
@@ -414,6 +460,7 @@ public sealed class AppSession : IDisposable
|
|||||||
DisposeRadios();
|
DisposeRadios();
|
||||||
cluster?.Dispose();
|
cluster?.Dispose();
|
||||||
network?.Dispose();
|
network?.Dispose();
|
||||||
|
alternating?.Dispose();
|
||||||
keyer?.Dispose();
|
keyer?.Dispose();
|
||||||
box?.Dispose();
|
box?.Dispose();
|
||||||
store?.Dispose();
|
store?.Dispose();
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ public sealed record Settings
|
|||||||
|
|
||||||
public int KeyerSpeed { get; init; } = 28;
|
public int KeyerSpeed { get; init; } = 28;
|
||||||
|
|
||||||
|
/// How long alternating CQ leaves between one message ending and the other
|
||||||
|
/// radio starting. N1MM asks for the same number and will not go below
|
||||||
|
/// 100 ms, because an SO2R box works relays.
|
||||||
|
public int AlternatingCqGapMs { get; init; } = 100;
|
||||||
|
|
||||||
/// Sub-band boundaries the operator has changed. Empty means the defaults
|
/// Sub-band boundaries the operator has changed. Empty means the defaults
|
||||||
/// in `BandPlan.Default`; an entry replaces one band's boundaries.
|
/// in `BandPlan.Default`; an entry replaces one band's boundaries.
|
||||||
public IReadOnlyList<StoredSubBand> SubBands { get; init; } = [];
|
public IReadOnlyList<StoredSubBand> SubBands { get; init; } = [];
|
||||||
|
|||||||
@@ -15,6 +15,13 @@
|
|||||||
<TextBlock Grid.Column="4" Text="Speed" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
<TextBlock Grid.Column="4" Text="Speed" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||||
<TextBox Name="SpeedBox" Grid.Row="1" Grid.Column="4" />
|
<TextBox Name="SpeedBox" Grid.Row="1" Grid.Column="4" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Grid ColumnDefinitions="150,8,*" RowDefinitions="Auto,Auto">
|
||||||
|
<TextBlock Text="Alternating CQ gap (ms)" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||||
|
<TextBox Name="AlternatingGapBox" Grid.Row="1" />
|
||||||
|
<TextBlock Grid.Column="2" Grid.Row="1" FontSize="11" Opacity="0.7"
|
||||||
|
VerticalAlignment="Center" TextWrapping="Wrap"
|
||||||
|
Text="Ctrl+B starts calling CQ on one radio and then the other. Needs two radios." />
|
||||||
|
</Grid>
|
||||||
<TextBlock Text="Messages" FontSize="11" Opacity="0.7" Margin="0,10,0,1" />
|
<TextBlock Text="Messages" FontSize="11" Opacity="0.7" Margin="0,10,0,1" />
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<RadioButton Name="CwButton" Content="CW" GroupName="mode" IsChecked="True" />
|
<RadioButton Name="CwButton" Content="CW" GroupName="mode" IsChecked="True" />
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public sealed partial class KeyerDialog : Window
|
|||||||
KindBox.SelectedItem = settings.KeyerKind;
|
KindBox.SelectedItem = settings.KeyerKind;
|
||||||
KindBox.SelectionChanged += (_, _) => ShowTarget();
|
KindBox.SelectionChanged += (_, _) => ShowTarget();
|
||||||
SpeedBox.Text = settings.KeyerSpeed.ToString();
|
SpeedBox.Text = settings.KeyerSpeed.ToString();
|
||||||
|
AlternatingGapBox.Text = settings.AlternatingCqGapMs.ToString();
|
||||||
CwButton.IsCheckedChanged += (_, _) => ShowMessages();
|
CwButton.IsCheckedChanged += (_, _) => ShowMessages();
|
||||||
ShowTarget();
|
ShowTarget();
|
||||||
BuildMessageBoxes();
|
BuildMessageBoxes();
|
||||||
@@ -91,6 +92,10 @@ public sealed partial class KeyerDialog : Window
|
|||||||
? port
|
? port
|
||||||
: settings.KeyerPort,
|
: settings.KeyerPort,
|
||||||
KeyerSpeed = int.TryParse(SpeedBox.Text, out int speed) ? speed : settings.KeyerSpeed,
|
KeyerSpeed = int.TryParse(SpeedBox.Text, out int speed) ? speed : settings.KeyerSpeed,
|
||||||
|
// N1MM will not go below 100 ms either: the box works relays
|
||||||
|
AlternatingCqGapMs = int.TryParse(AlternatingGapBox.Text, out int gap)
|
||||||
|
? Math.Max(100, gap)
|
||||||
|
: settings.AlternatingCqGapMs,
|
||||||
CwMessages = cwMessages,
|
CwMessages = cwMessages,
|
||||||
PhoneMessages = phoneMessages,
|
PhoneMessages = phoneMessages,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ public sealed partial class EntryWindow : Window
|
|||||||
break;
|
break;
|
||||||
case Key.Escape:
|
case Key.Escape:
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
|
session.Alternating?.Stop();
|
||||||
_ = session.Keyer?.AbortAsync();
|
_ = session.Keyer?.AbortAsync();
|
||||||
Logging.Wipe();
|
Logging.Wipe();
|
||||||
SyncBoxes();
|
SyncBoxes();
|
||||||
@@ -253,6 +254,10 @@ public sealed partial class EntryWindow : Window
|
|||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
MoveFocus(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift));
|
MoveFocus(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift));
|
||||||
break;
|
break;
|
||||||
|
case Key.B when e.KeyModifiers.HasFlag(KeyModifiers.Control):
|
||||||
|
e.Handled = true;
|
||||||
|
ToggleAlternatingCq();
|
||||||
|
break;
|
||||||
case Key.Y when e.KeyModifiers.HasFlag(KeyModifiers.Control):
|
case Key.Y when e.KeyModifiers.HasFlag(KeyModifiers.Control):
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
OnEditLastContact(this, new RoutedEventArgs());
|
OnEditLastContact(this, new RoutedEventArgs());
|
||||||
@@ -451,6 +456,46 @@ public sealed partial class EntryWindow : Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Alternating CQ: CQ on this radio, then the other as each message ends.
|
||||||
|
/// N1MM calls it dueling CQs and puts it on the same key.
|
||||||
|
private void ToggleAlternatingCq()
|
||||||
|
{
|
||||||
|
if (session.Alternating is not { } alternating)
|
||||||
|
{
|
||||||
|
Status("no keyer — Config ▸ Keyer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (alternating.IsRunning)
|
||||||
|
{
|
||||||
|
alternating.Stop();
|
||||||
|
Status("alternating CQ off");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.Positions.Count < 2)
|
||||||
|
{
|
||||||
|
Status("alternating CQ needs two radios");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!alternating.IsPossible)
|
||||||
|
{
|
||||||
|
Status("this keyer does not report when a message has gone out");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alternating.Stopped -= OnAlternatingStopped;
|
||||||
|
alternating.Stopped += OnAlternatingStopped;
|
||||||
|
alternating.Start(radioNumber);
|
||||||
|
Status($"alternating CQ · radio {radioNumber} first");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAlternatingStopped(object? sender, string reason)
|
||||||
|
{
|
||||||
|
if (sender is AlternatingCq alternating)
|
||||||
|
{
|
||||||
|
alternating.Stopped -= OnAlternatingStopped;
|
||||||
|
}
|
||||||
|
Dispatcher.UIThread.Post(() => Status($"alternating CQ stopped: {reason}"));
|
||||||
|
}
|
||||||
|
|
||||||
private void SendMessage(int index)
|
private void SendMessage(int index)
|
||||||
{
|
{
|
||||||
if (Logging is null || session.Keyer is null)
|
if (Logging is null || session.Keyer is null)
|
||||||
|
|||||||
@@ -7,21 +7,40 @@ namespace Nonemm.Keying;
|
|||||||
/// Sends CW through `cwdaemon`, which keys the radio from a serial or parallel
|
/// Sends CW through `cwdaemon`, which keys the radio from a serial or parallel
|
||||||
/// port and is what Linux stations usually run. Its protocol is one UDP
|
/// port and is what Linux stations usually run. Its protocol is one UDP
|
||||||
/// datagram per command; escape sequences start with a 0x1B byte.
|
/// datagram per command; escape sequences start with a 0x1B byte.
|
||||||
|
///
|
||||||
|
/// `<ESC>h<text>` asks for a reply once the next message has been played, and
|
||||||
|
/// cwdaemon answers with `h` and that text. The request covers one message
|
||||||
|
/// only, so it goes out in front of every message.
|
||||||
|
///
|
||||||
|
/// Why the timing is cwdaemon's job and not ours: `docs/keying.md`.
|
||||||
public sealed class CwDaemonSender : MessageSender
|
public sealed class CwDaemonSender : MessageSender
|
||||||
{
|
{
|
||||||
private readonly UdpClient socket = new();
|
private const string ReplyToken = "nonemm";
|
||||||
|
|
||||||
|
// bound before anything is sent, so the reply has somewhere to arrive
|
||||||
|
private readonly UdpClient socket = new(new IPEndPoint(IPAddress.Any, 0));
|
||||||
private readonly IPEndPoint daemon;
|
private readonly IPEndPoint daemon;
|
||||||
|
private readonly CancellationTokenSource reading = new();
|
||||||
|
|
||||||
public CwDaemonSender(string host = "127.0.0.1", int port = 6789)
|
public CwDaemonSender(string host = "127.0.0.1", int port = 6789)
|
||||||
{
|
{
|
||||||
daemon = new IPEndPoint(IPAddress.Parse(host), port);
|
daemon = new IPEndPoint(IPAddress.Parse(host), port);
|
||||||
IsReady = true;
|
IsReady = true;
|
||||||
|
_ = ReadRepliesAsync(reading.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsReady { get; private set; }
|
public bool IsReady { get; private set; }
|
||||||
|
|
||||||
public Task SendAsync(string text, CancellationToken cancellation = default) =>
|
public bool ReportsCompletion => true;
|
||||||
WriteAsync(Encoding.ASCII.GetBytes(text.ToUpperInvariant()), cancellation);
|
|
||||||
|
public event EventHandler? Finished;
|
||||||
|
|
||||||
|
public async Task SendAsync(string text, CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
await WriteAsync(Escape('h', ReplyToken), cancellation).ConfigureAwait(false);
|
||||||
|
await WriteAsync(Encoding.ASCII.GetBytes(text.ToUpperInvariant()), cancellation)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
public Task AbortAsync(CancellationToken cancellation = default) =>
|
public Task AbortAsync(CancellationToken cancellation = default) =>
|
||||||
WriteAsync([0x1B, (byte)'4'], cancellation);
|
WriteAsync([0x1B, (byte)'4'], cancellation);
|
||||||
@@ -29,11 +48,44 @@ public sealed class CwDaemonSender : MessageSender
|
|||||||
public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) =>
|
public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) =>
|
||||||
WriteAsync(Escape('2', wordsPerMinute.ToString()), cancellation);
|
WriteAsync(Escape('2', wordsPerMinute.ToString()), cancellation);
|
||||||
|
|
||||||
public void Dispose() => socket.Dispose();
|
public void Dispose()
|
||||||
|
{
|
||||||
|
reading.Cancel();
|
||||||
|
reading.Dispose();
|
||||||
|
socket.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
private static byte[] Escape(char command, string argument) =>
|
private static byte[] Escape(char command, string argument) =>
|
||||||
[0x1B, (byte)command, .. Encoding.ASCII.GetBytes(argument)];
|
[0x1B, (byte)command, .. Encoding.ASCII.GetBytes(argument)];
|
||||||
|
|
||||||
|
/// cwdaemon's reply arrives on the same socket the commands went out on.
|
||||||
|
/// A reply for anything other than our own token is another program's
|
||||||
|
/// business and is passed over.
|
||||||
|
private async Task ReadRepliesAsync(CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
while (!cancellation.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
UdpReceiveResult received;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
received = await socket.ReceiveAsync(cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (SocketException)
|
||||||
|
{
|
||||||
|
// a datagram that could not be read says nothing about the next
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Encoding.ASCII.GetString(received.Buffer).TrimEnd('\r', '\n') == $"h{ReplyToken}")
|
||||||
|
{
|
||||||
|
Finished?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task WriteAsync(byte[] message, CancellationToken cancellation)
|
private async Task WriteAsync(byte[] message, CancellationToken cancellation)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ public interface MessageSender : IDisposable
|
|||||||
{
|
{
|
||||||
bool IsReady { get; }
|
bool IsReady { get; }
|
||||||
|
|
||||||
|
/// True when the keyer tells us the message has gone out. Alternating CQ
|
||||||
|
/// needs it: timing a message from the length of its text is a guess that
|
||||||
|
/// goes wrong exactly when the contest is busy.
|
||||||
|
bool ReportsCompletion { get; }
|
||||||
|
|
||||||
|
/// Everything sent has now gone out on the air. Never raised by a keyer
|
||||||
|
/// whose `ReportsCompletion` is false. It arrives on whatever thread the
|
||||||
|
/// keyer reads on, so a handler that touches the screen has to post.
|
||||||
|
event EventHandler? Finished;
|
||||||
|
|
||||||
/// Sends the text. Whatever is already going out is finished first unless
|
/// Sends the text. Whatever is already going out is finished first unless
|
||||||
/// `Abort` is called.
|
/// `Abort` is called.
|
||||||
Task SendAsync(string text, CancellationToken cancellation = default);
|
Task SendAsync(string text, CancellationToken cancellation = default);
|
||||||
|
|||||||
@@ -15,14 +15,22 @@ public sealed class WinkeyerSender : MessageSender
|
|||||||
private const byte ClearBuffer = 0x0A;
|
private const byte ClearBuffer = 0x0A;
|
||||||
|
|
||||||
private readonly SerialPort port;
|
private readonly SerialPort port;
|
||||||
|
private readonly WinkeyerStatus status = new();
|
||||||
|
|
||||||
public WinkeyerSender(string portName, int baudRate = 1200)
|
public WinkeyerSender(string portName, int baudRate = 1200)
|
||||||
{
|
{
|
||||||
port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.Two);
|
port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.Two);
|
||||||
|
port.DataReceived += (_, _) => ReadStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsReady => port.IsOpen;
|
public bool IsReady => port.IsOpen;
|
||||||
|
|
||||||
|
/// The keyer sends a status byte of its own accord whenever it starts or
|
||||||
|
/// stops sending, so nothing has to be asked for.
|
||||||
|
public bool ReportsCompletion => true;
|
||||||
|
|
||||||
|
public event EventHandler? Finished;
|
||||||
|
|
||||||
/// Opens the port and puts the keyer in host mode. The keyer answers with
|
/// Opens the port and puts the keyer in host mode. The keyer answers with
|
||||||
/// its firmware version, which is read and thrown away.
|
/// its firmware version, which is read and thrown away.
|
||||||
public void Open()
|
public void Open()
|
||||||
@@ -61,6 +69,19 @@ public sealed class WinkeyerSender : MessageSender
|
|||||||
port.Dispose();
|
port.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs on the serial port's own thread.
|
||||||
|
private void ReadStatus()
|
||||||
|
{
|
||||||
|
int waiting = port.BytesToRead;
|
||||||
|
for (int at = 0; at < waiting; at++)
|
||||||
|
{
|
||||||
|
if (status.Read((byte)port.ReadByte()))
|
||||||
|
{
|
||||||
|
Finished?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void Write(byte[] message)
|
private void Write(byte[] message)
|
||||||
{
|
{
|
||||||
if (!port.IsOpen)
|
if (!port.IsOpen)
|
||||||
|
|||||||
33
src/Nonemm.Keying/WinkeyerStatus.cs
Normal file
33
src/Nonemm.Keying/WinkeyerStatus.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
namespace Nonemm.Keying;
|
||||||
|
|
||||||
|
/// Reads the bytes a WinKeyer sends back in host mode and works out when it has
|
||||||
|
/// stopped sending.
|
||||||
|
///
|
||||||
|
/// A byte from 0xC0 to 0xDF is a status byte; the low six bits are the flags,
|
||||||
|
/// and 0x04 is set while the keyer is sending. Bytes from 0x80 to 0xBF are the
|
||||||
|
/// speed pot, and printable bytes are the keyer echoing what it has sent.
|
||||||
|
/// Neither says anything about the buffer, so both are passed over.
|
||||||
|
///
|
||||||
|
/// Where the bit meanings come from: `docs/keying.md`.
|
||||||
|
public sealed class WinkeyerStatus
|
||||||
|
{
|
||||||
|
private const byte StatusLow = 0xC0;
|
||||||
|
private const byte StatusHigh = 0xDF;
|
||||||
|
private const byte BusyFlag = 0x04;
|
||||||
|
|
||||||
|
public bool IsSending { get; private set; }
|
||||||
|
|
||||||
|
/// True on the byte that says the keyer has finished: it was sending and
|
||||||
|
/// now is not. Every other byte returns false, including a second status
|
||||||
|
/// byte that repeats what the last one said.
|
||||||
|
public bool Read(byte value)
|
||||||
|
{
|
||||||
|
if (value is < StatusLow or > StatusHigh)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool wasSending = IsSending;
|
||||||
|
IsSending = (value & BusyFlag) != 0;
|
||||||
|
return wasSending && !IsSending;
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/Nonemm.Session/AlternatingCq.cs
Normal file
147
src/Nonemm.Session/AlternatingCq.cs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
using Nonemm.Keying;
|
||||||
|
|
||||||
|
namespace Nonemm.Session;
|
||||||
|
|
||||||
|
/// Calls CQ on one radio, and when that message has gone out, moves to the
|
||||||
|
/// other radio and calls there. N1MM calls it dueling CQs and toggles it with
|
||||||
|
/// Ctrl+B.
|
||||||
|
///
|
||||||
|
/// It runs off the keyer's completion signal. Working out when a message ends
|
||||||
|
/// from the length of the text would be a guess, and a guess that runs short
|
||||||
|
/// keys the second radio while the first is still sending.
|
||||||
|
///
|
||||||
|
/// Two radios, because that is what an SO2R station has.
|
||||||
|
public sealed class AlternatingCq : IDisposable
|
||||||
|
{
|
||||||
|
private readonly MessageSender keyer;
|
||||||
|
private readonly Func<int, Task> callCqOn;
|
||||||
|
private readonly Func<TimeSpan, CancellationToken, Task> wait;
|
||||||
|
private readonly Lock gate = new();
|
||||||
|
private CancellationTokenSource? running;
|
||||||
|
|
||||||
|
/// `callCqOn` moves the operator to that radio and sends the CQ message.
|
||||||
|
/// `wait` is there so a test does not have to sleep.
|
||||||
|
public AlternatingCq(
|
||||||
|
MessageSender keyer,
|
||||||
|
Func<int, Task> callCqOn,
|
||||||
|
TimeSpan gap,
|
||||||
|
Func<TimeSpan, CancellationToken, Task>? wait = null)
|
||||||
|
{
|
||||||
|
this.keyer = keyer;
|
||||||
|
this.callCqOn = callCqOn;
|
||||||
|
Gap = gap;
|
||||||
|
this.wait = wait ?? Task.Delay;
|
||||||
|
keyer.Finished += OnFinished;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long to leave between the message ending and the other radio
|
||||||
|
/// starting. The relays of an SO2R box need a moment.
|
||||||
|
public TimeSpan Gap { get; }
|
||||||
|
|
||||||
|
public bool IsRunning
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return running is not null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The radio it is calling on now.
|
||||||
|
public int RadioNumber { get; private set; }
|
||||||
|
|
||||||
|
/// Raised when it stops by itself, with the reason. Stopping by hand does
|
||||||
|
/// not raise it.
|
||||||
|
public event EventHandler<string>? Stopped;
|
||||||
|
|
||||||
|
/// True when the keyer can drive this at all.
|
||||||
|
public bool IsPossible => keyer.ReportsCompletion;
|
||||||
|
|
||||||
|
public void Start(int radioNumber)
|
||||||
|
{
|
||||||
|
if (!IsPossible)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("this keyer does not report when a message has gone out");
|
||||||
|
}
|
||||||
|
CancellationToken token;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
running?.Cancel();
|
||||||
|
running?.Dispose();
|
||||||
|
running = new CancellationTokenSource();
|
||||||
|
token = running.Token;
|
||||||
|
}
|
||||||
|
RadioNumber = radioNumber;
|
||||||
|
_ = CallAsync(radioNumber, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
running?.Cancel();
|
||||||
|
running?.Dispose();
|
||||||
|
running = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
keyer.Finished -= OnFinished;
|
||||||
|
Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFinished(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
CancellationToken token;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (running is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
token = running.Token;
|
||||||
|
}
|
||||||
|
_ = NextAsync(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NextAsync(CancellationToken token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await wait(Gap, token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RadioNumber = RadioNumber == 1 ? 2 : 1;
|
||||||
|
await CallAsync(RadioNumber, token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A keyer that has gone away stops the whole thing rather than leaving one
|
||||||
|
/// radio calling into a dead port.
|
||||||
|
private async Task CallAsync(int radioNumber, CancellationToken token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await callCqOn(radioNumber).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is InvalidOperationException or IOException)
|
||||||
|
{
|
||||||
|
// read before Stop, which cancels this same token
|
||||||
|
bool stoppedByHand = token.IsCancellationRequested;
|
||||||
|
Stop();
|
||||||
|
if (!stoppedByHand)
|
||||||
|
{
|
||||||
|
Stopped?.Invoke(this, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<ProjectReference Include="..\Nonemm.Storage\Nonemm.Storage.csproj" />
|
<ProjectReference Include="..\Nonemm.Storage\Nonemm.Storage.csproj" />
|
||||||
<ProjectReference Include="..\Nonemm.Formats\Nonemm.Formats.csproj" />
|
<ProjectReference Include="..\Nonemm.Formats\Nonemm.Formats.csproj" />
|
||||||
<ProjectReference Include="..\Nonemm.Spotting\Nonemm.Spotting.csproj" />
|
<ProjectReference Include="..\Nonemm.Spotting\Nonemm.Spotting.csproj" />
|
||||||
|
<ProjectReference Include="..\Nonemm.Keying\Nonemm.Keying.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@@ -27,11 +27,49 @@ public class CwDaemonSenderTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TextGoesOutInUpperCase()
|
public async Task TextGoesOutInUpperCaseBehindAReplyRequest()
|
||||||
{
|
{
|
||||||
using CwDaemonSender sender = new("127.0.0.1", Port);
|
using CwDaemonSender sender = new("127.0.0.1", Port);
|
||||||
byte[] sent = await NextDatagram(() => sender.SendAsync("cq test de dl1abc"));
|
await sender.SendAsync("cq test de dl1abc");
|
||||||
Assert.Equal("CQ TEST DE DL1ABC", Encoding.ASCII.GetString(sent));
|
|
||||||
|
// one receive at a time: two at once can take the datagrams in either order
|
||||||
|
UdpReceiveResult request = await daemon.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
UdpReceiveResult text = await daemon.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal("\u001bhnonemm", Encoding.ASCII.GetString(request.Buffer));
|
||||||
|
Assert.Equal("CQ TEST DE DL1ABC", Encoding.ASCII.GetString(text.Buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cwdaemon answers with `h` and the text of the reply request once the
|
||||||
|
/// message has been played.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheReplySaysTheMessageHasGoneOut()
|
||||||
|
{
|
||||||
|
using CwDaemonSender sender = new("127.0.0.1", Port);
|
||||||
|
TaskCompletionSource finished = new();
|
||||||
|
sender.Finished += (_, _) => finished.TrySetResult();
|
||||||
|
|
||||||
|
Task<UdpReceiveResult> request = daemon.ReceiveAsync();
|
||||||
|
await sender.SendAsync("test");
|
||||||
|
UdpReceiveResult from = await request.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
await daemon.SendAsync(Encoding.ASCII.GetBytes("hnonemm\r\n"), from.RemoteEndPoint);
|
||||||
|
|
||||||
|
await finished.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AReplyForSomebodyElseIsPassedOver()
|
||||||
|
{
|
||||||
|
using CwDaemonSender sender = new("127.0.0.1", Port);
|
||||||
|
TaskCompletionSource finished = new();
|
||||||
|
sender.Finished += (_, _) => finished.TrySetResult();
|
||||||
|
|
||||||
|
Task<UdpReceiveResult> request = daemon.ReceiveAsync();
|
||||||
|
await sender.SendAsync("test");
|
||||||
|
UdpReceiveResult from = await request.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
await daemon.SendAsync(Encoding.ASCII.GetBytes("hsomebodyelse\r\n"), from.RemoteEndPoint);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<TimeoutException>(
|
||||||
|
() => finished.Task.WaitAsync(TimeSpan.FromMilliseconds(300)));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
49
tests/Nonemm.Keying.Tests/WinkeyerStatusTests.cs
Normal file
49
tests/Nonemm.Keying.Tests/WinkeyerStatusTests.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
namespace Nonemm.Keying.Tests;
|
||||||
|
|
||||||
|
public class WinkeyerStatusTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SendingEndsWhenTheBusyFlagClears()
|
||||||
|
{
|
||||||
|
WinkeyerStatus status = new();
|
||||||
|
|
||||||
|
Assert.False(status.Read(0xC4));
|
||||||
|
Assert.True(status.IsSending);
|
||||||
|
Assert.True(status.Read(0xC0));
|
||||||
|
Assert.False(status.IsSending);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AStatusByteThatRepeatsTheLastOneIsNotAnEnding()
|
||||||
|
{
|
||||||
|
WinkeyerStatus status = new();
|
||||||
|
status.Read(0xC4);
|
||||||
|
status.Read(0xC0);
|
||||||
|
|
||||||
|
Assert.False(status.Read(0xC0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearingTheBusyFlagWithTheBreakInFlagSetStillEndsIt()
|
||||||
|
{
|
||||||
|
WinkeyerStatus status = new();
|
||||||
|
status.Read(0xC4);
|
||||||
|
|
||||||
|
Assert.True(status.Read(0xC2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The speed pot and the characters the keyer echoes say nothing about the
|
||||||
|
/// buffer.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x80)]
|
||||||
|
[InlineData(0xBF)]
|
||||||
|
[InlineData((byte)'K')]
|
||||||
|
public void OtherBytesAreNotStatus(byte value)
|
||||||
|
{
|
||||||
|
WinkeyerStatus status = new();
|
||||||
|
status.Read(0xC4);
|
||||||
|
|
||||||
|
Assert.False(status.Read(value));
|
||||||
|
Assert.True(status.IsSending);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
tests/Nonemm.Session.Tests/AlternatingCqTests.cs
Normal file
136
tests/Nonemm.Session.Tests/AlternatingCqTests.cs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
using Nonemm.Keying;
|
||||||
|
|
||||||
|
namespace Nonemm.Session.Tests;
|
||||||
|
|
||||||
|
public class AlternatingCqTests
|
||||||
|
{
|
||||||
|
/// A keyer that sends nothing and says it has finished when the test says
|
||||||
|
/// so, so the alternation is what is under test rather than a timer.
|
||||||
|
private sealed class FakeKeyer : MessageSender
|
||||||
|
{
|
||||||
|
public bool IsReady => true;
|
||||||
|
|
||||||
|
public bool ReportsCompletion { get; init; } = true;
|
||||||
|
|
||||||
|
public event EventHandler? Finished;
|
||||||
|
|
||||||
|
public void FinishMessage() => Finished?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
public Task SendAsync(string text, CancellationToken cancellation = default) =>
|
||||||
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task AbortAsync(CancellationToken cancellation = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task SetSpeedAsync(int wordsPerMinute, CancellationToken cancellation = default) =>
|
||||||
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Func<TimeSpan, CancellationToken, Task> NoWait =
|
||||||
|
(_, _) => Task.CompletedTask;
|
||||||
|
|
||||||
|
private static AlternatingCq Driving(FakeKeyer keyer, List<int> called) =>
|
||||||
|
new(
|
||||||
|
keyer,
|
||||||
|
radio =>
|
||||||
|
{
|
||||||
|
called.Add(radio);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
TimeSpan.Zero,
|
||||||
|
NoWait);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StartingCallsOnTheRadioItWasGiven()
|
||||||
|
{
|
||||||
|
FakeKeyer keyer = new();
|
||||||
|
List<int> called = [];
|
||||||
|
using AlternatingCq cq = Driving(keyer, called);
|
||||||
|
|
||||||
|
cq.Start(2);
|
||||||
|
|
||||||
|
Assert.Equal([2], called);
|
||||||
|
Assert.True(cq.IsRunning);
|
||||||
|
Assert.Equal(2, cq.RadioNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheOtherRadioCallsOnceTheMessageHasGoneOut()
|
||||||
|
{
|
||||||
|
FakeKeyer keyer = new();
|
||||||
|
List<int> called = [];
|
||||||
|
using AlternatingCq cq = Driving(keyer, called);
|
||||||
|
|
||||||
|
cq.Start(1);
|
||||||
|
keyer.FinishMessage();
|
||||||
|
keyer.FinishMessage();
|
||||||
|
keyer.FinishMessage();
|
||||||
|
|
||||||
|
Assert.Equal([1, 2, 1, 2], called);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StoppingLeavesTheKeyerAlone()
|
||||||
|
{
|
||||||
|
FakeKeyer keyer = new();
|
||||||
|
List<int> called = [];
|
||||||
|
using AlternatingCq cq = Driving(keyer, called);
|
||||||
|
|
||||||
|
cq.Start(1);
|
||||||
|
cq.Stop();
|
||||||
|
keyer.FinishMessage();
|
||||||
|
|
||||||
|
Assert.Equal([1], called);
|
||||||
|
Assert.False(cq.IsRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message that was already going out when the operator stopped still
|
||||||
|
/// reports itself finished, and that must not start the other radio.
|
||||||
|
[Fact]
|
||||||
|
public void AMessageFinishingAfterAStopStartsNothing()
|
||||||
|
{
|
||||||
|
FakeKeyer keyer = new();
|
||||||
|
List<int> called = [];
|
||||||
|
using AlternatingCq cq = Driving(keyer, called);
|
||||||
|
|
||||||
|
cq.Start(1);
|
||||||
|
keyer.FinishMessage();
|
||||||
|
cq.Stop();
|
||||||
|
keyer.FinishMessage();
|
||||||
|
|
||||||
|
Assert.Equal([1, 2], called);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AKeyerThatCannotReportCompletionCannotDriveIt()
|
||||||
|
{
|
||||||
|
FakeKeyer keyer = new() { ReportsCompletion = false };
|
||||||
|
List<int> called = [];
|
||||||
|
using AlternatingCq cq = Driving(keyer, called);
|
||||||
|
|
||||||
|
Assert.False(cq.IsPossible);
|
||||||
|
Assert.Throws<InvalidOperationException>(() => cq.Start(1));
|
||||||
|
Assert.Empty(called);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AKeyerThatHasGoneAwayStopsTheWholeThing()
|
||||||
|
{
|
||||||
|
FakeKeyer keyer = new();
|
||||||
|
List<string> reasons = [];
|
||||||
|
using AlternatingCq cq = new(
|
||||||
|
keyer,
|
||||||
|
_ => throw new InvalidOperationException("could not reach cwdaemon at 127.0.0.1:6789"),
|
||||||
|
TimeSpan.Zero,
|
||||||
|
NoWait);
|
||||||
|
cq.Stopped += (_, reason) => reasons.Add(reason);
|
||||||
|
|
||||||
|
cq.Start(1);
|
||||||
|
|
||||||
|
Assert.False(cq.IsRunning);
|
||||||
|
Assert.Equal(["could not reach cwdaemon at 127.0.0.1:6789"], reasons);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user