From 2a21c772adbf0a56126dd2082b4e7d682d40d5ac Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 3 Sep 2026 21:52:09 +0000 Subject: [PATCH] Resynchronize a log with the other stations, and rescore it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A station that was away — a laptop that lost the network, one that was switched off — comes back missing whatever the others logged while it was gone. N1MM fills that in by walking the log an hour at a time, and this is that walk, on the same messages: | 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 an hour that disagrees is replaced rather than merged. The answer to one block asks for the next, and the walk ends when the block starts after the end of the period. The Resynchronize tab counts the hours that agreed, the hours that were sent again and the hours that still disagreed, which are N1MM's columns. **`LogChecksum`** is N1MM's: a CRC32 over the `DXLOG` columns of the block, ordered by time and then callsign, leaving out what each station works out for itself — points, the three multiplier flags, the country prefix, the continent, the WPX prefix and `IsOriginal`. Two stations may 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. **The link still holds no log.** It asks one through `ContactLog` for the contacts of a computer in a block of time, and `SessionContactLog` answers on the window thread, because that is where the log is touched. **Every contact logged here now carries the computer that made it.** N1MM stamps `NetBiosName` and `NetworkedCompNr` on every row and this program did not, so its own contacts belonged to no station and a resync could not ask for them. **Rescore** writes the points and multiplier flags of a period back to the database. The log in memory scores itself whenever it changes; the stored rows do not, and N1MM reads them rather than working them out. A contact that arrives from another station after the one that took a multiplier leaves those columns behind. A station can also ask the others to rescore when its resync finishes, which is the flag on the end of `CHECKSUM`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU --- docs/unfinished.md | 49 ++++- src/Nonemm.App/AppSession.cs | 26 ++- src/Nonemm.App/SessionContactLog.cs | 38 ++++ .../Windows/NetworkStatusWindow.axaml | 47 +++++ .../Windows/NetworkStatusWindow.axaml.cs | 111 +++++++++- src/Nonemm.App/Windows/StationRow.cs | 12 ++ src/Nonemm.Contests/ContestLog.cs | 6 + src/Nonemm.Network/ContactLog.cs | 23 ++ src/Nonemm.Network/ContactUpdate.cs | 6 + src/Nonemm.Network/Crc32.cs | 40 ++++ src/Nonemm.Network/LogChecksum.cs | 111 ++++++++++ src/Nonemm.Network/NetworkedStation.cs | 15 ++ src/Nonemm.Network/StationLink.cs | 197 ++++++++++++++++++ src/Nonemm.Network/StationMessages.cs | 78 +++++++ src/Nonemm.Session/ContestSession.cs | 60 +++++- .../Nonemm.Network.Tests/LogChecksumTests.cs | 93 +++++++++ .../Nonemm.Network.Tests/StationLinkTests.cs | 103 +++++++++ .../NetworkedContactsTests.cs | 61 ++++++ 18 files changed, 1061 insertions(+), 15 deletions(-) create mode 100644 src/Nonemm.App/SessionContactLog.cs create mode 100644 src/Nonemm.Network/ContactLog.cs create mode 100644 src/Nonemm.Network/Crc32.cs create mode 100644 src/Nonemm.Network/LogChecksum.cs create mode 100644 tests/Nonemm.Network.Tests/LogChecksumTests.cs 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 @@ + + + + + + + + +