Spot a call locally and wipe when the radio moves far on a QSY

With a call in the entry window, a move of more than 2 kHz on the same
band puts the call on the bandmap where it was heard and wipes the boxes,
as N1MM does. The spot goes to the other stations as ADDSPOT and to the
Spots broadcast, never to the cluster. ADDSPOT from other stations is
added to the bandmap.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 05:50:20 +00:00
co-authored by Claude Sonnet 5.5
parent 986f359a40
commit f784a73115
10 changed files with 215 additions and 3 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ public sealed partial class AppSession
{
return;
}
position.Tune(frequency, mode, transmit);
TuneAndSpot(position, frequency, mode, transmit);
}
/// N1MM names reverse CW and FSK differently from our modes. Null for a
+95
View File
@@ -0,0 +1,95 @@
using Avalonia.Threading;
using Nonemm.Core;
using Nonemm.Network;
using Nonemm.Session;
using Nonemm.Spotting;
namespace Nonemm.App;
/// N1MM's local spot on a QSY: a callsign left in the entry window while the
/// radio moves a long way on the same band is put on the bandmap where it was
/// heard, and the boxes are wiped. The spot goes to the other stations on the
/// network and to the programs listening for broadcast data. It is never sent
/// to the cluster.
public sealed partial class AppSession
{
private const int ShortestCallToSpot = 3;
/// Raised after the boxes of a position were wiped here, so the window
/// showing it can read them again.
public event EventHandler<OperatingPosition>? EntryWiped;
private void TuneAndSpot(OperatingPosition position, Frequency frequency, Mode? mode, Frequency? transmit)
{
Frequency before = position.Frequency;
position.Tune(frequency, mode, transmit);
// the radio reports from its own thread, and the boxes belong to the
// window's thread
Dispatcher.UIThread.Post(() => SpotOnQsy(position, before));
}
private void SpotOnQsy(OperatingPosition position, Frequency before)
{
string call = position.Entry.Call.Trim().ToUpperInvariant();
Frequency now = position.Frequency;
Band? band = Bands.ForFrequency(before);
if (call.Length < ShortestCallToSpot
|| call.All(char.IsAsciiDigit)
|| position.IsRunning
|| position.Editing is not null
|| position.IsEntryComplete
|| band is null
|| band != Bands.ForFrequency(now)
|| Math.Abs(now.Hertz - before.Hertz) <= Settings.QsySpotHertz
|| position.PendingQsy() is not null)
{
return;
}
Frequency tolerance = Frequency.FromHertz(Settings.TuningToleranceFor(position.Mode.Category));
if (Bandmap.Near(before, tolerance) is { IsStation: true } there
&& there.Call.Text.Equals(call, StringComparison.OrdinalIgnoreCase))
{
return;
}
Spot spot = new(
Callsign.Parse(call),
before,
DateTime.UtcNow,
SpotSource.Operator,
position.Me.Callsign,
$"Local spot by {Settings.CurrentOperator}");
Bandmap.Add(spot);
SendSpots([spot]);
_ = link?.SendSpotAsync(new SharedSpot(
call,
spot.AtUtc,
before,
spot.Comment,
Frequency.Zero,
spot.Spotter,
IsWorked: false));
position.Wipe();
EntryWiped?.Invoke(this, position);
Reported?.Invoke(this, $"{call} spotted at {before.Kilohertz:0.0}");
}
/// A spot another station on the network put on its bandmap. It is shown
/// as it arrived, and is not passed on again.
private void TakeSharedSpot(SharedSpot shared)
{
if (shared.Call.Length == 0 || shared.Frequency.Hertz <= 0)
{
return;
}
Bandmap.Add(new Spot(
Callsign.Parse(shared.Call),
shared.Frequency,
shared.AtUtc == default ? DateTime.UtcNow : shared.AtUtc,
SpotSource.Operator,
shared.Spotter,
shared.Comment)
{
Qsx = shared.Qsx,
});
}
}
+3 -1
View File
@@ -421,6 +421,8 @@ public sealed partial class AppSession : IDisposable
Dispatcher.UIThread.Post(() => Rescore(DateTime.MinValue));
link.SerialNumbersReset += (_, _) =>
Dispatcher.UIThread.Post(ResetSerialNumbers);
link.SpotArrived += (_, shared) =>
Dispatcher.UIThread.Post(() => TakeSharedSpot(shared));
link.CallStacked += (_, stacked) =>
Dispatcher.UIThread.Post(() => TakeStackedCall(stacked));
link.NumberReserved += (_, taken) =>
@@ -897,7 +899,7 @@ public sealed partial class AppSession : IDisposable
{
return;
}
position.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
TuneAndSpot(position, state.Frequency, state.Mode, state.TransmitFrequency);
}
private void DisposeRadios()
+5
View File
@@ -91,6 +91,11 @@ public sealed record Settings
public int DigitalTuningToleranceHertz { get; init; } = 300;
/// How far the radio has to move on the band, in hertz, with a callsign
/// left in the entry window, before the call is spotted locally and the
/// boxes are wiped. N1MM uses 2 kHz.
public int QsySpotHertz { get; init; } = 2000;
public int TuningToleranceFor(ModeCategory mode) => mode switch
{
ModeCategory.Phone => PhoneTuningToleranceHertz,
@@ -83,6 +83,15 @@ public sealed partial class EntryWindow : Window
Closing += (_, _) => session.Save(WithLayout(session.Settings));
clock.Tick += (_, _) => UpdateClock();
clock.Start();
session.EntryWiped += (_, wiped) => Dispatcher.UIThread.Post(() =>
{
if (wiped == Logging)
{
ResetEsm();
SyncBoxes();
Refresh();
}
});
session.ActiveRadioChanged += (_, number) =>
Dispatcher.UIThread.Post(() => FollowActiveRadio(number));
// only the window for radio 1 opens the log and the connections; the