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:
2026-09-03 15:26:42 +00:00
parent c0e1bc1e1b
commit 6f2f39768c
12 changed files with 588 additions and 3 deletions

View File

@@ -25,6 +25,12 @@ public sealed class AppSession : IDisposable
private int activeRadio;
private ClusterClient? cluster;
private StationNetwork? network;
private StationLink? link;
/// 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;
private DispatcherTimer? announcing;
private MessageSender? keyer;
private AlternatingCq? alternating;
private MmttyEngine? digital;
@@ -124,6 +130,10 @@ public sealed class AppSession : IDisposable
public StationNetwork? Network => network;
/// The link to the other logging computers, or null when the operator has
/// not turned it on. The network status window reads it.
public StationLink? Link => link;
public MessageSender? Keyer => keyer;
/// The digital modem, started by the digital window rather than at startup:
@@ -221,6 +231,12 @@ public sealed class AppSession : IDisposable
Logging.Edited += (_, change) => _ = network?.SendEditAsync(
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(qso, Settings.Station.Callsign);
// and the same three to the other logging computers, which is a
// different protocol on a different port
Logging.Logged += (_, qso) => _ = link?.SendLoggedAsync(qso);
Logging.Edited += (_, change) =>
_ = link?.SendEditedAsync(change.Qso, change.OldCall, change.OldTimestampUtc);
Logging.Deleted += (_, qso) => _ = link?.SendDeletedAsync(qso);
Check = new CheckWindowSources(positions[0], Calls, Bandmap);
Available = new AvailableStations(positions[0], Bandmap);
Save(Settings with { ContestNumber = contestNumber });
@@ -234,7 +250,9 @@ public sealed class AppSession : IDisposable
/// multi-operator entry, following what the settings now say.
public void ApplyNetworkSettings()
{
announcing?.Stop();
network?.Dispose();
link?.Dispose();
network = null;
if (!Settings.NetworkEnabled)
{
@@ -253,6 +271,87 @@ public sealed class AppSession : IDisposable
Changed?.Invoke(this, EventArgs.Empty);
}
/// Starts, restarts or stops the link to the other logging computers. It is
/// separate from `ApplyNetworkSettings` because the two are separate
/// networks: one carries XML to other programs, the other carries contacts
/// to the other computers of this entry.
public void ApplyStationLinkSettings()
{
link?.Dispose();
link = null;
if (!Settings.StationLinkEnabled)
{
Changed?.Invoke(this, EventArgs.Empty);
return;
}
link = new StationLink(
Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName,
Settings.StationLinkVersion,
Settings.StationNumber,
Settings.StationLinkPort)
{
Operator = Settings.Station.Callsign,
};
// the socket thread must not touch the log: every other change to it is
// made where the windows read it
link.UpdateArrived += (_, update) =>
Dispatcher.UIThread.Post(() => TakeFromNetwork(update));
foreach (string peer in Settings.StationLinkPeers)
{
if (Peer(peer) is var (name, address, port))
{
link.AddStation(name, address, port);
}
}
link.Start();
// where this station is has a dozen places it can change from — the
// radio moving, a band button, a QSY typed into the callsign box, run
// turning on — so it is read once a second and sent when it has
// changed, rather than announced from each of them
announcing?.Stop();
announced = default;
announcing = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
announcing.Tick += (_, _) => AnnounceWhereIAm();
announcing.Start();
Changed?.Invoke(this, EventArgs.Empty);
}
/// Tells the other stations where this one is, if it has moved. In a
/// multi-single entry this is what the other operators watch: two stations
/// on one band is a contact nobody can make.
private void AnnounceWhereIAm()
{
if (link is null || Position is not { } position)
{
return;
}
(Frequency, string, bool, int) now =
(position.Frequency, position.Mode.Name, position.IsRunning, position.RadioNumber);
if (now == announced)
{
return;
}
announced = now;
_ = link.SendBandAsync(position.Frequency, position.Mode, position.IsRunning, position.RadioNumber);
}
/// 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.
private (string Name, string Address, int Port)? Peer(string text)
{
string[] parts = text.Split('@');
if (parts.Length != 2 || parts[0].Trim().Length == 0)
{
return null;
}
string[] host = parts[1].Split(':');
return (
parts[0].Trim(),
host[0].Trim(),
host.Length > 1 && int.TryParse(host[1], out int given) ? given : Settings.StationLinkPort);
}
/// What another station did to its log, applied to ours. Nothing goes back
/// out to the network, and the contact is scored here from the rules
/// instead of trusting what the sender put in the message.
@@ -595,7 +694,9 @@ public sealed class AppSession : IDisposable
spotFlush.Dispose();
DisposeRadios();
cluster?.Dispose();
announcing?.Stop();
network?.Dispose();
link?.Dispose();
alternating?.Dispose();
keyer?.Dispose();
box?.Dispose();

View File

@@ -1,6 +1,7 @@
using System.Reflection;
using System.Text.Json;
using Nonemm.Core;
using Nonemm.Network;
using Nonemm.Session;
namespace Nonemm.App.Configuration;
@@ -118,6 +119,29 @@ public sealed record Settings
public IReadOnlyList<string> NetworkPeers { get; init; } = [];
/// The link to the other logging computers of a multi-operator entry, on
/// N1MM's port 12070. It is not the same thing as `NetworkEnabled`, which
/// is the XML broadcast on 12060 that other programs read; a station can
/// want either, or both.
public bool StationLinkEnabled { get; init; }
public int StationLinkPort { get; init; } = StationBeacon.DefaultPort;
/// Which station of the entry this computer is. It goes into every message
/// and lets a contact say which position made it.
public int StationNumber { get; init; } = 1;
/// The version this program claims to be on the network. N1MM refuses a
/// station whose version is not its own, so to work beside N1MM this has to
/// be the version those copies are running. The default is the version of
/// the N1MM this program was written against.
public string StationLinkVersion { get; init; } = "1.0.11364";
/// Stations named by hand, for a network where a broadcast does not reach
/// every computer. Each is a name and an address — `RUN-PC@192.168.1.5`,
/// with `:port` on the end when that station is not on the usual port.
public IReadOnlyList<string> StationLinkPeers { get; init; } = [];
/// The call history file for this contest, or empty for none. They are
/// published per contest, so this is not a fixed name.
public string CallHistoryFile { get; init; } = "";

View File

@@ -14,7 +14,23 @@
</Grid>
<TextBlock Text="Other stations, one address per line" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="PeersBox" AcceptsReturn="True" Height="90" PlaceholderText="192.168.1.11" />
<CheckBox Name="EnabledBox" Content="Share contacts with the other stations" Margin="0,6,0,0" />
<CheckBox Name="EnabledBox" Content="Broadcast contacts to other programs (port 12060)" Margin="0,6,0,0" />
<TextBlock Text="The other logging computers" FontWeight="Bold" Margin="0,12,0,0" />
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
Text="This is N1MM's own link between the computers of one entry, on port 12070: contacts, edits, deletes and chat, over a connection to each station. Stations are found by broadcast, so nothing has to be listed." />
<Grid ColumnDefinitions="90,8,110,8,*" RowDefinitions="Auto,Auto">
<TextBlock Text="Station number" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="StationNumberBox" Grid.Row="1" />
<TextBlock Grid.Column="2" Text="Port" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="LinkPortBox" Grid.Row="1" Grid.Column="2" />
<TextBlock Grid.Column="4" Text="Version to claim" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="VersionBox" Grid.Row="1" Grid.Column="4" />
</Grid>
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
Text="N1MM turns away a station whose version is not its own, so this has to be the version the N1MM copies beside it are running. Help ▸ About in N1MM says which." />
<TextBlock Text="Stations to reach by address, one per line, as NAME@address" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="LinkPeersBox" AcceptsReturn="True" Height="60" PlaceholderText="RUN-PC@192.168.1.11" />
<CheckBox Name="LinkEnabledBox" Content="Share contacts with the other logging computers (port 12070)" Margin="0,6,0,0" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
<Button Content="Cancel" Click="OnCancel" />
<Button Content="Save" Click="OnSave" IsDefault="True" />

View File

@@ -18,6 +18,11 @@ public sealed partial class NetworkDialog : Window
PortBox.Text = settings.NetworkPort.ToString();
PeersBox.Text = string.Join("\n", settings.NetworkPeers);
EnabledBox.IsChecked = settings.NetworkEnabled;
StationNumberBox.Text = settings.StationNumber.ToString();
LinkPortBox.Text = settings.StationLinkPort.ToString();
VersionBox.Text = settings.StationLinkVersion;
LinkPeersBox.Text = string.Join("\n", settings.StationLinkPeers);
LinkEnabledBox.IsChecked = settings.StationLinkEnabled;
}
@@ -31,7 +36,23 @@ public sealed partial class NetworkDialog : Window
.Where(l => l.Length > 0)
.ToList(),
NetworkEnabled = EnabledBox.IsChecked == true,
StationNumber = int.TryParse(StationNumberBox.Text, out int number)
? Math.Clamp(number, 1, 99)
: settings.StationNumber,
StationLinkPort = int.TryParse(LinkPortBox.Text, out int linkPort)
? linkPort
: settings.StationLinkPort,
StationLinkVersion = (VersionBox.Text ?? "").Trim(),
StationLinkPeers = Lines(LinkPeersBox.Text),
StationLinkEnabled = LinkEnabledBox.IsChecked == true,
});
private static List<string> Lines(string? text) =>
(text ?? "")
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(l => l.Trim())
.Where(l => l.Length > 0)
.ToList();
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}

View File

@@ -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);

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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);
}
}
}