Take the rest of what reading N1MM turned up
The call frame. Above the callsign box, N1MM writes which spotted station the radio is sitting on, coloured like the bandmap colours it, or CQ-Frequency when this is the frequency the last CQ went out on. Space with an empty callsign box takes the call out of the frame and into the box, but only while searching: TabPressed does that when the run box is unchecked, because a running station is answering callers rather than chasing the one under its VFO. How close counts as sitting on it is N1MM's tuning tolerance, 300 Hz, now a setting. Sh/dx while unassisted. TelnetClient.Output refuses any command holding SH/DX when the entry is not assisted, and the telnet window does the same now. Whether an entry is assisted follows N1MM's own rule: assisted, multi-operator or checklog may use spots, a single operator who did not say assisted may not. The node's flavour. Click_LogonButton reads DXSpider, AR-Cluster, CC Cluster or GoCluster out of the banner; ClusterClient reads the same words and the window shows what it found. It changes nothing yet — the commands it would change are ones this does not send — but the node says so and now we know. CQ WW RTTY's state box. NextTab only stops on SectionText when the station is US or Canadian, because nobody else sends a state, so a contest can now say a box is not for this station and the walk steps over it. The box is still there to type in. The telnet button editor shows as many buttons as are defined, with an empty row to write the next one in, rather than N1MM's twelve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
19
README.md
19
README.md
@@ -212,6 +212,17 @@ the other, as N1MM has them. A button puts the radio where you left that band
|
|||||||
and mode, or at the start of that part of the band the first time. The button
|
and mode, or at the start of that part of the band the first time. The button
|
||||||
for where the radio is now is marked.
|
for where the radio is now is marked.
|
||||||
|
|
||||||
|
### The call frame
|
||||||
|
|
||||||
|
Above the callsign box is N1MM's call frame: it says which spotted station the
|
||||||
|
radio is sitting on, coloured the way the bandmap colours it, or `CQ-Frequency`
|
||||||
|
when this is the frequency you last called CQ on. While searching, space with an
|
||||||
|
empty callsign box takes the station out of the frame and into the box, so a
|
||||||
|
spotted station is worked without typing its call. While running it does not,
|
||||||
|
because a running station is answering callers rather than chasing the one it is
|
||||||
|
sitting on. How close the radio has to be to a spot is `TuningToleranceHertz` in
|
||||||
|
`settings.json`, 300 Hz out of the box, which is N1MM's default.
|
||||||
|
|
||||||
### Where the other station is
|
### Where the other station is
|
||||||
|
|
||||||
Under the entry window is the line N1MM writes there: the beam heading, the
|
Under the entry window is the line N1MM writes there: the beam heading, the
|
||||||
@@ -485,6 +496,14 @@ its tooltip shows the command behind it. What is typed on the command line goes
|
|||||||
out as typed, again as N1MM sends it. The one place this differs from N1MM: the
|
out as typed, again as N1MM sends it. The one place this differs from N1MM: the
|
||||||
commands of one button go out together rather than a quarter of a second apart.
|
commands of one button go out together rather than a quarter of a second apart.
|
||||||
|
|
||||||
|
The window says which cluster program the node runs — DXSpider, AR-Cluster, CC
|
||||||
|
Cluster or GoCluster — once the node's banner names it, which is where N1MM
|
||||||
|
reads it too.
|
||||||
|
|
||||||
|
`sh/dx` is refused while the contest entry is single operator and not assisted,
|
||||||
|
with the same message N1MM gives. Asking the node for spots is what an
|
||||||
|
unassisted entry may not do.
|
||||||
|
|
||||||
Three of the filters are worth spelling out:
|
Three of the filters are worth spelling out:
|
||||||
|
|
||||||
- **Busted spots.** A spotted call the callsign database has never heard, but
|
- **Busted spots.** A spotted call the callsign database has never heard, but
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ public sealed record Settings
|
|||||||
/// it has changed since it went out.
|
/// it has changed since it went out.
|
||||||
public bool EsmSendsCorrectedCall { get; init; } = true;
|
public bool EsmSendsCorrectedCall { get; init; } = true;
|
||||||
|
|
||||||
|
/// How close to a spot the radio has to be for the entry window to say the
|
||||||
|
/// station is there. N1MM asks for the same number, one per mode; this is
|
||||||
|
/// one number for all of them.
|
||||||
|
public int TuningToleranceHertz { get; init; } = 300;
|
||||||
|
|
||||||
/// How far `{FREQUP}` and `{FREQDN}` move the radio. N1MM asks for the same
|
/// How far `{FREQUP}` and `{FREQDN}` move the radio. N1MM asks for the same
|
||||||
/// number in its Configurer.
|
/// number in its Configurer.
|
||||||
public int FrequencyStepHertz { get; init; } = 100;
|
public int FrequencyStepHertz { get; init; } = 100;
|
||||||
|
|||||||
84
src/Nonemm.App/Windows/EntryWindow.CallFrame.cs
Normal file
84
src/Nonemm.App/Windows/EntryWindow.CallFrame.cs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
using Nonemm.Contests;
|
||||||
|
using Nonemm.Core;
|
||||||
|
using Nonemm.Session;
|
||||||
|
using Nonemm.Spotting;
|
||||||
|
|
||||||
|
namespace Nonemm.App.Windows;
|
||||||
|
|
||||||
|
/// The line above the callsign box. N1MM calls it the call frame: it says which
|
||||||
|
/// spotted station the radio is sitting on, coloured the way the bandmap
|
||||||
|
/// colours it, or that this is the frequency we are calling CQ on. Space with
|
||||||
|
/// an empty callsign box takes the station out of the frame and into the box,
|
||||||
|
/// which is how a station is worked without typing its call.
|
||||||
|
public sealed partial class EntryWindow
|
||||||
|
{
|
||||||
|
/// Where we last called CQ, so the frame can say so when the radio comes
|
||||||
|
/// back to it.
|
||||||
|
private Frequency cqFrequency;
|
||||||
|
|
||||||
|
/// What the frame holds, when it holds a callsign. `CQ-Frequency` is not
|
||||||
|
/// one, and neither is nothing.
|
||||||
|
private string framedCall = "";
|
||||||
|
|
||||||
|
private Frequency TuningTolerance =>
|
||||||
|
Frequency.FromHertz(Math.Max(0, session.Settings.TuningToleranceHertz));
|
||||||
|
|
||||||
|
private void ShowCallFrame()
|
||||||
|
{
|
||||||
|
framedCall = "";
|
||||||
|
if (Logging is null)
|
||||||
|
{
|
||||||
|
CallFrameText.Text = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cqFrequency.Hertz > 0
|
||||||
|
&& Math.Abs(Logging.Frequency.Hertz - cqFrequency.Hertz) <= TuningTolerance.Hertz)
|
||||||
|
{
|
||||||
|
CallFrameText.Text = "CQ-Frequency";
|
||||||
|
CallFrameText.Foreground = Verdicts.Worth;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.Bandmap.Near(Logging.Frequency, TuningTolerance) is not { } spot)
|
||||||
|
{
|
||||||
|
CallFrameText.Text = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
framedCall = spot.Call.Text;
|
||||||
|
CallFrameText.Text = spot.IsSplit
|
||||||
|
? $"{spot.Call.Text} — listening on {spot.Qsx.Kilohertz:0.0}"
|
||||||
|
: spot.Call.Text;
|
||||||
|
CallFrameText.Foreground = Verdicts.Colour(VerdictFor(spot));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Verdict? VerdictFor(Spot spot) => Logging?.Log.Judge(new Qso
|
||||||
|
{
|
||||||
|
Id = "",
|
||||||
|
TimestampUtc = DateTime.UtcNow,
|
||||||
|
Call = spot.Call,
|
||||||
|
Frequency = spot.Frequency,
|
||||||
|
Mode = Logging.Mode,
|
||||||
|
ContestName = Logging.Contest.Name,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// N1MM only takes the call out of the frame while searching: a running
|
||||||
|
/// station is answering callers, not chasing the one it is sitting on.
|
||||||
|
private bool TakeFramedCall()
|
||||||
|
{
|
||||||
|
if (Logging is null || Logging.IsRunning || framedCall.Length == 0
|
||||||
|
|| Logging.Entry.Call.Trim().Length > 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Logging.Entry.Call = framedCall;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frequency a CQ went out on is the one the frame calls ours.
|
||||||
|
private void RememberCqFrequency()
|
||||||
|
{
|
||||||
|
if (Logging is not null)
|
||||||
|
{
|
||||||
|
cqFrequency = Logging.Frequency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,10 @@ public sealed partial class EntryWindow
|
|||||||
foreach (int key in keys)
|
foreach (int key in keys)
|
||||||
{
|
{
|
||||||
await SendKeyAsync(key, CorrectedCall(key));
|
await SendKeyAsync(key, CorrectedCall(key));
|
||||||
|
if (key == Esm.CallCq)
|
||||||
|
{
|
||||||
|
RememberCqFrequency();
|
||||||
|
}
|
||||||
Remember(key);
|
Remember(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,9 @@
|
|||||||
Text="00:00:00Z" />
|
Text="00:00:00Z" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Name="CallFrameText" FontFamily="monospace" FontSize="13" Height="18"
|
||||||
|
Margin="2,0,0,1" Text="" />
|
||||||
|
|
||||||
<Grid Name="EntryGrid" ColumnDefinitions="Auto" RowDefinitions="Auto,Auto" />
|
<Grid Name="EntryGrid" ColumnDefinitions="Auto" RowDefinitions="Auto,Auto" />
|
||||||
|
|
||||||
<Border Name="VerdictBorder" Margin="0,6,0,0" Padding="6,3" CornerRadius="3"
|
<Border Name="VerdictBorder" Margin="0,6,0,0" Padding="6,3" CornerRadius="3"
|
||||||
|
|||||||
@@ -410,7 +410,8 @@ public sealed partial class EntryWindow : Window
|
|||||||
// the reports can be filled in
|
// the reports can be filled in
|
||||||
if (Logging.Entry.Focus == 0)
|
if (Logging.Entry.Focus == 0)
|
||||||
{
|
{
|
||||||
bool filled = Logging.FillReports();
|
bool filled = TakeFramedCall();
|
||||||
|
filled |= Logging.FillReports();
|
||||||
filled |= Logging.FillFromHistory();
|
filled |= Logging.FillFromHistory();
|
||||||
filled |= FillNameFromHistory();
|
filled |= FillNameFromHistory();
|
||||||
if (filled)
|
if (filled)
|
||||||
@@ -418,11 +419,11 @@ public sealed partial class EntryWindow : Window
|
|||||||
SyncBoxes();
|
SyncBoxes();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Logging.Entry.Advance();
|
Logging.MoveFocus(forward: true);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Logging.Entry.Retreat();
|
Logging.MoveFocus(forward: false);
|
||||||
}
|
}
|
||||||
FocusEntryBox();
|
FocusEntryBox();
|
||||||
}
|
}
|
||||||
@@ -552,6 +553,7 @@ public sealed partial class EntryWindow : Window
|
|||||||
ContestText.Text = ContestLine();
|
ContestText.Text = ContestLine();
|
||||||
|
|
||||||
Title = TitleLine();
|
Title = TitleLine();
|
||||||
|
ShowCallFrame();
|
||||||
PathText.Text = PathLine();
|
PathText.Text = PathLine();
|
||||||
UserTextText.Text = Logging.Session.History.Find(Logging.Entry.Call)?.UserText ?? "";
|
UserTextText.Text = Logging.Session.History.Find(Logging.Entry.Call)?.UserText ?? "";
|
||||||
ShowLights();
|
ShowLights();
|
||||||
@@ -687,6 +689,7 @@ public sealed partial class EntryWindow : Window
|
|||||||
if (index == Esm.CallCq)
|
if (index == Esm.CallCq)
|
||||||
{
|
{
|
||||||
Logging.IsRunning = true;
|
Logging.IsRunning = true;
|
||||||
|
RememberCqFrequency();
|
||||||
}
|
}
|
||||||
SendMessage(index);
|
SendMessage(index);
|
||||||
Refresh();
|
Refresh();
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ namespace Nonemm.App.Windows;
|
|||||||
/// telnet window has.
|
/// telnet window has.
|
||||||
public sealed partial class TelnetWindow
|
public sealed partial class TelnetWindow
|
||||||
{
|
{
|
||||||
private const int ButtonCount = 12;
|
/// N1MM stops at twelve buttons; this shows as many as are defined, and one
|
||||||
|
/// empty row to write the next one in.
|
||||||
|
private const int LeastButtonRows = 12;
|
||||||
|
|
||||||
private readonly List<(TextBox Label, TextBox Command)> buttonRows = [];
|
private readonly List<(TextBox Label, TextBox Command)> buttonRows = [];
|
||||||
|
|
||||||
@@ -18,7 +20,8 @@ public sealed partial class TelnetWindow
|
|||||||
ButtonRows.Children.Clear();
|
ButtonRows.Children.Clear();
|
||||||
ButtonRows.RowDefinitions.Clear();
|
ButtonRows.RowDefinitions.Clear();
|
||||||
IReadOnlyList<StoredTelnetButton> stored = Buttons();
|
IReadOnlyList<StoredTelnetButton> stored = Buttons();
|
||||||
for (int at = 0; at < ButtonCount; at++)
|
int rows = Math.Max(LeastButtonRows, stored.Count + 1);
|
||||||
|
for (int at = 0; at < rows; at++)
|
||||||
{
|
{
|
||||||
ButtonRows.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
ButtonRows.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||||
StoredTelnetButton button = at < stored.Count ? stored[at] : new StoredTelnetButton();
|
StoredTelnetButton button = at < stored.Count ? stored[at] : new StoredTelnetButton();
|
||||||
|
|||||||
@@ -51,10 +51,18 @@ public sealed partial class TelnetWindow : RefreshableWindow
|
|||||||
public override void Refresh()
|
public override void Refresh()
|
||||||
{
|
{
|
||||||
ClusterClient? cluster = session.Cluster;
|
ClusterClient? cluster = session.Cluster;
|
||||||
|
string kind = cluster?.Kind switch
|
||||||
|
{
|
||||||
|
ClusterKind.DxSpider => " · DXSpider",
|
||||||
|
ClusterKind.ArCluster => " · AR-Cluster",
|
||||||
|
ClusterKind.CcCluster => " · CC Cluster",
|
||||||
|
ClusterKind.GoCluster => " · GoCluster",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
StateText.Text = cluster is null
|
StateText.Text = cluster is null
|
||||||
? "not connected — pick a node on the Clusters tab"
|
? "not connected — pick a node on the Clusters tab"
|
||||||
: cluster.IsConnected
|
: cluster.IsConnected
|
||||||
? $"connected to {cluster.Host}:{cluster.Port}"
|
? $"connected to {cluster.Host}:{cluster.Port}{kind}"
|
||||||
: $"connecting to {cluster.Host}:{cluster.Port}…";
|
: $"connecting to {cluster.Host}:{cluster.Port}…";
|
||||||
ReconnectButton.Content = cluster is null ? "Connect" : "Reconnect";
|
ReconnectButton.Content = cluster is null ? "Connect" : "Reconnect";
|
||||||
}
|
}
|
||||||
@@ -258,9 +266,19 @@ public sealed partial class TelnetWindow : RefreshableWindow
|
|||||||
Show("*** not connected to a node", NoticeColour);
|
Show("*** not connected to a node", NoticeColour);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// N1MM refuses this one too: asking the node for spots is what an
|
||||||
|
// unassisted entry may not do, and the category is what the log claims
|
||||||
|
if (command.Contains("SH/DX", StringComparison.OrdinalIgnoreCase) && !IsAssisted)
|
||||||
|
{
|
||||||
|
Show("*** you cannot use sh/dx while unassisted", NoticeColour);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await cluster.SendAsync(command);
|
await cluster.SendAsync(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True while no contest is open: nothing is being claimed yet.
|
||||||
|
private bool IsAssisted => session.Logging?.Instance.IsAssisted ?? true;
|
||||||
|
|
||||||
private void OnReconnect(object? sender, RoutedEventArgs e) => Connect();
|
private void OnReconnect(object? sender, RoutedEventArgs e) => Connect();
|
||||||
|
|
||||||
/// Tunes the radio to the spot on the selected line, which is what N1MM's
|
/// Tunes the radio to the spot on the selected line, which is what N1MM's
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Nonemm.Core;
|
using Nonemm.Core;
|
||||||
|
using Nonemm.Core.Country;
|
||||||
|
|
||||||
namespace Nonemm.Contests;
|
namespace Nonemm.Contests;
|
||||||
|
|
||||||
@@ -18,6 +19,12 @@ public interface Contest
|
|||||||
/// different exchange depending on where the operator is.
|
/// different exchange depending on where the operator is.
|
||||||
IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me);
|
IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me);
|
||||||
|
|
||||||
|
/// Whether the walk between the entry boxes steps over this one for the
|
||||||
|
/// station being worked. CQ WW RTTY asks only US and Canadian stations for
|
||||||
|
/// a state or province, so everybody else's box is stepped over. Most
|
||||||
|
/// contests skip nothing.
|
||||||
|
bool SkipsField(ExchangeField field, CountryLookup? their) => false;
|
||||||
|
|
||||||
/// Up to three names, in the order the score summary shows them.
|
/// Up to three names, in the order the score summary shows them.
|
||||||
IReadOnlyList<string> MultiplierNames { get; }
|
IReadOnlyList<string> MultiplierNames { get; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Nonemm.Contests.Multipliers;
|
using Nonemm.Contests.Multipliers;
|
||||||
using Nonemm.Core;
|
using Nonemm.Core;
|
||||||
|
using Nonemm.Core.Country;
|
||||||
|
|
||||||
namespace Nonemm.Contests.Rules;
|
namespace Nonemm.Contests.Rules;
|
||||||
|
|
||||||
@@ -48,6 +49,15 @@ public sealed class CqWorldWide : Contest
|
|||||||
new ExchangeField("Zone", ExchangeSlot.Zone, ExchangeFieldKind.CqZone),
|
new ExchangeField("Zone", ExchangeSlot.Zone, ExchangeFieldKind.CqZone),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// On RTTY the exchange carries a state or province, but only from the US
|
||||||
|
/// and Canada; the box is stepped over for everybody else. N1MM's entry
|
||||||
|
/// window does the same, and the box is still there to type in.
|
||||||
|
public bool SkipsField(ExchangeField field, CountryLookup? their) =>
|
||||||
|
IsRtty
|
||||||
|
&& field.Slot == ExchangeSlot.Section
|
||||||
|
&& their is not null
|
||||||
|
&& their.Entity.PrimaryPrefix is not ("K" or "VE");
|
||||||
|
|
||||||
public IReadOnlyList<string> MultiplierNames =>
|
public IReadOnlyList<string> MultiplierNames =>
|
||||||
IsRtty ? ["Zones", "Countries", "States"] : ["Zones", "Countries"];
|
IsRtty ? ["Zones", "Countries", "States"] : ["Zones", "Countries"];
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,29 @@ public sealed class RadioPosition
|
|||||||
return filled;
|
return filled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Moves to the next box the operator types in. On top of the report boxes
|
||||||
|
/// the entry itself steps over, a contest can say a box is not for this
|
||||||
|
/// station: CQ WW RTTY asks only the US and Canada for a state.
|
||||||
|
public void MoveFocus(bool forward)
|
||||||
|
{
|
||||||
|
CountryLookup? their = Country();
|
||||||
|
for (int step = 0; step < Entry.FieldCount; step++)
|
||||||
|
{
|
||||||
|
if (forward)
|
||||||
|
{
|
||||||
|
Entry.Advance();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Entry.Retreat();
|
||||||
|
}
|
||||||
|
if (Entry.Focus == 0 || !Contest.SkipsField(Entry.Exchange[Entry.Focus - 1], their))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Wipe()
|
public void Wipe()
|
||||||
{
|
{
|
||||||
Entry.Clear();
|
Entry.Clear();
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ public sealed class ClusterClient : IDisposable
|
|||||||
|
|
||||||
public bool IsConnected { get; private set; }
|
public bool IsConnected { get; private set; }
|
||||||
|
|
||||||
|
/// Which cluster program the node runs, read out of what it says while
|
||||||
|
/// logging in. Unknown until it says something that names one.
|
||||||
|
public ClusterKind Kind { get; private set; }
|
||||||
|
|
||||||
public event EventHandler<Spot>? SpotArrived;
|
public event EventHandler<Spot>? SpotArrived;
|
||||||
|
|
||||||
public event EventHandler<string>? LineArrived;
|
public event EventHandler<string>? LineArrived;
|
||||||
@@ -175,6 +179,7 @@ public sealed class ClusterClient : IDisposable
|
|||||||
await client.ConnectAsync(Host, Port, cancellation).ConfigureAwait(false);
|
await client.ConnectAsync(Host, Port, cancellation).ConfigureAwait(false);
|
||||||
telnet = new TelnetStream(client.GetStream());
|
telnet = new TelnetStream(client.GetStream());
|
||||||
lines.Clear();
|
lines.Clear();
|
||||||
|
Kind = ClusterKind.Unknown;
|
||||||
login = autoLogon ? Login.WaitingForCallsign : Login.Done;
|
login = autoLogon ? Login.WaitingForCallsign : Login.Done;
|
||||||
deadline = DateTime.UtcNow + LoginDeadline;
|
deadline = DateTime.UtcNow + LoginDeadline;
|
||||||
lastHeard = DateTime.UtcNow;
|
lastHeard = DateTime.UtcNow;
|
||||||
@@ -221,6 +226,11 @@ public sealed class ClusterClient : IDisposable
|
|||||||
{
|
{
|
||||||
Remember(line, sent: false);
|
Remember(line, sent: false);
|
||||||
LineArrived?.Invoke(this, line);
|
LineArrived?.Invoke(this, line);
|
||||||
|
if (Kind == ClusterKind.Unknown && ClusterPrompts.KindOf(line) is var kind and not ClusterKind.Unknown)
|
||||||
|
{
|
||||||
|
Kind = kind;
|
||||||
|
ConnectionChanged?.Invoke(this, IsConnected);
|
||||||
|
}
|
||||||
Spot? spot = SpotLine.Parse(line, DateTime.UtcNow);
|
Spot? spot = SpotLine.Parse(line, DateTime.UtcNow);
|
||||||
if (spot is not null)
|
if (spot is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
12
src/Nonemm.Spotting/ClusterKind.cs
Normal file
12
src/Nonemm.Spotting/ClusterKind.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Nonemm.Spotting;
|
||||||
|
|
||||||
|
/// Which cluster program a node runs. N1MM reads it out of the node's opening
|
||||||
|
/// banner, because the four take slightly different commands.
|
||||||
|
public enum ClusterKind
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
DxSpider,
|
||||||
|
ArCluster,
|
||||||
|
CcCluster,
|
||||||
|
GoCluster,
|
||||||
|
}
|
||||||
@@ -16,6 +16,25 @@ public static class ClusterPrompts
|
|||||||
|
|
||||||
public static bool AsksForPassword(string text) => Holds(text, Password);
|
public static bool AsksForPassword(string text) => Holds(text, Password);
|
||||||
|
|
||||||
|
/// Which cluster program said this, or `Unknown` when the text names none
|
||||||
|
/// of them. The words are the ones N1MM looks for in the banner.
|
||||||
|
public static ClusterKind KindOf(string text)
|
||||||
|
{
|
||||||
|
if (Holds(text, ["gocluster"]))
|
||||||
|
{
|
||||||
|
return ClusterKind.GoCluster;
|
||||||
|
}
|
||||||
|
if (Holds(text, ["ar-cluster"]))
|
||||||
|
{
|
||||||
|
return ClusterKind.ArCluster;
|
||||||
|
}
|
||||||
|
if (Holds(text, ["cc cluster", "cc-cluster"]))
|
||||||
|
{
|
||||||
|
return ClusterKind.CcCluster;
|
||||||
|
}
|
||||||
|
return Holds(text, ["dxspider", "dx spider"]) ? ClusterKind.DxSpider : ClusterKind.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool Holds(string text, IReadOnlyList<string> phrases)
|
private static bool Holds(string text, IReadOnlyList<string> phrases)
|
||||||
{
|
{
|
||||||
foreach (string phrase in phrases)
|
foreach (string phrase in phrases)
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ public sealed record ContestInstance
|
|||||||
|
|
||||||
public string AssistedCategory { get; init; } = "NON-ASSISTED";
|
public string AssistedCategory { get; init; } = "NON-ASSISTED";
|
||||||
|
|
||||||
|
/// Whether the entry may use spots at all. N1MM's rule: an assisted or
|
||||||
|
/// multi-operator entry, or a checklog, may; a single operator who has not
|
||||||
|
/// said assisted may not.
|
||||||
|
public bool IsAssisted =>
|
||||||
|
OperatorCategory.Contains("ASSISTED", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| OperatorCategory.Contains("MULTI", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| OperatorCategory.Contains("CHECKLOG", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| AssistedCategory.Equals("ASSISTED", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public string TransmitterCategory { get; init; } = "ONE";
|
public string TransmitterCategory { get; init; } = "ONE";
|
||||||
|
|
||||||
public string TimeCategory { get; init; } = "";
|
public string TimeCategory { get; init; } = "";
|
||||||
|
|||||||
@@ -446,4 +446,29 @@ public class RadioPositionTests
|
|||||||
Assert.Equal("", session.OtherName);
|
Assert.Equal("", session.OtherName);
|
||||||
Assert.Equal("", session.Comment);
|
Assert.Equal("", session.Comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CqWwRttyStepsOverTheStateBoxForAStationOutsideTheUsAndCanada()
|
||||||
|
{
|
||||||
|
RadioPosition session = Session(new CqWorldWide(ModeCategory.Digital));
|
||||||
|
session.Entry.Call = "JA1XYZ";
|
||||||
|
|
||||||
|
// the boxes are the call, the report, the zone and the state
|
||||||
|
session.MoveFocus(forward: true);
|
||||||
|
Assert.Equal(2, session.Entry.Focus);
|
||||||
|
session.MoveFocus(forward: true);
|
||||||
|
Assert.Equal(0, session.Entry.Focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CqWwRttyStopsOnTheStateBoxForAUsStation()
|
||||||
|
{
|
||||||
|
RadioPosition session = Session(new CqWorldWide(ModeCategory.Digital));
|
||||||
|
session.Entry.Call = "K1ABC";
|
||||||
|
|
||||||
|
session.MoveFocus(forward: true);
|
||||||
|
Assert.Equal(2, session.Entry.Focus);
|
||||||
|
session.MoveFocus(forward: true);
|
||||||
|
Assert.Equal(3, session.Entry.Focus);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
tests/Nonemm.Spotting.Tests/ClusterPromptsKindTests.cs
Normal file
15
tests/Nonemm.Spotting.Tests/ClusterPromptsKindTests.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using Nonemm.Spotting.Telnet;
|
||||||
|
|
||||||
|
namespace Nonemm.Spotting.Tests;
|
||||||
|
|
||||||
|
public class ClusterPromptsKindTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Hello, this is DXSpider 1.55", ClusterKind.DxSpider)]
|
||||||
|
[InlineData("Welcome to the W3LPL AR-Cluster node", ClusterKind.ArCluster)]
|
||||||
|
[InlineData("N2WQ-1 CC Cluster Telnet", ClusterKind.CcCluster)]
|
||||||
|
[InlineData("GoCluster v1.2", ClusterKind.GoCluster)]
|
||||||
|
[InlineData("please enter your call:", ClusterKind.Unknown)]
|
||||||
|
public void TheBannerNamesTheClusterProgram(string banner, ClusterKind kind) =>
|
||||||
|
Assert.Equal(kind, ClusterPrompts.KindOf(banner));
|
||||||
|
}
|
||||||
@@ -2,9 +2,20 @@ namespace Nonemm.Storage.Tests;
|
|||||||
|
|
||||||
public class ContestInstanceTests
|
public class ContestInstanceTests
|
||||||
{
|
{
|
||||||
/// N1MM answers "Invalid Overlay Category:" and will not open a contest
|
private static ContestInstance Entry(string operatorCategory, string assisted) => new()
|
||||||
/// whose overlay column is empty, so an entry with no overlay says "N/A".
|
{
|
||||||
[Fact]
|
ContestNumber = 0,
|
||||||
public void OverlayCategoryDefaultsToNotApplicable() =>
|
ContestName = "CQWW",
|
||||||
Assert.Equal("N/A", new ContestInstance { ContestNumber = 1, ContestName = "CQWW" }.OverlayCategory);
|
OperatorCategory = operatorCategory,
|
||||||
|
AssistedCategory = assisted,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("SINGLE-OP", "ASSISTED", true)]
|
||||||
|
[InlineData("SINGLE-OP-ASSISTED", "NON-ASSISTED", true)]
|
||||||
|
[InlineData("MULTI-OP", "NON-ASSISTED", true)]
|
||||||
|
[InlineData("CHECKLOG", "NON-ASSISTED", true)]
|
||||||
|
[InlineData("SINGLE-OP", "NON-ASSISTED", false)]
|
||||||
|
public void WhoMayUseSpots(string operatorCategory, string assisted, bool mayUse) =>
|
||||||
|
Assert.Equal(mayUse, Entry(operatorCategory, assisted).IsAssisted);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user