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

@@ -16,6 +16,7 @@ is still sitting there undiscovered.
| `OtrspBox` | a `MemoryStream`, checking the bytes | no real SO2R box. Command forms are from N1MM's `N1MMPort.cs`. | | `OtrspBox` | a `MemoryStream`, checking the bytes | no real SO2R box. Command forms are from N1MM's `N1MMPort.cs`. |
| `ClusterClient` | a node fake over a real socket, sending the telnet negotiation, the login prompt and spot lines | no live cluster node. Which nodes send bare CR, and which send option negotiation, is guessed from N1MM's code. | | `ClusterClient` | a node fake over a real socket, sending the telnet negotiation, the login prompt and spot lines | no live cluster node. Which nodes send bare CR, and which send option negotiation, is guessed from N1MM's code. |
| `StationNetwork` | the message format, round-tripped | no second station, and no N1MM on the same network. | | `StationNetwork` | the message format, round-tripped | no second station, and no N1MM on the same network. |
| `StationLink` | the bytes of every message, and two links talking over loopback | no real N1MM has ever been on the other end. The version check is the thing to try first: N1MM turns away a station whose version is not its own, and this program has to be told what to claim. |
| `CwDaemonSender` | the UDP messages, and a fake daemon that answers the `<ESC>h` reply request | no `cwdaemon`, no radio keyed. | | `CwDaemonSender` | the UDP messages, and a fake daemon that answers the `<ESC>h` reply request | no `cwdaemon`, no radio keyed. |
| `MmttyEngine` and the Wine bridge | MMTTY 1.70 under Wine 10 on a machine with a sound card: started, keyed, a message transmitted and decoded back off the air, and stopped | no radio. No FSK through EXTFSK, no PTT on a serial port, and 2Tone has never been run. Keying was wrong until 2026-09-01: `SetMmttyPTT` stops a transmission and does not start one, which is why nothing ever went out before then. `docs/digital-bridge.md` | | `MmttyEngine` and the Wine bridge | MMTTY 1.70 under Wine 10 on a machine with a sound card: started, keyed, a message transmitted and decoded back off the air, and stopped | no radio. No FSK through EXTFSK, no PTT on a serial port, and 2Tone has never been run. Keying was wrong until 2026-09-01: `SetMmttyPTT` stops a transmission and does not start one, which is why nothing ever went out before then. `docs/digital-bridge.md` |
| `WinkeyerSender` | the status-byte reader, on its own | no test of the serial side, and no WinKeyer. The host-mode open sequence is from the WinKeyer datasheet; the status bits are from N1MM's `Winkey.cs`. | | `WinkeyerSender` | the status-byte reader, on its own | no test of the serial side, and no WinKeyer. The host-mode open sequence is from the WinKeyer datasheet; the status bits are from N1MM's `Winkey.cs`. |
@@ -348,6 +349,55 @@ keyed. Whether MMTTY holds it there with an empty buffer has not been measured:
the probe run that says it does was on a machine with no sound card, where the probe run that says it does was on a machine with no sound card, where
MMTTY does not really transmit. MMTTY does not really transmit.
**The link between the logging computers.** N1MM has two networks and this
program now has both. Port 12060 carries XML to other programs — a spot tool, a
score poster — which is `StationNetwork`. Computer to computer, N1MM uses port
12070 and a different protocol: `StationLink`.
That protocol is a UDP broadcast to find the other computers and a TCP
connection to each of them, opened both ways. A message reads
```
DATA__07%SHACK-PC%QSO%2026-09-03 12:34:56%DL1ABC%…~__DATA
```
which is the sending station's number, its computer name, the message type and
the fields of that type. `%` and `~` cannot appear in a field, so N1MM writes
`!` in their place. What is read and sent: contacts, edits, deletes, resyncs,
`IAM`, the echo pair, chat, the pass frequency, transmit on and off, and where a
station is. The rest of N1MM's forty-odd types — the score and sked windows, the
log check, the spot lists, the serial-number pool — are passed over, which
N1MM's own reader also does for a type it does not know.
**The version has to match.** N1MM compares the version in the beacon with its
own and, when they differ, puts up "Software versions must match. Update N1MM+."
and drops the station. So the version this program broadcasts is a setting —
Config ▸ Edit Networked-Computer Names — and it has to be the version of the
N1MM copies beside it. Help ▸ About in N1MM says which. A station that
broadcasts something else is kept in the list and the network status window
names it, because that is the one fault where everything looks connected and
nothing arrives.
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
what the other operators watch.
**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
itself: only a user-defined contest carried one, so every built-in contest
allowed anything. `BandChangeRules.ForCategory` now gives a multi-operator entry
with one or two transmitters a ten-minute stay, which is N1MM's fallback for
every contest that does not name a number of its own, and a contest that caps
the changes per hour overrides `Contest.BandChangesFor`. N1MM's per-contest table
is not repeated: it is a few hundred cases in a decompiled hash switch, and the
contests this station enters are the ones worth reading out of it one at a time.
What the link does not do yet: N1MM's resync, where a station that has been
away asks another for the contacts it missed, and the log check that compares
two stations' logs row by row. Both are message types this program can already
frame; neither is written. There is also no guard against two stations taking
the same station number.
**Voice keying.** `MessageSender` was written to cover a voice keyer playing a **Voice keying.** `MessageSender` was written to cover a voice keyer playing a
recording, and nothing implements it. Each operator's recordings folder is recording, and nothing implements it. Each operator's recordings folder is
stored with the rest of his settings and waits on the same thing. No DVK support either, so the second radio stored with the rest of his settings and waits on the same thing. No DVK support either, so the second radio

