Give the network status window N1MM's tabs and columns

The window was one flat table of columns this program made up. N1MM's has
tabs and a fixed set of columns, and an operator who knows one has to be
able to read the other.

**The window** now has Stations, Actions and Messages. Stations is a grid
with N1MM's headers in N1MM's order — Computer, IP Address, Pass, Run,
10, 100, Freq, Op, Msg, Send, Receive, Band, R/M, Mode — and a row per
station, this computer included. Right-click a row to pass the contact
being worked to that station, to talk, to show the connection log, or to
close and open its connection. Actions holds the talk line, the pass
frequency, the echo and the networking switch; Messages holds the chat,
the connection failures, the echo round trips and a trace of every
message that arrives.

**`STATUS`** is what fills those columns. N1MM sends it every few seconds
whether anything has changed or not — the pass frequency, the frequency,
run or search and pounce, the operator, the rates over the last ten and
the last hundred contacts, the mode, the categories and the version — so
a station sitting on one frequency for an hour still has a current row.
`BANDMAP` only says a station moved. The five transmit-interlock flags
are sent as false: this program does not block a transmitter.

**`Rates.PerHour`** reads the two rate columns out of the log. Count back
over the last N contacts, stop at a gap longer than the contest's minimum
off time, and treat a span under a minute as a minute, so three contacts
in five seconds read as 180 an hour rather than 2160.

A connection closed by hand stays closed. Without that flag the next
beacon opens it again within ten seconds, which makes the menu item look
broken.

