Mark the CQ frequency on the bandmap and leave Run when the radio moves off it

Starting Run stores the frequency in the position. The bandmap draws a
purple, bold "CQ Frequency" pseudospot there, which is not added to the
shared bandmap, and a click on it tunes back and sets Run. A move of more
than 3 kHz (a setting on the telnet Filters tab) while running switches
to search and pounce.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 12:12:35 +00:00
co-authored by Claude Sonnet 5.5
parent ab98c1b871
commit 1e0fb3eb08
14 changed files with 154 additions and 15 deletions
+5 -1
View File
@@ -313,7 +313,7 @@ public sealed partial class AppSession : IDisposable
positions.Clear();
for (int number = 1; number <= PositionCount; number++)
{
positions.Add(new OperatingPosition(Logging, number));
positions.Add(new OperatingPosition(Logging, number) { CqLeaveHertz = Settings.CqLeaveHertz });
}
activeRadio = Math.Min(activeRadio, positions.Count - 1);
// the other stations refuse to resynchronise with a station on another
@@ -995,6 +995,10 @@ public sealed partial class AppSession : IDisposable
{
spotFilter = Settings.SpotFilter.ToFilter(BandPlan, Settings.Station, Countries, Calls, History);
Bandmap.Lifetime = TimeSpan.FromMinutes(Math.Max(1, Settings.SpotTimeoutMinutes));
foreach (OperatingPosition position in positions)
{
position.CqLeaveHertz = Settings.CqLeaveHertz;
}
}
public void ReloadSupportFiles()
+4
View File
@@ -105,6 +105,10 @@ public sealed record Settings
/// boxes are wiped. N1MM uses 2 kHz.
public int QsySpotHertz { get; init; } = 2000;
/// How far the radio can move off the CQ frequency, in hertz, before a
/// running position goes back to searching.
public int CqLeaveHertz { get; init; } = 3000;
public int TuningToleranceFor(ModeCategory mode) => mode switch
{
ModeCategory.Phone => PhoneTuningToleranceHertz,
+1 -1
View File
@@ -181,7 +181,7 @@ public sealed class BandmapView : Control
new Point(ScaleX + 2, label.MarkY),
new Point(LabelX - 2, label.LabelY + (LabelHeight / 2)));
context.DrawText(
Text(label.Spot.Call.Text, colour, TextSize, label.Spot.IsLocal),
Text(label.Spot.Call.Text, colour, TextSize, label.Spot.IsLocal || label.Spot.IsCqFrequency),
new Point(LabelX, label.LabelY + (LabelPadding / 2)));
}
+31 -4
View File
@@ -34,15 +34,19 @@ public sealed partial class BandmapWindow : RefreshableWindow
private readonly AppSession session;
private readonly Action<Frequency, string> tune;
private readonly Action<Frequency> returnToCq;
/// Where the slice was last drawn from, so it stays put until the receiver
/// nears an edge.
private Frequency? sliceLow;
public BandmapWindow(AppSession session, Action<Frequency, string> tune)
private static readonly IBrush CqBrush = new SolidColorBrush(Color.FromRgb(0x9B, 0x59, 0xB6));
public BandmapWindow(AppSession session, Action<Frequency, string> tune, Action<Frequency> returnToCq)
{
this.session = session;
this.tune = tune;
this.returnToCq = returnToCq;
InitializeComponent();
SpanBox.ItemsSource = Spans.Select(s => s.Label).ToList();
SpanBox.SelectedIndex = Math.Max(
@@ -59,7 +63,17 @@ public sealed partial class BandmapWindow : RefreshableWindow
CentreBox.IsCheckedChanged += (_, _) => SaveView();
View.Zoomed += (_, zoom) => Zoom(zoom);
View.ColourOf = Colour;
View.SpotPicked += (_, spot) => tune(spot.Frequency, spot.Call.Text);
View.SpotPicked += (_, spot) =>
{
if (spot.IsCqFrequency)
{
returnToCq(spot.Frequency);
}
else
{
tune(spot.Frequency, spot.Call.Text);
}
};
View.FrequencyPicked += (_, frequency) => tune(frequency, "");
session.Bandmap.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
Refresh();
@@ -95,12 +109,24 @@ public sealed partial class BandmapWindow : RefreshableWindow
long shown = high.Hertz - low.Hertz;
View.TickStep = StepFor(shown);
View.DenseTicks = shown is > 40_000 and <= 200_000;
View.Spots = session.Bandmap.On(band);
View.Spots = WithCqFrequency(session.Bandmap.On(band), band);
View.Vfos = Vfos();
View.Segments = session.BandPlan.For(band);
View.InvalidateVisual();
}
/// The bandmap's spots with the pseudospot for the CQ frequency, when the
/// position has one on this band. It is added here and not to the bandmap,
/// so it does not age out and the other windows do not list it.
private IReadOnlyList<Spot> WithCqFrequency(IReadOnlyList<Spot> spots, Band band)
{
if (session.Position?.CqFrequency is not { } cq || Bands.ForFrequency(cq) != band)
{
return spots;
}
return [.. spots.Append(Spot.CqFrequency(cq, DateTime.UtcNow)).OrderBy(s => s.Frequency.Hertz)];
}
/// Green for where the operator is listening, red for where the radio
/// transmits when working split, orange for the other radio of a two-radio
/// station.
@@ -165,7 +191,8 @@ public sealed partial class BandmapWindow : RefreshableWindow
_ => Frequency.FromKilohertz(50),
};
private IBrush Colour(Spot spot) => Verdicts.Colour(VerdictFor(spot));
private IBrush Colour(Spot spot) =>
spot.IsCqFrequency ? CqBrush : Verdicts.Colour(VerdictFor(spot));
private Verdict? VerdictFor(Spot spot)
{
@@ -12,10 +12,6 @@ namespace Nonemm.App.Windows;
/// 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 = "";
@@ -35,7 +31,7 @@ public sealed partial class EntryWindow
CallFrameText.Text = "";
return;
}
if (cqFrequency.Hertz > 0
if (Logging.CqFrequency is { } cqFrequency
&& Math.Abs(Logging.Frequency.Hertz - cqFrequency.Hertz) <= TuningTolerance.Hertz)
{
CallFrameText.Text = "CQ-Frequency";
@@ -86,7 +82,7 @@ public sealed partial class EntryWindow
{
if (Logging is not null)
{
cqFrequency = Logging.Frequency;
Logging.CqFrequency = Logging.Frequency;
}
}
}
+9 -1
View File
@@ -462,7 +462,7 @@ public sealed partial class EntryWindow
private void OnShowCheck(object? sender, RoutedEventArgs e) => Show(() => new CheckWindow(session));
private void OnShowBandmap(object? sender, RoutedEventArgs e) => Show(() => new BandmapWindow(session, Tune));
private void OnShowBandmap(object? sender, RoutedEventArgs e) => Show(() => new BandmapWindow(session, Tune, ReturnToCq));
private void OnShowAvailable(object? sender, RoutedEventArgs e) =>
Show(() => new AvailableWindow(session, Tune));
@@ -712,6 +712,14 @@ public sealed partial class EntryWindow
}
}
/// A click on the CQ frequency on the bandmap: back to where the CQs went
/// out, and running again.
private void ReturnToCq(Frequency frequency)
{
Tune(frequency, "");
SetRunning(true);
}
private void Tune(Frequency frequency, string call)
{
if (Logging is null)
@@ -109,7 +109,7 @@ public sealed partial class EntryWindow
[
(nameof(LogWindow), () => _ = Show(() => new LogWindow(session), activate: false)),
(nameof(CheckWindow), () => _ = Show(() => new CheckWindow(session), activate: false)),
(nameof(BandmapWindow), () => _ = Show(() => new BandmapWindow(session, Tune), activate: false)),
(nameof(BandmapWindow), () => _ = Show(() => new BandmapWindow(session, Tune, ReturnToCq), activate: false)),
(nameof(AvailableWindow), () => _ = Show(() => new AvailableWindow(session, Tune), activate: false)),
(nameof(ScoreWindow), () => _ = Show(() => new ScoreWindow(session), activate: false)),
(nameof(StatisticsWindow), () => _ = Show(() => new StatisticsWindow(session), activate: false)),
@@ -50,6 +50,7 @@ public sealed partial class TelnetWindow
BlockedCallsBox.Text = string.Join(' ', filter.BlockedCalls);
TimeoutBox.Text = session.Settings.SpotTimeoutMinutes.ToString(CultureInfo.InvariantCulture);
QsySpotBox.Text = session.Settings.QsySpotHertz.ToString(CultureInfo.InvariantCulture);
CqLeaveBox.Text = session.Settings.CqLeaveHertz.ToString(CultureInfo.InvariantCulture);
SpotCommentBox.Text = session.Settings.SpotComment;
}
@@ -74,6 +75,7 @@ public sealed partial class TelnetWindow
RandomizeSpots = RandomizeBox.IsChecked == true,
SpotTimeoutMinutes = Minutes(TimeoutBox.Text, session.Settings.SpotTimeoutMinutes),
QsySpotHertz = Minutes(QsySpotBox.Text, session.Settings.QsySpotHertz),
CqLeaveHertz = Minutes(CqLeaveBox.Text, session.Settings.CqLeaveHertz),
});
Show("*** filters applied — they hold for the spots that arrive from now on", NoticeColour);
}
@@ -124,6 +124,8 @@
<TextBox Name="TimeoutBox" Width="60" HorizontalAlignment="Left" />
<TextBlock Classes="label" Text="Spot a call locally when the radio moves more than (Hz)" />
<TextBox Name="QsySpotBox" Width="60" HorizontalAlignment="Left" />
<TextBlock Classes="label" Text="Go back to search and pounce when the radio moves off the CQ frequency by more than (Hz)" TextWrapping="Wrap" />
<TextBox Name="CqLeaveBox" Width="60" HorizontalAlignment="Left" />
<Button Content="Apply" Click="OnApplyFilters" Margin="0,10,0,0" />
</StackPanel>
</ScrollViewer>
+26 -1
View File
@@ -46,7 +46,28 @@ public sealed class OperatingPosition
/// True while the operator is calling CQ on this radio rather than
/// searching. Each radio runs or searches on its own.
public bool IsRunning { get; set; }
public bool IsRunning
{
get => running;
set
{
if (value && !running)
{
CqFrequency = Frequency;
}
running = value;
}
}
private bool running;
/// Where this radio last started running or sent a CQ. It stays after the
/// radio goes back to searching, so the operator can return to it.
public Frequency? CqFrequency { get; set; }
/// How far, in hertz, the radio can move off the CQ frequency before the
/// position goes back to searching.
public long CqLeaveHertz { get; set; } = 3_000;
/// The other operator's name and a note about the contact. Neither is part
/// of any exchange: the name is what a contact being quick edited already
@@ -110,6 +131,10 @@ public sealed class OperatingPosition
FillSentExchange();
}
TransmitFrequency = transmitFrequency ?? TransmitFrequency;
if (running && CqFrequency is { } cq && Math.Abs(frequency.Hertz - cq.Hertz) > CqLeaveHertz)
{
running = false;
}
Session.NotifyChanged();
}
+11
View File
@@ -43,6 +43,17 @@ public sealed record Spot(
/// opposed to one from the cluster.
public bool IsLocal => Source == SpotSource.Operator && IsStation;
/// The pseudospot that marks where the operator is running. It is drawn on
/// the bandmap and is never added to it.
public bool IsCqFrequency { get; init; }
public static Spot CqFrequency(Frequency where, DateTime atUtc) =>
new(Callsign.Parse("CQ Frequency"), where, atUtc, SpotSource.Operator)
{
IsStation = false,
IsCqFrequency = true,
};
public bool IsSplit => Qsx.Hertz > 0;
public Band? Band => Bands.ForFrequency(Frequency);
+4
View File
@@ -10,6 +10,10 @@ public static partial class SpotDescription
{
public static string Of(Spot spot, DateTime? nowUtc = null)
{
if (spot.IsCqFrequency)
{
return $"{spot.Frequency.Kilohertz.ToString("0.0", CultureInfo.InvariantCulture)}, your CQ frequency";
}
string age = Age((nowUtc ?? DateTime.UtcNow) - spot.AtUtc);
string kilohertz = spot.Frequency.Kilohertz.ToString("0.0", CultureInfo.InvariantCulture);
string who = spot.IsLocal && spot.Computer.Length > 0 ? spot.Computer : spot.Spotter;