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
+20
View File
@@ -382,6 +382,26 @@ Where a station is goes out once a second when it has changed, rather than from
each of the dozen places it can change from. In a multi-single entry that is each of the dozen places it can change from. In a multi-single entry that is
what the other operators watch. what the other operators watch.
**The status message.** `STATUS` goes out every five seconds whether anything
has changed or not, and it is what fills the other operators' windows: 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. `Rates.PerHour` reads the two rates out of the log the way N1MM does —
counting back over the last N contacts, stopping at a gap longer than the
contest's minimum off time, and treating a span under a minute as a minute so a
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
transmitter.
**The network status window** has N1MM's Stations, Actions and Messages tabs and
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
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
otherwise open it within ten seconds. Left out: N1MM's Resynchronize and Rescore
tabs, the transmit interlocks of its Options tab, the Master column and the
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
band, and the entry window shows the countdown. What was missing was the rule band, and the entry window shows the countdown. What was missing was the rule
itself: only a user-defined contest carried one, so every built-in contest itself: only a user-defined contest carried one, so every built-in contest
+43 -1
View File
@@ -19,6 +19,11 @@ namespace Nonemm.App;
/// `Changed` event tells them all to redraw. /// `Changed` event tells them all to redraw.
public sealed class AppSession : IDisposable 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 LogStore? store;
private readonly List<RigctldRadio> radios = []; private readonly List<RigctldRadio> radios = [];
private readonly List<OperatingPosition> positions = []; 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 /// What was last said to the other stations about where this one is, so it
/// is said again only when it changes. /// is said again only when it changes.
private (Frequency Where, string Mode, bool Running, int Radio) announced; 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 DispatcherTimer? announcing;
private MessageSender? keyer; private MessageSender? keyer;
private AlternatingCq? alternating; private AlternatingCq? alternating;
@@ -310,8 +318,13 @@ public sealed class AppSession : IDisposable
// changed, rather than announced from each of them // changed, rather than announced from each of them
announcing?.Stop(); announcing?.Stop();
announced = default; announced = default;
statusSent = default;
announcing = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; announcing = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
announcing.Tick += (_, _) => AnnounceWhereIAm(); announcing.Tick += (_, _) =>
{
AnnounceWhereIAm();
SayHowIAmDoing();
};
announcing.Start(); announcing.Start();
Changed?.Invoke(this, EventArgs.Empty); Changed?.Invoke(this, EventArgs.Empty);
} }
@@ -335,6 +348,35 @@ 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);
} }
/// 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 /// 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 /// 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. /// 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:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Nonemm.App.Windows" xmlns:local="using:Nonemm.App.Windows"
x:Class="Nonemm.App.Windows.NetworkStatusWindow" x:Class="Nonemm.App.Windows.NetworkStatusWindow"
Title="Network status" Width="820" Height="320"> Title="Network Status" Width="900" Height="360">
<DockPanel Margin="8"> <DockPanel>
<StackPanel DockPanel.Dock="Bottom" Spacing="6" Margin="0,8,0,0"> <TextBlock DockPanel.Dock="Bottom" Name="StateText" Margin="8,4" FontSize="11"
<StackPanel Orientation="Horizontal" Spacing="6"> Opacity="0.75" TextWrapping="Wrap" />
<TextBox Name="TalkBox" Width="420" PlaceholderText="a line to the other operators" /> <TabControl Name="Tabs">
<Button Content="Send" Click="OnTalk" IsDefault="True" />
<Button Content="Echo" Click="OnEcho" /> <TabItem Header="Stations">
<Button Name="LinkButton" Content="Setup…" Click="OnSetup" /> <DataGrid Name="Rows" IsReadOnly="True" GridLinesVisibility="Horizontal"
</StackPanel> CanUserSortColumns="False" FontSize="12" FontFamily="monospace"
<TextBlock Name="StateText" FontSize="11" Opacity="0.75" TextWrapping="Wrap" /> AutoGenerateColumns="False" x:DataType="local:StationRow">
</StackPanel> <DataGrid.Styles>
<ScrollViewer> <!-- the grid's own font settings do not reach the cells -->
<StackPanel Spacing="6"> <Style Selector="DataGridCell TextBlock">
<Grid Name="Table" /> <Setter Property="FontFamily" Value="monospace" />
<TextBlock Name="TalkText" FontFamily="monospace" FontSize="11" TextWrapping="Wrap" /> <Setter Property="FontSize" Value="12" />
</StackPanel> </Style>
</ScrollViewer> </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> </DockPanel>
</local:RefreshableWindow> </local:RefreshableWindow>
@@ -1,35 +1,36 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Threading; using Avalonia.Threading;
using Nonemm.App.Dialogs;
using Nonemm.App.Configuration; using Nonemm.App.Configuration;
using Nonemm.App.Theming; using Nonemm.App.Dialogs;
using Nonemm.Network; using Nonemm.Network;
namespace Nonemm.App.Windows; namespace Nonemm.App.Windows;
/// The other computers of this entry: who they are, where they are on the /// 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 /// bands, how fast they are working people, and whether anything is arriving
/// window, with its columns. /// 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 /// 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 /// 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.
/// ///
/// The window redraws on every message, which on a busy network is several a /// Three of N1MM's tabs are here. Resynchronize, Rescore and the transmit
/// second. That is what a status window is for, and the table is a dozen rows. /// 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 public sealed partial class NetworkStatusWindow : RefreshableWindow
{ {
/// The columns, in N1MM's order as far as this program has the answers. /// How much of the messages pane is kept.
private static readonly string[] Columns = private const int MessageLines = 200;
["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;
private readonly AppSession session; 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 readonly DispatcherTimer clock;
private StationLink? link; private StationLink? link;
@@ -37,8 +38,15 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
{ {
this.session = session; this.session = session;
InitializeComponent(); InitializeComponent();
Rows.LoadingRow += (_, e) =>
{
if (e.Row.DataContext is StationRow row)
{
e.Row.Foreground = row.Colour;
}
};
Attach(); 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 // arriving
clock = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; clock = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
clock.Tick += (_, _) => Refresh(); clock.Tick += (_, _) => Refresh();
@@ -54,60 +62,31 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
public override void Refresh() public override void Refresh()
{ {
Attach(); Attach();
Table.Children.Clear(); NetworkingButton.Content = link is null ? "Start Networking" : "Stop Networking";
Table.ColumnDefinitions.Clear();
Table.RowDefinitions.Clear();
if (link is null) if (link is null)
{ {
Rows.ItemsSource = Array.Empty<StationRow>();
StateText.Text = StateText.Text =
"the link to the other logging computers is off — Setup turns it on"; "the link to the other logging computers is off — Edit Computer Addresses turns it on";
TalkText.Text = ""; ShowMessages();
return; return;
} }
foreach (string _ in Columns)
{
Table.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
}
AddRow(0, Columns, header: true);
int row = 1;
DateTime now = DateTime.UtcNow; DateTime now = DateTime.UtcNow;
foreach (NetworkedStation station in link.Stations.OrderBy(s => s.StationNumber)) string? selected = (Rows.SelectedItem as StationRow)?.Station.ComputerName;
{ List<StationRow> rows = link.Stations
AddRow(row++, Cells(station, now), refused: station.Refused.Length > 0 && !station.IsMine); .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(); StateText.Text = State();
TalkText.Text = string.Join("\n", talk); ShowMessages();
} }
private static string[] Cells(NetworkedStation station, DateTime now) => /// What the link is doing, under the tabs. A station that broadcast the
[
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
/// wrong version is named here: it is the one fault that leaves everything /// wrong version is named here: it is the one fault that leaves everything
/// looking connected and nothing arriving. /// looking connected and nothing arriving.
private string State() private string State()
@@ -128,6 +107,23 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
return refused.Count > 0 ? $"{what}\n{string.Join("\n", refused)}" : what; 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() private void Attach()
{ {
if (ReferenceEquals(link, session.Link)) if (ReferenceEquals(link, session.Link))
@@ -143,6 +139,7 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
link.StationsChanged += WhenStationsChanged; link.StationsChanged += WhenStationsChanged;
link.TalkArrived += WhenTalkArrived; link.TalkArrived += WhenTalkArrived;
link.Failed += WhenFailed; link.Failed += WhenFailed;
link.MessageArrived += WhenMessageArrived;
} }
private void Detach() private void Detach()
@@ -154,6 +151,7 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
link.StationsChanged -= WhenStationsChanged; link.StationsChanged -= WhenStationsChanged;
link.TalkArrived -= WhenTalkArrived; link.TalkArrived -= WhenTalkArrived;
link.Failed -= WhenFailed; link.Failed -= WhenFailed;
link.MessageArrived -= WhenMessageArrived;
link = null; link = null;
} }
@@ -168,16 +166,36 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
private void WhenFailed(object? sender, string why) => private void WhenFailed(object? sender, string why) =>
Dispatcher.UIThread.Post(() => Say(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) private void Say(string text)
{ {
talk.Add(text); messages.Add($"{DateTime.UtcNow:HH:mm:ss} {text}");
if (talk.Count > TalkLines) 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) private async void OnTalk(object? sender, RoutedEventArgs e)
{ {
if (link is null || (TalkBox.Text ?? "").Trim() is not { Length: > 0 } text) 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) private async void OnEcho(object? sender, RoutedEventArgs e)
{ {
if (link is not null) 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) private async void OnSetup(object? sender, RoutedEventArgs e)
{ {
NetworkDialog dialog = new(session.Settings); NetworkDialog dialog = new(session.Settings);
@@ -211,26 +299,4 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
Refresh(); 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",
};
}
+54
View File
@@ -0,0 +1,54 @@
using Nonemm.Core;
namespace Nonemm.Contests;
/// How fast contacts are being made, in contacts an hour. The network status
/// window shows two of these per station — over the last ten contacts and over
/// the last hundred — and the score window shows the same numbers for this
/// station.
public static class Rates
{
/// Where N1MM stops dividing by a very short span. Under ten contacts the
/// span counts as at least a minute, so three contacts in five seconds
/// reads as 180 an hour rather than 2160.
private const int ShortRun = 10;
private static readonly TimeSpan Floor = TimeSpan.FromMinutes(1);
/// The rate over the last `count` contacts before `nowUtc`.
///
/// Counting stops at the first gap longer than `minimumOff`, so a rate
/// after a night's rest is the rate since the operator came back rather
/// than a hundred contacts spread over twelve hours. A log with no contact
/// inside that gap is a rate of nothing.
public static int PerHour(
IEnumerable<Qso> qsos,
int count,
DateTime nowUtc,
TimeSpan minimumOff)
{
DateTime oldest = nowUtc;
int counted = 0;
foreach (Qso qso in qsos.OrderByDescending(q => q.TimestampUtc).Take(count))
{
if (oldest - qso.TimestampUtc > minimumOff)
{
break;
}
oldest = qso.TimestampUtc;
counted++;
}
if (counted == 0)
{
return 0;
}
TimeSpan span = nowUtc - oldest;
if (counted < ShortRun && span < Floor)
{
span = Floor;
}
return span > TimeSpan.Zero
? (int)Math.Round(counted / span.TotalHours)
: 0;
}
}
+16
View File
@@ -51,12 +51,23 @@ public sealed class NetworkedStation
public Band? Band => Bands.ForFrequency(Frequency); public Band? Band => Bands.ForFrequency(Frequency);
/// Contacts an hour over this station's last ten and last hundred
/// contacts, from its `STATUS` message. The two rate columns of the
/// network status window.
public int Last10 { get; internal set; }
public int Last100 { get; internal set; }
/// True while the station is running rather than searching. In a /// True while the station is running rather than searching. In a
/// multi-single entry this is the run station. /// multi-single entry this is the run station.
public bool IsRunning { get; internal set; } public bool IsRunning { get; internal set; }
public bool IsTransmitting { get; internal set; } public bool IsTransmitting { get; internal set; }
/// True when the station is the second run radio of a two-radio position.
/// N1MM shows Run1 or Run2/Mult in its `R/M` column.
public bool IsRun2 { get; internal set; }
public int RadioNumber { get; internal set; } = 1; public int RadioNumber { get; internal set; } = 1;
/// A frequency this station has passed, from `PASSFREQ`, and who is on it. /// A frequency this station has passed, from `PASSFREQ`, and who is on it.
@@ -91,6 +102,11 @@ public sealed class NetworkedStation
/// window. /// window.
public bool IsMine { get; internal init; } public bool IsMine { get; internal init; }
/// Set when the operator closed the connection by hand. The beacons keep
/// arriving and the station stays in the list, but nothing opens a
/// connection to it again until the operator asks for one.
public bool IsReconnectPrevented { get; internal set; }
/// Set when the station broadcast a version that is not ours. N1MM turns /// Set when the station broadcast a version that is not ours. N1MM turns
/// such a station away, so this program says why rather than failing to /// such a station away, so this program says why rather than failing to
/// connect for no visible reason. /// connect for no visible reason.
+82 -1
View File
@@ -88,6 +88,10 @@ public sealed class StationLink : IDisposable
set => mine.Operator = value; set => mine.Operator = value;
} }
/// This computer's own row, which is in the station list as well. The
/// window reads its pass frequency and its counts off it.
public NetworkedStation Me => mine;
/// Every station, this computer included, in the order they were first /// Every station, this computer included, in the order they were first
/// heard. /// heard.
public IReadOnlyList<NetworkedStation> Stations => stations.Values.ToList(); public IReadOnlyList<NetworkedStation> Stations => stations.Values.ToList();
@@ -104,6 +108,11 @@ public sealed class StationLink : IDisposable
public event EventHandler<string>? Failed; public event EventHandler<string>? Failed;
/// 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
/// the operator turns tracing on.
public event EventHandler<StationRecord>? MessageArrived;
public void Start() public void Start()
{ {
if (loops.Count > 0) if (loops.Count > 0)
@@ -257,6 +266,62 @@ public sealed class StationLink : IDisposable
cancellation); cancellation);
} }
/// Everything this station has to say about itself, which is what fills
/// the other operators' network status windows. It goes out every few
/// seconds whether anything has changed or not.
public Task<int> SendStatusAsync(StationStatus status, CancellationToken cancellation = default)
{
mine.PassFrequency = status.PassFrequency;
mine.Frequency = status.Frequency;
mine.IsRunning = status.IsRunning;
mine.Operator = status.Operator;
mine.Mode = status.Mode;
mine.Last10 = status.Last10;
mine.Last100 = status.Last100;
mine.IsRun2 = status.IsRun2;
StationsChanged?.Invoke(this, EventArgs.Empty);
return SendAsync(StationMessages.Status(status, version, StationNumber, computerName), cancellation);
}
/// A frequency this station is handing to the others, and who is on it.
/// With `to` set it goes to that station alone, which is how an operator
/// passes one contact to one position; without it every station is told,
/// which is how the pass frequency is announced.
public Task<int> SendPassAsync(
Frequency frequency,
string call,
NetworkedStation? to = null,
CancellationToken cancellation = default)
{
mine.PassFrequency = frequency;
mine.PassCall = call;
StationsChanged?.Invoke(this, EventArgs.Empty);
StationRecord record = StationMessages.PassFrequency(frequency, call, StationNumber, computerName);
return to is null
? SendAsync(record, cancellation)
: Sent(SendToAsync(to, record, cancellation));
}
/// Closes the connection to one station because the operator asked. It
/// stays closed: a beacon from that station opens a new one, so without
/// this flag the connection would be back within ten seconds.
public void Close(NetworkedStation station)
{
station.IsReconnectPrevented = true;
Drop(station.ComputerName, "closed by the operator");
}
/// Opens a connection to a station the operator closed, or to one whose
/// connection has not come back on its own.
public void Open(NetworkedStation station)
{
station.IsReconnectPrevented = false;
if (!station.IsMine && !writers.ContainsKey(station.ComputerName))
{
_ = ConnectAsync(station, stopping.Token);
}
}
/// 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)
{ {
@@ -264,6 +329,10 @@ public sealed class StationLink : IDisposable
return SendAsync(StationMessages.EchoRequest(StationNumber, computerName, DateTime.UtcNow), cancellation); return SendAsync(StationMessages.EchoRequest(StationNumber, computerName, DateTime.UtcNow), cancellation);
} }
/// One send read as the count every other send returns.
private static async Task<int> Sent(Task<bool> one) =>
await one.ConfigureAwait(false) ? 1 : 0;
public void Dispose() public void Dispose()
{ {
stopping.Cancel(); stopping.Cancel();
@@ -365,7 +434,7 @@ public sealed class StationLink : IDisposable
} }
station.Refused = ""; station.Refused = "";
StationsChanged?.Invoke(this, EventArgs.Empty); StationsChanged?.Invoke(this, EventArgs.Empty);
if (!writers.ContainsKey(station.ComputerName)) if (!station.IsReconnectPrevented && !writers.ContainsKey(station.ComputerName))
{ {
_ = ConnectAsync(station, stopping.Token); _ = ConnectAsync(station, stopping.Token);
} }
@@ -465,6 +534,7 @@ public sealed class StationLink : IDisposable
station.LastMessage = record.Type; station.LastMessage = record.Type;
station.Read++; station.Read++;
mine.Read++; mine.Read++;
MessageArrived?.Invoke(this, record);
switch (record.Type) switch (record.Type)
{ {
case "IAM": case "IAM":
@@ -476,6 +546,17 @@ public sealed class StationLink : IDisposable
station.IsRunning = record.Flag(2); station.IsRunning = record.Flag(2);
station.RadioNumber = Math.Max(1, record.Number(3)); station.RadioNumber = Math.Max(1, record.Number(3));
break; break;
case "STATUS":
StationStatus status = StationMessages.ReadStatus(record);
station.PassFrequency = status.PassFrequency;
station.Frequency = status.Frequency;
station.IsRunning = status.IsRunning;
station.Operator = status.Operator;
station.Mode = status.Mode;
station.Last10 = status.Last10;
station.Last100 = status.Last100;
station.IsRun2 = status.IsRun2;
break;
case "XMIT": case "XMIT":
station.IsTransmitting = record.Flag(0); station.IsTransmitting = record.Flag(0);
station.RadioNumber = Math.Max(1, record.Number(1)); station.RadioNumber = Math.Max(1, record.Number(1));
+58
View File
@@ -22,6 +22,7 @@ namespace Nonemm.Network;
/// | `PASSFREQ` | a frequency being passed to another radio | yes | yes | /// | `PASSFREQ` | a frequency being passed to another radio | yes | yes |
/// | `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 |
/// ///
/// 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
@@ -137,6 +138,58 @@ public static class StationMessages
Text(radioNumber), Text(radioNumber),
]); ]);
/// Everything a station says about itself, sent every few seconds. The two
/// frequencies go out in hundredths of a kilohertz, which is what N1MM
/// writes and reads them as.
///
/// The five transmit-interlock flags after the version are sent as false:
/// this program does not block a transmitter, and a station that says so is
/// one the other end does not wait for. The country file version is empty
/// for the same reason — N1MM only shows it.
public static StationRecord Status(
StationStatus status,
string version,
int stationNumber,
string computerName) =>
Record(
"STATUS",
stationNumber,
computerName,
[
Hundredths(status.PassFrequency),
Hundredths(status.Frequency),
StationRecord.Written(status.IsRunning),
status.Operator,
Text(status.Last10),
Text(status.Last100),
status.Mode?.Name ?? "",
status.OperatorCategory,
status.TransmitterCategory,
"",
version,
StationRecord.Written(false),
StationRecord.Written(false),
StationRecord.Written(false),
StationRecord.Written(false),
StationRecord.Written(false),
StationRecord.Written(status.IsRun2),
]);
/// What a `STATUS` message says. A station on another version sends fewer
/// fields, and every one that is missing reads as empty rather than
/// throwing the message away.
public static StationStatus ReadStatus(StationRecord record) => new(
Frequency.FromKilohertz(record.Decimal(0) / 100),
Frequency.FromKilohertz(record.Decimal(1) / 100),
record.Flag(2),
record.Field(3),
Modes.Parse(record.Field(6)),
record.Number(4),
record.Number(5),
record.Field(7),
record.Field(8),
record.Flag(16));
/// 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
@@ -166,4 +219,9 @@ public static class StationMessages
new(stationNumber, computerName, type, fields); new(stationNumber, computerName, type, fields);
private static string Text(int number) => number.ToString(CultureInfo.InvariantCulture); private static string Text(int number) => number.ToString(CultureInfo.InvariantCulture);
/// A frequency as `STATUS` carries it: kilohertz times a hundred, with no
/// decimal point.
private static string Hundredths(Frequency frequency) =>
(frequency.Kilohertz * 100).ToString("0", CultureInfo.InvariantCulture);
} }
+29
View File
@@ -0,0 +1,29 @@
using Nonemm.Core;
namespace Nonemm.Network;
/// What a station says about itself every few seconds, which is what fills the
/// rows of the network status window.
///
/// It is one message rather than a field on each of the others because N1MM
/// sends it that way: `BANDMAP` says a station moved, `STATUS` says everything
/// the window shows about it and arrives whether anything moved or not, so a
/// station that has been sitting on one frequency for an hour still has a
/// current row.
///
/// `Last10` and `Last100` are contacts an hour over the last ten and the last
/// hundred contacts. N1MM shows both, and the pair is how an operator sees a
/// station that has stopped working anybody without reading its log. `IsRun2`
/// is true when the station is the second run radio of a two-radio position,
/// which is the `R/M` column.
public sealed record StationStatus(
Frequency PassFrequency,
Frequency Frequency,
bool IsRunning,
string Operator,
Mode? Mode,
int Last10,
int Last100,
string OperatorCategory = "",
string TransmitterCategory = "",
bool IsRun2 = false);
+68
View File
@@ -0,0 +1,68 @@
using Nonemm.Core;
namespace Nonemm.Contests.Tests;
/// Contacts an hour, as the network status window and the score window show
/// them.
public class RatesTests
{
private static readonly DateTime Now = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan Off = TimeSpan.FromMinutes(30);
private static Qso MinutesAgo(int minutes) =>
TestLog.Contact("OM3XYZ") with { TimestampUtc = Now.AddMinutes(-minutes) };
/// Ten contacts spread over the last ten minutes: the oldest of them was
/// ten minutes ago, so the rate is ten in a sixth of an hour.
[Fact]
public void TheRateIsTheContactsOverTheTimeTheyTook()
{
List<Qso> log = [.. Enumerable.Range(1, 10).Select(MinutesAgo)];
Assert.Equal(60, Rates.PerHour(log, 10, Now, Off));
}
/// Only the last `count` contacts count, whatever else is in the log.
[Fact]
public void OlderContactsThanTheWindowAreLeftOut()
{
List<Qso> log = [.. Enumerable.Range(1, 40).Select(MinutesAgo)];
Assert.Equal(60, Rates.PerHour(log, 10, Now, Off));
}
/// A gap as long as the contest's minimum off time ends the count.
[Fact]
public void CountingStopsAtATimeOffGap()
{
List<Qso> log = [MinutesAgo(1), MinutesAgo(2), MinutesAgo(90), MinutesAgo(91)];
// two contacts in the last two minutes, floored at a minute
Assert.Equal(60, Rates.PerHour(log, 10, Now, Off));
}
/// Under ten contacts the span counts as at least a minute, so a burst does
/// not read as thousands an hour.
[Fact]
public void AShortBurstIsFlooredAtAMinute()
{
List<Qso> log = [MinutesAgo(0), MinutesAgo(0), MinutesAgo(0)];
Assert.Equal(180, Rates.PerHour(log, 10, Now, Off));
}
[Fact]
public void AnEmptyLogIsNoRate() =>
Assert.Equal(0, Rates.PerHour([], 10, Now, Off));
/// A station that has worked nobody since the gap has no rate, even with a
/// full log behind it.
[Fact]
public void NothingSinceTheGapIsNoRate()
{
List<Qso> log = [.. Enumerable.Range(60, 20).Select(MinutesAgo)];
Assert.Equal(0, Rates.PerHour(log, 10, Now, Off));
}
}
@@ -78,6 +78,66 @@ public class StationLinkTests
Assert.True(seen?.IsRunning); Assert.True(seen?.IsRunning);
} }
/// The status message carries what the network status window shows: the
/// operator, the rates, the pass frequency and which radio is running.
[Fact]
public async Task AStationSaysHowItIsDoing()
{
(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);
one.Start();
two.Start();
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
await one.SendStatusAsync(new StationStatus(
Frequency.FromKilohertz(14_205.5),
Frequency.FromKilohertz(21_025.1),
IsRunning: true,
"OM3RM",
Modes.Rtty,
Last10: 120,
Last100: 84,
"MULTI-ONE",
"TWO",
IsRun2: true));
await WaitForAsync(() =>
two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.Last10 == 120);
NetworkedStation? seen = two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC");
Assert.Equal(120, seen?.Last10);
Assert.Equal(84, seen?.Last100);
Assert.Equal("OM3RM", seen?.Operator);
Assert.Equal("RTTY", seen?.Mode?.Name);
Assert.Equal(21_025.1, seen?.Frequency.Kilohertz ?? 0, 1);
Assert.Equal(14_205.5, seen?.PassFrequency.Kilohertz ?? 0, 1);
Assert.True(seen?.IsRunning);
Assert.True(seen?.IsRun2);
}
/// A connection the operator closed stays closed: a beacon from that
/// station would otherwise open it again within ten seconds.
[Fact]
public async Task AClosedConnectionStaysClosedUntilItIsOpenedAgain()
{
(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);
one.Start();
two.Start();
NetworkedStation station = one.AddStation("MULT-PC", "127.0.0.1", twoPort);
await WaitForAsync(() => station.IsConnected);
one.Close(station);
Assert.False(station.IsConnected);
Assert.Equal(0, await one.SendTalkAsync("anybody there"));
one.Open(station);
await WaitForAsync(() => station.IsConnected);
Assert.True(station.IsConnected);
}
[Fact] [Fact]
public async Task ChatReachesTheOtherOperator() public async Task ChatReachesTheOtherOperator()
{ {