diff --git a/README.md b/README.md index 999f816..ea6dd0b 100644 --- a/README.md +++ b/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 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 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 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: - **Busted spots.** A spotted call the callsign database has never heard, but diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index 5274deb..40b4271 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -66,6 +66,11 @@ public sealed record Settings /// it has changed since it went out. 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 /// number in its Configurer. public int FrequencyStepHertz { get; init; } = 100; diff --git a/src/Nonemm.App/Windows/EntryWindow.CallFrame.cs b/src/Nonemm.App/Windows/EntryWindow.CallFrame.cs new file mode 100644 index 0000000..5e10ee9 --- /dev/null +++ b/src/Nonemm.App/Windows/EntryWindow.CallFrame.cs @@ -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; + } + } +} diff --git a/src/Nonemm.App/Windows/EntryWindow.Esm.cs b/src/Nonemm.App/Windows/EntryWindow.Esm.cs index a17d284..d4213d1 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Esm.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Esm.cs @@ -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); } } diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml index 98ebf2a..e05cbf9 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml +++ b/src/Nonemm.App/Windows/EntryWindow.axaml @@ -131,6 +131,9 @@ Text="00:00:00Z" /> + + buttonRows = []; @@ -18,7 +20,8 @@ public sealed partial class TelnetWindow ButtonRows.Children.Clear(); ButtonRows.RowDefinitions.Clear(); IReadOnlyList 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(); diff --git a/src/Nonemm.App/Windows/TelnetWindow.axaml.cs b/src/Nonemm.App/Windows/TelnetWindow.axaml.cs index 3286ede..e5780e6 100644 --- a/src/Nonemm.App/Windows/TelnetWindow.axaml.cs +++ b/src/Nonemm.App/Windows/TelnetWindow.axaml.cs @@ -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 diff --git a/src/Nonemm.Contests/Contest.cs b/src/Nonemm.Contests/Contest.cs index d75bee0..b1d9865 100644 --- a/src/Nonemm.Contests/Contest.cs +++ b/src/Nonemm.Contests/Contest.cs @@ -1,4 +1,5 @@ using Nonemm.Core; +using Nonemm.Core.Country; namespace Nonemm.Contests; @@ -18,6 +19,12 @@ public interface Contest /// different exchange depending on where the operator is. IReadOnlyList 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. IReadOnlyList MultiplierNames { get; } diff --git a/src/Nonemm.Contests/Rules/CqWorldWide.cs b/src/Nonemm.Contests/Rules/CqWorldWide.cs index 859a171..427297e 100644 --- a/src/Nonemm.Contests/Rules/CqWorldWide.cs +++ b/src/Nonemm.Contests/Rules/CqWorldWide.cs @@ -1,5 +1,6 @@ using Nonemm.Contests.Multipliers; using Nonemm.Core; +using Nonemm.Core.Country; namespace Nonemm.Contests.Rules; @@ -48,6 +49,15 @@ public sealed class CqWorldWide : Contest 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 MultiplierNames => IsRtty ? ["Zones", "Countries", "States"] : ["Zones", "Countries"]; diff --git a/src/Nonemm.Session/RadioPosition.cs b/src/Nonemm.Session/RadioPosition.cs index 25d2fe0..20f3f7d 100644 --- a/src/Nonemm.Session/RadioPosition.cs +++ b/src/Nonemm.Session/RadioPosition.cs @@ -147,6 +147,29 @@ public sealed class RadioPosition 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() { Entry.Clear(); diff --git a/src/Nonemm.Spotting/ClusterClient.cs b/src/Nonemm.Spotting/ClusterClient.cs index ff73285..6806f6a 100644 --- a/src/Nonemm.Spotting/ClusterClient.cs +++ b/src/Nonemm.Spotting/ClusterClient.cs @@ -72,6 +72,10 @@ public sealed class ClusterClient : IDisposable 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? SpotArrived; public event EventHandler? LineArrived; @@ -175,6 +179,7 @@ public sealed class ClusterClient : IDisposable await client.ConnectAsync(Host, Port, cancellation).ConfigureAwait(false); telnet = new TelnetStream(client.GetStream()); lines.Clear(); + Kind = ClusterKind.Unknown; login = autoLogon ? Login.WaitingForCallsign : Login.Done; deadline = DateTime.UtcNow + LoginDeadline; lastHeard = DateTime.UtcNow; @@ -221,6 +226,11 @@ public sealed class ClusterClient : IDisposable { Remember(line, sent: false); 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); if (spot is not null) { diff --git a/src/Nonemm.Spotting/ClusterKind.cs b/src/Nonemm.Spotting/ClusterKind.cs new file mode 100644 index 0000000..de80673 --- /dev/null +++ b/src/Nonemm.Spotting/ClusterKind.cs @@ -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, +} diff --git a/src/Nonemm.Spotting/Telnet/ClusterPrompts.cs b/src/Nonemm.Spotting/Telnet/ClusterPrompts.cs index e35f879..141dbad 100644 --- a/src/Nonemm.Spotting/Telnet/ClusterPrompts.cs +++ b/src/Nonemm.Spotting/Telnet/ClusterPrompts.cs @@ -16,6 +16,25 @@ public static class ClusterPrompts 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 phrases) { foreach (string phrase in phrases) diff --git a/src/Nonemm.Storage/ContestInstance.cs b/src/Nonemm.Storage/ContestInstance.cs index 9937550..d90e1cf 100644 --- a/src/Nonemm.Storage/ContestInstance.cs +++ b/src/Nonemm.Storage/ContestInstance.cs @@ -30,6 +30,15 @@ public sealed record ContestInstance 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 TimeCategory { get; init; } = ""; diff --git a/tests/Nonemm.Session.Tests/RadioPositionTests.cs b/tests/Nonemm.Session.Tests/RadioPositionTests.cs index 9bd565f..04ce114 100644 --- a/tests/Nonemm.Session.Tests/RadioPositionTests.cs +++ b/tests/Nonemm.Session.Tests/RadioPositionTests.cs @@ -446,4 +446,29 @@ public class RadioPositionTests Assert.Equal("", session.OtherName); 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); + } } diff --git a/tests/Nonemm.Spotting.Tests/ClusterPromptsKindTests.cs b/tests/Nonemm.Spotting.Tests/ClusterPromptsKindTests.cs new file mode 100644 index 0000000..adfd788 --- /dev/null +++ b/tests/Nonemm.Spotting.Tests/ClusterPromptsKindTests.cs @@ -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)); +} diff --git a/tests/Nonemm.Storage.Tests/ContestInstanceTests.cs b/tests/Nonemm.Storage.Tests/ContestInstanceTests.cs index cb2fcc9..f6c8883 100644 --- a/tests/Nonemm.Storage.Tests/ContestInstanceTests.cs +++ b/tests/Nonemm.Storage.Tests/ContestInstanceTests.cs @@ -2,9 +2,20 @@ namespace Nonemm.Storage.Tests; public class ContestInstanceTests { - /// N1MM answers "Invalid Overlay Category:" and will not open a contest - /// whose overlay column is empty, so an entry with no overlay says "N/A". - [Fact] - public void OverlayCategoryDefaultsToNotApplicable() => - Assert.Equal("N/A", new ContestInstance { ContestNumber = 1, ContestName = "CQWW" }.OverlayCategory); + private static ContestInstance Entry(string operatorCategory, string assisted) => new() + { + ContestNumber = 0, + ContestName = "CQWW", + 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); }