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:
2026-08-28 10:23:30 +00:00
parent ac63987e0c
commit 8956c2fd22
18 changed files with 291 additions and 11 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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