diff --git a/docs/unfinished.md b/docs/unfinished.md index 2cb1539..b447a12 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -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, 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 -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 that will work it: `STACKANOTHERCALL` with the call alone, `STACKCALL` with the diff --git a/src/Nonemm.App/AppSession.MirrorRadio.cs b/src/Nonemm.App/AppSession.MirrorRadio.cs index 9ece128..08b3d82 100644 --- a/src/Nonemm.App/AppSession.MirrorRadio.cs +++ b/src/Nonemm.App/AppSession.MirrorRadio.cs @@ -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 diff --git a/src/Nonemm.App/AppSession.QsySpot.cs b/src/Nonemm.App/AppSession.QsySpot.cs new file mode 100644 index 0000000..6ccf3f4 --- /dev/null +++ b/src/Nonemm.App/AppSession.QsySpot.cs @@ -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? 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, + }); + } +} diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 2978ce1..9d01ec2 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -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() diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index f51a41e..bdf9e76 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -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, diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index b43121f..d2b2dde 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -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 diff --git a/src/Nonemm.Network/SharedSpot.cs b/src/Nonemm.Network/SharedSpot.cs new file mode 100644 index 0000000..7afd782 --- /dev/null +++ b/src/Nonemm.Network/SharedSpot.cs @@ -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); diff --git a/src/Nonemm.Network/StationLink.cs b/src/Nonemm.Network/StationLink.cs index 1c1375b..f58e841 100644 --- a/src/Nonemm.Network/StationLink.cs +++ b/src/Nonemm.Network/StationLink.cs @@ -145,6 +145,9 @@ public sealed class StationLink : IDisposable /// contact behind it when there was one. public event EventHandler? CallStacked; + /// A spot another station put on its bandmap and passed on. + public event EventHandler? SpotArrived; + /// 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. public event EventHandler? RescoreRequested; @@ -485,6 +488,12 @@ public sealed class StationLink : IDisposable : StationMessages.StackContact(contact, StationNumber, computerName), 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 SendSpotAsync(SharedSpot spot, CancellationToken cancellation = default) => + SendAsync(StationMessages.AddSpot(spot, StationNumber, computerName), cancellation); + /// Asks every station whether it is there. The answer sets `EchoTime`. public Task SendEchoRequestAsync(CancellationToken cancellation = default) { @@ -957,6 +966,9 @@ public sealed class StationLink : IDisposable Qso stacked = QsoRecord.Read(record, 0, station.ComputerName); CallStacked?.Invoke(this, new StackedCall(stacked.Call.Text, stacked, station.ComputerName)); break; + case "ADDSPOT": + SpotArrived?.Invoke(this, StationMessages.ReadSpot(record)); + break; case "TALK": TalkArrived?.Invoke(this, record.Field(0)); break; diff --git a/src/Nonemm.Network/StationMessages.cs b/src/Nonemm.Network/StationMessages.cs index eb988f0..eb5dd40 100644 --- a/src/Nonemm.Network/StationMessages.cs +++ b/src/Nonemm.Network/StationMessages.cs @@ -31,6 +31,7 @@ namespace Nonemm.Network; /// | `QSONRS` | the highest serial number taken on each band | yes | yes | /// | `STACKCALL` | a contact 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 | /// | `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 | @@ -345,6 +346,34 @@ public static class StationMessages public static StationRecord StackCall(string call, int stationNumber, string computerName) => 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 /// nothing with. public static ContactUpdate? Read(StationRecord record) => record.Type switch diff --git a/tests/Nonemm.Network.Tests/StationLinkTests.cs b/tests/Nonemm.Network.Tests/StationLinkTests.cs index dca02af..2925479 100644 --- a/tests/Nonemm.Network.Tests/StationLinkTests.cs +++ b/tests/Nonemm.Network.Tests/StationLinkTests.cs @@ -495,6 +495,41 @@ public class StationLinkTests 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 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 /// other's logs. Each says which contest it is on as soon as it connects. [Fact]