View File

@@ -25,6 +25,12 @@ public sealed class AppSession : IDisposable
private int activeRadio; private int activeRadio;
private ClusterClient? cluster; private ClusterClient? cluster;
private StationNetwork? network; 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 MessageSender? keyer;
private AlternatingCq? alternating; private AlternatingCq? alternating;
private MmttyEngine? digital; private MmttyEngine? digital;
@@ -124,6 +130,10 @@ public sealed class AppSession : IDisposable
public StationNetwork? Network => network; 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; public MessageSender? Keyer => keyer;
/// The digital modem, started by the digital window rather than at startup: /// 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( Logging.Edited += (_, change) => _ = network?.SendEditAsync(
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc); change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(qso, Settings.Station.Callsign); 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); Check = new CheckWindowSources(positions[0], Calls, Bandmap);
Available = new AvailableStations(positions[0], Bandmap); Available = new AvailableStations(positions[0], Bandmap);
Save(Settings with { ContestNumber = contestNumber }); Save(Settings with { ContestNumber = contestNumber });
@@ -234,7 +250,9 @@ public sealed class AppSession : IDisposable
/// multi-operator entry, following what the settings now say. /// multi-operator entry, following what the settings now say.
public void ApplyNetworkSettings() public void ApplyNetworkSettings()
{ {
announcing?.Stop();
network?.Dispose(); network?.Dispose();
link?.Dispose();
network = null; network = null;
if (!Settings.NetworkEnabled) if (!Settings.NetworkEnabled)
{ {
@@ -253,6 +271,87 @@ public sealed class AppSession : IDisposable
Changed?.Invoke(this, EventArgs.Empty); 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 /// 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 /// out to the network, and the contact is scored here from the rules
/// instead of trusting what the sender put in the message. /// instead of trusting what the sender put in the message.
@@ -595,7 +694,9 @@ public sealed class AppSession : IDisposable
spotFlush.Dispose(); spotFlush.Dispose();
DisposeRadios(); DisposeRadios();
cluster?.Dispose(); cluster?.Dispose();
announcing?.Stop();
network?.Dispose(); network?.Dispose();
link?.Dispose();
alternating?.Dispose(); alternating?.Dispose();
keyer?.Dispose(); keyer?.Dispose();
box?.Dispose(); box?.Dispose();

View File

@@ -1,6 +1,7 @@
using System.Reflection; using System.Reflection;
using System.Text.Json; using System.Text.Json;
using Nonemm.Core; using Nonemm.Core;
using Nonemm.Network;
using Nonemm.Session; using Nonemm.Session;
namespace Nonemm.App.Configuration; namespace Nonemm.App.Configuration;
@@ -118,6 +119,29 @@ public sealed record Settings
public IReadOnlyList<string> NetworkPeers { get; init; } = []; 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 /// The call history file for this contest, or empty for none. They are
/// published per contest, so this is not a fixed name. /// published per contest, so this is not a fixed name.
public string CallHistoryFile { get; init; } = ""; public string CallHistoryFile { get; init; } = "";

View File

@@ -14,7 +14,23 @@
</Grid> </Grid>
<TextBlock Text="Other stations, one address per line" FontSize="11" Opacity="0.7" Margin="0,6,0,1" /> <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" /> <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"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
<Button Content="Cancel" Click="OnCancel" /> <Button Content="Cancel" Click="OnCancel" />
<Button Content="Save" Click="OnSave" IsDefault="True" /> <Button Content="Save" Click="OnSave" IsDefault="True" />

View File

@@ -18,6 +18,11 @@ public sealed partial class NetworkDialog : Window
PortBox.Text = settings.NetworkPort.ToString(); PortBox.Text = settings.NetworkPort.ToString();
PeersBox.Text = string.Join("\n", settings.NetworkPeers); PeersBox.Text = string.Join("\n", settings.NetworkPeers);
EnabledBox.IsChecked = settings.NetworkEnabled; 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) .Where(l => l.Length > 0)
.ToList(), .ToList(),
NetworkEnabled = EnabledBox.IsChecked == true, 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); 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 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. /// N1MM's grey line window: where the daylight is now, and where it will be.
private void OnShowGrayline(object? sender, RoutedEventArgs e) => private void OnShowGrayline(object? sender, RoutedEventArgs e) =>
Show(() => new GraylineWindow(session)); Show(() => new GraylineWindow(session));
@@ -518,10 +521,21 @@ public sealed partial class EntryWindow
{ {
session.Save(updated); session.Save(updated);
session.ApplyNetworkSettings(); 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) private async void OnKeyerSettings(object? sender, RoutedEventArgs e)
{ {
KeyerDialog dialog = new(session.Settings); KeyerDialog dialog = new(session.Settings);

View File

@@ -119,6 +119,7 @@
<MenuItem Header="Call Stack" Click="OnShowCallStack" /> <MenuItem Header="Call Stack" Click="OnShowCallStack" />
<MenuItem Header="Check" Click="OnShowCheck" /> <MenuItem Header="Check" Click="OnShowCheck" />
<MenuItem Header="Log" Click="OnShowLog" InputGesture="Ctrl+L" /> <MenuItem Header="Log" Click="OnShowLog" InputGesture="Ctrl+L" />
<MenuItem Header="Network Status" Click="OnShowNetworkStatus" />
<MenuItem Header="Grey Line" Click="OnShowGrayline" /> <MenuItem Header="Grey Line" Click="OnShowGrayline" />
<MenuItem Header="Score Summary" Click="OnShowScore" /> <MenuItem Header="Score Summary" Click="OnShowScore" />
<MenuItem Header="Telnet" Click="OnShowTelnet" /> <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);
}
}
}

View File

@@ -22,6 +22,48 @@ public sealed record BandChangeRules(
/// A contest that does not limit band changes at all. /// A contest that does not limit band changes at all.
public static readonly BandChangeRules None = new(0); public static readonly BandChangeRules None = new(0);
/// How long a multi-operator entry with one transmitter has to stay on a
/// band. It is the ten-minute rule, and it is N1MM's fallback for every
/// contest that does not name a number of its own.
public static readonly TimeSpan MultiOneStay = TimeSpan.FromMinutes(10);
/// What a contest allows an entry in this category, before the contest has
/// its say. These are N1MM's defaults, from
/// `ContestInstance.BandChangeTimerDuration` and `BandChangeCountMax`:
///
/// | Category | Changes counted | Stay |
/// |---|---|---|
/// | single operator | no | none |
/// | multi-operator, one transmitter | no | ten minutes |
/// | multi-operator, two transmitters | no | ten minutes |
/// | multi-operator, more | no | none |
///
/// The count is off by default because N1MM's own fallback for it is zero:
/// the contests that cap band changes per hour name the cap themselves, and
/// a contest class that does so overrides `Contest.BandChangesFor`. The
/// stay is the other way round — ten minutes is the fallback, and a contest
/// with no rule of its own gets it.
///
/// A station with a transmitter per band changes band by moving to another
/// radio, so nothing is counted for it.
public static BandChangeRules ForCategory(ContestEntry entry)
{
if (!entry.OperatorCategory.StartsWith("MULTI", StringComparison.OrdinalIgnoreCase))
{
return None;
}
// N1MM reads the number of transmitters off the operator category on
// Cabrillo 2.0 and off the transmitter category after it. Both are
// checked, so an entry written either way is read
bool one = Is(entry, "MULTI-ONE", "ONE");
bool two = Is(entry, "MULTI-TWO", "TWO");
return one || two ? None with { MinimumStay = MultiOneStay } : None;
}
private static bool Is(ContestEntry entry, string operatorCategory, string transmitterCategory) =>
entry.OperatorCategory.Equals(operatorCategory, StringComparison.OrdinalIgnoreCase)
|| entry.TransmitterCategory.Equals(transmitterCategory, StringComparison.OrdinalIgnoreCase);
public bool IsCounted => Max > 0; public bool IsCounted => Max > 0;
/// True for a contest that says how long a station has to stay on a band. /// True for a contest that says how long a station has to stay on a band.

View File

@@ -77,7 +77,12 @@ public interface Contest
/// How many band changes the entry may make, and over what stretch of time /// How many band changes the entry may make, and over what stretch of time
/// they are counted. Most contests do not limit them. /// they are counted. Most contests do not limit them.
BandChangeRules BandChangesFor(ContestEntry entry) => BandChangeRules.None; /// What this contest allows in the way of band changes. The default is
/// what the entry's category alone says, which is the ten-minute rule for a
/// multi-operator entry with one or two transmitters and nothing for a
/// single operator. A contest that caps the changes per hour, or asks for a
/// different stay, says so here.
BandChangeRules BandChangesFor(ContestEntry entry) => BandChangeRules.ForCategory(entry);
/// How long a gap between contacts has to be before it counts as time off. /// How long a gap between contacts has to be before it counts as time off.
/// N1MM asks each contest and takes 30 minutes when it says nothing. /// N1MM asks each contest and takes 30 minutes when it says nothing.

View File

@@ -14,6 +14,58 @@ public class BandChangeRulesTests
TimestampUtc = Start.AddMinutes(minutes).AddSeconds(seconds), TimestampUtc = Start.AddMinutes(minutes).AddSeconds(seconds),
}; };
/// N1MM's fallback, which is the ten-minute rule: a multi-operator entry
/// with one transmitter has to stay ten minutes on a band, and nothing caps
/// how many times it may move.
[Theory]
[InlineData("MULTI-ONE", "ONE", 10)]
[InlineData("MULTI-OP", "ONE", 10)]
[InlineData("MULTI-TWO", "TWO", 10)]
[InlineData("MULTI-OP", "TWO", 10)]
[InlineData("MULTI-OP", "UNLIMITED", 0)]
[InlineData("SINGLE-OP", "ONE", 0)]
public void TheCategoryAloneSaysHowLongAStationStaysOnABand(
string operatorCategory,
string transmitters,
int minutes)
{
BandChangeRules rules = BandChangeRules.ForCategory(new ContestEntry
{
OperatorCategory = operatorCategory,
TransmitterCategory = transmitters,
});
Assert.Equal(TimeSpan.FromMinutes(minutes), rules.MinimumStay);
Assert.False(rules.IsCounted);
}
/// A contest that says nothing about band changes still gets the rule for
/// the category, which is what N1MM does.
[Fact]
public void AContestThatSaysNothingStillGetsTheTenMinuteRule()
{
Contest contest = new Rules.CqWorldWide(ModeCategory.Cw);
BandChangeRules rules = contest.BandChangesFor(new ContestEntry
{
OperatorCategory = "MULTI-ONE",
TransmitterCategory = "ONE",
});
Assert.True(rules.HasStayTimer);
Assert.Equal(TimeSpan.FromMinutes(10), rules.MinimumStay);
}
[Fact]
public void ASingleOperatorEntryHasNoBandChangeRule()
{
Contest contest = new Rules.CqWorldWide(ModeCategory.Cw);
Assert.Equal(
BandChangeRules.None,
contest.BandChangesFor(new ContestEntry { OperatorCategory = "SINGLE-OP" }));
}
[Fact] [Fact]
public void EachMoveToAnotherBandIsOneChange() => public void EachMoveToAnotherBandIsOneChange() =>
Assert.Equal( Assert.Equal(