Show the other computers, and apply the ten-minute rule
Three pieces on top of the link: **The network status window**, on the entry window's Window menu. A row per station, this computer included the way N1MM shows it: number, address, operator, band, mode, run, transmit, pass frequency, the last message type, how long ago it arrived, the counts each way and the echo round trip. A station that broadcast the wrong version is named under the table in red — that is the one fault where everything looks connected and nothing arrives. The box at the bottom sends a line of chat, and Echo asks every station whether it is there. **Where this station is** goes out once a second when it has changed. The frequency, the mode and run have a dozen places they can change from — the radio moving, a band button, a QSY typed into the callsign box — so it is read and compared rather than announced from each of them. **The ten-minute rule.** `BandChangeRules` already counted changes and the stay on a band, and the entry window already showed the countdown, but only a user-defined contest carried a rule, so every built-in contest allowed anything. `ForCategory` gives a multi-operator entry with one or two transmitters a ten-minute stay, which is N1MM's fallback in `ContestInstance.BandChangeTimerDuration` for every contest that does not name its own. Its per-contest table is a few hundred cases in a decompiled hash switch and is not repeated. Config ▸ Edit Networked-Computer Names now covers both networks: the 12060 broadcast to other programs and the 12070 link, with the station number, the port, the version to claim and stations named by address for a network where a broadcast does not reach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
This commit is contained in:
@@ -431,6 +431,9 @@ public sealed partial class EntryWindow
|
||||
|
||||
private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session));
|
||||
|
||||
private void OnShowNetworkStatus(object? sender, RoutedEventArgs e) =>
|
||||
Show(() => new NetworkStatusWindow(session));
|
||||
|
||||
/// N1MM's grey line window: where the daylight is now, and where it will be.
|
||||
private void OnShowGrayline(object? sender, RoutedEventArgs e) =>
|
||||
Show(() => new GraylineWindow(session));
|
||||
@@ -518,10 +521,21 @@ public sealed partial class EntryWindow
|
||||
{
|
||||
session.Save(updated);
|
||||
session.ApplyNetworkSettings();
|
||||
Status(updated.NetworkEnabled ? "networked with the other stations" : "networking off");
|
||||
session.ApplyStationLinkSettings();
|
||||
Status(Networking(updated));
|
||||
}
|
||||
}
|
||||
|
||||
/// What the two networks are now doing, for the status line.
|
||||
private static string Networking(Settings settings) =>
|
||||
(settings.NetworkEnabled, settings.StationLinkEnabled) switch
|
||||
{
|
||||
(true, true) => "broadcasting contacts and linked to the other computers",
|
||||
(true, false) => "broadcasting contacts to other programs",
|
||||
(false, true) => "linked to the other logging computers",
|
||||
_ => "networking off",
|
||||
};
|
||||
|
||||
private async void OnKeyerSettings(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
KeyerDialog dialog = new(session.Settings);
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
<MenuItem Header="Call Stack" Click="OnShowCallStack" />
|
||||
<MenuItem Header="Check" Click="OnShowCheck" />
|
||||
<MenuItem Header="Log" Click="OnShowLog" InputGesture="Ctrl+L" />
|
||||
<MenuItem Header="Network Status" Click="OnShowNetworkStatus" />
|
||||
<MenuItem Header="Grey Line" Click="OnShowGrayline" />
|
||||
<MenuItem Header="Score Summary" Click="OnShowScore" />
|
||||
<MenuItem Header="Telnet" Click="OnShowTelnet" />
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||
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>
|
||||
</DockPanel>
|
||||
</local:RefreshableWindow>
|
||||
@@ -0,0 +1,236 @@
|
||||
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.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.
|
||||
///
|
||||
/// 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.
|
||||
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;
|
||||
|
||||
private readonly AppSession session;
|
||||
private readonly List<string> talk = [];
|
||||
private readonly DispatcherTimer clock;
|
||||
private StationLink? link;
|
||||
|
||||
public NetworkStatusWindow(AppSession session)
|
||||
{
|
||||
this.session = session;
|
||||
InitializeComponent();
|
||||
Attach();
|
||||
// the Heard 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();
|
||||
Table.Children.Clear();
|
||||
Table.ColumnDefinitions.Clear();
|
||||
Table.RowDefinitions.Clear();
|
||||
if (link is null)
|
||||
{
|
||||
StateText.Text =
|
||||
"the link to the other logging computers is off — Setup turns it on";
|
||||
TalkText.Text = "";
|
||||
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);
|
||||
}
|
||||
StateText.Text = State();
|
||||
TalkText.Text = string.Join("\n", talk);
|
||||
}
|
||||
|
||||
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
|
||||
/// wrong version is named here: it is the one fault that leaves everything
|
||||
/// looking connected and nothing arriving.
|
||||
private string State()
|
||||
{
|
||||
if (link is null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
List<NetworkedStation> others = link.Stations.Where(s => !s.IsMine).ToList();
|
||||
int connected = others.Count(s => s.IsConnected);
|
||||
string what = $"station {link.StationNumber} as {link.ComputerName}, "
|
||||
+ $"claiming version {session.Settings.StationLinkVersion} — "
|
||||
+ $"{connected} of {others.Count} other stations connected";
|
||||
List<string> refused = others
|
||||
.Where(s => s.Refused.Length > 0)
|
||||
.Select(s => $"{s.ComputerName}: {s.Refused}")
|
||||
.ToList();
|
||||
return refused.Count > 0 ? $"{what}\n{string.Join("\n", refused)}" : what;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private void Detach()
|
||||
{
|
||||
if (link is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
link.StationsChanged -= WhenStationsChanged;
|
||||
link.TalkArrived -= WhenTalkArrived;
|
||||
link.Failed -= WhenFailed;
|
||||
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 Say(string text)
|
||||
{
|
||||
talk.Add(text);
|
||||
if (talk.Count > TalkLines)
|
||||
{
|
||||
talk.RemoveRange(0, talk.Count - TalkLines);
|
||||
}
|
||||
Refresh();
|
||||
}
|
||||
|
||||
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 async void OnEcho(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (link is not null)
|
||||
{
|
||||
await link.SendEchoRequestAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnSetup(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
NetworkDialog dialog = new(session.Settings);
|
||||
if (await dialog.ShowDialog<Settings?>(this) is { } updated)
|
||||
{
|
||||
session.Save(updated);
|
||||
session.ApplyNetworkSettings();
|
||||
session.ApplyStationLinkSettings();
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
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