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:
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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; } = "";
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
23
src/Nonemm.App/Windows/NetworkStatusWindow.axaml
Normal file
23
src/Nonemm.App/Windows/NetworkStatusWindow.axaml
Normal 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>
|
||||||
236
src/Nonemm.App/Windows/NetworkStatusWindow.axaml.cs
Normal file
236
src/Nonemm.App/Windows/NetworkStatusWindow.axaml.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user