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:
2026-09-03 21:39:42 +00:00
co-authored by Claude Opus 5
parent 6f2f39768c
commit 68b29d722a
12 changed files with 756 additions and 105 deletions
+43 -1
View File
@@ -19,6 +19,11 @@ namespace Nonemm.App;
/// `Changed` event tells them all to redraw.
public sealed class AppSession : IDisposable
{
/// How often this station tells the others how it is doing. N1MM sends its
/// status on the same sort of interval; every second would read the log
/// sixty times a minute for two numbers that barely move.
private static readonly TimeSpan StatusInterval = TimeSpan.FromSeconds(5);
private LogStore? store;
private readonly List<RigctldRadio> radios = [];
private readonly List<OperatingPosition> positions = [];
@@ -30,6 +35,9 @@ public sealed class AppSession : IDisposable
/// What was last said to the other stations about where this one is, so it
/// is said again only when it changes.
private (Frequency Where, string Mode, bool Running, int Radio) announced;
/// When the last `STATUS` went out. It is not sent every second: it carries
/// the rates, which are read out of the log.
private DateTime statusSent;
private DispatcherTimer? announcing;
private MessageSender? keyer;
private AlternatingCq? alternating;
@@ -310,8 +318,13 @@ public sealed class AppSession : IDisposable
// changed, rather than announced from each of them
announcing?.Stop();
announced = default;
statusSent = default;
announcing = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
announcing.Tick += (_, _) => AnnounceWhereIAm();
announcing.Tick += (_, _) =>
{
AnnounceWhereIAm();
SayHowIAmDoing();
};
announcing.Start();
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -335,6 +348,35 @@ public sealed class AppSession : IDisposable
_ = link.SendBandAsync(position.Frequency, position.Mode, position.IsRunning, position.RadioNumber);
}
/// The `STATUS` message: everything the other operators' network status
/// 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
/// log each time.
private void SayHowIAmDoing()
{
if (link is null || Position is not { } position
|| DateTime.UtcNow - statusSent < StatusInterval)
{
return;
}
statusSent = DateTime.UtcNow;
TimeSpan off = Logging?.Contest.MinimumOffTime ?? TimeSpan.FromMinutes(30);
IReadOnlyList<Qso> qsos = Logging?.Log.Qsos ?? [];
_ = link.SendStatusAsync(new StationStatus(
link.Me.PassFrequency,
position.Frequency,
position.IsRunning,
link.Operator,
position.Mode,
Rates.PerHour(qsos, 10, DateTime.UtcNow, off),
Rates.PerHour(qsos, 100, DateTime.UtcNow, off),
Logging?.Instance.OperatorCategory ?? "",
Logging?.Instance.TransmitterCategory ?? "",
// the second radio of a two-radio position is N1MM's Run2, which is
// what its R/M column reads
position.RadioNumber == 2));
}
/// A station named by hand, written `RUN-PC@192.168.1.5` with `:port` on
/// the end when that station is not on the usual one. Null for anything
/// else, because a line the operator has half typed is not an address.
@@ -2,22 +2,85 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Nonemm.App.Windows"
x:Class="Nonemm.App.Windows.NetworkStatusWindow"
Title="Network status" Width="820" Height="320">
<DockPanel Margin="8">
<StackPanel DockPanel.Dock="Bottom" Spacing="6" Margin="0,8,0,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBox Name="TalkBox" Width="420" PlaceholderText="a line to the other operators" />
<Button Content="Send" Click="OnTalk" IsDefault="True" />
<Button Content="Echo" Click="OnEcho" />
<Button Name="LinkButton" Content="Setup…" Click="OnSetup" />
</StackPanel>
<TextBlock Name="StateText" FontSize="11" Opacity="0.75" TextWrapping="Wrap" />
</StackPanel>
<ScrollViewer>
<StackPanel Spacing="6">
<Grid Name="Table" />
<TextBlock Name="TalkText" FontFamily="monospace" FontSize="11" TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>
Title="Network Status" Width="900" Height="360">
<DockPanel>
<TextBlock DockPanel.Dock="Bottom" Name="StateText" Margin="8,4" FontSize="11"
Opacity="0.75" TextWrapping="Wrap" />
<TabControl Name="Tabs">
<TabItem Header="Stations">
<DataGrid Name="Rows" IsReadOnly="True" GridLinesVisibility="Horizontal"
CanUserSortColumns="False" FontSize="12" FontFamily="monospace"
AutoGenerateColumns="False" x:DataType="local:StationRow">
<DataGrid.Styles>
<!-- the grid's own font settings do not reach the cells -->
<Style Selector="DataGridCell TextBlock">
<Setter Property="FontFamily" Value="monospace" />
<Setter Property="FontSize" Value="12" />
</Style>
</DataGrid.Styles>
<DataGrid.ContextMenu>
<ContextMenu>
<MenuItem Header="Pass Current/Last QSO" Click="OnPassToStation" />
<Separator />
<MenuItem Header="Talk" Click="OnTalkTo" />
<MenuItem Header="Show Connection Log" Click="OnShowMessages" />
<Separator />
<MenuItem Header="Close Connection" Click="OnCloseConnection" />
<MenuItem Header="Open Connection" Click="OnOpenConnection" />
<Separator />
<MenuItem Header="Edit Computer Addresses" Click="OnSetup" />
<MenuItem Header="Stop Networking" Click="OnStopNetworking" />
</ContextMenu>
</DataGrid.ContextMenu>
<DataGrid.Columns>
<DataGridTextColumn Header="Computer" Binding="{Binding Computer}" />
<DataGridTextColumn Header="IP Address" Binding="{Binding IpAddress}" />
<DataGridTextColumn Header="Pass" Binding="{Binding Pass}" />
<DataGridTextColumn Header="Run" Binding="{Binding Run}" />
<DataGridTextColumn Header="10" Binding="{Binding Last10}" />
<DataGridTextColumn Header="100" Binding="{Binding Last100}" />
<DataGridTextColumn Header="Freq" Binding="{Binding Freq}" />
<DataGridTextColumn Header="Op" Binding="{Binding Op}" />
<DataGridTextColumn Header="Msg" Binding="{Binding Msg}" />
<DataGridTextColumn Header="Send" Binding="{Binding Send}" />
<DataGridTextColumn Header="Receive" Binding="{Binding Receive}" />
<DataGridTextColumn Header="Band" Binding="{Binding Band}" />
<DataGridTextColumn Header="R/M" Binding="{Binding RunMult}" />
<DataGridTextColumn Header="Mode" Binding="{Binding Mode}" />
</DataGrid.Columns>
</DataGrid>
</TabItem>
<TabItem Header="Actions">
<StackPanel Margin="8" Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBox Name="TalkBox" Width="460" PlaceholderText="a line to the other operators" />
<Button Content="Talk" Click="OnTalk" IsDefault="True" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Content="Set Your Pass Frequency" Click="OnSetPass" />
<Button Content="Ask Every Station If It Is There" Click="OnEcho" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Content="Edit Computer Addresses" Click="OnSetup" />
<Button Name="NetworkingButton" Content="Stop Networking" Click="OnStopNetworking" />
</StackPanel>
</StackPanel>
</TabItem>
<TabItem Header="Messages">
<DockPanel Margin="8">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,6">
<CheckBox Name="TraceBox" Content="Trace networked computer messages" />
<Button Content="Clear Messages" Click="OnClearMessages" />
</StackPanel>
<ScrollViewer Name="MessageScroll">
<TextBlock Name="MessageText" FontFamily="monospace" FontSize="11" TextWrapping="Wrap" />
</ScrollViewer>
</DockPanel>
</TabItem>
</TabControl>
</DockPanel>
</local:RefreshableWindow>
@@ -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);
}
}
}
+94
View File
@@ -0,0 +1,94 @@
using Avalonia.Media;
using Nonemm.App.Theming;
using Nonemm.Network;
namespace Nonemm.App.Windows;
/// One line of the network status window, in N1MM's columns. The station
/// itself holds the raw values; this turns them into the text of a cell.
public sealed class StationRow
{
private readonly NetworkedStation station;
private readonly DateTime now;
public StationRow(NetworkedStation station, DateTime now)
{
this.station = station;
this.now = now;
}
public NetworkedStation Station => station;
public string Computer => station.StationNumber > 0
? $"{station.ComputerName} ({station.StationNumber})"
: station.ComputerName;
public string IpAddress => station.Port > 0 && station.Address.Length > 0
? $"{station.Address}:{station.Port}"
: station.Address;
/// The frequency this station is passing contacts on, and who is on it.
public string Pass => station.PassFrequency.Hertz > 0
? $"{station.PassFrequency.Kilohertz:0.0} {station.PassCall}".TrimEnd()
: "";
/// Run or search and pounce, which is N1MM's Run column.
public string Run => station.IsRunning ? "Run" : "S&P";
public string Last10 => station.Last10 > 0 ? station.Last10.ToString() : "";
public string Last100 => station.Last100 > 0 ? station.Last100.ToString() : "";
public string Freq => station.Frequency.Hertz > 0 ? $"{station.Frequency.Kilohertz:0.0}" : "";
public string Op => station.Operator;
/// The last message this station sent, and TX while it is transmitting.
/// Two stations keying at once is what an operator watches this column for.
public string Msg => station.IsTransmitting
? $"TX {station.LastMessage}".TrimEnd()
: station.LastMessage;
/// Whether this program can write to the station. N1MM prints OK and so
/// does this.
public string Send => station.IsMine ? "" : station.IsConnected ? "OK" : "no";
/// Whether anything is arriving. A station that has said nothing for a
/// while reads as how long ago that was, because a connection that stands
/// open is not the same as a station that is still there.
public string Receive
{
get
{
if (station.IsMine)
{
return "";
}
TimeSpan since = now - station.LastHeardUtc;
return since < StationLink.Patience ? "OK" : Ago(since);
}
}
public string Band => station.Band?.Name ?? "";
/// Run1 or Run2, which N1MM heads `R/M`.
public string RunMult => station.IsRun2 ? "Run2" : "Run1";
public string Mode => station.Mode?.Name ?? "";
/// 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
/// own text colour: the grid reuses its rows, so a row that was red has to
/// be painted back.
public IBrush Colour => !station.IsMine && (station.Refused.Length > 0 || !station.IsConnected)
? Themes.Brush(Themes.Current.BadBackground)
: Themes.Brush(Themes.Current.FieldForeground);
/// How long ago, in the shortest form that says it.
private static string Ago(TimeSpan since) => since switch
{
{ TotalMinutes: < 1 } => $"{since.Seconds} s",
{ TotalHours: < 1 } => $"{since.Minutes} min",
_ => "over an hour",
};
}