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:
@@ -41,6 +41,11 @@ public sealed class StationLink : IDisposable
|
||||
/// on a network carrying contest traffic.
|
||||
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.
|
||||
/// Three beacons.
|
||||
public static readonly TimeSpan Patience = TimeSpan.FromSeconds(30);
|
||||
@@ -58,6 +63,15 @@ public sealed class StationLink : IDisposable
|
||||
private UdpClient? beacons;
|
||||
private TcpListener? listener;
|
||||
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)
|
||||
{
|
||||
@@ -112,6 +126,15 @@ public sealed class StationLink : IDisposable
|
||||
|
||||
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
|
||||
/// not this program's to score, so the owner is told and does it.
|
||||
public event EventHandler? RescoreRequested;
|
||||
@@ -141,6 +164,12 @@ public sealed class StationLink : IDisposable
|
||||
Failed?.Invoke(this, $"could not take port {port}: {e.Message}");
|
||||
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(() => ListenForBeaconsAsync(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`.
|
||||
public Task<int> SendEchoRequestAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
@@ -401,6 +485,7 @@ public sealed class StationLink : IDisposable
|
||||
try
|
||||
{
|
||||
StationBeacon beacon = new(computerName, Address(), port, version, mine.Operator);
|
||||
myAddress = beacon.Address;
|
||||
byte[] message = Encoding.UTF8.GetBytes(beacon.ToWire());
|
||||
await beacons!
|
||||
.SendAsync(message, new IPEndPoint(IPAddress.Broadcast, port), cancellation)
|
||||
@@ -462,6 +547,19 @@ public sealed class StationLink : IDisposable
|
||||
/// opened to.
|
||||
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(
|
||||
beacon.ComputerName,
|
||||
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
|
||||
await SendToAsync(station, StationMessages.IAm(StationNumber, computerName), cancellation)
|
||||
.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
|
||||
// of ours only up to some time of its own, and starts a resync when
|
||||
// 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
|
||||
/// nothing of its that late, the hours in between are missing and a resync
|
||||
/// asks for them.
|
||||
@@ -642,6 +824,12 @@ public sealed class StationLink : IDisposable
|
||||
{
|
||||
case "IAM":
|
||||
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;
|
||||
case "FREQ":
|
||||
station.Frequency = Frequency.FromKilohertz(record.Decimal(0) / 100);
|
||||
@@ -658,7 +846,9 @@ public sealed class StationLink : IDisposable
|
||||
station.Mode = status.Mode;
|
||||
station.Last10 = status.Last10;
|
||||
station.Last100 = status.Last100;
|
||||
station.CountryFileVersion = status.CountryFileVersion;
|
||||
station.IsRun2 = status.IsRun2;
|
||||
WarnAboutCountryFile(station);
|
||||
break;
|
||||
case "XMIT":
|
||||
station.IsTransmitting = record.Flag(0);
|
||||
@@ -688,6 +878,28 @@ public sealed class StationLink : IDisposable
|
||||
UpdateArrived?.Invoke(this, cleared);
|
||||
}
|
||||
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":
|
||||
TalkArrived?.Invoke(this, record.Field(0));
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user