diff --git a/docs/unfinished.md b/docs/unfinished.md index 61853e1..9eb042e 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -393,13 +393,15 @@ burst of three does not read as thousands an hour. The five transmit-interlock flags in the message are sent as false: this program does not block a transmitter. -**The network status window** has N1MM's Stations, Actions and Messages tabs and +**The network status window** has N1MM's Stations, Actions, Resynchronize, +Rescore and Messages tabs and its columns — Computer, IP Address, Pass, Run, 10, 100, Freq, Op, Msg, Send, Receive, Band, R/M, Mode. Right-click a row to pass the contact being worked to that station, to talk, or to close and open its connection; a connection closed by hand stays closed until it is opened again, because the next beacon would -otherwise open it within ten seconds. Left out: N1MM's Resynchronize and Rescore -tabs, the transmit interlocks of its Options tab, the Master column and the +otherwise open it within ten seconds. The Resynchronize and Rescore tabs work +on a period — the contest, since a time, or the last few hours. Left out: the +transmit interlocks of N1MM's Options tab, the Master column and the call-stacking target tab. There is nothing behind any of them here. **The ten-minute rule.** `BandChangeRules` counts band changes and the stay on a @@ -412,11 +414,42 @@ the changes per hour overrides `Contest.BandChangesFor`. N1MM's per-contest tabl is not repeated: it is a few hundred cases in a decompiled hash switch, and the contests this station enters are the ones worth reading out of it one at a time. -What the link does not do yet: N1MM's resync, where a station that has been -away asks another for the contacts it missed, and the log check that compares -two stations' logs row by row. Both are message types this program can already -frame; neither is written. There is also no guard against two stations taking -the same station number. +**Resync.** A station that was away asks the others for the hours it missed. +The walk is N1MM's, an hour at a time: + +| Message | What it says | +|---|---| +| `CHECKSUM` | here is what I hold for your contacts in this hour, and the contest and callsign I am logging | +| `DELETEQS` | drop your copies of my contacts in that hour, I am sending them again | +| `RESYNCQSO` | one of those contacts | +| `CONFIRMED` | here is what the hour hashes to at my end — which also asks for the next hour | + +Each station is the authority on the contacts it made, so the answer replaces +the block rather than merging into it. The walk ends when the block being asked +about starts after the end of the period. + +`LogChecksum` is N1MM's: a CRC32 over the `DXLOG` columns of the block, ordered +by time and then callsign, leaving out the columns each station works out for +itself — points, the three multiplier flags, the country prefix, the continent, +the WPX prefix and `IsOriginal`. Two stations are allowed to disagree about +those and still hold the same log. A checksum that disagrees for any other +reason costs one block of traffic, which is what a resync is for. + +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. + +**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 +contact that arrives from another station after the one that took a multiplier +leaves them behind. A station can also ask the others to rescore when its resync +finishes, which is the flag on the end of `CHECKSUM`. + +What the link does not do yet: the log check that compares two stations' logs +row by row. It is a message type this program can already frame; it is not +written. There is also no guard against two stations taking the same station +number. **Voice keying.** `MessageSender` was written to cover a voice keyer playing a recording, and nothing implements it. Each operator's recordings folder is diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 6aec4e7..02fb056 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -219,6 +219,11 @@ public sealed class AppSession : IDisposable // whoever was at the radio when the last contest was open is still // there, and every contact he logs carries his callsign Operator = Settings.CurrentOperator, + // N1MM stamps every contact with the computer that made it and the + // station number of that computer, and a resync asks for the + // contacts of one computer by name + StationName = ComputerName, + StationNumber = Settings.StationNumber, }; positions.Clear(); for (int number = 1; number <= PositionCount; number++) @@ -283,6 +288,11 @@ public sealed class AppSession : IDisposable /// separate from `ApplyNetworkSettings` because the two are separate /// networks: one carries XML to other programs, the other carries contacts /// to the other computers of this entry. + /// What this computer is called on the network of the entry, which is also + /// what every contact it logs is stamped with. + public string ComputerName => + Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName; + public void ApplyStationLinkSettings() { link?.Dispose(); @@ -293,7 +303,7 @@ public sealed class AppSession : IDisposable return; } link = new StationLink( - Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName, + ComputerName, Settings.StationLinkVersion, Settings.StationNumber, Settings.StationLinkPort) @@ -304,6 +314,11 @@ public sealed class AppSession : IDisposable // made where the windows read it link.UpdateArrived += (_, update) => Dispatcher.UIThread.Post(() => TakeFromNetwork(update)); + // the resync reads the log rather than being handed it, and another + // station can ask for a rescore when its resync is done + link.Log = new SessionContactLog(this); + link.RescoreRequested += (_, _) => + Dispatcher.UIThread.Post(() => Rescore(DateTime.MinValue)); foreach (string peer in Settings.StationLinkPeers) { if (Peer(peer) is var (name, address, port)) @@ -348,6 +363,12 @@ public sealed class AppSession : IDisposable _ = link.SendBandAsync(position.Frequency, position.Mode, position.IsRunning, position.RadioNumber); } + /// Scores the log again and writes the points and multiplier flags from + /// `fromUtc` on back to the database. Returns how many rows were written. + /// Another station asks for this at the end of a resync, and the rescore + /// tab of the network status window asks for it by hand. + public int Rescore(DateTime fromUtc) => Logging?.Rescore(fromUtc) ?? 0; + /// The `STATUS` message: everything the other operators' network status /// windows show about this station, whether anything has changed or not. It /// goes out on N1MM's own interval, and the rates in it are read out of the @@ -414,6 +435,9 @@ public sealed class AppSession : IDisposable case ContactDeleted deleted: Logging.DeleteFromNetwork(deleted.Id, deleted.Call, deleted.TimestampUtc); break; + case ContactsCleared cleared: + Logging.ClearFromNetwork(cleared.StationName, cleared.FromUtc, cleared.Length); + break; } } diff --git a/src/Nonemm.App/SessionContactLog.cs b/src/Nonemm.App/SessionContactLog.cs new file mode 100644 index 0000000..776df4c --- /dev/null +++ b/src/Nonemm.App/SessionContactLog.cs @@ -0,0 +1,38 @@ +using Avalonia.Threading; +using Nonemm.Core; +using Nonemm.Network; + +namespace Nonemm.App; + +/// The log as a resync reads it. The link asks these questions on a socket +/// thread and the log is only touched where the windows are, so every answer is +/// fetched on the window thread and waited for. +/// +/// The wait is short — a block is an hour of contacts out of a list already in +/// memory — and nothing on the window thread waits on the socket thread, so +/// there is nothing here for the two to deadlock over. +internal sealed class SessionContactLog : ContactLog +{ + private readonly AppSession session; + + internal SessionContactLog(AppSession session) => this.session = session; + + public string ContestName => Ask(() => session.Logging?.Contest.Name ?? ""); + + public string MyCall => Ask(() => session.Settings.Station.Callsign); + + public IReadOnlyList Contacts(string computerName, DateTime fromUtc, TimeSpan length) => + Ask(() => session.Logging is not { } logging + ? [] + : logging.Log.Qsos + .Where(q => + string.Equals(q.StationName, computerName, StringComparison.OrdinalIgnoreCase) + && q.TimestampUtc >= fromUtc + && q.TimestampUtc < fromUtc + length) + .OrderBy(q => q.TimestampUtc) + .ThenBy(q => q.Call.Text, StringComparer.Ordinal) + .ToList()); + + private static T Ask(Func question) => + Dispatcher.UIThread.CheckAccess() ? question() : Dispatcher.UIThread.Invoke(question); +} diff --git a/src/Nonemm.App/Windows/NetworkStatusWindow.axaml b/src/Nonemm.App/Windows/NetworkStatusWindow.axaml index 1980fbf..61b1328 100644 --- a/src/Nonemm.App/Windows/NetworkStatusWindow.axaml +++ b/src/Nonemm.App/Windows/NetworkStatusWindow.axaml @@ -69,6 +69,53 @@ + + + + + + + + +