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
404 lines
14 KiB
C#
404 lines
14 KiB
C#
using System.Globalization;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Threading;
|
|
using Nonemm.App.Configuration;
|
|
using Nonemm.App.Dialogs;
|
|
using Nonemm.Network;
|
|
|
|
namespace Nonemm.App.Windows;
|
|
|
|
/// The other computers of this entry: who they are, where they are on the
|
|
/// bands, how fast they are working people, and whether anything is arriving
|
|
/// from them. N1MM's network status window, with its tabs and its columns.
|
|
///
|
|
/// A row per station, this computer included, which is how N1MM shows it: the
|
|
/// operator reads its own station number and the version it is claiming off the
|
|
/// same window as everybody else's.
|
|
///
|
|
/// Five of N1MM's tabs are here. Its Options tab and its call-stacking target
|
|
/// tab are not: the transmit interlocks behind Options are not written, and a
|
|
/// tab of controls that do nothing is worse than no tab.
|
|
///
|
|
/// 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
|
|
/// table is a dozen rows.
|
|
public sealed partial class NetworkStatusWindow : RefreshableWindow
|
|
{
|
|
/// How much of the messages pane is kept.
|
|
private const int MessageLines = 200;
|
|
|
|
private readonly AppSession session;
|
|
private readonly List<string> messages = [];
|
|
private readonly Dictionary<string, TimeSpan> echoes = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly DispatcherTimer clock;
|
|
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)
|
|
{
|
|
this.session = session;
|
|
InitializeComponent();
|
|
Rows.LoadingRow += (_, e) =>
|
|
{
|
|
if (e.Row.DataContext is StationRow row)
|
|
{
|
|
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();
|
|
// the Receive column is a countdown, so it has to redraw with nothing
|
|
// arriving
|
|
clock = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
|
clock.Tick += (_, _) => Refresh();
|
|
clock.Start();
|
|
Closed += (_, _) =>
|
|
{
|
|
clock.Stop();
|
|
Detach();
|
|
};
|
|
Refresh();
|
|
}
|
|
|
|
public override void Refresh()
|
|
{
|
|
Attach();
|
|
NetworkingButton.Content = link is null ? "Start Networking" : "Stop Networking";
|
|
if (link is null)
|
|
{
|
|
Rows.ItemsSource = Array.Empty<StationRow>();
|
|
StateText.Text =
|
|
"the link to the other logging computers is off — Edit Computer Addresses turns it on";
|
|
ShowMessages();
|
|
return;
|
|
}
|
|
DateTime now = DateTime.UtcNow;
|
|
string? selected = (Rows.SelectedItem as StationRow)?.Station.ComputerName;
|
|
List<StationRow> rows = link.Stations
|
|
.OrderBy(s => s.StationNumber)
|
|
.ThenBy(s => s.ComputerName, StringComparer.OrdinalIgnoreCase)
|
|
.Select(s => new StationRow(s, now))
|
|
.ToList();
|
|
Rows.ItemsSource = rows;
|
|
Rows.SelectedItem = rows.FirstOrDefault(r =>
|
|
string.Equals(r.Station.ComputerName, selected, StringComparison.OrdinalIgnoreCase));
|
|
ResyncRows.ItemsSource = rows.Where(r => !r.Station.IsMine).ToList();
|
|
RescoreWhenResyncDone();
|
|
ReportEchoes();
|
|
StateText.Text = State();
|
|
ShowMessages();
|
|
}
|
|
|
|
/// What the link is doing, under the tabs. A station that broadcast the
|
|
/// wrong version is named here: it is the one fault that leaves everything
|
|
/// looking connected and nothing arriving.
|
|
private string State()
|
|
{
|
|
if (link is null)
|
|
{
|
|
return "";
|
|
}
|
|
List<NetworkedStation> others = link.Stations.Where(s => !s.IsMine).ToList();
|
|
int connected = others.Count(s => s.IsConnected);
|
|
string what = $"station {link.StationNumber} as {link.ComputerName}, "
|
|
+ $"claiming version {session.Settings.StationLinkVersion} — "
|
|
+ $"{connected} of {others.Count} other stations connected";
|
|
List<string> refused = others
|
|
.Where(s => s.Refused.Length > 0)
|
|
.Select(s => $"{s.ComputerName}: {s.Refused}")
|
|
.ToList();
|
|
return refused.Count > 0 ? $"{what}\n{string.Join("\n", refused)}" : what;
|
|
}
|
|
|
|
/// An echo that has come back since the last redraw goes into the messages
|
|
/// pane, because that is where the operator who pressed the button is
|
|
/// looking.
|
|
private void ReportEchoes()
|
|
{
|
|
foreach (NetworkedStation station in link!.Stations)
|
|
{
|
|
if (station.EchoTime is not { } echo
|
|
|| (echoes.TryGetValue(station.ComputerName, out TimeSpan last) && last == echo))
|
|
{
|
|
continue;
|
|
}
|
|
echoes[station.ComputerName] = echo;
|
|
Say($"{station.ComputerName} answered in {echo.TotalMilliseconds:0} ms");
|
|
}
|
|
}
|
|
|
|
private void Attach()
|
|
{
|
|
if (ReferenceEquals(link, session.Link))
|
|
{
|
|
return;
|
|
}
|
|
Detach();
|
|
link = session.Link;
|
|
if (link is null)
|
|
{
|
|
return;
|
|
}
|
|
link.StationsChanged += WhenStationsChanged;
|
|
link.TalkArrived += WhenTalkArrived;
|
|
link.Failed += WhenFailed;
|
|
link.MessageArrived += WhenMessageArrived;
|
|
}
|
|
|
|
private void Detach()
|
|
{
|
|
if (link is null)
|
|
{
|
|
return;
|
|
}
|
|
link.StationsChanged -= WhenStationsChanged;
|
|
link.TalkArrived -= WhenTalkArrived;
|
|
link.Failed -= WhenFailed;
|
|
link.MessageArrived -= WhenMessageArrived;
|
|
link = null;
|
|
}
|
|
|
|
/// The link raises its events on a socket thread, so everything that draws
|
|
/// is posted.
|
|
private void WhenStationsChanged(object? sender, EventArgs e) =>
|
|
Dispatcher.UIThread.Post(Refresh);
|
|
|
|
private void WhenTalkArrived(object? sender, string text) =>
|
|
Dispatcher.UIThread.Post(() => Say(text));
|
|
|
|
private void WhenFailed(object? sender, string why) =>
|
|
Dispatcher.UIThread.Post(() => Say(why));
|
|
|
|
private void WhenMessageArrived(object? sender, StationRecord record) =>
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (TraceBox.IsChecked == true)
|
|
{
|
|
Say($"{record.ComputerName} {record.Type} {string.Join(" ", record.Fields)}".TrimEnd());
|
|
}
|
|
});
|
|
|
|
private void Say(string text)
|
|
{
|
|
messages.Add($"{DateTime.UtcNow:HH:mm:ss} {text}");
|
|
if (messages.Count > MessageLines)
|
|
{
|
|
messages.RemoveRange(0, messages.Count - MessageLines);
|
|
}
|
|
ShowMessages();
|
|
}
|
|
|
|
private void ShowMessages()
|
|
{
|
|
MessageText.Text = string.Join("\n", messages);
|
|
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
|
|
/// computer's own: nothing in the menu is worth doing to yourself.
|
|
private NetworkedStation? Chosen() =>
|
|
Rows.SelectedItem is StationRow row && !row.Station.IsMine ? row.Station : null;
|
|
|
|
private async void OnTalk(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (link is null || (TalkBox.Text ?? "").Trim() is not { Length: > 0 } text)
|
|
{
|
|
return;
|
|
}
|
|
TalkBox.Text = "";
|
|
Say($"[{link.ComputerName}] {text}");
|
|
if (await link.SendTalkAsync(text) == 0)
|
|
{
|
|
Say("nobody is connected");
|
|
}
|
|
}
|
|
|
|
private void OnTalkTo(object? sender, RoutedEventArgs e)
|
|
{
|
|
Tabs.SelectedIndex = 1;
|
|
TalkBox.Focus();
|
|
}
|
|
|
|
private void OnShowMessages(object? sender, RoutedEventArgs e) => Tabs.SelectedIndex = 4;
|
|
|
|
private async void OnEcho(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (link is not null)
|
|
{
|
|
await link.SendEchoRequestAsync();
|
|
}
|
|
}
|
|
|
|
/// This station's pass frequency: where it is now, and the callsign in the
|
|
/// entry window if there is one. The other operators see it in their Pass
|
|
/// column and know where to send a station that belongs on this band.
|
|
private async void OnSetPass(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (link is null || session.Position is not { } position)
|
|
{
|
|
return;
|
|
}
|
|
await link.SendPassAsync(position.Frequency, position.Entry.Call);
|
|
Say($"passing on {position.Frequency.Kilohertz:0.0}");
|
|
}
|
|
|
|
/// The contact being worked, handed to one station. N1MM's Pass
|
|
/// Current/Last QSO: the frequency this radio is on and the callsign in the
|
|
/// entry window, to the position that is going to work it.
|
|
private async void OnPassToStation(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (link is null || Chosen() is not { } station || session.Position is not { } position)
|
|
{
|
|
return;
|
|
}
|
|
await link.SendPassAsync(position.Frequency, position.Entry.Call, station);
|
|
Say($"passed {position.Entry.Call} on {position.Frequency.Kilohertz:0.0} to {station.ComputerName}");
|
|
}
|
|
|
|
private void OnCloseConnection(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (link is not null && Chosen() is { } station)
|
|
{
|
|
link.Close(station);
|
|
Say($"closed the connection to {station.ComputerName}");
|
|
Refresh();
|
|
}
|
|
}
|
|
|
|
private void OnOpenConnection(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (link is not null && Chosen() is { } station)
|
|
{
|
|
link.Open(station);
|
|
Say($"opening a connection to {station.ComputerName}");
|
|
Refresh();
|
|
}
|
|
}
|
|
|
|
/// Turns the link off, or back on. N1MM warns that stopping it during a
|
|
/// contest is not a good idea, and it is right: the contacts made while it
|
|
/// is off have to be resynchronised by hand.
|
|
private void OnStopNetworking(object? sender, RoutedEventArgs e)
|
|
{
|
|
session.Save(session.Settings with { StationLinkEnabled = link is null });
|
|
session.ApplyStationLinkSettings();
|
|
Refresh();
|
|
}
|
|
|
|
private void OnClearMessages(object? sender, RoutedEventArgs e)
|
|
{
|
|
messages.Clear();
|
|
ShowMessages();
|
|
}
|
|
|
|
private async void OnSetup(object? sender, RoutedEventArgs e)
|
|
{
|
|
NetworkDialog dialog = new(session.Settings);
|
|
if (await dialog.ShowDialog<Settings?>(this) is { } updated)
|
|
{
|
|
session.Save(updated);
|
|
session.ApplyNetworkSettings();
|
|
session.ApplyStationLinkSettings();
|
|
Refresh();
|
|
}
|
|
}
|
|
}
|