Fill in the rest of N1MM's network status window
Seven tabs now, in N1MM's order, and the messages behind them. **Options** holds what there is something behind: Log QSOs at all Stations, which decides whether the contacts the others log are kept here; the message trace, moved off the Messages tab where N1MM does not keep it; and Run1/Run2, which used to be guessed from the radio number and is now the operator's choice, sent in every `STATUS` and every `XMIT`. The transmit interlocks stay out: this program does not block a transmitter, and a station that claimed it did would be waited on for nothing. **Call Stack Targets.** A callsign heard at one station and handed to the position that will work it — `STACKANOTHERCALL` with the call alone, `STACKCALL` with the contact behind it. The target is picked from the tab or from the station list, and what arrives goes on the call stack of the radio the operator is on, sorted by whether it is a multiplier here. **The master** is the station the entry follows for cluster spots and the time. `MASTER` names it to everybody, so the column agrees everywhere. **The clock.** `TIME` carries a station's clock and Force Time Sync sends it. N1MM sets the clock from it when it is running as an administrator and says how far out it is when it is not; this program always does the second, because setting the clock needs root and a contest logger has no business asking for it. Anything over two seconds is called out. **Serial numbers.** Reset Serial Numbers at all Stations sends `RESETQSONRS` and every station starts again at one. The contacts already logged keep the numbers they were sent with. **What the entry agrees on** is now checked rather than assumed: | What | How | |---|---| | the contest | `CONTESTNAME` as soon as a connection opens, and again in every `CHECKSUM` | | the country file | its issue date — the publisher's `=VER20260826` — rides in `STATUS` | | the computer name | longer than N1MM's fifteen characters is named at startup; a second computer answering to this one's name is named when its beacon arrives | | the station number | two stations on the same number are named when the second says so | `CountryFile` reads its issue date, which nothing had asked it for before. Left for later: the master relaying its cluster spots, and the shared serial-number pool where stations claim numbers in advance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
This commit is contained in:
+46
-5
@@ -396,16 +396,57 @@ burst of three does not read as thousands an hour. The five transmit-interlock
|
|||||||
flags in the message are sent as false: this program does not block a
|
flags in the message are sent as false: this program does not block a
|
||||||
transmitter.
|
transmitter.
|
||||||
|
|
||||||
**The network status window** has N1MM's Stations, Actions, Resynchronize,
|
**The network status window** has all seven of N1MM's tabs — Stations,
|
||||||
Rescore and Messages tabs and
|
Resynchronize, Rescore, Options, Call Stack Targets, Actions and Messages — and
|
||||||
its columns — Computer, IP Address, Pass, Run, 10, 100, Freq, Op, Msg, Send,
|
its columns — Computer, IP Address, Pass, Run, 10, 100, Freq, Op, Msg, Send,
|
||||||
Receive, Band, R/M, Mode. Right-click a row to pass the contact being worked to
|
Receive, Band, R/M, Mode. Right-click a row to pass the contact being worked to
|
||||||
that station, to talk, or to close and open its connection; a connection closed
|
that station, to talk, or to close and open its connection; a connection closed
|
||||||
by hand stays closed until it is opened again, because the next beacon would
|
by hand stays closed until it is opened again, because the next beacon would
|
||||||
otherwise open it within ten seconds. The Resynchronize and Rescore tabs work
|
otherwise open it within ten seconds. The Resynchronize and Rescore tabs work
|
||||||
on a period — the contest, since a time, or the last few hours. Left out: the
|
on a period — the contest, since a time, or the last few hours. Options holds
|
||||||
transmit interlocks of N1MM's Options tab, the Master column and the
|
what there is something behind: Log QSOs at all Stations, the message trace, and
|
||||||
call-stacking target tab. There is nothing behind any of them here.
|
Run1/Run2. Left out are the transmit interlocks — this program does not block a
|
||||||
|
transmitter, and a station that claimed it did would be waited on for nothing —
|
||||||
|
and the two telnet options, which wait on the master relaying cluster spots.
|
||||||
|
|
||||||
|
**What the entry agrees on.** Four things have to match across the stations of
|
||||||
|
an entry, and each is now checked rather than assumed:
|
||||||
|
|
||||||
|
| What | How it is checked |
|
||||||
|
|---|---|
|
||||||
|
| the program version | the beacon, which N1MM refuses to talk past |
|
||||||
|
| the contest | `CONTESTNAME` as soon as a connection opens, and again in every `CHECKSUM` |
|
||||||
|
| the callsign | `CHECKSUM` |
|
||||||
|
| the country file | its issue date rides in `STATUS`; two issues score the same contact differently |
|
||||||
|
|
||||||
|
A computer name longer than the fifteen characters N1MM allows is named at
|
||||||
|
startup, a second computer answering to this one's name is named when its
|
||||||
|
beacon arrives, and two stations that have taken the same station number are
|
||||||
|
named when the second one says so.
|
||||||
|
|
||||||
|
**The clock.** `TIME` carries a station's clock and Force Time Sync sends it.
|
||||||
|
N1MM sets the clock from it when it is running as an administrator and says how
|
||||||
|
far out it is when it is not; this program always does the second, because
|
||||||
|
setting the clock needs root and a contest logger has no business asking for it.
|
||||||
|
Anything over two seconds is called out.
|
||||||
|
|
||||||
|
**The master** is the station the entry follows for cluster spots and the time,
|
||||||
|
set from the station list and told to everybody, so the `Master` column agrees
|
||||||
|
everywhere. What it does not do yet is relay: N1MM's master passes its cluster
|
||||||
|
spots to the others over the link, and `ADDSPOT` and `PACKET` are not written.
|
||||||
|
|
||||||
|
**Call stacking.** A callsign heard at one station and handed to the position
|
||||||
|
that will work it: `STACKANOTHERCALL` with the call alone, `STACKCALL` with the
|
||||||
|
contact behind it. The target is picked from the Call Stack Targets tab or from
|
||||||
|
the station list, and what arrives goes on the call stack of the radio the
|
||||||
|
operator is on, sorted by whether it is a multiplier here.
|
||||||
|
|
||||||
|
**Serial numbers.** Reset Serial Numbers at all Stations sends `RESETQSONRS`
|
||||||
|
and every station starts again at one. The contacts already logged keep the
|
||||||
|
numbers they were sent with; the reset lasts as long as the program is open,
|
||||||
|
the way N1MM's does. The shared number pool — `RESERVENR`, `QSONRS`, `REJECTNR`,
|
||||||
|
where stations claim numbers in advance so two positions cannot send the same
|
||||||
|
one — is not written.
|
||||||
|
|
||||||
**The ten-minute rule.** `BandChangeRules` counts band changes and the stay on a
|
**The ten-minute rule.** `BandChangeRules` counts band changes and the stay on a
|
||||||
band, and the entry window shows the countdown. What was missing was the rule
|
band, and the entry window shows the countdown. What was missing was the rule
|
||||||
|
|||||||
@@ -231,6 +231,9 @@ public sealed class AppSession : IDisposable
|
|||||||
positions.Add(new OperatingPosition(Logging, number));
|
positions.Add(new OperatingPosition(Logging, number));
|
||||||
}
|
}
|
||||||
activeRadio = Math.Min(activeRadio, positions.Count - 1);
|
activeRadio = Math.Min(activeRadio, positions.Count - 1);
|
||||||
|
// the other stations refuse to resynchronise with a station on another
|
||||||
|
// contest, and say so, which is only any use if they are told
|
||||||
|
_ = link?.SendContestNameAsync();
|
||||||
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
||||||
Logging.Logged += (_, qso) =>
|
Logging.Logged += (_, qso) =>
|
||||||
{
|
{
|
||||||
@@ -319,6 +322,10 @@ public sealed class AppSession : IDisposable
|
|||||||
link.Log = new SessionContactLog(this);
|
link.Log = new SessionContactLog(this);
|
||||||
link.RescoreRequested += (_, _) =>
|
link.RescoreRequested += (_, _) =>
|
||||||
Dispatcher.UIThread.Post(() => Rescore(DateTime.MinValue));
|
Dispatcher.UIThread.Post(() => Rescore(DateTime.MinValue));
|
||||||
|
link.SerialNumbersReset += (_, _) =>
|
||||||
|
Dispatcher.UIThread.Post(ResetSerialNumbers);
|
||||||
|
link.CallStacked += (_, stacked) =>
|
||||||
|
Dispatcher.UIThread.Post(() => TakeStackedCall(stacked));
|
||||||
foreach (string peer in Settings.StationLinkPeers)
|
foreach (string peer in Settings.StationLinkPeers)
|
||||||
{
|
{
|
||||||
if (Peer(peer) is var (name, address, port))
|
if (Peer(peer) is var (name, address, port))
|
||||||
@@ -372,6 +379,62 @@ public sealed class AppSession : IDisposable
|
|||||||
statusSent = default;
|
statusSent = default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True while this station is the second run radio of a two-radio
|
||||||
|
/// position, which is N1MM's Run1/Run2 switch on the network status window.
|
||||||
|
/// It goes out in every `STATUS` and is what the other stations read in
|
||||||
|
/// their `R/M` column.
|
||||||
|
public bool IsRun2
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
field = value;
|
||||||
|
statusSent = default;
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the serial numbers again at one. The contacts already logged keep
|
||||||
|
/// the numbers they were sent with; it is the next number that changes.
|
||||||
|
public void ResetSerialNumbers()
|
||||||
|
{
|
||||||
|
if (Logging is not null)
|
||||||
|
{
|
||||||
|
Logging.SerialNumbersFrom = DateTime.UtcNow;
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A callsign another station handed to this one. It goes on the call stack
|
||||||
|
/// of the radio the operator is on, which is where the entry window takes
|
||||||
|
/// them from.
|
||||||
|
private void TakeStackedCall(StackedCall stacked)
|
||||||
|
{
|
||||||
|
if (Position is not { } position || stacked.Call.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
position.Stack.Add(stacked.Call, IsNewMultiplier(stacked));
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a stacked call is worth a multiplier here, which is what decides
|
||||||
|
/// where it goes in the stack. A call with no contact behind it is judged
|
||||||
|
/// on the callsign alone.
|
||||||
|
private bool IsNewMultiplier(StackedCall stacked) =>
|
||||||
|
Logging is not null
|
||||||
|
&& (stacked.Contact is { } contact
|
||||||
|
? Logging.Log.Judge(contact).NewMultipliers.Count > 0
|
||||||
|
: Logging.Log.Judge(new Qso
|
||||||
|
{
|
||||||
|
Id = "",
|
||||||
|
TimestampUtc = DateTime.UtcNow,
|
||||||
|
Call = Callsign.Parse(stacked.Call),
|
||||||
|
Frequency = Position?.Frequency ?? Frequency.Zero,
|
||||||
|
Mode = Position?.Mode ?? Modes.Cw,
|
||||||
|
ContestName = Logging.Contest.Name,
|
||||||
|
}).NewMultipliers.Count > 0);
|
||||||
|
|
||||||
/// Scores the log again and writes the points and multiplier flags from
|
/// Scores the log again and writes the points and multiplier flags from
|
||||||
/// `fromUtc` on back to the database. Returns how many rows were written.
|
/// `fromUtc` on back to the database. Returns how many rows were written.
|
||||||
/// Another station asks for this at the end of a resync, and the rescore
|
/// Another station asks for this at the end of a resync, and the rescore
|
||||||
@@ -402,9 +465,8 @@ public sealed class AppSession : IDisposable
|
|||||||
Rates.PerHour(qsos, 100, DateTime.UtcNow, off),
|
Rates.PerHour(qsos, 100, DateTime.UtcNow, off),
|
||||||
Logging?.Instance.OperatorCategory ?? "",
|
Logging?.Instance.OperatorCategory ?? "",
|
||||||
Logging?.Instance.TransmitterCategory ?? "",
|
Logging?.Instance.TransmitterCategory ?? "",
|
||||||
// the second radio of a two-radio position is N1MM's Run2, which is
|
Countries?.Version ?? "",
|
||||||
// what its R/M column reads
|
IsRun2));
|
||||||
position.RadioNumber == 2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A station named by hand, written `RUN-PC@192.168.1.5` with `:port` on
|
/// A station named by hand, written `RUN-PC@192.168.1.5` with `:port` on
|
||||||
@@ -433,6 +495,10 @@ public sealed class AppSession : IDisposable
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!Settings.LogQsosAtAllStations && update is not ContactsCleared)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
switch (update)
|
switch (update)
|
||||||
{
|
{
|
||||||
case ContactLogged logged:
|
case ContactLogged logged:
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ public sealed record Settings
|
|||||||
/// with `:port` on the end when that station is not on the usual port.
|
/// with `:port` on the end when that station is not on the usual port.
|
||||||
public IReadOnlyList<string> StationLinkPeers { get; init; } = [];
|
public IReadOnlyList<string> StationLinkPeers { get; init; } = [];
|
||||||
|
|
||||||
|
/// Whether the contacts the other stations log are kept here. N1MM calls it
|
||||||
|
/// Log QSOs at all Stations and every station of an entry normally has it
|
||||||
|
/// on: a log per station is a log the sponsor has to be sent in pieces.
|
||||||
|
public bool LogQsosAtAllStations { get; init; } = true;
|
||||||
|
|
||||||
/// 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; } = "";
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ internal sealed class SessionContactLog : ContactLog
|
|||||||
|
|
||||||
public string MyCall => Ask(() => session.Settings.Station.Callsign);
|
public string MyCall => Ask(() => session.Settings.Station.Callsign);
|
||||||
|
|
||||||
|
public string ContestSubType => Ask(() => session.Logging?.Instance.SubType ?? "");
|
||||||
|
|
||||||
|
public string CountryFileVersion => Ask(() => session.Countries?.Version ?? "");
|
||||||
|
|
||||||
public IReadOnlyList<Qso> Contacts(string computerName, DateTime fromUtc, TimeSpan length) =>
|
public IReadOnlyList<Qso> Contacts(string computerName, DateTime fromUtc, TimeSpan length) =>
|
||||||
Ask(() => session.Logging is not { } logging
|
Ask(() => session.Logging is not { } logging
|
||||||
? []
|
? []
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:local="using:Nonemm.App.Windows"
|
xmlns:local="using:Nonemm.App.Windows"
|
||||||
x:Class="Nonemm.App.Windows.NetworkStatusWindow"
|
x:Class="Nonemm.App.Windows.NetworkStatusWindow"
|
||||||
Title="Network Status" Width="900" Height="360">
|
Title="Network Status" Width="1000" Height="380">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<TextBlock DockPanel.Dock="Bottom" Name="StateText" Margin="8,4" FontSize="11"
|
<TextBlock DockPanel.Dock="Bottom" Name="StateText" Margin="8,4" FontSize="11"
|
||||||
Opacity="0.75" TextWrapping="Wrap" />
|
Opacity="0.75" TextWrapping="Wrap" />
|
||||||
<TabControl Name="Tabs">
|
<TabControl Name="Tabs">
|
||||||
|
|
||||||
<TabItem Header="Stations">
|
<TabItem Header="Stations" Name="StationsTab">
|
||||||
<DataGrid Name="Rows" IsReadOnly="True" GridLinesVisibility="Horizontal"
|
<DataGrid Name="Rows" IsReadOnly="True" GridLinesVisibility="Horizontal"
|
||||||
CanUserSortColumns="False" FontSize="12" FontFamily="monospace"
|
CanUserSortColumns="False" FontSize="12" FontFamily="monospace"
|
||||||
AutoGenerateColumns="False" x:DataType="local:StationRow">
|
AutoGenerateColumns="False" x:DataType="local:StationRow">
|
||||||
@@ -25,9 +25,11 @@
|
|||||||
<Separator />
|
<Separator />
|
||||||
<MenuItem Header="Talk" Click="OnTalkTo" />
|
<MenuItem Header="Talk" Click="OnTalkTo" />
|
||||||
<MenuItem Header="Show Connection Log" Click="OnShowMessages" />
|
<MenuItem Header="Show Connection Log" Click="OnShowMessages" />
|
||||||
|
<MenuItem Header="Target for Call Stacking" Click="OnStackTarget" />
|
||||||
<Separator />
|
<Separator />
|
||||||
<MenuItem Header="Close Connection" Click="OnCloseConnection" />
|
<MenuItem Header="Close Connection" Click="OnCloseConnection" />
|
||||||
<MenuItem Header="Open Connection" Click="OnOpenConnection" />
|
<MenuItem Header="Open Connection" Click="OnOpenConnection" />
|
||||||
|
<MenuItem Header="Set station as master for telnet and time sync" Click="OnSetMaster" />
|
||||||
<Separator />
|
<Separator />
|
||||||
<MenuItem Header="Edit Computer Addresses" Click="OnSetup" />
|
<MenuItem Header="Edit Computer Addresses" Click="OnSetup" />
|
||||||
<MenuItem Header="Stop Networking" Click="OnStopNetworking" />
|
<MenuItem Header="Stop Networking" Click="OnStopNetworking" />
|
||||||
@@ -45,6 +47,7 @@
|
|||||||
<DataGridTextColumn Header="Msg" Binding="{Binding Msg}" />
|
<DataGridTextColumn Header="Msg" Binding="{Binding Msg}" />
|
||||||
<DataGridTextColumn Header="Send" Binding="{Binding Send}" />
|
<DataGridTextColumn Header="Send" Binding="{Binding Send}" />
|
||||||
<DataGridTextColumn Header="Receive" Binding="{Binding Receive}" />
|
<DataGridTextColumn Header="Receive" Binding="{Binding Receive}" />
|
||||||
|
<DataGridTextColumn Header="Master" Binding="{Binding Master}" />
|
||||||
<DataGridTextColumn Header="Band" Binding="{Binding Band}" />
|
<DataGridTextColumn Header="Band" Binding="{Binding Band}" />
|
||||||
<DataGridTextColumn Header="R/M" Binding="{Binding RunMult}" />
|
<DataGridTextColumn Header="R/M" Binding="{Binding RunMult}" />
|
||||||
<DataGridTextColumn Header="Mode" Binding="{Binding Mode}" />
|
<DataGridTextColumn Header="Mode" Binding="{Binding Mode}" />
|
||||||
@@ -52,24 +55,7 @@
|
|||||||
</DataGrid>
|
</DataGrid>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<TabItem Header="Actions">
|
<TabItem Header="Resynchronize" Name="ResyncTab">
|
||||||
<StackPanel Margin="8" Spacing="8">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
||||||
<TextBox Name="TalkBox" Width="460" PlaceholderText="a line to the other operators" />
|
|
||||||
<Button Content="Talk" Click="OnTalk" IsDefault="True" />
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
||||||
<Button Content="Set Your Pass Frequency" Click="OnSetPass" />
|
|
||||||
<Button Content="Ask Every Station If It Is There" Click="OnEcho" />
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
||||||
<Button Content="Edit Computer Addresses" Click="OnSetup" />
|
|
||||||
<Button Name="NetworkingButton" Content="Stop Networking" Click="OnStopNetworking" />
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
</TabItem>
|
|
||||||
|
|
||||||
<TabItem Header="Resynchronize">
|
|
||||||
<DockPanel Margin="8">
|
<DockPanel Margin="8">
|
||||||
<StackPanel DockPanel.Dock="Top" Spacing="6" Margin="0,0,0,8">
|
<StackPanel DockPanel.Dock="Top" Spacing="6" Margin="0,0,0,8">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||||
@@ -101,7 +87,7 @@
|
|||||||
</DockPanel>
|
</DockPanel>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<TabItem Header="Rescore">
|
<TabItem Header="Rescore" Name="RescoreTab">
|
||||||
<StackPanel Margin="8" Spacing="8">
|
<StackPanel Margin="8" Spacing="8">
|
||||||
<TextBlock Text="Rescore Log, No Log Resync" FontWeight="Bold" />
|
<TextBlock Text="Rescore Log, No Log Resync" FontWeight="Bold" />
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
@@ -116,10 +102,57 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<TabItem Header="Messages">
|
<TabItem Header="Options" Name="OptionsTab">
|
||||||
|
<StackPanel Margin="8" Spacing="8">
|
||||||
|
<CheckBox Name="LogEverywhereBox" Content="Log QSOs at all Stations"
|
||||||
|
IsCheckedChanged="OnLogEverywhereChanged" />
|
||||||
|
<CheckBox Name="TraceBox" Content="Trace Networked Computer Messages" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||||
|
<RadioButton Name="Run1Button" GroupName="RunPosition" Content="Run1"
|
||||||
|
IsCheckedChanged="OnRunPositionChanged" />
|
||||||
|
<RadioButton Name="Run2Button" GroupName="RunPosition" Content="Run2/Mult"
|
||||||
|
IsCheckedChanged="OnRunPositionChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||||
|
Text="N1MM's transmit interlocks are not here: this program does not block a transmitter, so a station that claims it does would be waited on for nothing." />
|
||||||
|
</StackPanel>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<TabItem Header="Call Stack Targets" Name="StackTargetTab">
|
||||||
|
<StackPanel Margin="8" Spacing="8">
|
||||||
|
<TextBlock Text="Destination Station" FontWeight="Bold" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<ComboBox Name="StackTargetBox" Width="220" SelectionChanged="OnStackTargetPicked" />
|
||||||
|
<Button Content="Send the Call Being Worked" Click="OnSendStackedCall" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Name="StackText" TextWrapping="Wrap" FontSize="11" Opacity="0.75" />
|
||||||
|
</StackPanel>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<TabItem Header="Actions" Name="ActionsTab">
|
||||||
|
<StackPanel Margin="8" Spacing="8">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<TextBox Name="TalkBox" Width="460" PlaceholderText="a line to the other operators" />
|
||||||
|
<Button Content="Talk" Click="OnTalk" IsDefault="True" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<Button Content="Set Your Pass Frequency" Click="OnSetPass" />
|
||||||
|
<Button Content="Ask Every Station If It Is There" Click="OnEcho" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<Button Content="Force Time Sync" Click="OnForceTimeSync" />
|
||||||
|
<Button Content="Reset Serial Numbers at all Stations" Click="OnResetSerialNumbers" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<Button Content="Edit Computer Addresses" Click="OnSetup" />
|
||||||
|
<Button Name="NetworkingButton" Content="Stop Networking" Click="OnStopNetworking" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<TabItem Header="Messages" Name="MessagesTab">
|
||||||
<DockPanel Margin="8">
|
<DockPanel Margin="8">
|
||||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,6">
|
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,6">
|
||||||
<CheckBox Name="TraceBox" Content="Trace networked computer messages" />
|
|
||||||
<Button Content="Clear Messages" Click="OnClearMessages" />
|
<Button Content="Clear Messages" Click="OnClearMessages" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<ScrollViewer Name="MessageScroll">
|
<ScrollViewer Name="MessageScroll">
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
|||||||
.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||||
ResyncSinceBox.Text = start;
|
ResyncSinceBox.Text = start;
|
||||||
RescoreSinceBox.Text = start;
|
RescoreSinceBox.Text = start;
|
||||||
|
LogEverywhereBox.IsChecked = session.Settings.LogQsosAtAllStations;
|
||||||
|
Run1Button.IsChecked = !session.IsRun2;
|
||||||
|
Run2Button.IsChecked = session.IsRun2;
|
||||||
Attach();
|
Attach();
|
||||||
// the Receive column is a countdown, so it has to redraw with nothing
|
// the Receive column is a countdown, so it has to redraw with nothing
|
||||||
// arriving
|
// arriving
|
||||||
@@ -91,6 +94,7 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
|||||||
Rows.SelectedItem = rows.FirstOrDefault(r =>
|
Rows.SelectedItem = rows.FirstOrDefault(r =>
|
||||||
string.Equals(r.Station.ComputerName, selected, StringComparison.OrdinalIgnoreCase));
|
string.Equals(r.Station.ComputerName, selected, StringComparison.OrdinalIgnoreCase));
|
||||||
ResyncRows.ItemsSource = rows.Where(r => !r.Station.IsMine).ToList();
|
ResyncRows.ItemsSource = rows.Where(r => !r.Station.IsMine).ToList();
|
||||||
|
ShowStackTargets(rows);
|
||||||
RescoreWhenResyncDone();
|
RescoreWhenResyncDone();
|
||||||
ReportEchoes();
|
ReportEchoes();
|
||||||
StateText.Text = State();
|
StateText.Text = State();
|
||||||
@@ -268,7 +272,7 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
|||||||
rescoreAfterResync = rescore ? fromUtc : null;
|
rescoreAfterResync = rescore ? fromUtc : null;
|
||||||
link.StartResyncAll(fromUtc, DateTime.UtcNow, everyStation, rescore);
|
link.StartResyncAll(fromUtc, DateTime.UtcNow, everyStation, rescore);
|
||||||
Say($"resync from {fromUtc:yyyy-MM-dd HH:mm}Z");
|
Say($"resync from {fromUtc:yyyy-MM-dd HH:mm}Z");
|
||||||
Tabs.SelectedIndex = 2;
|
Tabs.SelectedItem = ResyncTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnRescoreContest(object? sender, RoutedEventArgs e) => Rescore(DateTime.MinValue);
|
private void OnRescoreContest(object? sender, RoutedEventArgs e) => Rescore(DateTime.MinValue);
|
||||||
@@ -292,6 +296,117 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
|||||||
Say($"rescored {written} contacts");
|
Say($"rescored {written} contacts");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The stations a call can be handed to, and which one it goes to now.
|
||||||
|
/// Picking one here and picking one from the Stations tab are the same
|
||||||
|
/// thing, so the box follows the station that is marked.
|
||||||
|
private void ShowStackTargets(IReadOnlyList<StationRow> rows)
|
||||||
|
{
|
||||||
|
List<string> names = rows.Where(r => !r.Station.IsMine).Select(r => r.Station.ComputerName).ToList();
|
||||||
|
if (!names.SequenceEqual(StackTargetBox.Items.Cast<string>()))
|
||||||
|
{
|
||||||
|
StackTargetBox.ItemsSource = names;
|
||||||
|
}
|
||||||
|
StackTargetBox.SelectedItem = rows
|
||||||
|
.FirstOrDefault(r => r.Station.IsCallStackTarget)?.Station.ComputerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnStackTargetPicked(object? sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (link is null || StackTargetBox.SelectedItem is not string name)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
link.SetCallStackTarget(link.Stations.FirstOrDefault(s =>
|
||||||
|
string.Equals(s.ComputerName, name, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the station picked in the Stations tab as the one stacked calls
|
||||||
|
/// go to.
|
||||||
|
private void OnStackTarget(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (link is not null && Chosen() is { } station)
|
||||||
|
{
|
||||||
|
link.SetCallStackTarget(station);
|
||||||
|
Say($"stacked calls now go to {station.ComputerName}");
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hands the callsign in the entry window to the target station's call
|
||||||
|
/// stack. It is what an operator does with a station he has heard and is
|
||||||
|
/// not going to work himself.
|
||||||
|
private async void OnSendStackedCall(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (link is null || session.Position is not { } position)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NetworkedStation? target = link.Stations.FirstOrDefault(s => s.IsCallStackTarget);
|
||||||
|
string call = position.Entry.Call.Trim();
|
||||||
|
if (target is null || call.Length == 0)
|
||||||
|
{
|
||||||
|
StackText.Text = target is null
|
||||||
|
? "no station is the target — pick one"
|
||||||
|
: "nothing is typed in the callsign box";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StackText.Text = await link.SendStackedAsync(target, call)
|
||||||
|
? $"sent {call} to {target.ComputerName}"
|
||||||
|
: $"{target.ComputerName} is not connected";
|
||||||
|
Say(StackText.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSetMaster(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (link is not null && Chosen() is { } station)
|
||||||
|
{
|
||||||
|
_ = link.SendMasterAsync(station);
|
||||||
|
Say($"{station.ComputerName} is the master for the cluster and the time");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends this station's clock to the others. N1MM sets the clock from it
|
||||||
|
/// when it is running as an administrator; this program says how far apart
|
||||||
|
/// the two clocks are and leaves them alone, which is what N1MM does the
|
||||||
|
/// rest of the time.
|
||||||
|
private async void OnForceTimeSync(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (link is not null)
|
||||||
|
{
|
||||||
|
await link.SendTimeAsync();
|
||||||
|
Say("sent this station's clock to the others");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the serial numbers again at one, here and at every station.
|
||||||
|
private async void OnResetSerialNumbers(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (link is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.ResetSerialNumbers();
|
||||||
|
await link.SendResetSerialNumbersAsync();
|
||||||
|
Say("serial numbers start again at one");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLogEverywhereChanged(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
bool wanted = LogEverywhereBox.IsChecked == true;
|
||||||
|
if (wanted != session.Settings.LogQsosAtAllStations)
|
||||||
|
{
|
||||||
|
session.Save(session.Settings with { LogQsosAtAllStations = wanted });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRunPositionChanged(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (Run2Button.IsChecked == true != session.IsRun2)
|
||||||
|
{
|
||||||
|
session.IsRun2 = Run2Button.IsChecked == true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The station the operator right-clicked, or null when the row is this
|
/// The station the operator right-clicked, or null when the row is this
|
||||||
/// computer's own: nothing in the menu is worth doing to yourself.
|
/// computer's own: nothing in the menu is worth doing to yourself.
|
||||||
private NetworkedStation? Chosen() =>
|
private NetworkedStation? Chosen() =>
|
||||||
@@ -313,11 +428,11 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
|||||||
|
|
||||||
private void OnTalkTo(object? sender, RoutedEventArgs e)
|
private void OnTalkTo(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
Tabs.SelectedIndex = 1;
|
Tabs.SelectedItem = ActionsTab;
|
||||||
TalkBox.Focus();
|
TalkBox.Focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnShowMessages(object? sender, RoutedEventArgs e) => Tabs.SelectedIndex = 4;
|
private void OnShowMessages(object? sender, RoutedEventArgs e) => Tabs.SelectedItem = MessagesTab;
|
||||||
|
|
||||||
private async void OnEcho(object? sender, RoutedEventArgs e)
|
private async void OnEcho(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ public sealed class StationRow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The station the whole entry follows for cluster spots and the time.
|
||||||
|
public string Master => station.IsMaster ? "yes" : "";
|
||||||
|
|
||||||
public string Band => station.Band?.Name ?? "";
|
public string Band => station.Band?.Name ?? "";
|
||||||
|
|
||||||
/// Run1 or Run2, which N1MM heads `R/M`.
|
/// Run1 or Run2, which N1MM heads `R/M`.
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ public sealed class CountryFile
|
|||||||
private CountryFile(
|
private CountryFile(
|
||||||
IReadOnlyList<DxccEntity> entities,
|
IReadOnlyList<DxccEntity> entities,
|
||||||
Dictionary<string, PrefixRule> exactCalls,
|
Dictionary<string, PrefixRule> exactCalls,
|
||||||
Dictionary<string, PrefixRule> prefixes)
|
Dictionary<string, PrefixRule> prefixes,
|
||||||
|
string version)
|
||||||
{
|
{
|
||||||
Entities = entities;
|
Entities = entities;
|
||||||
|
Version = version;
|
||||||
this.exactCalls = exactCalls;
|
this.exactCalls = exactCalls;
|
||||||
this.prefixes = prefixes;
|
this.prefixes = prefixes;
|
||||||
longestPrefix = prefixes.Count == 0 ? 0 : prefixes.Keys.Max(k => k.Length);
|
longestPrefix = prefixes.Count == 0 ? 0 : prefixes.Keys.Max(k => k.Length);
|
||||||
@@ -23,6 +25,12 @@ public sealed class CountryFile
|
|||||||
|
|
||||||
public IReadOnlyList<DxccEntity> Entities { get; }
|
public IReadOnlyList<DxccEntity> Entities { get; }
|
||||||
|
|
||||||
|
/// Which issue of the file this is, written as a date. The publisher puts
|
||||||
|
/// it in as a callsign of its own — `=VER20260826` — and the stations of a
|
||||||
|
/// multi-operator entry compare it, because two stations on different
|
||||||
|
/// issues score the same contact differently.
|
||||||
|
public string Version { get; } = "";
|
||||||
|
|
||||||
/// Throws `FormatException` when the text is not a country file, so a
|
/// Throws `FormatException` when the text is not a country file, so a
|
||||||
/// download that returned an error page cannot replace a good file.
|
/// download that returned an error page cannot replace a good file.
|
||||||
public static CountryFile Parse(string text)
|
public static CountryFile Parse(string text)
|
||||||
@@ -31,6 +39,7 @@ public sealed class CountryFile
|
|||||||
Dictionary<string, PrefixRule> exactCalls = new(StringComparer.Ordinal);
|
Dictionary<string, PrefixRule> exactCalls = new(StringComparer.Ordinal);
|
||||||
Dictionary<string, PrefixRule> prefixes = new(StringComparer.Ordinal);
|
Dictionary<string, PrefixRule> prefixes = new(StringComparer.Ordinal);
|
||||||
DxccEntity? current = null;
|
DxccEntity? current = null;
|
||||||
|
string version = "";
|
||||||
|
|
||||||
foreach (string rawLine in text.Split('\n'))
|
foreach (string rawLine in text.Split('\n'))
|
||||||
{
|
{
|
||||||
@@ -51,7 +60,14 @@ public sealed class CountryFile
|
|||||||
}
|
}
|
||||||
foreach (string alias in line.TrimEnd(';').Split(',', StringSplitOptions.RemoveEmptyEntries))
|
foreach (string alias in line.TrimEnd(';').Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
{
|
{
|
||||||
AddAlias(current, alias.Trim(), exactCalls, prefixes);
|
string name = alias.Trim();
|
||||||
|
if (name.StartsWith("=VER", StringComparison.Ordinal)
|
||||||
|
&& name.Length == 12
|
||||||
|
&& name[4..].All(char.IsAsciiDigit))
|
||||||
|
{
|
||||||
|
version = name[4..];
|
||||||
|
}
|
||||||
|
AddAlias(current, name, exactCalls, prefixes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +75,7 @@ public sealed class CountryFile
|
|||||||
{
|
{
|
||||||
throw new FormatException("country file holds no entities");
|
throw new FormatException("country file holds no entities");
|
||||||
}
|
}
|
||||||
return new CountryFile(entities, exactCalls, prefixes);
|
return new CountryFile(entities, exactCalls, prefixes, version);
|
||||||
}
|
}
|
||||||
|
|
||||||
public CountryLookup? Find(Callsign call)
|
public CountryLookup? Find(Callsign call)
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ public interface ContactLog
|
|||||||
/// The callsign this station is logging under, for the same reason.
|
/// The callsign this station is logging under, for the same reason.
|
||||||
string MyCall { get; }
|
string MyCall { get; }
|
||||||
|
|
||||||
|
/// The sub-type of the contest — the state of a QSO party, say. Empty for
|
||||||
|
/// a contest that has none.
|
||||||
|
string ContestSubType { get; }
|
||||||
|
|
||||||
|
/// Which issue of the country file this station is scoring with, or empty
|
||||||
|
/// when it has no country file.
|
||||||
|
string CountryFileVersion { get; }
|
||||||
|
|
||||||
/// The contacts one computer made inside one block of time, oldest first.
|
/// The contacts one computer made inside one block of time, oldest first.
|
||||||
IReadOnlyList<Qso> Contacts(string computerName, DateTime fromUtc, TimeSpan length);
|
IReadOnlyList<Qso> Contacts(string computerName, DateTime fromUtc, TimeSpan length);
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,27 @@ public sealed class NetworkedStation
|
|||||||
|
|
||||||
public string Operator { get; internal set; } = "";
|
public string Operator { get; internal set; } = "";
|
||||||
|
|
||||||
|
/// Which contest the station said it is logging, and the sub-type of it —
|
||||||
|
/// the state of a QSO party, say. Two stations on different contests must
|
||||||
|
/// not resynchronise into each other's logs.
|
||||||
public string ContestName { get; internal set; } = "";
|
public string ContestName { get; internal set; } = "";
|
||||||
|
|
||||||
|
public string ContestSubType { get; internal set; } = "";
|
||||||
|
|
||||||
|
/// Which issue of the country file the station is scoring with.
|
||||||
|
public string CountryFileVersion { get; internal set; } = "";
|
||||||
|
|
||||||
|
/// True for the station the entry has named master: the one whose cluster
|
||||||
|
/// spots the others take and whose clock the others follow.
|
||||||
|
public bool IsMaster { get; internal set; }
|
||||||
|
|
||||||
|
/// How far the station's clock is ahead of this one's, from the last `TIME`
|
||||||
|
/// it sent. Null until it has sent one.
|
||||||
|
public TimeSpan? ClockOffset { get; internal set; }
|
||||||
|
|
||||||
|
/// True for the station this one hands stacked callsigns to.
|
||||||
|
public bool IsCallStackTarget { get; internal set; }
|
||||||
|
|
||||||
/// Where the station is, from the last `FREQ` or `STATUS` it sent.
|
/// Where the station is, from the last `FREQ` or `STATUS` it sent.
|
||||||
public Frequency Frequency { get; internal set; }
|
public Frequency Frequency { get; internal set; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Network;
|
||||||
|
|
||||||
|
/// A callsign another station handed to this one's call stack: a station it
|
||||||
|
/// heard and is not going to work itself.
|
||||||
|
///
|
||||||
|
/// `Contact` is the contact behind it when the other end had one — the
|
||||||
|
/// frequency and whatever exchange it had already taken — and null when the
|
||||||
|
/// operator sent nothing but the call.
|
||||||
|
public sealed record StackedCall(string Call, Qso? Contact, string FromComputerName);
|
||||||
@@ -41,6 +41,11 @@ public sealed class StationLink : IDisposable
|
|||||||
/// on a network carrying contest traffic.
|
/// on a network carrying contest traffic.
|
||||||
public static readonly TimeSpan BeaconInterval = TimeSpan.FromSeconds(10);
|
public static readonly TimeSpan BeaconInterval = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
/// The longest a computer name may be. It is a NetBIOS name, which is
|
||||||
|
/// fifteen characters, and N1MM asks for names inside that so every station
|
||||||
|
/// is known by the same name everywhere.
|
||||||
|
public const int LongestComputerName = 15;
|
||||||
|
|
||||||
/// How long a station may say nothing before the window shows it as gone.
|
/// How long a station may say nothing before the window shows it as gone.
|
||||||
/// Three beacons.
|
/// Three beacons.
|
||||||
public static readonly TimeSpan Patience = TimeSpan.FromSeconds(30);
|
public static readonly TimeSpan Patience = TimeSpan.FromSeconds(30);
|
||||||
@@ -58,6 +63,15 @@ public sealed class StationLink : IDisposable
|
|||||||
private UdpClient? beacons;
|
private UdpClient? beacons;
|
||||||
private TcpListener? listener;
|
private TcpListener? listener;
|
||||||
private readonly List<Task> loops = [];
|
private readonly List<Task> loops = [];
|
||||||
|
/// The stations already told about a country file that is not this one's,
|
||||||
|
/// so the message is not repeated with every status that arrives.
|
||||||
|
private readonly HashSet<string> warnedAboutCountryFile = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
/// The addresses already named as answering to this computer's name.
|
||||||
|
private readonly HashSet<string> warnedAboutName = [];
|
||||||
|
/// The address this station's own beacon goes out with, so a beacon from
|
||||||
|
/// somewhere else with this station's name can be told apart from the echo
|
||||||
|
/// of our own.
|
||||||
|
private string myAddress = "";
|
||||||
|
|
||||||
public StationLink(string computerName, string version, int stationNumber = 1, int port = StationBeacon.DefaultPort)
|
public StationLink(string computerName, string version, int stationNumber = 1, int port = StationBeacon.DefaultPort)
|
||||||
{
|
{
|
||||||
@@ -112,6 +126,15 @@ public sealed class StationLink : IDisposable
|
|||||||
|
|
||||||
public event EventHandler<string>? Failed;
|
public event EventHandler<string>? Failed;
|
||||||
|
|
||||||
|
/// Another station asked every station to start its serial numbers again
|
||||||
|
/// at one. The numbers come out of the log, which is not the link's, so the
|
||||||
|
/// owner is told and does it.
|
||||||
|
public event EventHandler? SerialNumbersReset;
|
||||||
|
|
||||||
|
/// A callsign another station handed to this one's call stack, with the
|
||||||
|
/// contact behind it when there was one.
|
||||||
|
public event EventHandler<StackedCall>? CallStacked;
|
||||||
|
|
||||||
/// Another station asked for a rescore when its resync is done. The log is
|
/// Another station asked for a rescore when its resync is done. The log is
|
||||||
/// not this program's to score, so the owner is told and does it.
|
/// not this program's to score, so the owner is told and does it.
|
||||||
public event EventHandler? RescoreRequested;
|
public event EventHandler? RescoreRequested;
|
||||||
@@ -141,6 +164,12 @@ public sealed class StationLink : IDisposable
|
|||||||
Failed?.Invoke(this, $"could not take port {port}: {e.Message}");
|
Failed?.Invoke(this, $"could not take port {port}: {e.Message}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (computerName.Length > LongestComputerName)
|
||||||
|
{
|
||||||
|
Failed?.Invoke(
|
||||||
|
this,
|
||||||
|
$"this computer is called {computerName}, which is longer than the {LongestComputerName} characters N1MM allows — the other stations may know it by another name");
|
||||||
|
}
|
||||||
loops.Add(Task.Run(() => BeaconLoopAsync(stopping.Token)));
|
loops.Add(Task.Run(() => BeaconLoopAsync(stopping.Token)));
|
||||||
loops.Add(Task.Run(() => ListenForBeaconsAsync(stopping.Token)));
|
loops.Add(Task.Run(() => ListenForBeaconsAsync(stopping.Token)));
|
||||||
loops.Add(Task.Run(() => AcceptAsync(stopping.Token)));
|
loops.Add(Task.Run(() => AcceptAsync(stopping.Token)));
|
||||||
@@ -367,6 +396,61 @@ public sealed class StationLink : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This station's clock, so the others can see whether theirs agree.
|
||||||
|
/// N1MM sets the clock from it when it is running as an administrator; this
|
||||||
|
/// program says how far off it is and leaves the clock alone, which is what
|
||||||
|
/// N1MM does the rest of the time.
|
||||||
|
public Task<int> SendTimeAsync(CancellationToken cancellation = default) =>
|
||||||
|
SendAsync(StationMessages.Time(DateTime.UtcNow, StationNumber, computerName), cancellation);
|
||||||
|
|
||||||
|
/// Names the station the whole entry follows for cluster spots and the
|
||||||
|
/// time. Every station is told, this one included, so the window agrees
|
||||||
|
/// everywhere.
|
||||||
|
public Task<int> SendMasterAsync(NetworkedStation master, CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
MarkMaster(master.ComputerName);
|
||||||
|
return SendAsync(StationMessages.Master(master.ComputerName, StationNumber, computerName), cancellation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the station stacked callsigns go to, or none with null. It is
|
||||||
|
/// only this station's choice: N1MM keeps the target at the station doing
|
||||||
|
/// the sending and tells nobody.
|
||||||
|
public void SetCallStackTarget(NetworkedStation? target)
|
||||||
|
{
|
||||||
|
foreach (NetworkedStation station in stations.Values)
|
||||||
|
{
|
||||||
|
station.IsCallStackTarget = ReferenceEquals(station, target);
|
||||||
|
}
|
||||||
|
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells every station to start its serial numbers again at one.
|
||||||
|
public Task<int> SendResetSerialNumbersAsync(CancellationToken cancellation = default) =>
|
||||||
|
SendAsync(StationMessages.ResetSerialNumbers(StationNumber, computerName), cancellation);
|
||||||
|
|
||||||
|
/// Which contest this station is logging. It goes out when a connection
|
||||||
|
/// opens and whenever the operator opens another contest.
|
||||||
|
public Task<int> SendContestNameAsync(CancellationToken cancellation = default) =>
|
||||||
|
Log is null
|
||||||
|
? Task.FromResult(0)
|
||||||
|
: SendAsync(
|
||||||
|
StationMessages.ContestName(Log.ContestName, Log.ContestSubType, StationNumber, computerName),
|
||||||
|
cancellation);
|
||||||
|
|
||||||
|
/// Hands a callsign to another station's call stack. With a contact behind
|
||||||
|
/// it the other end gets the frequency and the exchange as well.
|
||||||
|
public Task<bool> SendStackedAsync(
|
||||||
|
NetworkedStation to,
|
||||||
|
string call,
|
||||||
|
Qso? contact = null,
|
||||||
|
CancellationToken cancellation = default) =>
|
||||||
|
SendToAsync(
|
||||||
|
to,
|
||||||
|
contact is null
|
||||||
|
? StationMessages.StackCall(call, StationNumber, computerName)
|
||||||
|
: StationMessages.StackContact(contact, StationNumber, computerName),
|
||||||
|
cancellation);
|
||||||
|
|
||||||
/// Asks every station whether it is there. The answer sets `EchoTime`.
|
/// Asks every station whether it is there. The answer sets `EchoTime`.
|
||||||
public Task<int> SendEchoRequestAsync(CancellationToken cancellation = default)
|
public Task<int> SendEchoRequestAsync(CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
@@ -401,6 +485,7 @@ public sealed class StationLink : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
StationBeacon beacon = new(computerName, Address(), port, version, mine.Operator);
|
StationBeacon beacon = new(computerName, Address(), port, version, mine.Operator);
|
||||||
|
myAddress = beacon.Address;
|
||||||
byte[] message = Encoding.UTF8.GetBytes(beacon.ToWire());
|
byte[] message = Encoding.UTF8.GetBytes(beacon.ToWire());
|
||||||
await beacons!
|
await beacons!
|
||||||
.SendAsync(message, new IPEndPoint(IPAddress.Broadcast, port), cancellation)
|
.SendAsync(message, new IPEndPoint(IPAddress.Broadcast, port), cancellation)
|
||||||
@@ -462,6 +547,19 @@ public sealed class StationLink : IDisposable
|
|||||||
/// opened to.
|
/// opened to.
|
||||||
private void Heard(StationBeacon beacon, string from)
|
private void Heard(StationBeacon beacon, string from)
|
||||||
{
|
{
|
||||||
|
if (string.Equals(beacon.ComputerName, computerName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// our own beacon comes back to us: every other computer answering
|
||||||
|
// to this name is a second station calling itself the same thing,
|
||||||
|
// and their contacts would be stamped with the same name
|
||||||
|
if (myAddress.Length > 0 && from != myAddress && warnedAboutName.Add(from))
|
||||||
|
{
|
||||||
|
Failed?.Invoke(
|
||||||
|
this,
|
||||||
|
$"another computer at {from} is also calling itself {computerName} — give one of them another name");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
NetworkedStation station = stations.GetOrAdd(
|
NetworkedStation station = stations.GetOrAdd(
|
||||||
beacon.ComputerName,
|
beacon.ComputerName,
|
||||||
name => new NetworkedStation(name, from, beacon.Port));
|
name => new NetworkedStation(name, from, beacon.Port));
|
||||||
@@ -513,6 +611,15 @@ public sealed class StationLink : IDisposable
|
|||||||
// the others were told when their own connection opened
|
// the others were told when their own connection opened
|
||||||
await SendToAsync(station, StationMessages.IAm(StationNumber, computerName), cancellation)
|
await SendToAsync(station, StationMessages.IAm(StationNumber, computerName), cancellation)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
// which contest this station is logging, so the other end can say so
|
||||||
|
// before the two of them resynchronise into the wrong log
|
||||||
|
if (Log is not null)
|
||||||
|
{
|
||||||
|
await SendToAsync(
|
||||||
|
station,
|
||||||
|
StationMessages.ContestName(Log.ContestName, Log.ContestSubType, StationNumber, computerName),
|
||||||
|
cancellation).ConfigureAwait(false);
|
||||||
|
}
|
||||||
// when this station last worked anybody. The other end holds contacts
|
// when this station last worked anybody. The other end holds contacts
|
||||||
// of ours only up to some time of its own, and starts a resync when
|
// of ours only up to some time of its own, and starts a resync when
|
||||||
// this one is later
|
// this one is later
|
||||||
@@ -532,6 +639,81 @@ public sealed class StationLink : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How far apart two clocks may be before it is worth saying so. N1MM
|
||||||
|
/// ignores a second either way.
|
||||||
|
private static readonly TimeSpan ClockPatience = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
private void MarkMaster(string name)
|
||||||
|
{
|
||||||
|
foreach (NetworkedStation station in stations.Values)
|
||||||
|
{
|
||||||
|
station.IsMaster = string.Equals(station.ComputerName, name, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Says so when another station is logging a different contest. It is
|
||||||
|
/// worth saying twice: the contacts arriving from it are being scored into
|
||||||
|
/// this log against rules that are not theirs.
|
||||||
|
private void WarnAboutContest(NetworkedStation station, int fieldCount)
|
||||||
|
{
|
||||||
|
if (Log is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// N1MM counts the fields of this message and says a station with fewer
|
||||||
|
// of them is on another version. The trailing separator leaves an empty
|
||||||
|
// one on the end, which is the fourth
|
||||||
|
if (fieldCount < 4)
|
||||||
|
{
|
||||||
|
Failed?.Invoke(this, $"{station.ComputerName} is running another version of the program");
|
||||||
|
}
|
||||||
|
if (!string.Equals(station.ContestName, Log.ContestName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Failed?.Invoke(
|
||||||
|
this,
|
||||||
|
$"{station.ComputerName} is logging {station.ContestName}, this station is logging {Log.ContestName}");
|
||||||
|
}
|
||||||
|
else if (station.ContestSubType.Length > 0
|
||||||
|
&& Log.ContestSubType.Length > 0
|
||||||
|
&& !string.Equals(station.ContestSubType, Log.ContestSubType, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Failed?.Invoke(
|
||||||
|
this,
|
||||||
|
$"{station.ComputerName} is logging {station.ContestName} for {station.ContestSubType}, this station for {Log.ContestSubType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WarnAboutClock(NetworkedStation station)
|
||||||
|
{
|
||||||
|
if (station.ClockOffset is not { } offset || offset.Duration() <= ClockPatience)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
string which = offset > TimeSpan.Zero ? "ahead of" : "behind";
|
||||||
|
Failed?.Invoke(
|
||||||
|
this,
|
||||||
|
$"{station.ComputerName}'s clock is {offset.Duration().TotalSeconds:0} s {which} this one — set one of them");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Says so once when another station is scoring with a different issue of
|
||||||
|
/// the country file. Two issues score the same contact differently, and the
|
||||||
|
/// two stations then disagree about the score all contest.
|
||||||
|
private void WarnAboutCountryFile(NetworkedStation station)
|
||||||
|
{
|
||||||
|
if (Log is null
|
||||||
|
|| station.CountryFileVersion.Length == 0
|
||||||
|
|| Log.CountryFileVersion.Length == 0
|
||||||
|
|| station.CountryFileVersion == Log.CountryFileVersion
|
||||||
|
|| !warnedAboutCountryFile.Add(station.ComputerName))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Failed?.Invoke(
|
||||||
|
this,
|
||||||
|
$"{station.ComputerName} has country file {station.CountryFileVersion}, this station has {Log.CountryFileVersion}");
|
||||||
|
}
|
||||||
|
|
||||||
/// A station said when it last worked anybody. When this station holds
|
/// A station said when it last worked anybody. When this station holds
|
||||||
/// nothing of its that late, the hours in between are missing and a resync
|
/// nothing of its that late, the hours in between are missing and a resync
|
||||||
/// asks for them.
|
/// asks for them.
|
||||||
@@ -642,6 +824,12 @@ public sealed class StationLink : IDisposable
|
|||||||
{
|
{
|
||||||
case "IAM":
|
case "IAM":
|
||||||
station.StationNumber = record.Number(0);
|
station.StationNumber = record.Number(0);
|
||||||
|
if (station.StationNumber == StationNumber && !station.IsMine)
|
||||||
|
{
|
||||||
|
Failed?.Invoke(
|
||||||
|
this,
|
||||||
|
$"{station.ComputerName} is station {station.StationNumber} as well — every station of an entry needs its own number");
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "FREQ":
|
case "FREQ":
|
||||||
station.Frequency = Frequency.FromKilohertz(record.Decimal(0) / 100);
|
station.Frequency = Frequency.FromKilohertz(record.Decimal(0) / 100);
|
||||||
@@ -658,7 +846,9 @@ public sealed class StationLink : IDisposable
|
|||||||
station.Mode = status.Mode;
|
station.Mode = status.Mode;
|
||||||
station.Last10 = status.Last10;
|
station.Last10 = status.Last10;
|
||||||
station.Last100 = status.Last100;
|
station.Last100 = status.Last100;
|
||||||
|
station.CountryFileVersion = status.CountryFileVersion;
|
||||||
station.IsRun2 = status.IsRun2;
|
station.IsRun2 = status.IsRun2;
|
||||||
|
WarnAboutCountryFile(station);
|
||||||
break;
|
break;
|
||||||
case "XMIT":
|
case "XMIT":
|
||||||
station.IsTransmitting = record.Flag(0);
|
station.IsTransmitting = record.Flag(0);
|
||||||
@@ -688,6 +878,28 @@ public sealed class StationLink : IDisposable
|
|||||||
UpdateArrived?.Invoke(this, cleared);
|
UpdateArrived?.Invoke(this, cleared);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "CONTESTNAME":
|
||||||
|
station.ContestName = record.Field(1);
|
||||||
|
station.ContestSubType = record.Field(2);
|
||||||
|
WarnAboutContest(station, record.Fields.Count);
|
||||||
|
break;
|
||||||
|
case "TIME":
|
||||||
|
station.ClockOffset = record.Time(0) - DateTime.UtcNow;
|
||||||
|
WarnAboutClock(station);
|
||||||
|
break;
|
||||||
|
case "MASTER":
|
||||||
|
MarkMaster(record.Field(0));
|
||||||
|
break;
|
||||||
|
case "RESETQSONRS":
|
||||||
|
SerialNumbersReset?.Invoke(this, EventArgs.Empty);
|
||||||
|
break;
|
||||||
|
case "STACKANOTHERCALL":
|
||||||
|
CallStacked?.Invoke(this, new StackedCall(record.Field(0), null, station.ComputerName));
|
||||||
|
break;
|
||||||
|
case "STACKCALL":
|
||||||
|
Qso stacked = QsoRecord.Read(record, 0, station.ComputerName);
|
||||||
|
CallStacked?.Invoke(this, new StackedCall(stacked.Call.Text, stacked, station.ComputerName));
|
||||||
|
break;
|
||||||
case "TALK":
|
case "TALK":
|
||||||
TalkArrived?.Invoke(this, record.Field(0));
|
TalkArrived?.Invoke(this, record.Field(0));
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ namespace Nonemm.Network;
|
|||||||
/// | `XMIT` | a station started or stopped transmitting | yes | yes |
|
/// | `XMIT` | a station started or stopped transmitting | yes | yes |
|
||||||
/// | `FREQ` | where a station is tuned | yes | yes |
|
/// | `FREQ` | where a station is tuned | yes | yes |
|
||||||
/// | `LASTQAT` | the time of the sender's last contact, which starts a resync | yes | yes |
|
/// | `LASTQAT` | the time of the sender's last contact, which starts a resync | yes | yes |
|
||||||
|
/// | `CONTESTNAME` | which contest the sender is logging | yes | yes |
|
||||||
|
/// | `TIME` | the sender's clock | yes | yes |
|
||||||
|
/// | `MASTER` | which station is the master for the cluster and the time | yes | yes |
|
||||||
|
/// | `RESETQSONRS` | start the serial numbers again at one | yes | yes |
|
||||||
|
/// | `STACKCALL` | a contact for another station's call stack | yes | yes |
|
||||||
|
/// | `STACKANOTHERCALL` | a callsign for another station's call stack | yes | yes |
|
||||||
/// | `STATUS` | everything the network status window shows about a station | yes | yes |
|
/// | `STATUS` | everything the network status window shows about a station | yes | yes |
|
||||||
/// | `CHECKSUM` | the checksum of one block of the log, which starts a resync | yes | yes |
|
/// | `CHECKSUM` | the checksum of one block of the log, which starts a resync | yes | yes |
|
||||||
/// | `CONFIRMED` | the answer to a checksum, and what moves a resync on | yes | yes |
|
/// | `CONFIRMED` | the answer to a checksum, and what moves a resync on | yes | yes |
|
||||||
@@ -174,7 +180,7 @@ public static class StationMessages
|
|||||||
status.Mode?.Name ?? "",
|
status.Mode?.Name ?? "",
|
||||||
status.OperatorCategory,
|
status.OperatorCategory,
|
||||||
status.TransmitterCategory,
|
status.TransmitterCategory,
|
||||||
"",
|
status.CountryFileVersion,
|
||||||
version,
|
version,
|
||||||
StationRecord.Written(false),
|
StationRecord.Written(false),
|
||||||
StationRecord.Written(false),
|
StationRecord.Written(false),
|
||||||
@@ -197,6 +203,7 @@ public static class StationMessages
|
|||||||
record.Number(5),
|
record.Number(5),
|
||||||
record.Field(7),
|
record.Field(7),
|
||||||
record.Field(8),
|
record.Field(8),
|
||||||
|
record.Field(9),
|
||||||
record.Flag(16));
|
record.Flag(16));
|
||||||
|
|
||||||
/// The checksum of one block of the log, which is how a resync asks
|
/// The checksum of one block of the log, which is how a resync asks
|
||||||
@@ -270,6 +277,46 @@ public static class StationMessages
|
|||||||
computerName,
|
computerName,
|
||||||
[StationRecord.Written(fromUtc), ofComputerName, Text(minutes)]);
|
[StationRecord.Written(fromUtc), ofComputerName, Text(minutes)]);
|
||||||
|
|
||||||
|
/// Which contest this station is logging, sent as soon as a connection
|
||||||
|
/// opens. Two stations logging different contests would resynchronise each
|
||||||
|
/// other's contacts into the wrong log, so each says which it is on and the
|
||||||
|
/// other tells its operator when they differ.
|
||||||
|
///
|
||||||
|
/// N1MM counts the fields: a message with fewer than four is from a program
|
||||||
|
/// on another version, and it says so rather than guessing.
|
||||||
|
public static StationRecord ContestName(
|
||||||
|
string contestName,
|
||||||
|
string subType,
|
||||||
|
int stationNumber,
|
||||||
|
string computerName) =>
|
||||||
|
Record("CONTESTNAME", stationNumber, computerName, [computerName, contestName, subType]);
|
||||||
|
|
||||||
|
/// The sender's clock. The station that reads it compares the time with its
|
||||||
|
/// own: contacts logged at two stations whose clocks disagree cannot be
|
||||||
|
/// matched up by the sponsor.
|
||||||
|
public static StationRecord Time(DateTime nowUtc, int stationNumber, string computerName) =>
|
||||||
|
Record("TIME", stationNumber, computerName, [StationRecord.Written(nowUtc)]);
|
||||||
|
|
||||||
|
/// Which station is the master: the one whose cluster spots the others
|
||||||
|
/// take and whose clock the others follow.
|
||||||
|
public static StationRecord Master(string masterName, int stationNumber, string computerName) =>
|
||||||
|
Record("MASTER", stationNumber, computerName, [masterName]);
|
||||||
|
|
||||||
|
/// Start the serial numbers again at one. It is sent when an operator finds
|
||||||
|
/// the entry has been logging with numbers from a contest before this one.
|
||||||
|
public static StationRecord ResetSerialNumbers(int stationNumber, string computerName) =>
|
||||||
|
Record("RESETQSONRS", stationNumber, computerName, []);
|
||||||
|
|
||||||
|
/// A contact for another station's call stack: a station this one has heard
|
||||||
|
/// but is not going to work, handed to the position that will.
|
||||||
|
public static StationRecord StackContact(Qso qso, int stationNumber, string computerName) =>
|
||||||
|
Record("STACKCALL", stationNumber, computerName, QsoRecord.Write(qso));
|
||||||
|
|
||||||
|
/// A callsign for another station's call stack, without a contact behind
|
||||||
|
/// it. It is what an operator sends when he has nothing but the call.
|
||||||
|
public static StationRecord StackCall(string call, int stationNumber, string computerName) =>
|
||||||
|
Record("STACKANOTHERCALL", stationNumber, computerName, [call]);
|
||||||
|
|
||||||
/// What a message that has arrived means, or null for one this program does
|
/// What a message that has arrived means, or null for one this program does
|
||||||
/// nothing with.
|
/// nothing with.
|
||||||
public static ContactUpdate? Read(StationRecord record) => record.Type switch
|
public static ContactUpdate? Read(StationRecord record) => record.Type switch
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ namespace Nonemm.Network;
|
|||||||
/// hundred contacts. N1MM shows both, and the pair is how an operator sees a
|
/// hundred contacts. N1MM shows both, and the pair is how an operator sees a
|
||||||
/// station that has stopped working anybody without reading its log. `IsRun2`
|
/// station that has stopped working anybody without reading its log. `IsRun2`
|
||||||
/// is true when the station is the second run radio of a two-radio position,
|
/// is true when the station is the second run radio of a two-radio position,
|
||||||
/// which is the `R/M` column.
|
/// which is the `R/M` column. `CountryFileVersion` is the issue of the country
|
||||||
|
/// file the station is scoring with: two stations on different issues score the
|
||||||
|
/// same contact differently, so each says which it has.
|
||||||
public sealed record StationStatus(
|
public sealed record StationStatus(
|
||||||
Frequency PassFrequency,
|
Frequency PassFrequency,
|
||||||
Frequency Frequency,
|
Frequency Frequency,
|
||||||
@@ -26,4 +28,5 @@ public sealed record StationStatus(
|
|||||||
int Last100,
|
int Last100,
|
||||||
string OperatorCategory = "",
|
string OperatorCategory = "",
|
||||||
string TransmitterCategory = "",
|
string TransmitterCategory = "",
|
||||||
|
string CountryFileVersion = "",
|
||||||
bool IsRun2 = false);
|
bool IsRun2 = false);
|
||||||
|
|||||||
@@ -64,6 +64,20 @@ public sealed class ContestSession
|
|||||||
|
|
||||||
public int StationNumber { get; set; } = 1;
|
public int StationNumber { get; set; } = 1;
|
||||||
|
|
||||||
|
/// Contacts logged before this are not counted when the next serial number
|
||||||
|
/// is worked out, so Reset Serial Numbers starts them again at one. The
|
||||||
|
/// contacts already logged keep the numbers they were sent with. It lasts
|
||||||
|
/// as long as the program is open, the way N1MM's does.
|
||||||
|
public DateTime SerialNumbersFrom
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
field = value;
|
||||||
|
SentNumber = NextSentNumber();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public event EventHandler? Changed;
|
public event EventHandler? Changed;
|
||||||
|
|
||||||
public event EventHandler<Qso>? Logged;
|
public event EventHandler<Qso>? Logged;
|
||||||
@@ -247,6 +261,7 @@ public sealed class ContestSession
|
|||||||
? Log.Qsos.Where(q =>
|
? Log.Qsos.Where(q =>
|
||||||
Contest.SessionOf(q.TimestampUtc) == Contest.SessionOf(DateTime.UtcNow))
|
Contest.SessionOf(q.TimestampUtc) == Contest.SessionOf(DateTime.UtcNow))
|
||||||
: Log.Qsos;
|
: Log.Qsos;
|
||||||
|
counted = counted.Where(q => q.TimestampUtc >= SerialNumbersFrom);
|
||||||
return counted.Select(q => q.SentNumber).DefaultIfEmpty(0).Max() + 1;
|
return counted.Select(q => q.SentNumber).DefaultIfEmpty(0).Max() + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ public class CountryFileTests
|
|||||||
|
|
||||||
private static readonly CountryFile File = CountryFile.Parse(Sample);
|
private static readonly CountryFile File = CountryFile.Parse(Sample);
|
||||||
|
|
||||||
|
/// The publisher writes the issue date in as a callsign of its own. The
|
||||||
|
/// stations of a multi-operator entry compare it: two issues score the same
|
||||||
|
/// contact differently.
|
||||||
|
[Fact]
|
||||||
|
public void TheIssueDateIsRead() =>
|
||||||
|
Assert.Equal(
|
||||||
|
"20260826",
|
||||||
|
CountryFile.Parse(Sample.Replace("3D2;", "3D2,=VER20260826;")).Version);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AFileWithNoIssueDateHasNoVersion() =>
|
||||||
|
Assert.Equal("", File.Version);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void LongestPrefixWins() =>
|
public void LongestPrefixWins() =>
|
||||||
Assert.Equal("IT9", File.Find("IT9ABC")?.Entity.PrimaryPrefix);
|
Assert.Equal("IT9", File.Find("IT9ABC")?.Entity.PrimaryPrefix);
|
||||||
|
|||||||
@@ -365,10 +365,14 @@ public class StationLinkTests
|
|||||||
{
|
{
|
||||||
private readonly List<Qso> qsos = [.. contacts];
|
private readonly List<Qso> qsos = [.. contacts];
|
||||||
|
|
||||||
public string ContestName => "CQWW";
|
public string ContestName { get; set; } = "CQWW";
|
||||||
|
|
||||||
public string MyCall => "OM3XYZ";
|
public string MyCall => "OM3XYZ";
|
||||||
|
|
||||||
|
public string ContestSubType { get; set; } = "";
|
||||||
|
|
||||||
|
public string CountryFileVersion { get; set; } = "";
|
||||||
|
|
||||||
public IReadOnlyList<Qso> All => qsos;
|
public IReadOnlyList<Qso> All => qsos;
|
||||||
|
|
||||||
public IReadOnlyList<Qso> Contacts(string computerName, DateTime fromUtc, TimeSpan length) =>
|
public IReadOnlyList<Qso> Contacts(string computerName, DateTime fromUtc, TimeSpan length) =>
|
||||||
@@ -400,6 +404,173 @@ public class StationLinkTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The master is the station the whole entry follows for cluster spots and
|
||||||
|
/// the time. Every station is told which one it is, so the window agrees
|
||||||
|
/// everywhere.
|
||||||
|
[Fact]
|
||||||
|
public async Task TheMasterIsTheSameStationEverywhere()
|
||||||
|
{
|
||||||
|
(int onePort, int twoPort) = SparePorts();
|
||||||
|
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||||
|
using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort);
|
||||||
|
one.Start();
|
||||||
|
two.Start();
|
||||||
|
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||||
|
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
|
||||||
|
|
||||||
|
await one.SendMasterAsync(one.Stations.First(s => s.ComputerName == "MULT-PC"));
|
||||||
|
|
||||||
|
await WaitForAsync(() => two.Me.IsMaster);
|
||||||
|
Assert.True(two.Me.IsMaster);
|
||||||
|
Assert.True(one.Stations.First(s => s.ComputerName == "MULT-PC").IsMaster);
|
||||||
|
Assert.False(one.Me.IsMaster);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two clocks that disagree cannot both be right, and the sponsor matches
|
||||||
|
/// contacts by time. Each station says what its clock reads and the other
|
||||||
|
/// works out the difference.
|
||||||
|
[Fact]
|
||||||
|
public async Task AStationSaysWhatItsClockReads()
|
||||||
|
{
|
||||||
|
(int onePort, int twoPort) = SparePorts();
|
||||||
|
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||||
|
using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort);
|
||||||
|
one.Start();
|
||||||
|
two.Start();
|
||||||
|
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||||
|
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
|
||||||
|
|
||||||
|
await one.SendTimeAsync();
|
||||||
|
|
||||||
|
await WaitForAsync(() =>
|
||||||
|
two.Stations.FirstOrDefault(s => s.ComputerName == "RUN-PC")?.ClockOffset is not null);
|
||||||
|
TimeSpan? offset = two.Stations.First(s => s.ComputerName == "RUN-PC").ClockOffset;
|
||||||
|
Assert.NotNull(offset);
|
||||||
|
Assert.True(offset.Value.Duration() < TimeSpan.FromSeconds(2), $"two clocks of one machine differ by {offset}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SerialNumbersCanBeStartedAgainAtEveryStation()
|
||||||
|
{
|
||||||
|
(int onePort, int twoPort) = SparePorts();
|
||||||
|
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||||
|
using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort);
|
||||||
|
one.Start();
|
||||||
|
two.Start();
|
||||||
|
bool asked = false;
|
||||||
|
two.SerialNumbersReset += (_, _) => asked = true;
|
||||||
|
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||||
|
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
|
||||||
|
|
||||||
|
await one.SendResetSerialNumbersAsync();
|
||||||
|
|
||||||
|
await WaitForAsync(() => asked);
|
||||||
|
Assert.True(asked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A callsign handed to another station's call stack, with the contact
|
||||||
|
/// behind it when there is one.
|
||||||
|
[Fact]
|
||||||
|
public async Task ACallCanBeHandedToAnotherStationsStack()
|
||||||
|
{
|
||||||
|
(int onePort, int twoPort) = SparePorts();
|
||||||
|
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||||
|
using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort);
|
||||||
|
one.Start();
|
||||||
|
two.Start();
|
||||||
|
List<StackedCall> stacked = [];
|
||||||
|
two.CallStacked += (_, call) => stacked.Add(call);
|
||||||
|
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||||
|
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
|
||||||
|
NetworkedStation target = one.Stations.First(s => s.ComputerName == "MULT-PC");
|
||||||
|
|
||||||
|
await one.SendStackedAsync(target, "G3XYZ");
|
||||||
|
await one.SendStackedAsync(target, "DL1ABC", Contact() with { Call = Callsign.Parse("DL1ABC") });
|
||||||
|
|
||||||
|
await WaitForAsync(() => stacked.Count == 2);
|
||||||
|
Assert.Equal("G3XYZ", stacked[0].Call);
|
||||||
|
Assert.Null(stacked[0].Contact);
|
||||||
|
Assert.Equal("DL1ABC", stacked[1].Call);
|
||||||
|
Assert.Equal(14_025, stacked[1].Contact?.Frequency.Kilohertz ?? 0);
|
||||||
|
Assert.Equal("RUN-PC", stacked[1].FromComputerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two stations logging different contests would resynchronise into each
|
||||||
|
/// other's logs. Each says which contest it is on as soon as it connects.
|
||||||
|
[Fact]
|
||||||
|
public async Task AStationOnAnotherContestIsNamed()
|
||||||
|
{
|
||||||
|
(int onePort, int twoPort) = SparePorts();
|
||||||
|
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||||
|
using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort);
|
||||||
|
one.Log = new FakeLog([]) { ContestName = "CQWPX" };
|
||||||
|
two.Log = new FakeLog([]) { ContestName = "CQWW" };
|
||||||
|
List<string> saidAtTwo = [];
|
||||||
|
two.Failed += (_, why) => saidAtTwo.Add(why);
|
||||||
|
one.Start();
|
||||||
|
two.Start();
|
||||||
|
|
||||||
|
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||||
|
|
||||||
|
await WaitForAsync(() => saidAtTwo.Any(w => w.Contains("CQWPX")));
|
||||||
|
Assert.Contains(saidAtTwo, w => w.Contains("RUN-PC") && w.Contains("CQWPX") && w.Contains("CQWW"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two issues of the country file score the same contact differently, so a
|
||||||
|
/// station on another one is named — once, not with every status.
|
||||||
|
[Fact]
|
||||||
|
public async Task AStationWithAnotherCountryFileIsNamedOnce()
|
||||||
|
{
|
||||||
|
(int onePort, int twoPort) = SparePorts();
|
||||||
|
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||||
|
using StationLink two = new("MULT-PC", Version, stationNumber: 2, port: twoPort);
|
||||||
|
one.Log = new FakeLog([]) { CountryFileVersion = "20260826" };
|
||||||
|
two.Log = new FakeLog([]) { CountryFileVersion = "20260101" };
|
||||||
|
List<string> saidAtTwo = [];
|
||||||
|
two.Failed += (_, why) => saidAtTwo.Add(why);
|
||||||
|
one.Start();
|
||||||
|
two.Start();
|
||||||
|
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||||
|
await WaitForAsync(() => one.Stations.Any(s => s is { IsMine: false, IsConnected: true }));
|
||||||
|
|
||||||
|
await one.SendStatusAsync(Status());
|
||||||
|
await one.SendStatusAsync(Status());
|
||||||
|
|
||||||
|
await WaitForAsync(() => saidAtTwo.Any(w => w.Contains("20260826")));
|
||||||
|
Assert.Single(saidAtTwo, w => w.Contains("20260826") && w.Contains("20260101"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every station of an entry needs its own number: two stations on the same
|
||||||
|
/// one stamp their contacts as made by the same position.
|
||||||
|
[Fact]
|
||||||
|
public async Task TwoStationsOnTheSameNumberAreNamed()
|
||||||
|
{
|
||||||
|
(int onePort, int twoPort) = SparePorts();
|
||||||
|
using StationLink one = new("RUN-PC", Version, stationNumber: 1, port: onePort);
|
||||||
|
using StationLink two = new("MULT-PC", Version, stationNumber: 1, port: twoPort);
|
||||||
|
List<string> saidAtTwo = [];
|
||||||
|
two.Failed += (_, why) => saidAtTwo.Add(why);
|
||||||
|
one.Start();
|
||||||
|
two.Start();
|
||||||
|
|
||||||
|
one.AddStation("MULT-PC", "127.0.0.1", twoPort);
|
||||||
|
|
||||||
|
await WaitForAsync(() => saidAtTwo.Any(w => w.Contains("station 1")));
|
||||||
|
Assert.Contains(saidAtTwo, w => w.Contains("RUN-PC") && w.Contains("station 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StationStatus Status() => new(
|
||||||
|
Frequency.Zero,
|
||||||
|
Frequency.FromKilohertz(14_025),
|
||||||
|
IsRunning: true,
|
||||||
|
"OM3XYZ",
|
||||||
|
Modes.Cw,
|
||||||
|
Last10: 60,
|
||||||
|
Last100: 55,
|
||||||
|
"MULTI-ONE",
|
||||||
|
"TWO",
|
||||||
|
"20260826");
|
||||||
|
|
||||||
private static async Task WaitForAsync(Func<bool> ready)
|
private static async Task WaitForAsync(Func<bool> ready)
|
||||||
{
|
{
|
||||||
DateTime giveUp = DateTime.UtcNow + Patience;
|
DateTime giveUp = DateTime.UtcNow + Patience;
|
||||||
|
|||||||
@@ -182,6 +182,22 @@ public class NetworkedContactsTests
|
|||||||
Assert.Equal(2, store.Qsos(session.Instance.ContestNumber).Count);
|
Assert.Equal(2, store.Qsos(session.Instance.ContestNumber).Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reset Serial Numbers at all Stations starts the numbers again at one.
|
||||||
|
/// The contacts already logged keep the numbers they were sent with.
|
||||||
|
[Fact]
|
||||||
|
public void SerialNumbersCanBeStartedAgainAtOne()
|
||||||
|
{
|
||||||
|
(ContestSession session, _) = Station();
|
||||||
|
session.Add(Arriving("JA1XYZ", 1) with { Id = Qso.NewId(), SentNumber = 1 });
|
||||||
|
session.Add(Arriving("JA2XYZ", 2) with { Id = Qso.NewId(), SentNumber = 2 });
|
||||||
|
Assert.Equal(3, session.SentNumber);
|
||||||
|
|
||||||
|
session.SerialNumbersFrom = DateTime.UtcNow;
|
||||||
|
|
||||||
|
Assert.Equal(1, session.SentNumber);
|
||||||
|
Assert.Equal([1, 2], session.Log.Qsos.Select(q => q.SentNumber));
|
||||||
|
}
|
||||||
|
|
||||||
/// Every contact logged here carries the computer that made it, which is
|
/// Every contact logged here carries the computer that made it, which is
|
||||||
/// what a resync asks for and what N1MM stamps into `NetBiosName`.
|
/// what a resync asks for and what N1MM stamps into `NetBiosName`.
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user