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

@@ -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)
{
await SendKeyAsync(key, CorrectedCall(key));
if (key == Esm.CallCq)
{
RememberCqFrequency();
}
Remember(key);
}
}

View File

@@ -131,6 +131,9 @@
Text="00:00:00Z" />
</Grid>
<TextBlock Name="CallFrameText" FontFamily="monospace" FontSize="13" Height="18"
Margin="2,0,0,1" Text="" />
<Grid Name="EntryGrid" ColumnDefinitions="Auto" RowDefinitions="Auto,Auto" />
<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
if (Logging.Entry.Focus == 0)
{
bool filled = Logging.FillReports();
bool filled = TakeFramedCall();
filled |= Logging.FillReports();
filled |= Logging.FillFromHistory();
filled |= FillNameFromHistory();
if (filled)
@@ -418,11 +419,11 @@ public sealed partial class EntryWindow : Window
SyncBoxes();
}
}
Logging.Entry.Advance();
Logging.MoveFocus(forward: true);
}
else
{
Logging.Entry.Retreat();
Logging.MoveFocus(forward: false);
}
FocusEntryBox();
}
@@ -552,6 +553,7 @@ public sealed partial class EntryWindow : Window
ContestText.Text = ContestLine();
Title = TitleLine();
ShowCallFrame();
PathText.Text = PathLine();
UserTextText.Text = Logging.Session.History.Find(Logging.Entry.Call)?.UserText ?? "";
ShowLights();
@@ -687,6 +689,7 @@ public sealed partial class EntryWindow : Window
if (index == Esm.CallCq)
{
Logging.IsRunning = true;
RememberCqFrequency();
}
SendMessage(index);
Refresh();

View File

@@ -8,7 +8,9 @@ namespace Nonemm.App.Windows;
/// telnet window has.
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 = [];
@@ -18,7 +20,8 @@ public sealed partial class TelnetWindow
ButtonRows.Children.Clear();
ButtonRows.RowDefinitions.Clear();
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));
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()
{
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
? "not connected — pick a node on the Clusters tab"
: cluster.IsConnected
? $"connected to {cluster.Host}:{cluster.Port}"
? $"connected to {cluster.Host}:{cluster.Port}{kind}"
: $"connecting to {cluster.Host}:{cluster.Port}…";
ReconnectButton.Content = cluster is null ? "Connect" : "Reconnect";
}
@@ -258,9 +266,19 @@ public sealed partial class TelnetWindow : RefreshableWindow
Show("*** not connected to a node", NoticeColour);
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);
}
/// 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();
/// Tunes the radio to the spot on the selected line, which is what N1MM's