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:
+12
-1
@@ -443,7 +443,18 @@ Anything over two seconds is called out.
|
|||||||
**The master** is the station the entry follows for cluster spots and the time,
|
**The master** is the station the entry follows for cluster spots and the time,
|
||||||
set from the station list and told to everybody, so the `Master` column agrees
|
set from the station list and told to everybody, so the `Master` column agrees
|
||||||
everywhere. What it does not do yet is relay: N1MM's master passes its cluster
|
everywhere. What it does not do yet is relay: N1MM's master passes its cluster
|
||||||
spots to the others over the link, and `ADDSPOT` and `PACKET` are not written.
|
spots to the others over the link, and `PACKET` is not written. `ADDSPOT` is
|
||||||
|
written and read, but only for a local spot made on a QSY (see below); the
|
||||||
|
master does not relay its cluster spots with it.
|
||||||
|
|
||||||
|
**Local spot on a QSY.** With a callsign in the entry window, the radio moving
|
||||||
|
more than `QsySpotHertz` (default 2 kHz, N1MM's value) on the same band puts the
|
||||||
|
call on the bandmap where it was heard and wipes the boxes. It sends `ADDSPOT`
|
||||||
|
to the other stations and a Spots broadcast, never a cluster spot. Nothing
|
||||||
|
happens while running, in quick edit, with a complete exchange, with a number in
|
||||||
|
the box, or when the bandmap already has the call there. N1MM's other
|
||||||
|
trigger, a dupe left behind by a move of 0.1 kHz or more, is not done. There is
|
||||||
|
no dialog for the distance; it is in the settings file.
|
||||||
|
|
||||||
**Call stacking.** A callsign heard at one station and handed to the position
|
**Call stacking.** A callsign heard at one station and handed to the position
|
||||||
that will work it: `STACKANOTHERCALL` with the call alone, `STACKCALL` with the
|
that will work it: `STACKANOTHERCALL` with the call alone, `STACKCALL` with the
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ public sealed partial class AppSession
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
position.Tune(frequency, mode, transmit);
|
TuneAndSpot(position, frequency, mode, transmit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// N1MM names reverse CW and FSK differently from our modes. Null for a
|
/// N1MM names reverse CW and FSK differently from our modes. Null for a
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -421,6 +421,8 @@ public sealed partial class AppSession : IDisposable
|
|||||||
Dispatcher.UIThread.Post(() => Rescore(DateTime.MinValue));
|
Dispatcher.UIThread.Post(() => Rescore(DateTime.MinValue));
|
||||||
link.SerialNumbersReset += (_, _) =>
|
link.SerialNumbersReset += (_, _) =>
|
||||||
Dispatcher.UIThread.Post(ResetSerialNumbers);
|
Dispatcher.UIThread.Post(ResetSerialNumbers);
|
||||||
|
link.SpotArrived += (_, shared) =>
|
||||||
|
Dispatcher.UIThread.Post(() => TakeSharedSpot(shared));
|
||||||
link.CallStacked += (_, stacked) =>
|
link.CallStacked += (_, stacked) =>
|
||||||
Dispatcher.UIThread.Post(() => TakeStackedCall(stacked));
|
Dispatcher.UIThread.Post(() => TakeStackedCall(stacked));
|
||||||
link.NumberReserved += (_, taken) =>
|
link.NumberReserved += (_, taken) =>
|
||||||
@@ -897,7 +899,7 @@ public sealed partial class AppSession : IDisposable
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
position.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
|
TuneAndSpot(position, state.Frequency, state.Mode, state.TransmitFrequency);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DisposeRadios()
|
private void DisposeRadios()
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ public sealed record Settings
|
|||||||
|
|
||||||
public int DigitalTuningToleranceHertz { get; init; } = 300;
|
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
|
public int TuningToleranceFor(ModeCategory mode) => mode switch
|
||||||
{
|
{
|
||||||
ModeCategory.Phone => PhoneTuningToleranceHertz,
|
ModeCategory.Phone => PhoneTuningToleranceHertz,
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ public sealed partial class EntryWindow : Window
|
|||||||
Closing += (_, _) => session.Save(WithLayout(session.Settings));
|
Closing += (_, _) => session.Save(WithLayout(session.Settings));
|
||||||
clock.Tick += (_, _) => UpdateClock();
|
clock.Tick += (_, _) => UpdateClock();
|
||||||
clock.Start();
|
clock.Start();
|
||||||
|
session.EntryWiped += (_, wiped) => Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (wiped == Logging)
|
||||||
|
{
|
||||||
|
ResetEsm();
|
||||||
|
SyncBoxes();
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
session.ActiveRadioChanged += (_, number) =>
|
session.ActiveRadioChanged += (_, number) =>
|
||||||
Dispatcher.UIThread.Post(() => FollowActiveRadio(number));
|
Dispatcher.UIThread.Post(() => FollowActiveRadio(number));
|
||||||
// only the window for radio 1 opens the log and the connections; the
|
// only the window for radio 1 opens the log and the connections; the
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Network;
|
||||||
|
|
||||||
|
/// A spot another station put on its own bandmap and passed on, as `ADDSPOT`
|
||||||
|
/// carries it. `Qsx` is zero unless the station is working split.
|
||||||
|
public sealed record SharedSpot(
|
||||||
|
string Call,
|
||||||
|
DateTime AtUtc,
|
||||||
|
Frequency Frequency,
|
||||||
|
string Comment,
|
||||||
|
Frequency Qsx,
|
||||||
|
string Spotter,
|
||||||
|
bool IsWorked);
|
||||||
@@ -145,6 +145,9 @@ public sealed class StationLink : IDisposable
|
|||||||
/// contact behind it when there was one.
|
/// contact behind it when there was one.
|
||||||
public event EventHandler<StackedCall>? CallStacked;
|
public event EventHandler<StackedCall>? CallStacked;
|
||||||
|
|
||||||
|
/// A spot another station put on its bandmap and passed on.
|
||||||
|
public event EventHandler<SharedSpot>? SpotArrived;
|
||||||
|
|
||||||
/// Another station asked for a rescore when its resync is done. The log is
|
/// Another station asked for a rescore when its resync is done. The log is
|
||||||
/// not this program's to score, so the owner is told and does it.
|
/// not this program's to score, so the owner is told and does it.
|
||||||
public event EventHandler? RescoreRequested;
|
public event EventHandler? RescoreRequested;
|
||||||
@@ -485,6 +488,12 @@ public sealed class StationLink : IDisposable
|
|||||||
: StationMessages.StackContact(contact, StationNumber, computerName),
|
: StationMessages.StackContact(contact, StationNumber, computerName),
|
||||||
cancellation);
|
cancellation);
|
||||||
|
|
||||||
|
/// Passes a spot put on this station's bandmap to every other station,
|
||||||
|
/// which is what N1MM does with a local spot. It does not go to the
|
||||||
|
/// cluster.
|
||||||
|
public Task<int> SendSpotAsync(SharedSpot spot, CancellationToken cancellation = default) =>
|
||||||
|
SendAsync(StationMessages.AddSpot(spot, StationNumber, computerName), cancellation);
|
||||||
|
|
||||||
/// Asks every station whether it is there. The answer sets `EchoTime`.
|
/// Asks every station whether it is there. The answer sets `EchoTime`.
|
||||||
public Task<int> SendEchoRequestAsync(CancellationToken cancellation = default)
|
public Task<int> SendEchoRequestAsync(CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
@@ -957,6 +966,9 @@ public sealed class StationLink : IDisposable
|
|||||||
Qso stacked = QsoRecord.Read(record, 0, station.ComputerName);
|
Qso stacked = QsoRecord.Read(record, 0, station.ComputerName);
|
||||||
CallStacked?.Invoke(this, new StackedCall(stacked.Call.Text, stacked, station.ComputerName));
|
CallStacked?.Invoke(this, new StackedCall(stacked.Call.Text, stacked, station.ComputerName));
|
||||||
break;
|
break;
|
||||||
|
case "ADDSPOT":
|
||||||
|
SpotArrived?.Invoke(this, StationMessages.ReadSpot(record));
|
||||||
|
break;
|
||||||
case "TALK":
|
case "TALK":
|
||||||
TalkArrived?.Invoke(this, record.Field(0));
|
TalkArrived?.Invoke(this, record.Field(0));
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ namespace Nonemm.Network;
|
|||||||
/// | `QSONRS` | the highest serial number taken on each band | yes | yes |
|
/// | `QSONRS` | the highest serial number taken on each band | yes | yes |
|
||||||
/// | `STACKCALL` | a contact for another station's call stack | yes | yes |
|
/// | `STACKCALL` | a contact for another station's call stack | yes | yes |
|
||||||
/// | `STACKANOTHERCALL` | a callsign for another station's call stack | yes | yes |
|
/// | `STACKANOTHERCALL` | a callsign for another station's call stack | yes | yes |
|
||||||
|
/// | `ADDSPOT` | a spot the sender put on its own bandmap | yes | yes |
|
||||||
/// | `STATUS` | everything the network status window shows about a station | yes | yes |
|
/// | `STATUS` | everything the network status window shows about a station | yes | yes |
|
||||||
/// | `CHECKSUM` | the checksum of one block of the log, which starts a resync | yes | yes |
|
/// | `CHECKSUM` | the checksum of one block of the log, which starts a resync | yes | yes |
|
||||||
/// | `CONFIRMED` | the answer to a checksum, and what moves a resync on | yes | yes |
|
/// | `CONFIRMED` | the answer to a checksum, and what moves a resync on | yes | yes |
|
||||||
@@ -345,6 +346,34 @@ public static class StationMessages
|
|||||||
public static StationRecord StackCall(string call, int stationNumber, string computerName) =>
|
public static StationRecord StackCall(string call, int stationNumber, string computerName) =>
|
||||||
Record("STACKANOTHERCALL", stationNumber, computerName, [call]);
|
Record("STACKANOTHERCALL", stationNumber, computerName, [call]);
|
||||||
|
|
||||||
|
/// A spot put on the sender's bandmap, passed to the others so every
|
||||||
|
/// operator sees it. The frequencies are kilohertz times a hundred, as in
|
||||||
|
/// `STATUS`, and the last field says whether the call is already worked.
|
||||||
|
public static StationRecord AddSpot(SharedSpot spot, int stationNumber, string computerName) =>
|
||||||
|
Record(
|
||||||
|
"ADDSPOT",
|
||||||
|
stationNumber,
|
||||||
|
computerName,
|
||||||
|
[
|
||||||
|
spot.Call,
|
||||||
|
StationRecord.Written(spot.AtUtc),
|
||||||
|
Hundredths(spot.Frequency),
|
||||||
|
spot.Comment,
|
||||||
|
Hundredths(spot.Qsx),
|
||||||
|
spot.Spotter,
|
||||||
|
spot.IsWorked ? "1" : "0",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/// The spot out of an `ADDSPOT` message.
|
||||||
|
public static SharedSpot ReadSpot(StationRecord record) => new(
|
||||||
|
record.Field(0),
|
||||||
|
record.Time(1),
|
||||||
|
Frequency.FromKilohertz(record.Decimal(2) / 100),
|
||||||
|
record.Field(3),
|
||||||
|
Frequency.FromKilohertz(record.Decimal(4) / 100),
|
||||||
|
record.Field(5),
|
||||||
|
record.Flag(6));
|
||||||
|
|
||||||
/// What a message that has arrived means, or null for one this program does
|
/// What a message that has arrived means, or null for one this program does
|
||||||
/// nothing with.
|
/// nothing with.
|
||||||
public static ContactUpdate? Read(StationRecord record) => record.Type switch
|
public static ContactUpdate? Read(StationRecord record) => record.Type switch
|
||||||
|
|||||||
@@ -495,6 +495,41 @@ public class StationLinkTests
|
|||||||
Assert.Equal("RUN-PC", stacked[1].FromComputerName);
|
Assert.Equal("RUN-PC", stacked[1].FromComputerName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A spot put on one station's bandmap reaches the others with the
|
||||||
|
/// frequency, the time and the spotter intact.
|
||||||
|
[Fact]
|
||||||
|
public async Task ASpotIsPassedToTheOtherStations()
|
||||||
|
{
|
||||||
|
(int onePort, int twoPort) = SparePorts();
|
||||||
|
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||||
|
using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort);
|
||||||
|
one.Start();
|
||||||
|
two.Start();
|
||||||
|
List<SharedSpot> spots = [];
|
||||||
|
two.SpotArrived += (_, spot) => spots.Add(spot);
|
||||||
|
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||||
|
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
|
||||||
|
DateTime at = new(2026, 10, 24, 13, 5, 9, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
await one.SendSpotAsync(new SharedSpot(
|
||||||
|
"DL1ABC",
|
||||||
|
at,
|
||||||
|
Frequency.FromKilohertz(14_195.4),
|
||||||
|
"Local spot by OM5M",
|
||||||
|
Frequency.Zero,
|
||||||
|
"OM5M",
|
||||||
|
IsWorked: false));
|
||||||
|
|
||||||
|
await WaitForAsync(() => spots.Count == 1);
|
||||||
|
SharedSpot arrived = spots.Single();
|
||||||
|
Assert.Equal("DL1ABC", arrived.Call);
|
||||||
|
Assert.Equal(at, arrived.AtUtc);
|
||||||
|
Assert.Equal(14_195.4, arrived.Frequency.Kilohertz, 1);
|
||||||
|
Assert.Equal("Local spot by OM5M", arrived.Comment);
|
||||||
|
Assert.Equal("OM5M", arrived.Spotter);
|
||||||
|
Assert.False(arrived.IsWorked);
|
||||||
|
}
|
||||||
|
|
||||||
/// Two stations logging different contests would resynchronise into each
|
/// Two stations logging different contests would resynchronise into each
|
||||||
/// other's logs. Each says which contest it is on as soon as it connects.
|
/// other's logs. Each says which contest it is on as soon as it connects.
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user