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 messages = []; private readonly Dictionary 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(); 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 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 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 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(this) is { } updated) { session.Save(updated); session.ApplyNetworkSettings(); session.ApplyStationLinkSettings(); Refresh(); } } }