Left out, because there is nothing behind them: N1MM's Resynchronize and
Rescore tabs, the transmit interlocks of its Options tab, the Master
column and the call-stacking target tab.

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 21:39:42 +00:00
co-authored by Claude Opus 5
parent 6f2f39768c
commit 68b29d722a
12 changed files with 756 additions and 105 deletions
+16
View File
@@ -51,12 +51,23 @@ public sealed class NetworkedStation
public Band? Band => Bands.ForFrequency(Frequency);
/// Contacts an hour over this station's last ten and last hundred
/// contacts, from its `STATUS` message. The two rate columns of the
/// network status window.
public int Last10 { get; internal set; }
public int Last100 { get; internal set; }
/// True while the station is running rather than searching. In a
/// multi-single entry this is the run station.
public bool IsRunning { get; internal set; }
public bool IsTransmitting { get; internal set; }
/// True when the station is the second run radio of a two-radio position.
/// N1MM shows Run1 or Run2/Mult in its `R/M` column.
public bool IsRun2 { get; internal set; }
public int RadioNumber { get; internal set; } = 1;
/// A frequency this station has passed, from `PASSFREQ`, and who is on it.
@@ -91,6 +102,11 @@ public sealed class NetworkedStation
/// window.
public bool IsMine { get; internal init; }
/// Set when the operator closed the connection by hand. The beacons keep
/// arriving and the station stays in the list, but nothing opens a
/// connection to it again until the operator asks for one.
public bool IsReconnectPrevented { get; internal set; }
/// Set when the station broadcast a version that is not ours. N1MM turns
/// such a station away, so this program says why rather than failing to
/// connect for no visible reason.
+82 -1
View File
@@ -88,6 +88,10 @@ public sealed class StationLink : IDisposable
set => mine.Operator = value;
}
/// This computer's own row, which is in the station list as well. The
/// window reads its pass frequency and its counts off it.
public NetworkedStation Me => mine;
/// Every station, this computer included, in the order they were first
/// heard.
public IReadOnlyList<NetworkedStation> Stations => stations.Values.ToList();
@@ -104,6 +108,11 @@ public sealed class StationLink : IDisposable
public event EventHandler<string>? Failed;
/// Every message that arrives, whatever it says. Nothing needs this to run:
/// it is what the messages pane of the network status window prints when
/// the operator turns tracing on.
public event EventHandler<StationRecord>? MessageArrived;
public void Start()
{
if (loops.Count > 0)
@@ -257,6 +266,62 @@ public sealed class StationLink : IDisposable
cancellation);
}
/// Everything this station has to say about itself, which is what fills
/// the other operators' network status windows. It goes out every few
/// seconds whether anything has changed or not.
public Task<int> SendStatusAsync(StationStatus status, CancellationToken cancellation = default)
{
mine.PassFrequency = status.PassFrequency;
mine.Frequency = status.Frequency;
mine.IsRunning = status.IsRunning;
mine.Operator = status.Operator;
mine.Mode = status.Mode;
mine.Last10 = status.Last10;
mine.Last100 = status.Last100;
mine.IsRun2 = status.IsRun2;
StationsChanged?.Invoke(this, EventArgs.Empty);
return SendAsync(StationMessages.Status(status, version, StationNumber, computerName), cancellation);
}
/// A frequency this station is handing to the others, and who is on it.
/// With `to` set it goes to that station alone, which is how an operator
/// passes one contact to one position; without it every station is told,
/// which is how the pass frequency is announced.
public Task<int> SendPassAsync(
Frequency frequency,
string call,
NetworkedStation? to = null,
CancellationToken cancellation = default)
{
mine.PassFrequency = frequency;
mine.PassCall = call;
StationsChanged?.Invoke(this, EventArgs.Empty);
StationRecord record = StationMessages.PassFrequency(frequency, call, StationNumber, computerName);
return to is null
? SendAsync(record, cancellation)
: Sent(SendToAsync(to, record, cancellation));
}
/// Closes the connection to one station because the operator asked. It
/// stays closed: a beacon from that station opens a new one, so without
/// this flag the connection would be back within ten seconds.
public void Close(NetworkedStation station)
{
station.IsReconnectPrevented = true;
Drop(station.ComputerName, "closed by the operator");
}
/// Opens a connection to a station the operator closed, or to one whose
/// connection has not come back on its own.
public void Open(NetworkedStation station)
{
station.IsReconnectPrevented = false;
if (!station.IsMine && !writers.ContainsKey(station.ComputerName))
{
_ = ConnectAsync(station, stopping.Token);
}
}
/// Asks every station whether it is there. The answer sets `EchoTime`.
public Task<int> SendEchoRequestAsync(CancellationToken cancellation = default)
{
@@ -264,6 +329,10 @@ public sealed class StationLink : IDisposable
return SendAsync(StationMessages.EchoRequest(StationNumber, computerName, DateTime.UtcNow), cancellation);
}
/// One send read as the count every other send returns.
private static async Task<int> Sent(Task<bool> one) =>
await one.ConfigureAwait(false) ? 1 : 0;
public void Dispose()
{
stopping.Cancel();
@@ -365,7 +434,7 @@ public sealed class StationLink : IDisposable
}
station.Refused = "";
StationsChanged?.Invoke(this, EventArgs.Empty);
if (!writers.ContainsKey(station.ComputerName))
if (!station.IsReconnectPrevented && !writers.ContainsKey(station.ComputerName))
{
_ = ConnectAsync(station, stopping.Token);
}
@@ -465,6 +534,7 @@ public sealed class StationLink : IDisposable
station.LastMessage = record.Type;
station.Read++;
mine.Read++;
MessageArrived?.Invoke(this, record);
switch (record.Type)
{
case "IAM":
@@ -476,6 +546,17 @@ public sealed class StationLink : IDisposable
station.IsRunning = record.Flag(2);
station.RadioNumber = Math.Max(1, record.Number(3));
break;
case "STATUS":
StationStatus status = StationMessages.ReadStatus(record);
station.PassFrequency = status.PassFrequency;
station.Frequency = status.Frequency;
station.IsRunning = status.IsRunning;
station.Operator = status.Operator;
station.Mode = status.Mode;
station.Last10 = status.Last10;
station.Last100 = status.Last100;
station.IsRun2 = status.IsRun2;
break;
case "XMIT":
station.IsTransmitting = record.Flag(0);
station.RadioNumber = Math.Max(1, record.Number(1));
+58
View File
@@ -22,6 +22,7 @@ namespace Nonemm.Network;
/// | `PASSFREQ` | a frequency being passed to another radio | yes | yes |
/// | `XMIT` | a station started or stopped transmitting | yes | yes |
/// | `BANDMAP` | which band and mode a station is on | yes | yes |
/// | `STATUS` | everything the network status window shows about a station | yes | yes |
///
/// The rest — the score and sked windows, the log check, the spot lists, the
/// serial-number pool — are passed over. An unknown type is not an error: N1MM
@@ -137,6 +138,58 @@ public static class StationMessages
Text(radioNumber),
]);
/// Everything a station says about itself, sent every few seconds. The two
/// frequencies go out in hundredths of a kilohertz, which is what N1MM
/// writes and reads them as.
///
/// The five transmit-interlock flags after the version are sent as false:
/// this program does not block a transmitter, and a station that says so is
/// one the other end does not wait for. The country file version is empty
/// for the same reason — N1MM only shows it.
public static StationRecord Status(
StationStatus status,
string version,
int stationNumber,
string computerName) =>
Record(
"STATUS",
stationNumber,
computerName,
[
Hundredths(status.PassFrequency),
Hundredths(status.Frequency),
StationRecord.Written(status.IsRunning),
status.Operator,
Text(status.Last10),
Text(status.Last100),
status.Mode?.Name ?? "",
status.OperatorCategory,
status.TransmitterCategory,
"",
version,
StationRecord.Written(false),
StationRecord.Written(false),
StationRecord.Written(false),
StationRecord.Written(false),
StationRecord.Written(false),
StationRecord.Written(status.IsRun2),
]);
/// What a `STATUS` message says. A station on another version sends fewer
/// fields, and every one that is missing reads as empty rather than
/// throwing the message away.
public static StationStatus ReadStatus(StationRecord record) => new(
Frequency.FromKilohertz(record.Decimal(0) / 100),
Frequency.FromKilohertz(record.Decimal(1) / 100),
record.Flag(2),
record.Field(3),
Modes.Parse(record.Field(6)),
record.Number(4),
record.Number(5),
record.Field(7),
record.Field(8),
record.Flag(16));
/// What a message that has arrived means, or null for one this program does
/// nothing with.
public static ContactUpdate? Read(StationRecord record) => record.Type switch
@@ -166,4 +219,9 @@ public static class StationMessages
new(stationNumber, computerName, type, fields);
private static string Text(int number) => number.ToString(CultureInfo.InvariantCulture);
/// A frequency as `STATUS` carries it: kilohertz times a hundred, with no
/// decimal point.
private static string Hundredths(Frequency frequency) =>
(frequency.Kilohertz * 100).ToString("0", CultureInfo.InvariantCulture);
}
+29
View File
@@ -0,0 +1,29 @@
using Nonemm.Core;
namespace Nonemm.Network;
/// What a station says about itself every few seconds, which is what fills the
/// rows of the network status window.
///
/// It is one message rather than a field on each of the others because N1MM
/// sends it that way: `BANDMAP` says a station moved, `STATUS` says everything
/// the window shows about it and arrives whether anything moved or not, so a
/// station that has been sitting on one frequency for an hour still has a
/// current row.
///
/// `Last10` and `Last100` are contacts an hour over the last ten and the last
/// 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`
/// is true when the station is the second run radio of a two-radio position,
/// which is the `R/M` column.
public sealed record StationStatus(
Frequency PassFrequency,
Frequency Frequency,
bool IsRunning,
string Operator,
Mode? Mode,
int Last10,
int Last100,
string OperatorCategory = "",
string TransmitterCategory = "",
bool IsRun2 = false);