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:
2026-09-03 23:15:44 +00:00
co-authored by Claude Opus 5
parent b5d86c1677
commit 0b92bfc6ee
18 changed files with 838 additions and 40 deletions
+69 -3
View File
@@ -231,6 +231,9 @@ public sealed class AppSession : IDisposable
positions.Add(new OperatingPosition(Logging, number));
}
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.Logged += (_, qso) =>
{
@@ -319,6 +322,10 @@ public sealed class AppSession : IDisposable
link.Log = new SessionContactLog(this);
link.RescoreRequested += (_, _) =>
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)
{
if (Peer(peer) is var (name, address, port))
@@ -372,6 +379,62 @@ public sealed class AppSession : IDisposable
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
/// `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
@@ -402,9 +465,8 @@ public sealed class AppSession : IDisposable
Rates.PerHour(qsos, 100, DateTime.UtcNow, off),
Logging?.Instance.OperatorCategory ?? "",
Logging?.Instance.TransmitterCategory ?? "",
// the second radio of a two-radio position is N1MM's Run2, which is
// what its R/M column reads
position.RadioNumber == 2));
Countries?.Version ?? "",
IsRun2));
}
/// 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;
}
if (!Settings.LogQsosAtAllStations && update is not ContactsCleared)
{
return;
}
switch (update)
{
case ContactLogged logged: