Resynchronize a log with the other stations, and rescore it

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
This commit is contained in:
2026-09-03 21:52:09 +00:00
co-authored by Claude Opus 5
parent 68b29d722a
commit 2a21c772ad
18 changed files with 1061 additions and 15 deletions
+197
View File
@@ -88,6 +88,10 @@ public sealed class StationLink : IDisposable
set => mine.Operator = value;
}
/// The log a resync reads. Without one this station answers no checksum:
/// it has nothing to compare and nothing to send.
public ContactLog? Log { get; set; }
/// This computer's own row, which is in the station list as well. The
/// window reads its pass frequency and its counts off it.
public NetworkedStation Me => mine;
@@ -108,6 +112,10 @@ public sealed class StationLink : IDisposable
public event EventHandler<string>? Failed;
/// 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;
/// Every message that arrives, whatever it says. Nothing needs this to run:
/// it is what the messages pane of the network status window prints when
/// the operator turns tracing on.
@@ -322,6 +330,50 @@ public sealed class StationLink : IDisposable
}
}
/// How long a block of the log a resync compares at a time. N1MM uses an
/// hour, which is why its counts are of hours.
public static readonly TimeSpan ResyncBlock = TimeSpan.FromHours(1);
/// Starts a resync with one station: are your contacts and my copies of
/// them the same, hour by hour, between these two times?
///
/// It is a walk rather than one message. This station sends the checksum of
/// the first block, the other end answers with its own and sends the block
/// again if they differ, and its answer is what asks for the next block. The
/// walk stops when the block being asked about starts after `toUtc`.
public void StartResync(
NetworkedStation station,
DateTime fromUtc,
DateTime toUtc,
bool everyStation = false,
bool rescoreWhenDone = false)
{
station.IsResyncing = true;
station.ResyncStatus = "Starting";
station.HoursOk = 0;
station.HoursFixed = 0;
station.HoursFailed = 0;
StationsChanged?.Invoke(this, EventArgs.Empty);
_ = AskAboutBlockAsync(station, fromUtc, toUtc, everyStation, rescoreWhenDone);
}
/// The same walk with every station that is connected, which is N1MM's
/// Resync All.
public void StartResyncAll(
DateTime fromUtc,
DateTime toUtc,
bool everyStation = false,
bool rescoreWhenDone = false)
{
foreach (NetworkedStation station in stations.Values)
{
if (!station.IsMine && station.IsConnected)
{
StartResync(station, fromUtc, toUtc, everyStation, rescoreWhenDone);
}
}
}
/// Asks every station whether it is there. The answer sets `EchoTime`.
public Task<int> SendEchoRequestAsync(CancellationToken cancellation = default)
{
@@ -571,6 +623,20 @@ public sealed class StationLink : IDisposable
case "ECHO":
station.EchoTime = DateTime.UtcNow - echoSentAt;
break;
case "CHECKSUM":
_ = AnswerChecksumAsync(station, record);
break;
case "CONFIRMED":
_ = TakeConfirmationAsync(station, record);
break;
case "DELETEQS":
station.HoursFixed++;
station.ResyncStatus = $"Replacing {record.Time(0):yyyy-MM-dd HH:mm}Z";
if (StationMessages.Read(record) is { } cleared)
{
UpdateArrived?.Invoke(this, cleared);
}
break;
case "TALK":
TalkArrived?.Invoke(this, record.Field(0));
break;
@@ -584,6 +650,137 @@ public sealed class StationLink : IDisposable
StationsChanged?.Invoke(this, EventArgs.Empty);
}
/// One block of the walk: what this station holds for the other one, sent
/// as a checksum for it to compare with its own.
private async Task AskAboutBlockAsync(
NetworkedStation station,
DateTime fromUtc,
DateTime toUtc,
bool everyStation,
bool rescoreWhenDone)
{
if (Log is null)
{
return;
}
int checksum = LogChecksum.Of(Log.Contacts(station.ComputerName, fromUtc, ResyncBlock));
await SendToAsync(
station,
StationMessages.CheckSum(
fromUtc,
toUtc,
checksum,
Log.ContestName,
Log.MyCall,
(int)ResyncBlock.TotalMinutes,
everyStation,
rescoreWhenDone,
StationNumber,
computerName)).ConfigureAwait(false);
}
/// A station asked whether we hold the same block. This end is the
/// authority on the contacts it made: when the two checksums differ it
/// tells the asker to drop its copies and sends the block again, and it
/// answers with its own checksum either way.
private async Task AnswerChecksumAsync(NetworkedStation station, StationRecord record)
{
if (Log is null)
{
return;
}
DateTime fromUtc = record.Time(0);
DateTime toUtc = record.Time(1);
int theirs = record.Number(2);
string contest = record.Field(3);
string call = record.Field(4);
TimeSpan block = TimeSpan.FromMinutes(Math.Max(1, record.Number(5)));
bool everyStation = record.Flag(6);
if (record.Flag(7))
{
RescoreRequested?.Invoke(this, EventArgs.Empty);
}
if (!string.Equals(contest, Log.ContestName, StringComparison.OrdinalIgnoreCase))
{
Failed?.Invoke(
this,
$"{station.ComputerName} is logging {contest}, this station is logging {Log.ContestName} — not resynchronising");
return;
}
if (call.Length > 0 && !string.Equals(call, Log.MyCall, StringComparison.OrdinalIgnoreCase))
{
Failed?.Invoke(this, $"{station.ComputerName} is logging as {call}, this station as {Log.MyCall}");
}
IReadOnlyList<Qso> mine = Log.Contacts(computerName, fromUtc, block);
int ours = LogChecksum.Of(mine);
if (everyStation)
{
StartResyncAll(fromUtc, toUtc);
}
if (theirs != ours)
{
await SendToAsync(
station,
StationMessages.DeleteContacts(
fromUtc,
computerName,
(int)block.TotalMinutes,
StationNumber,
computerName)).ConfigureAwait(false);
foreach (Qso qso in mine)
{
await SendToAsync(station, StationMessages.Resynced(qso, StationNumber, computerName))
.ConfigureAwait(false);
}
}
await SendToAsync(
station,
StationMessages.Confirmed(
fromUtc,
toUtc,
ours,
(int)block.TotalMinutes,
StationNumber,
computerName)).ConfigureAwait(false);
}
/// The answer to one block. It says how that block went and asks about the
/// next one, which is what walks the resync along; a block that starts
/// after the end of the period is the end of the walk.
private async Task TakeConfirmationAsync(NetworkedStation station, StationRecord record)
{
if (Log is null)
{
return;
}
DateTime fromUtc = record.Time(0);
DateTime toUtc = record.Time(1);
int theirs = record.Number(2);
TimeSpan block = TimeSpan.FromMinutes(Math.Max(1, record.Number(3)));
if (fromUtc > toUtc)
{
station.IsResyncing = false;
station.ResyncStatus = "Resync complete";
StationsChanged?.Invoke(this, EventArgs.Empty);
return;
}
// the contacts arrived before this answer did, so what is counted here
// is the block as it now stands
if (LogChecksum.Of(Log.Contacts(station.ComputerName, fromUtc, block)) == theirs)
{
station.HoursOk++;
}
else
{
station.HoursFailed++;
}
DateTime next = fromUtc + block;
station.ResyncStatus = $"Syncing {next:yyyy-MM-dd HH:mm}Z";
StationsChanged?.Invoke(this, EventArgs.Empty);
await AskAboutBlockAsync(station, next, toUtc, everyStation: false, rescoreWhenDone: false)
.ConfigureAwait(false);
}
/// Closes a connection that has failed or gone quiet. The station stays in
/// the list: it
/// is still part of the entry, and the window showing it as not connected