diff --git a/docs/unfinished.md b/docs/unfinished.md index 9eb042e..1456b83 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -364,8 +364,9 @@ DATA__07%SHACK-PC%QSO%2026-09-03 12:34:56%DL1ABC%…~__DATA which is the sending station's number, its computer name, the message type and the fields of that type. `%` and `~` cannot appear in a field, so N1MM writes `!` in their place. What is read and sent: contacts, edits, deletes, resyncs, -`IAM`, the echo pair, chat, the pass frequency, transmit on and off, and where a -station is. The rest of N1MM's forty-odd types — the score and sked windows, the +`IAM`, the echo pair, chat, the pass frequency, transmit on and off, where a +station is tuned, the time of a station's last contact, and the four messages a +resync is made of. The rest of N1MM's forty-odd types — the score and sked windows, the log check, the spot lists, the serial-number pool — are passed over, which N1MM's own reader also does for a type it does not know. @@ -380,7 +381,9 @@ nothing arrives. Where a station is goes out once a second when it has changed, rather than from each of the dozen places it can change from. In a multi-single entry that is -what the other operators watch. +what the other operators watch. The frequency goes on its own in N1MM's `FREQ`, +in hundredths of a kilohertz; the mode, run and the rest ride in the `STATUS` +that follows it. **The status message.** `STATUS` goes out every five seconds whether anything has changed or not, and it is what fills the other operators' windows: the pass @@ -439,6 +442,13 @@ Every contact logged here now carries the computer that made it — `NetBiosName`, which N1MM stamps and this program did not — because that is what a resync asks for a block of contacts by. +A station that reconnects resynchronises by itself. N1MM sends `LASTQAT` — when +I last worked anybody — as soon as a connection opens, and a station that holds +nothing of that station's that late starts a resync from where its own copy +stops. An empty log asks for nothing: a program that has just been started has +no business pulling the whole contest across, and N1MM suppresses it the same +way. + **Rescore** writes the points and multiplier flags of a period back to the database. The log in memory scores itself whenever it changes, so this is for the stored rows: N1MM reads those columns rather than working them out, and a diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 02fb056..7838879 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -344,7 +344,9 @@ public sealed class AppSession : IDisposable Changed?.Invoke(this, EventArgs.Empty); } - /// Tells the other stations where this one is, if it has moved. In a + /// Tells the other stations where this one is, if it has moved. The + /// frequency goes out on its own — N1MM's `FREQ` — and everything else + /// about the position rides in the `STATUS` that follows it. In a /// multi-single entry this is what the other operators watch: two stations /// on one band is a contact nobody can make. private void AnnounceWhereIAm() @@ -359,8 +361,15 @@ public sealed class AppSession : IDisposable { return; } + bool moved = announced.Where != position.Frequency; announced = now; - _ = link.SendBandAsync(position.Frequency, position.Mode, position.IsRunning, position.RadioNumber); + if (moved) + { + _ = link.SendFrequencyAsync(position.Frequency); + } + // the mode, run and the rest of it ride in `STATUS`, which is sent + // again now rather than waiting out the rest of its interval + statusSent = default; } /// Scores the log again and writes the points and multiplier flags from diff --git a/src/Nonemm.App/SessionContactLog.cs b/src/Nonemm.App/SessionContactLog.cs index 776df4c..d957efb 100644 --- a/src/Nonemm.App/SessionContactLog.cs +++ b/src/Nonemm.App/SessionContactLog.cs @@ -33,6 +33,20 @@ internal sealed class SessionContactLog : ContactLog .ThenBy(q => q.Call.Text, StringComparer.Ordinal) .ToList()); + public DateTime LastContactUtc(string computerName) => + Ask(() => session.Logging is not { } logging + ? default + : logging.Log.Qsos + .Where(q => string.Equals(q.StationName, computerName, StringComparison.OrdinalIgnoreCase)) + .Select(q => q.TimestampUtc) + .DefaultIfEmpty(default) + .Max()); + + public DateTime FirstContactUtc => + Ask(() => session.Logging is not { } logging + ? default + : logging.Log.Qsos.Select(q => q.TimestampUtc).DefaultIfEmpty(default).Min()); + private static T Ask(Func question) => Dispatcher.UIThread.CheckAccess() ? question() : Dispatcher.UIThread.Invoke(question); } diff --git a/src/Nonemm.App/Windows/NetworkStatusWindow.axaml.cs b/src/Nonemm.App/Windows/NetworkStatusWindow.axaml.cs index 92357b4..25db11b 100644 --- a/src/Nonemm.App/Windows/NetworkStatusWindow.axaml.cs +++ b/src/Nonemm.App/Windows/NetworkStatusWindow.axaml.cs @@ -336,7 +336,7 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow { return; } - await link.SendPassAsync(position.Frequency, position.Entry.Call); + await link.SendPassAsync(position.Frequency); Say($"passing on {position.Frequency.Kilohertz:0.0}"); } @@ -349,8 +349,8 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow { return; } - await link.SendPassAsync(position.Frequency, position.Entry.Call, station); - Say($"passed {position.Entry.Call} on {position.Frequency.Kilohertz:0.0} to {station.ComputerName}"); + await link.SendPassAsync(position.Frequency, station); + Say($"passed {position.Frequency.Kilohertz:0.0} to {station.ComputerName}"); } private void OnCloseConnection(object? sender, RoutedEventArgs e) diff --git a/src/Nonemm.App/Windows/StationRow.cs b/src/Nonemm.App/Windows/StationRow.cs index e997f96..3c21774 100644 --- a/src/Nonemm.App/Windows/StationRow.cs +++ b/src/Nonemm.App/Windows/StationRow.cs @@ -27,9 +27,9 @@ public sealed class StationRow ? $"{station.Address}:{station.Port}" : station.Address; - /// The frequency this station is passing contacts on, and who is on it. + /// The frequency this station is passing contacts on. public string Pass => station.PassFrequency.Hertz > 0 - ? $"{station.PassFrequency.Kilohertz:0.0} {station.PassCall}".TrimEnd() + ? $"{station.PassFrequency.Kilohertz:0.0}" : ""; /// Run or search and pounce, which is N1MM's Run column. @@ -43,10 +43,12 @@ public sealed class StationRow public string Op => station.Operator; - /// The last message this station sent, and TX while it is transmitting. - /// Two stations keying at once is what an operator watches this column for. + /// What the station is sending, which is what N1MM shows here. A station + /// that is not transmitting has nothing to say, so the last message type it + /// sent stands in: two stations keying at once is what an operator watches + /// this column for, and an empty column says nothing about that. public string Msg => station.IsTransmitting - ? $"TX {station.LastMessage}".TrimEnd() + ? $"TX {station.Message}".TrimEnd() : station.LastMessage; /// Whether this program can write to the station. N1MM prints OK and so diff --git a/src/Nonemm.Network/ContactLog.cs b/src/Nonemm.Network/ContactLog.cs index 5c57eaa..569fcd9 100644 --- a/src/Nonemm.Network/ContactLog.cs +++ b/src/Nonemm.Network/ContactLog.cs @@ -20,4 +20,12 @@ public interface ContactLog /// The contacts one computer made inside one block of time, oldest first. IReadOnlyList Contacts(string computerName, DateTime fromUtc, TimeSpan length); + + /// When one computer last worked anybody, as far as this log knows, or the + /// default time when it holds nothing of that computer's. + DateTime LastContactUtc(string computerName); + + /// When this log starts, which is as far back as a resync can reach. The + /// default time means the log is empty. + DateTime FirstContactUtc { get; } } diff --git a/src/Nonemm.Network/NetworkedStation.cs b/src/Nonemm.Network/NetworkedStation.cs index 98a04a5..88f2262 100644 --- a/src/Nonemm.Network/NetworkedStation.cs +++ b/src/Nonemm.Network/NetworkedStation.cs @@ -44,7 +44,7 @@ public sealed class NetworkedStation public string ContestName { get; internal set; } = ""; - /// Where the station is, from the last `BANDMAP` it sent. + /// Where the station is, from the last `FREQ` or `STATUS` it sent. public Frequency Frequency { get; internal set; } public Mode? Mode { get; internal set; } @@ -64,17 +64,20 @@ public sealed class NetworkedStation public bool IsTransmitting { get; internal set; } + /// What the station is sending, which is the caption of the function key + /// it pressed. N1MM shows it in the `Msg` column while the station is + /// transmitting and clears it when the station stops. + public string Message { get; internal set; } = ""; + /// True when the station is the second run radio of a two-radio position. /// N1MM shows Run1 or Run2/Mult in its `R/M` column. public bool IsRun2 { get; internal set; } - public int RadioNumber { get; internal set; } = 1; - - /// A frequency this station has passed, from `PASSFREQ`, and who is on it. + /// A frequency this station has passed, from `PASSFREQ`. N1MM sends the + /// frequency alone: which station is on it is what the operator is about to + /// say over the talk line. public Frequency PassFrequency { get; internal set; } - public string PassCall { get; internal set; } = ""; - /// When anything last arrived from the station, and what it was. The window /// shows both: a station whose last message is minutes old is not there any /// more, whatever its connection says. @@ -111,6 +114,10 @@ public sealed class NetworkedStation public string ResyncStatus { get; internal set; } = ""; + /// Set when this station said it is ahead of us before there was a + /// connection to answer over. The resync starts as soon as one opens. + internal DateTime? ResyncWanted { get; set; } + public int HoursOk { get; internal set; } public int HoursFixed { get; internal set; } diff --git a/src/Nonemm.Network/StationLink.cs b/src/Nonemm.Network/StationLink.cs index af23aa0..bf9c8e7 100644 --- a/src/Nonemm.Network/StationLink.cs +++ b/src/Nonemm.Network/StationLink.cs @@ -243,34 +243,29 @@ public sealed class StationLink : IDisposable public Task SendTalkAsync(string text, CancellationToken cancellation = default) => SendAsync(StationMessages.Talk(text, StationNumber, computerName), cancellation); - /// Says where this station is. It goes out whenever the radio moves or the - /// operator turns run on or off, and it is what the other stations show and - /// what the band-change rule counts. - public Task SendBandAsync( - Frequency frequency, - Mode mode, - bool running, - int radioNumber, - CancellationToken cancellation = default) + /// Says where this station is tuned. It goes out whenever the radio moves; + /// the mode and whether the station is running go with the next `STATUS`. + public Task SendFrequencyAsync(Frequency frequency, CancellationToken cancellation = default) { mine.Frequency = frequency; - mine.Mode = mode; - mine.IsRunning = running; - mine.RadioNumber = radioNumber; StationsChanged?.Invoke(this, EventArgs.Empty); - return SendAsync( - StationMessages.OnBand(frequency, mode, running, radioNumber, StationNumber, computerName), - cancellation); + return SendAsync(StationMessages.OnFrequency(frequency, StationNumber, computerName), cancellation); } + /// This station started or stopped transmitting, and what it is sending. + /// `message` is the caption of the function key, which is what the other + /// operators read in the window. public Task SendTransmittingAsync( bool transmitting, - int radioNumber, + string message = "", + bool isRun2 = false, CancellationToken cancellation = default) { mine.IsTransmitting = transmitting; + mine.Message = transmitting ? message : ""; + StationsChanged?.Invoke(this, EventArgs.Empty); return SendAsync( - StationMessages.Transmitting(transmitting, radioNumber, StationNumber, computerName), + StationMessages.Transmitting(transmitting, message, isRun2, StationNumber, computerName), cancellation); } @@ -291,20 +286,18 @@ public sealed class StationLink : IDisposable return SendAsync(StationMessages.Status(status, version, StationNumber, computerName), cancellation); } - /// A frequency this station is handing to the others, and who is on it. - /// With `to` set it goes to that station alone, which is how an operator - /// passes one contact to one position; without it every station is told, - /// which is how the pass frequency is announced. + /// A frequency this station is handing to the others. With `to` set it goes + /// to that station alone, which is how an operator passes one contact to one + /// position; without it every station is told, which is how the pass + /// frequency is announced. public Task SendPassAsync( Frequency frequency, - string call, NetworkedStation? to = null, CancellationToken cancellation = default) { mine.PassFrequency = frequency; - mine.PassCall = call; StationsChanged?.Invoke(this, EventArgs.Empty); - StationRecord record = StationMessages.PassFrequency(frequency, call, StationNumber, computerName); + StationRecord record = StationMessages.PassFrequency(frequency, StationNumber, computerName); return to is null ? SendAsync(record, cancellation) : Sent(SendToAsync(to, record, cancellation)); @@ -520,6 +513,64 @@ public sealed class StationLink : IDisposable // the others were told when their own connection opened await SendToAsync(station, StationMessages.IAm(StationNumber, computerName), cancellation) .ConfigureAwait(false); + // when this station last worked anybody. The other end holds contacts + // of ours only up to some time of its own, and starts a resync when + // this one is later + if (Log is not null) + { + await SendToAsync( + station, + StationMessages.LastContactAt(Log.LastContactUtc(computerName), StationNumber, computerName), + cancellation).ConfigureAwait(false); + } + // that station told us it was ahead of us before there was anything to + // answer over + if (station.ResyncWanted is { } behindSince) + { + station.ResyncWanted = null; + StartResync(station, behindSince, DateTime.UtcNow); + } + } + + /// A station said when it last worked anybody. When this station holds + /// nothing of its that late, the hours in between are missing and a resync + /// asks for them. + /// + /// An empty log asks for nothing: a station that has just been started has + /// no business pulling the whole contest across, and N1MM suppresses it the + /// same way. + private void ResyncIfBehind(NetworkedStation station, DateTime theirLast) + { + if (Log is null || station.IsResyncing || theirLast == default) + { + return; + } + DateTime ourLast = Log.LastContactUtc(station.ComputerName); + if (theirLast <= ourLast) + { + return; + } + // nothing at all from that station: everything it worked since this log + // started is missing + DateTime from = ourLast == default ? Log.FirstContactUtc : ourLast; + if (from == default) + { + Failed?.Invoke( + this, + $"{station.ComputerName} has worked somebody since this log was started, and this log is empty — resync by hand"); + return; + } + Failed?.Invoke(this, $"resynchronising with {station.ComputerName}, which has contacts this log has not"); + // the message arrived over the connection that station opened, and the + // answer goes over the one this station opens, which may not be up yet + if (writers.ContainsKey(station.ComputerName)) + { + StartResync(station, from, DateTime.UtcNow); + } + else + { + station.ResyncWanted = from; + } } private async Task AcceptAsync(CancellationToken cancellation) @@ -592,11 +643,11 @@ public sealed class StationLink : IDisposable case "IAM": station.StationNumber = record.Number(0); break; - case "BANDMAP": - station.Frequency = Frequency.FromKilohertz(record.Decimal(0)); - station.Mode = Modes.Parse(record.Field(1)); - station.IsRunning = record.Flag(2); - station.RadioNumber = Math.Max(1, record.Number(3)); + case "FREQ": + station.Frequency = Frequency.FromKilohertz(record.Decimal(0) / 100); + break; + case "LASTQAT": + ResyncIfBehind(station, record.Time(0)); break; case "STATUS": StationStatus status = StationMessages.ReadStatus(record); @@ -611,11 +662,11 @@ public sealed class StationLink : IDisposable break; case "XMIT": station.IsTransmitting = record.Flag(0); - station.RadioNumber = Math.Max(1, record.Number(1)); + station.Message = station.IsTransmitting ? record.Field(1) : ""; + station.IsRun2 = record.Flag(2); break; case "PASSFREQ": - station.PassFrequency = Frequency.FromKilohertz(record.Decimal(0)); - station.PassCall = record.Field(1); + station.PassFrequency = Frequency.FromKilohertz(record.Decimal(0) / 100); break; case "ECHOREQ": _ = SendAsync(StationMessages.Echo(record, StationNumber, computerName)); @@ -664,7 +715,7 @@ public sealed class StationLink : IDisposable return; } int checksum = LogChecksum.Of(Log.Contacts(station.ComputerName, fromUtc, ResyncBlock)); - await SendToAsync( + bool sent = await SendToAsync( station, StationMessages.CheckSum( fromUtc, @@ -677,6 +728,14 @@ public sealed class StationLink : IDisposable rescoreWhenDone, StationNumber, computerName)).ConfigureAwait(false); + if (!sent) + { + // nothing to ask over. Left running, the row would say it is + // resynchronising for the rest of the contest + station.IsResyncing = false; + station.ResyncStatus = "Stopped: no connection"; + StationsChanged?.Invoke(this, EventArgs.Empty); + } } /// A station asked whether we hold the same block. This end is the diff --git a/src/Nonemm.Network/StationMessages.cs b/src/Nonemm.Network/StationMessages.cs index fc046f6..94911aa 100644 --- a/src/Nonemm.Network/StationMessages.cs +++ b/src/Nonemm.Network/StationMessages.cs @@ -21,7 +21,8 @@ namespace Nonemm.Network; /// | `TALK` | a line of chat between operators | yes | yes | /// | `PASSFREQ` | a frequency being passed to another radio | yes | yes | /// | `XMIT` | a station started or stopped transmitting | yes | yes | -/// | `BANDMAP` | which band and mode a station is on | yes | yes | +/// | `FREQ` | where a station is tuned | yes | yes | +/// | `LASTQAT` | the time of the sender's last contact, which starts a resync | 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 | @@ -93,52 +94,57 @@ public static class StationMessages public static StationRecord Talk(string text, int stationNumber, string computerName) => Record("TALK", stationNumber, computerName, [$"[{computerName}] {text}"]); - /// A frequency handed to another radio, which is N1MM's pass. The - /// callsign is who is on it. + /// A frequency handed to another radio, which is N1MM's pass. It carries + /// the frequency and nothing else — no callsign — in hundredths of a + /// kilohertz. public static StationRecord PassFrequency( Frequency frequency, - string call, int stationNumber, string computerName) => - Record( - "PASSFREQ", - stationNumber, - computerName, - [StationRecord.Written(frequency.Kilohertz), call]); + Record("PASSFREQ", stationNumber, computerName, [Hundredths(frequency)]); - /// A station started or stopped transmitting. The other stations of a - /// multi-single entry need this: two of them keying at once is one signal - /// too many. + /// Where this station is tuned. It goes out whenever the radio moves, and + /// the mode and whether the station is running arrive in `STATUS`. + public static StationRecord OnFrequency( + Frequency frequency, + int stationNumber, + string computerName) => + Record("FREQ", stationNumber, computerName, [Hundredths(frequency)]); + + /// The time of the last contact this station made. It goes out as soon as a + /// connection opens: a station that has been away holds nothing that late + /// and starts a resync on the strength of it. + public static StationRecord LastContactAt( + DateTime lastUtc, + int stationNumber, + string computerName) => + Record("LASTQAT", stationNumber, computerName, [StationRecord.Written(lastUtc)]); + + /// A station started or stopped transmitting, and what it is sending: N1MM + /// carries the caption of the function key, which is what the other + /// operators read in the `Msg` column. Two stations of a multi-single entry + /// keying at once is one signal too many. + /// + /// The last two fields are whether the F1 key was the one pressed and + /// whether the other stations are being told to stop sending. Both go out + /// as false: this program neither reports which key was pressed nor stops + /// another station's transmitter. public static StationRecord Transmitting( bool transmitting, - int radioNumber, + string message, + bool isRun2, int stationNumber, string computerName) => Record( "XMIT", stationNumber, computerName, - [StationRecord.Written(transmitting), Text(radioNumber)]); - - /// Where a station is: the band and the mode it is on, and whether it is - /// running. This is what fills the band and Running columns of the network - /// status window, and what the band-change rule counts. - public static StationRecord OnBand( - Frequency frequency, - Mode mode, - bool running, - int radioNumber, - int stationNumber, - string computerName) => - Record( - "BANDMAP", - stationNumber, - computerName, [ - StationRecord.Written(frequency.Kilohertz), - mode.Name, - StationRecord.Written(running), - Text(radioNumber), + StationRecord.Written(transmitting), + message, + StationRecord.Written(isRun2), + StationRecord.Written(false), + StationRecord.Written(false), ]); /// Everything a station says about itself, sent every few seconds. The two diff --git a/src/Nonemm.Network/StationStatus.cs b/src/Nonemm.Network/StationStatus.cs index 1806f55..9ab6ff8 100644 --- a/src/Nonemm.Network/StationStatus.cs +++ b/src/Nonemm.Network/StationStatus.cs @@ -6,7 +6,7 @@ namespace Nonemm.Network; /// rows of the network status window. /// /// It is one message rather than a field on each of the others because N1MM -/// sends it that way: `BANDMAP` says a station moved, `STATUS` says everything +/// sends it that way: `FREQ` says a station moved, `STATUS` says everything /// the window shows about it and arrives whether anything moved or not, so a /// station that has been sitting on one frequency for an hour still has a /// current row. diff --git a/tests/Nonemm.Network.Tests/StationLinkTests.cs b/tests/Nonemm.Network.Tests/StationLinkTests.cs index 1182966..6c3d02d 100644 --- a/tests/Nonemm.Network.Tests/StationLinkTests.cs +++ b/tests/Nonemm.Network.Tests/StationLinkTests.cs @@ -57,8 +57,10 @@ public class StationLinkTests /// The band a station is on is what the status window shows and what the /// band-change rule counts. + /// N1MM carries the frequency in hundredths of a kilohertz, in its own + /// `FREQ` message. A tenth of a hertz survives the trip. [Fact] - public async Task WhereAStationIsReachesTheOthers() + public async Task WhereAStationIsTunedReachesTheOthers() { (int onePort, int twoPort) = SparePorts(); using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort); @@ -68,14 +70,95 @@ public class StationLinkTests one.AddStation("MULT-PC", "127.0.0.1", twoPort); await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true })); - await one.SendBandAsync(Frequency.FromKilohertz(21_025), Modes.Cw, running: true, radioNumber: 1); + await one.SendFrequencyAsync(Frequency.FromKilohertz(21_025.25)); await WaitForAsync(() => two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.Band?.Name == "15M"); NetworkedStation? seen = two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC"); Assert.Equal("15M", seen?.Band?.Name); - Assert.Equal("CW", seen?.Mode?.Name); - Assert.True(seen?.IsRunning); + Assert.Equal(21_025.25, seen?.Frequency.Kilohertz ?? 0, 2); + } + + /// What a station is sending is what the other operators read in the `Msg` + /// column, and N1MM carries it in the transmit message. + [Fact] + public async Task WhatAStationIsSendingReachesTheOthers() + { + (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(); + one.AddStation("MULT-PC", "127.0.0.1", twoPort); + await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true })); + + await one.SendTransmittingAsync(true, "F1 CQ", isRun2: true); + + await WaitForAsync(() => + two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.IsTransmitting == true); + NetworkedStation? seen = two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC"); + Assert.Equal("F1 CQ", seen?.Message); + Assert.True(seen?.IsRun2); + + await one.SendTransmittingAsync(false); + + await WaitForAsync(() => + two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.IsTransmitting == false); + Assert.Equal("", two.Stations.First(s => s.ComputerName == "RUN-PC").Message); + } + + /// A pass frequency is the frequency and nothing else, in hundredths of a + /// kilohertz. + [Fact] + public async Task APassFrequencyReachesTheOthers() + { + (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(); + one.AddStation("MULT-PC", "127.0.0.1", twoPort); + await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true })); + + await one.SendPassAsync(Frequency.FromKilohertz(14_205.5)); + + await WaitForAsync(() => + two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.PassFrequency.Hertz > 0); + Assert.Equal( + 14_205.5, + two.Stations.First(s => s.ComputerName == "RUN-PC").PassFrequency.Kilohertz, + 1); + } + + /// A station that reconnects is told when the other one last worked + /// somebody, and asks for what it is missing without anybody pressing a + /// button. + [Fact] + public async Task AStationThatIsBehindResynchronisesOnItsOwn() + { + (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); + DateTime hour = DateTime.UtcNow.AddMinutes(-30); + Qso first = Contact() with { TimestampUtc = hour, Id = "first" }; + Qso later = Contact() with + { + TimestampUtc = hour.AddMinutes(10), + Call = Callsign.Parse("DL1ABC"), + Id = "later", + }; + one.Log = new FakeLog([first, later]); + // MULT-PC has the first contact and missed the one after it + FakeLog multLog = new([first]); + two.Log = multLog; + two.UpdateArrived += (_, update) => multLog.Take(update); + one.Start(); + two.Start(); + one.AddStation("MULT-PC", "127.0.0.1", twoPort); + two.AddStation("RUN-PC", "127.0.0.1", onePort); + + await WaitForAsync(() => multLog.All.Count == 2); + Assert.Equal(["DL1ABC", "G3XYZ"], multLog.All.Select(q => q.Call.Text).Order()); } /// The status message carries what the network status window shows: the @@ -291,6 +374,15 @@ public class StationLinkTests public IReadOnlyList Contacts(string computerName, DateTime fromUtc, TimeSpan length) => LogChecksum.Block(qsos, computerName, fromUtc, length); + public DateTime LastContactUtc(string computerName) => + qsos.Where(q => string.Equals(q.StationName, computerName, StringComparison.OrdinalIgnoreCase)) + .Select(q => q.TimestampUtc) + .DefaultIfEmpty(default) + .Max(); + + public DateTime FirstContactUtc => + qsos.Select(q => q.TimestampUtc).DefaultIfEmpty(default).Min(); + public void Take(ContactUpdate update) { switch (update)