Give the network status window N1MM's tabs and columns
The window was one flat table of columns this program made up. N1MM's has tabs and a fixed set of columns, and an operator who knows one has to be able to read the other. **The window** now has Stations, Actions and Messages. Stations is a grid with N1MM's headers in N1MM's order — Computer, IP Address, Pass, Run, 10, 100, Freq, Op, Msg, Send, Receive, Band, R/M, Mode — and a row per station, this computer included. Right-click a row to pass the contact being worked to that station, to talk, to show the connection log, or to close and open its connection. Actions holds the talk line, the pass frequency, the echo and the networking switch; Messages holds the chat, the connection failures, the echo round trips and a trace of every message that arrives. **`STATUS`** is what fills those columns. N1MM sends it every few seconds whether anything has changed or not — the pass frequency, the frequency, run or search and pounce, the operator, the rates over the last ten and the last hundred contacts, the mode, the categories and the version — so a station sitting on one frequency for an hour still has a current row. `BANDMAP` only says a station moved. The five transmit-interlock flags are sent as false: this program does not block a transmitter. **`Rates.PerHour`** reads the two rate columns out of the log. Count back over the last N contacts, stop at a gap longer than the contest's minimum off time, and treat a span under a minute as a minute, so three contacts in five seconds read as 180 an hour rather than 2160. A connection closed by hand stays closed. Without that flag the next beacon opens it again within ten seconds, which makes the menu item look broken. Left out, because there is nothing behind them: N1MM's Resynchronize and Rescore tabs, the transmit interlocks of its Options tab, the Master column and the call-stacking target tab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
This commit is contained in:
@@ -1,35 +1,36 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Nonemm.App.Dialogs;
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.App.Theming;
|
||||
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, and whether anything is arriving from them. N1MM's network status
|
||||
/// window, with its columns.
|
||||
/// 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.
|
||||
///
|
||||
/// The window redraws 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.
|
||||
/// Three of N1MM's tabs are here. Resynchronize, Rescore and the transmit
|
||||
/// interlocks of its Options tab are not: this program has neither the resync
|
||||
/// messages nor a transmit interlock, 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
|
||||
{
|
||||
/// The columns, in N1MM's order as far as this program has the answers.
|
||||
private static readonly string[] Columns =
|
||||
["Computer", "Nr", "Address", "Operator", "Band", "Mode", "Run", "TX", "Pass", "Last", "Heard", "Sent", "Read", "Echo"];
|
||||
|
||||
/// How much chat is kept on screen.
|
||||
private const int TalkLines = 8;
|
||||
/// How much of the messages pane is kept.
|
||||
private const int MessageLines = 200;
|
||||
|
||||
private readonly AppSession session;
|
||||
private readonly List<string> talk = [];
|
||||
private readonly List<string> messages = [];
|
||||
private readonly Dictionary<string, TimeSpan> echoes = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly DispatcherTimer clock;
|
||||
private StationLink? link;
|
||||
|
||||
@@ -37,8 +38,15 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
||||
{
|
||||
this.session = session;
|
||||
InitializeComponent();
|
||||
Rows.LoadingRow += (_, e) =>
|
||||
{
|
||||
if (e.Row.DataContext is StationRow row)
|
||||
{
|
||||
e.Row.Foreground = row.Colour;
|
||||
}
|
||||
};
|
||||
Attach();
|
||||
// the Heard 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
|
||||
clock = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||||
clock.Tick += (_, _) => Refresh();
|
||||
@@ -54,60 +62,31 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
||||
public override void Refresh()
|
||||
{
|
||||
Attach();
|
||||
Table.Children.Clear();
|
||||
Table.ColumnDefinitions.Clear();
|
||||
Table.RowDefinitions.Clear();
|
||||
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 — Setup turns it on";
|
||||
TalkText.Text = "";
|
||||
"the link to the other logging computers is off — Edit Computer Addresses turns it on";
|
||||
ShowMessages();
|
||||
return;
|
||||
}
|
||||
foreach (string _ in Columns)
|
||||
{
|
||||
Table.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
|
||||
}
|
||||
AddRow(0, Columns, header: true);
|
||||
int row = 1;
|
||||
DateTime now = DateTime.UtcNow;
|
||||
foreach (NetworkedStation station in link.Stations.OrderBy(s => s.StationNumber))
|
||||
{
|
||||
AddRow(row++, Cells(station, now), refused: station.Refused.Length > 0 && !station.IsMine);
|
||||
}
|
||||
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));
|
||||
ReportEchoes();
|
||||
StateText.Text = State();
|
||||
TalkText.Text = string.Join("\n", talk);
|
||||
ShowMessages();
|
||||
}
|
||||
|
||||
private static string[] Cells(NetworkedStation station, DateTime now) =>
|
||||
[
|
||||
station.ComputerName + (station.IsMine ? " (this one)" : ""),
|
||||
station.StationNumber > 0 ? station.StationNumber.ToString() : "",
|
||||
station.Address,
|
||||
station.Operator,
|
||||
station.Band?.Name ?? "",
|
||||
station.Mode?.Name ?? "",
|
||||
station.IsRunning ? "run" : "",
|
||||
station.IsTransmitting ? "TX" : "",
|
||||
station.PassFrequency.Hertz > 0 ? $"{station.PassFrequency.Kilohertz:0.0} {station.PassCall}" : "",
|
||||
station.LastMessage,
|
||||
station.IsMine ? "" : Ago(now - station.LastHeardUtc),
|
||||
station.Sent.ToString(),
|
||||
station.Read.ToString(),
|
||||
station.EchoTime is { } echo ? $"{echo.TotalMilliseconds:0} ms" : "",
|
||||
];
|
||||
|
||||
/// How long ago, in the shortest form that says it. A station heard from
|
||||
/// less than a second ago reads as now rather than as 0 s.
|
||||
private static string Ago(TimeSpan since) => since switch
|
||||
{
|
||||
{ TotalSeconds: < 2 } => "now",
|
||||
{ TotalMinutes: < 1 } => $"{since.Seconds} s",
|
||||
{ TotalHours: < 1 } => $"{since.Minutes} min",
|
||||
_ => "over an hour",
|
||||
};
|
||||
|
||||
/// What the link is doing, under the table. A station that broadcast the
|
||||
/// 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()
|
||||
@@ -128,6 +107,23 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
||||
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))
|
||||
@@ -143,6 +139,7 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
||||
link.StationsChanged += WhenStationsChanged;
|
||||
link.TalkArrived += WhenTalkArrived;
|
||||
link.Failed += WhenFailed;
|
||||
link.MessageArrived += WhenMessageArrived;
|
||||
}
|
||||
|
||||
private void Detach()
|
||||
@@ -154,6 +151,7 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
||||
link.StationsChanged -= WhenStationsChanged;
|
||||
link.TalkArrived -= WhenTalkArrived;
|
||||
link.Failed -= WhenFailed;
|
||||
link.MessageArrived -= WhenMessageArrived;
|
||||
link = null;
|
||||
}
|
||||
|
||||
@@ -168,16 +166,36 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
||||
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)
|
||||
{
|
||||
talk.Add(text);
|
||||
if (talk.Count > TalkLines)
|
||||
messages.Add($"{DateTime.UtcNow:HH:mm:ss} {text}");
|
||||
if (messages.Count > MessageLines)
|
||||
{
|
||||
talk.RemoveRange(0, talk.Count - TalkLines);
|
||||
messages.RemoveRange(0, messages.Count - MessageLines);
|
||||
}
|
||||
Refresh();
|
||||
ShowMessages();
|
||||
}
|
||||
|
||||
private void ShowMessages()
|
||||
{
|
||||
MessageText.Text = string.Join("\n", messages);
|
||||
MessageScroll.ScrollToEnd();
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@@ -192,6 +210,14 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTalkTo(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Tabs.SelectedIndex = 1;
|
||||
TalkBox.Focus();
|
||||
}
|
||||
|
||||
private void OnShowMessages(object? sender, RoutedEventArgs e) => Tabs.SelectedIndex = 2;
|
||||
|
||||
private async void OnEcho(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (link is not null)
|
||||
@@ -200,6 +226,68 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -211,26 +299,4 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private void AddRow(int row, IReadOnlyList<string> cells, bool header = false, bool refused = false)
|
||||
{
|
||||
Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
for (int column = 0; column < cells.Count; column++)
|
||||
{
|
||||
TextBlock text = new()
|
||||
{
|
||||
Text = cells[column],
|
||||
FontWeight = header ? FontWeight.Bold : FontWeight.Normal,
|
||||
Margin = new Avalonia.Thickness(4, 2, 8, 2),
|
||||
FontSize = 12,
|
||||
};
|
||||
if (refused)
|
||||
{
|
||||
text.Foreground = Themes.Brush(Themes.Current.BadBackground);
|
||||
}
|
||||
Grid.SetRow(text, row);
|
||||
Grid.SetColumn(text, column);
|
||||
Table.Children.Add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user