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
+41 -8
View File
@@ -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 flags in the message are sent as false: this program does not block a
transmitter. 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, 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 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 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 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 otherwise open it within ten seconds. The Resynchronize and Rescore tabs work
tabs, the transmit interlocks of its Options tab, the Master column and the 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. 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 **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 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. 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 **Resync.** A station that was away asks the others for the hours it missed.
away asks another for the contacts it missed, and the log check that compares The walk is N1MM's, an hour at a time:
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 | Message | What it says |
the same station number. |---|---|
| `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 **Voice keying.** `MessageSender` was written to cover a voice keyer playing a
recording, and nothing implements it. Each operator's recordings folder is recording, and nothing implements it. Each operator's recordings folder is
+25 -1
View File
@@ -219,6 +219,11 @@ public sealed class AppSession : IDisposable
// whoever was at the radio when the last contest was open is still // whoever was at the radio when the last contest was open is still
// there, and every contact he logs carries his callsign // there, and every contact he logs carries his callsign
Operator = Settings.CurrentOperator, 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(); positions.Clear();
for (int number = 1; number <= PositionCount; number++) for (int number = 1; number <= PositionCount; number++)
@@ -283,6 +288,11 @@ public sealed class AppSession : IDisposable
/// separate from `ApplyNetworkSettings` because the two are separate /// separate from `ApplyNetworkSettings` because the two are separate
/// networks: one carries XML to other programs, the other carries contacts /// networks: one carries XML to other programs, the other carries contacts
/// to the other computers of this entry. /// 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() public void ApplyStationLinkSettings()
{ {
link?.Dispose(); link?.Dispose();
@@ -293,7 +303,7 @@ public sealed class AppSession : IDisposable
return; return;
} }
link = new StationLink( link = new StationLink(
Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName, ComputerName,
Settings.StationLinkVersion, Settings.StationLinkVersion,
Settings.StationNumber, Settings.StationNumber,
Settings.StationLinkPort) Settings.StationLinkPort)
@@ -304,6 +314,11 @@ public sealed class AppSession : IDisposable
// made where the windows read it // made where the windows read it
link.UpdateArrived += (_, update) => link.UpdateArrived += (_, update) =>
Dispatcher.UIThread.Post(() => TakeFromNetwork(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) foreach (string peer in Settings.StationLinkPeers)
{ {
if (Peer(peer) is var (name, address, port)) 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); _ = 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 /// The `STATUS` message: everything the other operators' network status
/// windows show about this station, whether anything has changed or not. It /// 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 /// 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: case ContactDeleted deleted:
Logging.DeleteFromNetwork(deleted.Id, deleted.Call, deleted.TimestampUtc); Logging.DeleteFromNetwork(deleted.Id, deleted.Call, deleted.TimestampUtc);
break; break;
case ContactsCleared cleared:
Logging.ClearFromNetwork(cleared.StationName, cleared.FromUtc, cleared.Length);
break;
} }
} }
+38
View File
@@ -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<Qso> 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<T>(Func<T> question) =>
Dispatcher.UIThread.CheckAccess() ? question() : Dispatcher.UIThread.Invoke(question);
}
@@ -69,6 +69,53 @@
</StackPanel> </StackPanel>
</TabItem> </TabItem>
<TabItem Header="Resynchronize">
<DockPanel Margin="8">
<StackPanel DockPanel.Dock="Top" Spacing="6" Margin="0,0,0,8">
<StackPanel Orientation="Horizontal" Spacing="12">
<CheckBox Name="ResyncAllBox" Content="Resync All Stations" />
<CheckBox Name="RescoreWhenDoneBox" Content="Rescore When Done" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Content="Contest" Click="OnResyncContest" />
<Button Content="Since:" Click="OnResyncSince" />
<TextBox Name="ResyncSinceBox" Width="150" />
<Button Content="Last" Click="OnResyncLast" />
<TextBox Name="ResyncHoursBox" Width="44" Text="4" />
<TextBlock Text="Hours" VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
<DataGrid Name="ResyncRows" IsReadOnly="True" GridLinesVisibility="Horizontal"
CanUserSortColumns="False" FontSize="12" FontFamily="monospace"
AutoGenerateColumns="False" x:DataType="local:StationRow">
<DataGrid.Columns>
<DataGridTextColumn Header="Name" Binding="{Binding Computer}" />
<DataGridTextColumn Header="IP Address" Binding="{Binding IpAddress}" />
<DataGridTextColumn Header="In Prog" Binding="{Binding InProgress}" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="240" />
<DataGridTextColumn Header="# Hours OK" Binding="{Binding HoursOk}" />
<DataGridTextColumn Header="# Hours Fixed" Binding="{Binding HoursFixed}" />
<DataGridTextColumn Header="# Hours Failed" Binding="{Binding HoursFailed}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</TabItem>
<TabItem Header="Rescore">
<StackPanel Margin="8" Spacing="8">
<TextBlock Text="Rescore Log, No Log Resync" FontWeight="Bold" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Content="Contest" Click="OnRescoreContest" />
<Button Content="Since:" Click="OnRescoreSince" />
<TextBox Name="RescoreSinceBox" Width="150" />
<Button Content="Last" Click="OnRescoreLast" />
<TextBox Name="RescoreHoursBox" Width="44" Text="4" />
<TextBlock Text="Hours" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Name="RescoreText" TextWrapping="Wrap" Opacity="0.75" FontSize="11" />
</StackPanel>
</TabItem>
<TabItem Header="Messages"> <TabItem Header="Messages">
<DockPanel Margin="8"> <DockPanel Margin="8">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,6"> <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,6">
@@ -1,3 +1,4 @@
using System.Globalization;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Threading; using Avalonia.Threading;
@@ -15,10 +16,9 @@ namespace Nonemm.App.Windows;
/// operator reads its own station number and the version it is claiming off the /// operator reads its own station number and the version it is claiming off the
/// same window as everybody else's. /// same window as everybody else's.
/// ///
/// Three of N1MM's tabs are here. Resynchronize, Rescore and the transmit /// Five of N1MM's tabs are here. Its Options tab and its call-stacking target
/// interlocks of its Options tab are not: this program has neither the resync /// tab are not: the transmit interlocks behind Options are not written, and a
/// messages nor a transmit interlock, and a tab of controls that do nothing is /// tab of controls that do nothing is worse than no tab.
/// worse than no tab.
/// ///
/// The window redraws once a second and on every message, which on a busy /// The window redraws once a second and on every message, which on a busy
/// network is several a second. That is what a status window is for, and the /// network is several a second. That is what a status window is for, and the
@@ -33,6 +33,9 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
private readonly Dictionary<string, TimeSpan> echoes = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, TimeSpan> echoes = new(StringComparer.OrdinalIgnoreCase);
private readonly DispatcherTimer clock; private readonly DispatcherTimer clock;
private StationLink? link; private StationLink? link;
/// Set while a resync that was asked to rescore when it finishes is
/// running. It holds the start of the period, which is what gets rescored.
private DateTime? rescoreAfterResync;
public NetworkStatusWindow(AppSession session) public NetworkStatusWindow(AppSession session)
{ {
@@ -45,6 +48,12 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
e.Row.Foreground = row.Colour; e.Row.Foreground = row.Colour;
} }
}; };
// the two Since boxes start at the contest, which is the period an
// operator asks for most often
string start = (session.Logging?.Instance.StartDate ?? DateTime.UtcNow)
.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
ResyncSinceBox.Text = start;
RescoreSinceBox.Text = start;
Attach(); Attach();
// the Receive column is a countdown, so it has to redraw with nothing // the Receive column is a countdown, so it has to redraw with nothing
// arriving // arriving
@@ -81,6 +90,8 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
Rows.ItemsSource = rows; Rows.ItemsSource = rows;
Rows.SelectedItem = rows.FirstOrDefault(r => Rows.SelectedItem = rows.FirstOrDefault(r =>
string.Equals(r.Station.ComputerName, selected, StringComparison.OrdinalIgnoreCase)); string.Equals(r.Station.ComputerName, selected, StringComparison.OrdinalIgnoreCase));
ResyncRows.ItemsSource = rows.Where(r => !r.Station.IsMine).ToList();
RescoreWhenResyncDone();
ReportEchoes(); ReportEchoes();
StateText.Text = State(); StateText.Text = State();
ShowMessages(); ShowMessages();
@@ -191,6 +202,96 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
MessageScroll.ScrollToEnd(); MessageScroll.ScrollToEnd();
} }
/// The rescore an operator asked for when the resync is done, once every
/// station has stopped.
private void RescoreWhenResyncDone()
{
if (rescoreAfterResync is not { } from
|| link is null
|| link.Stations.Any(s => s.IsResyncing))
{
return;
}
rescoreAfterResync = null;
Say($"rescored {session.Rescore(from)} contacts");
}
/// The start of the period the resync and rescore buttons work on.
/// `Since` reads the box, which is a time of day in UTC the way the rest of
/// the program writes one.
private DateTime? Since(TextBox box)
{
if (DateTime.TryParseExact(
(box.Text ?? "").Trim(),
["yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd"],
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out DateTime when))
{
return when;
}
Say($"{box.Text} is not a time — write it as 2026-09-03 14:00");
return null;
}
private static DateTime HoursAgo(TextBox box) =>
DateTime.UtcNow.AddHours(-Math.Max(1, int.TryParse((box.Text ?? "").Trim(), out int hours) ? hours : 4));
/// The whole contest, from three hours before it started. N1MM allows the
/// same margin either side, for a clock that is out and for the contacts
/// entered after the contest ended.
private DateTime ContestStart() =>
(session.Logging?.Instance.StartDate ?? DateTime.UtcNow.AddDays(-1)).AddHours(-3);
private void OnResyncContest(object? sender, RoutedEventArgs e) => Resync(ContestStart());
private void OnResyncSince(object? sender, RoutedEventArgs e)
{
if (Since(ResyncSinceBox) is { } from)
{
Resync(from);
}
}
private void OnResyncLast(object? sender, RoutedEventArgs e) => Resync(HoursAgo(ResyncHoursBox));
/// Asks every connected station whether it and this one hold the same
/// contacts, an hour at a time, from `fromUtc` until now.
private void Resync(DateTime fromUtc)
{
if (link is null)
{
return;
}
bool everyStation = ResyncAllBox.IsChecked == true;
bool rescore = RescoreWhenDoneBox.IsChecked == true;
rescoreAfterResync = rescore ? fromUtc : null;
link.StartResyncAll(fromUtc, DateTime.UtcNow, everyStation, rescore);
Say($"resync from {fromUtc:yyyy-MM-dd HH:mm}Z");
Tabs.SelectedIndex = 2;
}
private void OnRescoreContest(object? sender, RoutedEventArgs e) => Rescore(DateTime.MinValue);
private void OnRescoreSince(object? sender, RoutedEventArgs e)
{
if (Since(RescoreSinceBox) is { } from)
{
Rescore(from);
}
}
private void OnRescoreLast(object? sender, RoutedEventArgs e) => Rescore(HoursAgo(RescoreHoursBox));
/// Scores the log again and writes the points and multiplier flags back.
/// Nothing is sent: the other stations score their own logs.
private void Rescore(DateTime fromUtc)
{
int written = session.Rescore(fromUtc);
RescoreText.Text = $"wrote {written} contacts at {DateTime.UtcNow:HH:mm:ss}Z";
Say($"rescored {written} contacts");
}
/// The station the operator right-clicked, or null when the row is this /// The station the operator right-clicked, or null when the row is this
/// computer's own: nothing in the menu is worth doing to yourself. /// computer's own: nothing in the menu is worth doing to yourself.
private NetworkedStation? Chosen() => private NetworkedStation? Chosen() =>
@@ -216,7 +317,7 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
TalkBox.Focus(); TalkBox.Focus();
} }
private void OnShowMessages(object? sender, RoutedEventArgs e) => Tabs.SelectedIndex = 2; private void OnShowMessages(object? sender, RoutedEventArgs e) => Tabs.SelectedIndex = 4;
private async void OnEcho(object? sender, RoutedEventArgs e) private async void OnEcho(object? sender, RoutedEventArgs e)
{ {
+12
View File
@@ -76,6 +76,18 @@ public sealed class StationRow
public string Mode => station.Mode?.Name ?? ""; public string Mode => station.Mode?.Name ?? "";
/// The resync columns. N1MM counts blocks of an hour, which is why they
/// say hours.
public string InProgress => station.IsResyncing ? "yes" : "";
public string Status => station.ResyncStatus;
public string HoursOk => station.HoursOk > 0 ? station.HoursOk.ToString() : "";
public string HoursFixed => station.HoursFixed > 0 ? station.HoursFixed.ToString() : "";
public string HoursFailed => station.HoursFailed > 0 ? station.HoursFailed.ToString() : "";
/// Red for a station that is not talking, and for the version mismatch that /// Red for a station that is not talking, and for the version mismatch that
/// leaves everything else looking right. Every other row takes the theme's /// leaves everything else looking right. Every other row takes the theme's
/// own text colour: the grid reuses its rows, so a row that was red has to /// own text colour: the grid reuses its rows, so a row that was red has to
+6
View File
@@ -179,6 +179,12 @@ public sealed class ContestLog
} }
} }
/// Scores the whole log again. Nothing here needs it — every change
/// rescores what it touches — but the stored points and multiplier flags
/// are written from this, and a caller that is about to write them asks for
/// a pass first.
public void Rescore() => Rebuild();
/// Rescores the whole log. A removed or edited contact can hand its /// Rescores the whole log. A removed or edited contact can hand its
/// multiplier to a later one, which only a full pass gets right. /// multiplier to a later one, which only a full pass gets right.
private void Rebuild() private void Rebuild()
+23
View File
@@ -0,0 +1,23 @@
using Nonemm.Core;
namespace Nonemm.Network;
/// What the link asks the log while a resync is running. The link itself keeps
/// no contacts: it frames the messages and counts the blocks, and the program
/// that owns the log answers these.
///
/// The socket thread calls them, so an implementation whose log is owned
/// somewhere else — a window thread, say — has to hand the question over and
/// wait for the answer.
public interface ContactLog
{
/// The contest being logged here. A station resynchronising into another
/// contest is told rather than answered.
string ContestName { get; }
/// The callsign this station is logging under, for the same reason.
string MyCall { get; }
/// The contacts one computer made inside one block of time, oldest first.
IReadOnlyList<Qso> Contacts(string computerName, DateTime fromUtc, TimeSpan length);
}
+6
View File
@@ -21,3 +21,9 @@ public sealed record ContactDeleted(
int ContestNumber, int ContestNumber,
string StationName) string StationName)
: ContactUpdate(StationName); : ContactUpdate(StationName);
/// Drop every contact this station holds for `StationName` between
/// `FromUtc` and `FromUtc + Length`. It arrives in front of the same block
/// sent again, so a resync replaces a block rather than adding to it.
public sealed record ContactsCleared(string StationName, DateTime FromUtc, TimeSpan Length)
: ContactUpdate(StationName);
+40
View File
@@ -0,0 +1,40 @@
namespace Nonemm.Network;
/// The ordinary CRC32 — the one with polynomial 0xEDB88320, all bits set to
/// start and inverted at the end — over one byte per character. N1MM hashes its
/// log blocks with it and keeps the result as a signed integer, which is what
/// goes on the wire, so this returns one too.
///
/// A character above 255 cannot appear in a log column N1MM wrote, and is
/// hashed by its low byte rather than by throwing.
internal static class Crc32
{
private const uint Polynomial = 0xEDB88320;
private static readonly uint[] Table = BuildTable();
internal static int Of(string text)
{
uint crc = 0xFFFFFFFF;
foreach (char c in text)
{
crc = (crc >> 8) ^ Table[(crc ^ (byte)c) & 0xFF];
}
return unchecked((int)(crc ^ 0xFFFFFFFF));
}
private static uint[] BuildTable()
{
uint[] table = new uint[256];
for (uint i = 0; i < table.Length; i++)
{
uint value = i;
for (int bit = 0; bit < 8; bit++)
{
value = (value & 1) != 0 ? (value >> 1) ^ Polynomial : value >> 1;
}
table[i] = value;
}
return table;
}
}
+111
View File
@@ -0,0 +1,111 @@
using System.Globalization;
using System.Text;
using Nonemm.Core;
namespace Nonemm.Network;
/// The checksum of one block of contacts. Two stations compare these to find
/// out whether they hold the same log without sending the log.
///
/// It is N1MM's: a CRC32 over the `DXLOG` columns of every contact in the
/// block, in the order the columns are read out of the database, oldest first
/// and by callsign within a timestamp. The columns this program works out from
/// the rules — points, the three multiplier flags, the country prefix, the
/// continent, the WPX prefix and `IsOriginal` — are left out, because the two
/// stations are allowed to disagree about them. `ContestNR` counts only when it
/// is -1.
///
/// The checksum is over what a station stored, so a field that one program
/// writes as empty and the other as a space is a difference. N1MM writes a
/// single space into `GridSquare`, `MiscText`, `ContactType` and
/// `RoverLocation` when it has nothing for them, so an empty value in those
/// four is hashed as a space.
///
/// A checksum that disagrees is not a fault: the block is sent again, which is
/// what a resync is for. It only costs traffic.
public static class LogChecksum
{
public static int Of(IEnumerable<Qso> contacts)
{
StringBuilder text = new();
IEnumerable<Qso> ordered = contacts
.OrderBy(q => q.TimestampUtc)
.ThenBy(q => q.Call.Text, StringComparer.Ordinal);
foreach (Qso qso in ordered)
{
Append(text, qso);
}
return Crc32.Of(text.ToString());
}
/// The contacts of one station inside one block of time. Both ends of a
/// resync pick the same rows this way.
public static IReadOnlyList<Qso> Block(
IEnumerable<Qso> contacts,
string computerName,
DateTime fromUtc,
TimeSpan length) =>
contacts
.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 void Append(StringBuilder text, Qso qso)
{
text.Append(qso.TimestampUtc.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
text.Append(qso.Call.Text);
Number(text, qso.Frequency.Kilohertz);
Number(text, qso.QsxFrequency.Kilohertz);
text.Append(qso.Mode.Name);
text.Append(qso.ContestName);
text.Append(qso.SentReport);
text.Append(qso.ReceivedReport);
text.Append(qso.StationPrefix);
text.Append(qso.Qth);
text.Append(qso.Name);
text.Append(qso.Comment);
Number(text, qso.ReceivedNumber);
text.Append(qso.Section);
text.Append(qso.Precedence);
Number(text, qso.Check);
Number(text, qso.Zone);
Number(text, qso.SentNumber);
text.Append(qso.Power);
Number(text, qso.Band?.MegahertzLabel ?? 0);
text.Append(qso.Exchange1);
Number(text, qso.RadioNumber);
text.Append(qso.Operator);
text.Append(Filled(qso.GridSquare));
// the contest number counts only when it says the contact belongs to no
// contest
if (qso.ContestNumber == -1)
{
text.Append("-1");
}
text.Append(Filled(qso.MiscText));
text.Append(Filled(qso.ContactType));
Number(text, qso.RunPosition);
text.Append(Filled(qso.RoverLocation));
Flag(text, qso.IsRadioInterfaced);
Number(text, qso.NetworkedComputerNumber);
text.Append(qso.StationName);
Flag(text, qso.IsRunQso);
text.Append(qso.Id);
Flag(text, qso.IsClaimed);
}
/// A column N1MM stores as a single space when it has nothing for it.
private static string Filled(string value) => value.Length == 0 ? " " : value;
private static void Number(StringBuilder text, double value) =>
text.Append(value.ToString(CultureInfo.InvariantCulture));
private static void Number(StringBuilder text, int value) =>
text.Append(value.ToString(CultureInfo.InvariantCulture));
private static void Flag(StringBuilder text, bool value) => text.Append(value ? '1' : '0');
}
+15
View File
@@ -102,6 +102,21 @@ public sealed class NetworkedStation
/// window. /// window.
public bool IsMine { get; internal init; } public bool IsMine { get; internal init; }
/// What a resync with this station is doing, and how it went. N1MM counts
/// the blocks an hour at a time, which is why its columns say hours: `Ok`
/// is a block both ends already agreed on, `Fixed` is one this station was
/// told to drop and was sent again, `Failed` is one that still did not
/// agree after being sent.
public bool IsResyncing { get; internal set; }
public string ResyncStatus { get; internal set; } = "";
public int HoursOk { get; internal set; }
public int HoursFixed { get; internal set; }
public int HoursFailed { get; internal set; }
/// Set when the operator closed the connection by hand. The beacons keep /// Set when the operator closed the connection by hand. The beacons keep
/// arriving and the station stays in the list, but nothing opens a /// arriving and the station stays in the list, but nothing opens a
/// connection to it again until the operator asks for one. /// connection to it again until the operator asks for one.
+197
View File
@@ -88,6 +88,10 @@ public sealed class StationLink : IDisposable
set => mine.Operator = value; 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 /// This computer's own row, which is in the station list as well. The
/// window reads its pass frequency and its counts off it. /// window reads its pass frequency and its counts off it.
public NetworkedStation Me => mine; public NetworkedStation Me => mine;
@@ -108,6 +112,10 @@ public sealed class StationLink : IDisposable
public event EventHandler<string>? Failed; 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: /// 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 /// it is what the messages pane of the network status window prints when
/// the operator turns tracing on. /// 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`. /// Asks every station whether it is there. The answer sets `EchoTime`.
public Task<int> SendEchoRequestAsync(CancellationToken cancellation = default) public Task<int> SendEchoRequestAsync(CancellationToken cancellation = default)
{ {
@@ -571,6 +623,20 @@ public sealed class StationLink : IDisposable
case "ECHO": case "ECHO":
station.EchoTime = DateTime.UtcNow - echoSentAt; station.EchoTime = DateTime.UtcNow - echoSentAt;
break; 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": case "TALK":
TalkArrived?.Invoke(this, record.Field(0)); TalkArrived?.Invoke(this, record.Field(0));
break; break;
@@ -584,6 +650,137 @@ public sealed class StationLink : IDisposable
StationsChanged?.Invoke(this, EventArgs.Empty); 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 /// Closes a connection that has failed or gone quiet. The station stays in
/// the list: it /// the list: it
/// is still part of the entry, and the window showing it as not connected /// is still part of the entry, and the window showing it as not connected
+78
View File
@@ -23,6 +23,9 @@ namespace Nonemm.Network;
/// | `XMIT` | a station started or stopped transmitting | yes | yes | /// | `XMIT` | a station started or stopped transmitting | yes | yes |
/// | `BANDMAP` | which band and mode a station is on | yes | yes | /// | `BANDMAP` | which band and mode a station is on | yes | yes |
/// | `STATUS` | everything the network status window shows about a station | 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 |
/// | `DELETEQS` | drop your copies of one station's contacts in this block | yes | yes |
/// ///
/// The rest — the score and sked windows, the log check, the spot lists, the /// The rest — the score and sked windows, the log check, the spot lists, the
/// serial-number pool — are passed over. An unknown type is not an error: N1MM /// serial-number pool — are passed over. An unknown type is not an error: N1MM
@@ -190,10 +193,85 @@ public static class StationMessages
record.Field(8), record.Field(8),
record.Flag(16)); record.Flag(16));
/// The checksum of one block of the log, which is how a resync asks
/// "do you and I hold the same contacts for this station between these two
/// times?".
///
/// The sender hashes its copy of the *other* station's contacts and the
/// receiver hashes its own, so each station is the authority on the
/// contacts it made. The contest name and the callsign are carried so a
/// station that is logging another contest, or another callsign, is told
/// rather than resynchronised into the wrong log.
public static StationRecord CheckSum(
DateTime fromUtc,
DateTime toUtc,
int checksum,
string contestName,
string myCall,
int minutes,
bool everyStation,
bool rescoreWhenDone,
int stationNumber,
string computerName) =>
Record(
"CHECKSUM",
stationNumber,
computerName,
[
StationRecord.Written(fromUtc),
StationRecord.Written(toUtc),
Text(checksum),
contestName,
myCall,
Text(minutes),
StationRecord.Written(everyStation),
StationRecord.Written(rescoreWhenDone),
]);
/// The answer to a checksum: what the block hashes to at this end. The
/// asker compares it with its own and moves on to the next block, which is
/// what walks a resync through the contest.
public static StationRecord Confirmed(
DateTime fromUtc,
DateTime toUtc,
int checksum,
int minutes,
int stationNumber,
string computerName) =>
Record(
"CONFIRMED",
stationNumber,
computerName,
[
StationRecord.Written(fromUtc),
StationRecord.Written(toUtc),
Text(checksum),
Text(minutes),
]);
/// Drop your copies of the named station's contacts in this block. It goes
/// out in front of the contacts themselves, so the block is replaced rather
/// than added to.
public static StationRecord DeleteContacts(
DateTime fromUtc,
string ofComputerName,
int minutes,
int stationNumber,
string computerName) =>
Record(
"DELETEQS",
stationNumber,
computerName,
[StationRecord.Written(fromUtc), ofComputerName, Text(minutes)]);
/// What a message that has arrived means, or null for one this program does /// What a message that has arrived means, or null for one this program does
/// nothing with. /// nothing with.
public static ContactUpdate? Read(StationRecord record) => record.Type switch public static ContactUpdate? Read(StationRecord record) => record.Type switch
{ {
"DELETEQS" => new ContactsCleared(
record.Field(1),
record.Time(0),
TimeSpan.FromMinutes(record.Number(2))),
"QSO" or "RESYNCQSO" => new ContactLogged( "QSO" or "RESYNCQSO" => new ContactLogged(
QsoRecord.Read(record, 1, record.ComputerName), QsoRecord.Read(record, 1, record.ComputerName),
record.ComputerName), record.ComputerName),
+59 -1
View File
@@ -56,6 +56,14 @@ public sealed class ContestSession
public string Operator { get; set; } = ""; public string Operator { get; set; } = "";
/// The computer this station logs as, and which station of the entry it is.
/// N1MM stamps every contact with both — `NetBiosName` and
/// `NetworkedCompNr` — and a resync asks for the contacts of one computer
/// by name, so a contact logged without it belongs to no station.
public string StationName { get; set; } = "";
public int StationNumber { get; set; } = 1;
public event EventHandler? Changed; public event EventHandler? Changed;
public event EventHandler<Qso>? Logged; public event EventHandler<Qso>? Logged;
@@ -71,7 +79,7 @@ public sealed class ContestSession
/// already holds it. /// already holds it.
public Qso Add(Qso qso) public Qso Add(Qso qso)
{ {
Qso scored = Log.Add(qso); Qso scored = Log.Add(Stamped(qso));
Qso stored = store.Add(scored); Qso stored = store.Add(scored);
if (stored.TimestampUtc != scored.TimestampUtc) if (stored.TimestampUtc != scored.TimestampUtc)
{ {
@@ -83,6 +91,56 @@ public sealed class ContestSession
return stored; return stored;
} }
/// Drops every contact one computer made inside a block of time, which is
/// what a resync asks for before it sends the block again. Returns how many
/// went.
public int ClearFromNetwork(string computerName, DateTime fromUtc, TimeSpan length)
{
List<Qso> gone = Log.Qsos
.Where(q =>
string.Equals(q.StationName, computerName, StringComparison.OrdinalIgnoreCase)
&& q.TimestampUtc >= fromUtc
&& q.TimestampUtc < fromUtc + length)
.ToList();
foreach (Qso qso in gone)
{
store.Delete(qso.Id);
Log.Remove(qso.Id);
}
if (gone.Count > 0)
{
Changed?.Invoke(this, EventArgs.Empty);
}
return gone.Count;
}
/// Scores the log again and writes the points and multiplier flags of the
/// contacts from `fromUtc` on back to storage. Returns how many rows were
/// written.
///
/// The log in memory scores itself whenever it changes, so this is for the
/// database: N1MM reads the stored points and flags rather than working
/// them out, and a contact that arrived from another station after the one
/// that took a multiplier leaves those columns behind.
public int Rescore(DateTime fromUtc)
{
Log.Rescore();
int written = 0;
foreach (Qso qso in Log.Qsos.Where(q => q.TimestampUtc >= fromUtc))
{
store.Update(qso);
written++;
}
Changed?.Invoke(this, EventArgs.Empty);
return written;
}
/// Whose contact this is. A contact that already names a station keeps it.
private Qso Stamped(Qso qso) =>
StationName.Length == 0 || qso.StationName.Length > 0
? qso
: qso with { StationName = StationName, NetworkedComputerNumber = StationNumber };
public void Delete(string id) public void Delete(string id)
{ {
Qso? gone = Log.Qsos.FirstOrDefault(q => q.Id == id); Qso? gone = Log.Qsos.FirstOrDefault(q => q.Id == id);
@@ -0,0 +1,93 @@
using Nonemm.Core;
namespace Nonemm.Network.Tests;
/// The checksum two stations compare during a resync. What matters is that the
/// same contacts hash the same whatever order they are handed over in, that a
/// change anywhere in a contact changes it, and that the columns each station
/// works out for itself do not.
public class LogChecksumTests
{
private static readonly DateTime Start = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void TheSameContactsHashTheSameInAnyOrder()
{
Qso one = Contact("G3XYZ", 0);
Qso two = Contact("DL1ABC", 1);
Assert.Equal(LogChecksum.Of([one, two]), LogChecksum.Of([two, one]));
}
/// Two contacts at the same second are ordered by callsign, the way N1MM
/// reads them out of the database.
[Fact]
public void ContactsAtTheSameSecondAreOrderedByCall()
{
Qso one = Contact("G3XYZ", 0);
Qso two = Contact("DL1ABC", 0);
Assert.Equal(LogChecksum.Of([one, two]), LogChecksum.Of([two, one]));
}
[Fact]
public void AChangedExchangeChangesTheChecksum()
{
Qso qso = Contact("G3XYZ", 0);
Assert.NotEqual(
LogChecksum.Of([qso]),
LogChecksum.Of([qso with { ReceivedNumber = 42 }]));
}
/// Points and the multiplier flags are worked out from the rules at each
/// station, and the two are allowed to disagree about them while holding
/// the same contact.
[Fact]
public void ScoringDoesNotChangeTheChecksum()
{
Qso qso = Contact("G3XYZ", 0);
Assert.Equal(
LogChecksum.Of([qso]),
LogChecksum.Of([qso with { Points = 3, IsMultiplier1 = true, IsMultiplier2 = true }]));
}
[Fact]
public void AnEmptyBlockHashesToTheEmptyChecksum()
{
Assert.Equal(LogChecksum.Of([]), LogChecksum.Of([]));
}
/// A block is the contacts of one computer inside one stretch of time. The
/// end of the block is not in it, so two blocks side by side neither share
/// a contact nor drop one.
[Fact]
public void ABlockHoldsOneStationsContactsInsideTheHour()
{
List<Qso> log =
[
Contact("G3XYZ", 0) with { StationName = "RUN-PC" },
Contact("DL1ABC", 30) with { StationName = "RUN-PC" },
Contact("OK1AB", 45) with { StationName = "MULT-PC" },
Contact("F5XY", 60) with { StationName = "RUN-PC" },
];
IReadOnlyList<Qso> block = LogChecksum.Block(log, "run-pc", Start, TimeSpan.FromHours(1));
Assert.Equal(["DL1ABC", "G3XYZ"], block.Select(q => q.Call.Text).Order());
}
private static Qso Contact(string call, int minutes) => new()
{
Id = $"{call}-{minutes}",
TimestampUtc = Start.AddMinutes(minutes),
Call = Callsign.Parse(call),
Frequency = Frequency.FromKilohertz(14_025),
Mode = Modes.Cw,
ContestName = "CQWW",
ContestNumber = 2,
SentNumber = 41,
StationName = "RUN-PC",
};
}
@@ -206,6 +206,108 @@ public class StationLinkTests
Assert.Equal(0, await link.SendLoggedAsync(Contact())); Assert.Equal(0, await link.SendLoggedAsync(Contact()));
} }
/// A station that missed an hour of the contest asks for it and gets it.
/// The other station is the authority on the contacts it made: it tells the
/// asker to drop what it holds for that hour and sends the hour again.
[Fact]
public async Task AResyncFillsInWhatAStationMissed()
{
(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 = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
FakeLog runLog = new([Contact() with { TimestampUtc = hour }, Contact() with { TimestampUtc = hour.AddMinutes(20), Call = Callsign.Parse("DL1ABC"), Id = Qso.NewId() }]);
FakeLog multLog = new([]);
one.Log = runLog;
two.Log = multLog;
two.UpdateArrived += (_, update) => multLog.Take(update);
one.Start();
two.Start();
// each station opens its own connection, which is N1MM's arrangement:
// an answer goes back over the one this end opened
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
two.AddStation("RUN-PC", "127.0.0.1", onePort);
await WaitForAsync(() =>
one.Stations.Any(s => s is { IsMine: false, IsConnected: true })
&& two.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
NetworkedStation runStation = two.Stations.First(s => s.ComputerName == "RUN-PC");
two.StartResync(runStation, hour, hour.AddHours(1));
await WaitForAsync(() => !runStation.IsResyncing && multLog.All.Count == 2);
Assert.Equal(["DL1ABC", "G3XYZ"], multLog.All.Select(q => q.Call.Text).Order());
Assert.Equal("Resync complete", runStation.ResyncStatus);
Assert.True(runStation.HoursOk > 0, "the hour that was sent again should now agree");
}
/// A station whose log already agrees is told nothing but the answer.
[Fact]
public async Task AResyncOfAnHourThatAgreesSendsNoContacts()
{
(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 = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
Qso shared = Contact() with { TimestampUtc = hour };
one.Log = new FakeLog([shared]);
FakeLog multLog = new([shared]);
two.Log = multLog;
int arrived = 0;
two.UpdateArrived += (_, update) =>
{
arrived++;
multLog.Take(update);
};
one.Start();
two.Start();
// each station opens its own connection, which is N1MM's arrangement:
// an answer goes back over the one this end opened
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
two.AddStation("RUN-PC", "127.0.0.1", onePort);
await WaitForAsync(() =>
one.Stations.Any(s => s is { IsMine: false, IsConnected: true })
&& two.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
NetworkedStation runStation = two.Stations.First(s => s.ComputerName == "RUN-PC");
two.StartResync(runStation, hour, hour.AddHours(1));
await WaitForAsync(() => !runStation.IsResyncing);
Assert.Equal(0, arrived);
Assert.Equal(0, runStation.HoursFailed);
Assert.Single(multLog.All);
}
/// A log a resync can ask about, and that takes what a resync sends it.
private sealed class FakeLog(IEnumerable<Qso> contacts) : ContactLog
{
private readonly List<Qso> qsos = [.. contacts];
public string ContestName => "CQWW";
public string MyCall => "OM3XYZ";
public IReadOnlyList<Qso> All => qsos;
public IReadOnlyList<Qso> Contacts(string computerName, DateTime fromUtc, TimeSpan length) =>
LogChecksum.Block(qsos, computerName, fromUtc, length);
public void Take(ContactUpdate update)
{
switch (update)
{
case ContactLogged logged when !qsos.Any(q => q.Id == logged.Qso.Id):
qsos.Add(logged.Qso);
break;
case ContactsCleared cleared:
qsos.RemoveAll(q =>
string.Equals(q.StationName, cleared.StationName, StringComparison.OrdinalIgnoreCase)
&& q.TimestampUtc >= cleared.FromUtc
&& q.TimestampUtc < cleared.FromUtc + cleared.Length);
break;
}
}
}
private static async Task WaitForAsync(Func<bool> ready) private static async Task WaitForAsync(Func<bool> ready)
{ {
DateTime giveUp = DateTime.UtcNow + Patience; DateTime giveUp = DateTime.UtcNow + Patience;
@@ -244,5 +346,6 @@ public class StationLinkTests
ContestName = "CQWW", ContestName = "CQWW",
ContestNumber = 2, ContestNumber = 2,
SentNumber = 41, SentNumber = 41,
StationName = "RUN-PC",
}; };
} }
@@ -136,6 +136,67 @@ public class NetworkedContactsTests
Assert.Equal("14", position.Entry.ValueOf(ExchangeSlot.Zone)); Assert.Equal("14", position.Entry.ValueOf(ExchangeSlot.Zone));
} }
/// The stored points and multiplier flags go stale when a contact from
/// another station arrives after the one that took the multiplier. The
/// rescore is what writes them back, because N1MM reads those columns
/// rather than working them out.
[Fact]
public void ARescoreWritesTheScoringBackToTheStore()
{
(ContestSession session, FakeLogStore store) = Station();
Qso mine = WorkHere(session, "JA9ZZZ", minute: 5);
session.AddFromNetwork(Arriving("JA1XYZ", minute: 1));
Assert.True(store.Qsos(session.Instance.ContestNumber).Single(q => q.Id == mine.Id).IsMultiplier1);
Assert.Equal(2, session.Rescore(DateTime.MinValue));
Qso stored = store.Qsos(session.Instance.ContestNumber).Single(q => q.Id == mine.Id);
Assert.False(stored.IsMultiplier1);
Assert.Equal(session.Log.Qsos.Single(q => q.Id == mine.Id).Points, stored.Points);
}
/// A rescore of the last hour leaves the rows before it alone.
[Fact]
public void ARescoreOnlyWritesTheContactsInsideThePeriod()
{
(ContestSession session, _) = Station();
WorkHere(session, "JA9ZZZ", minute: 5);
WorkHere(session, "JA8YYY", minute: 90);
Assert.Equal(1, session.Rescore(Start.AddMinutes(60)));
}
/// A resync drops the block it is about to be sent again, so the contacts
/// are replaced rather than added to.
[Fact]
public void ABlockOfOneStationsContactsCanBeCleared()
{
(ContestSession session, FakeLogStore store) = Station();
session.AddFromNetwork(Arriving("JA1XYZ", minute: 1) with { StationName = "RUN-PC" });
session.AddFromNetwork(Arriving("JA2XYZ", minute: 90) with { StationName = "RUN-PC" });
session.AddFromNetwork(Arriving("JA3XYZ", minute: 2) with { StationName = "MULT-PC" });
Assert.Equal(1, session.ClearFromNetwork("run-pc", Start, TimeSpan.FromHours(1)));
Assert.Equal(["JA2XYZ", "JA3XYZ"], session.Log.Qsos.Select(q => q.Call.Text).Order());
Assert.Equal(2, store.Qsos(session.Instance.ContestNumber).Count);
}
/// Every contact logged here carries the computer that made it, which is
/// what a resync asks for and what N1MM stamps into `NetBiosName`.
[Fact]
public void AContactLoggedHereCarriesThisComputersName()
{
(ContestSession session, _) = Station();
session.StationName = "RUN-PC";
session.StationNumber = 3;
Qso stored = WorkHere(session, "JA1XYZ", minute: 1);
Assert.Equal("RUN-PC", stored.StationName);
Assert.Equal(3, stored.NetworkedComputerNumber);
}
[Fact] [Fact]
public void AnEditFromAnotherStationIsAppliedById() public void AnEditFromAnotherStationIsAppliedById()
{ {