Send the messages N1MM actually reads

Three of the messages this program sends were its own invention or in the
wrong units, so an N1MM beside it read them wrongly or not at all.

| Message | N1MM | What was sent |
|---|---|---|
| `XMIT` | transmitting, the function key's caption, Run2, F1 pressed, stop sending | transmitting, the radio number |
| `PASSFREQ` | the frequency in hundredths of a kilohertz, and nothing else | the frequency in kilohertz, and a callsign |
| `FREQ` | the frequency in hundredths of a kilohertz | `BANDMAP`, which N1MM has no such message for |

So N1MM showed a radio number where the other operators read what a
station is sending, read a pass on 14205.5 as 140.25, and never saw this
station move at all. `BANDMAP` is gone: the frequency goes out in `FREQ`
when the radio moves and the mode, run and the rest ride in the `STATUS`
that follows it, which is where N1MM carries them.

The `Msg` column now shows what a station is sending, which is what the
caption is for. `PassCall` and `RadioNumber` are gone from the station:
neither is on the wire, so neither was ever anything but this program
talking to itself.

**A station that reconnects resynchronises by itself.** N1MM sends
`LASTQAT` — when I last worked anybody — as soon as a connection opens,
and a station holding nothing that late asks for the hours in between. An
empty log asks for nothing: a program that has just been started has no
business pulling the whole contest across, and N1MM suppresses it the same
way. The answer goes over the connection this station opens, which is not
always up when the message arrives, so a resync that has nothing to ask
over waits for it rather than being lost — and one that still cannot send
stops and says so instead of leaving the row resynchronising for the rest
of the contest.

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 22:54:16 +00:00
co-authored by Claude Opus 5
parent 2a21c772ad
commit b5d86c1677
11 changed files with 297 additions and 90 deletions
+92 -33
View File
@@ -243,34 +243,29 @@ public sealed class StationLink : IDisposable
public Task<int> SendTalkAsync(string text, CancellationToken cancellation = default) =>
SendAsync(StationMessages.Talk(text, StationNumber, computerName), cancellation);
/// Says where this station is. It goes out whenever the radio moves or the
/// operator turns run on or off, and it is what the other stations show and
/// what the band-change rule counts.
public Task<int> SendBandAsync(
Frequency frequency,
Mode mode,
bool running,
int radioNumber,
CancellationToken cancellation = default)
/// Says where this station is tuned. It goes out whenever the radio moves;
/// the mode and whether the station is running go with the next `STATUS`.
public Task<int> SendFrequencyAsync(Frequency frequency, CancellationToken cancellation = default)
{
mine.Frequency = frequency;
mine.Mode = mode;
mine.IsRunning = running;
mine.RadioNumber = radioNumber;
StationsChanged?.Invoke(this, EventArgs.Empty);
return SendAsync(
StationMessages.OnBand(frequency, mode, running, radioNumber, StationNumber, computerName),
cancellation);
return SendAsync(StationMessages.OnFrequency(frequency, StationNumber, computerName), cancellation);
}
/// This station started or stopped transmitting, and what it is sending.
/// `message` is the caption of the function key, which is what the other
/// operators read in the window.
public Task<int> SendTransmittingAsync(
bool transmitting,
int radioNumber,
string message = "",
bool isRun2 = false,
CancellationToken cancellation = default)
{
mine.IsTransmitting = transmitting;
mine.Message = transmitting ? message : "";
StationsChanged?.Invoke(this, EventArgs.Empty);
return SendAsync(
StationMessages.Transmitting(transmitting, radioNumber, StationNumber, computerName),
StationMessages.Transmitting(transmitting, message, isRun2, StationNumber, computerName),
cancellation);
}
@@ -291,20 +286,18 @@ public sealed class StationLink : IDisposable
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.
/// A frequency this station is handing to the others. 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);
StationRecord record = StationMessages.PassFrequency(frequency, StationNumber, computerName);
return to is null
? SendAsync(record, cancellation)
: Sent(SendToAsync(to, record, cancellation));
@@ -520,6 +513,64 @@ public sealed class StationLink : IDisposable
// the others were told when their own connection opened
await SendToAsync(station, StationMessages.IAm(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
if (Log is not null)
{
await SendToAsync(
station,
StationMessages.LastContactAt(Log.LastContactUtc(computerName), StationNumber, computerName),
cancellation).ConfigureAwait(false);
}
// that station told us it was ahead of us before there was anything to
// answer over
if (station.ResyncWanted is { } behindSince)
{
station.ResyncWanted = null;
StartResync(station, behindSince, DateTime.UtcNow);
}
}
/// 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.
///
/// An empty log asks for nothing: a station that has just been started has
/// no business pulling the whole contest across, and N1MM suppresses it the
/// same way.
private void ResyncIfBehind(NetworkedStation station, DateTime theirLast)
{
if (Log is null || station.IsResyncing || theirLast == default)
{
return;
}
DateTime ourLast = Log.LastContactUtc(station.ComputerName);
if (theirLast <= ourLast)
{
return;
}
// nothing at all from that station: everything it worked since this log
// started is missing
DateTime from = ourLast == default ? Log.FirstContactUtc : ourLast;
if (from == default)
{
Failed?.Invoke(
this,
$"{station.ComputerName} has worked somebody since this log was started, and this log is empty — resync by hand");
return;
}
Failed?.Invoke(this, $"resynchronising with {station.ComputerName}, which has contacts this log has not");
// the message arrived over the connection that station opened, and the
// answer goes over the one this station opens, which may not be up yet
if (writers.ContainsKey(station.ComputerName))
{
StartResync(station, from, DateTime.UtcNow);
}
else
{
station.ResyncWanted = from;
}
}
private async Task AcceptAsync(CancellationToken cancellation)
@@ -592,11 +643,11 @@ public sealed class StationLink : IDisposable
case "IAM":
station.StationNumber = record.Number(0);
break;
case "BANDMAP":
station.Frequency = Frequency.FromKilohertz(record.Decimal(0));
station.Mode = Modes.Parse(record.Field(1));
station.IsRunning = record.Flag(2);
station.RadioNumber = Math.Max(1, record.Number(3));
case "FREQ":
station.Frequency = Frequency.FromKilohertz(record.Decimal(0) / 100);
break;
case "LASTQAT":
ResyncIfBehind(station, record.Time(0));
break;
case "STATUS":
StationStatus status = StationMessages.ReadStatus(record);
@@ -611,11 +662,11 @@ public sealed class StationLink : IDisposable
break;
case "XMIT":
station.IsTransmitting = record.Flag(0);
station.RadioNumber = Math.Max(1, record.Number(1));
station.Message = station.IsTransmitting ? record.Field(1) : "";
station.IsRun2 = record.Flag(2);
break;
case "PASSFREQ":
station.PassFrequency = Frequency.FromKilohertz(record.Decimal(0));
station.PassCall = record.Field(1);
station.PassFrequency = Frequency.FromKilohertz(record.Decimal(0) / 100);
break;
case "ECHOREQ":
_ = SendAsync(StationMessages.Echo(record, StationNumber, computerName));
@@ -664,7 +715,7 @@ public sealed class StationLink : IDisposable
return;
}
int checksum = LogChecksum.Of(Log.Contacts(station.ComputerName, fromUtc, ResyncBlock));
await SendToAsync(
bool sent = await SendToAsync(
station,
StationMessages.CheckSum(
fromUtc,
@@ -677,6 +728,14 @@ public sealed class StationLink : IDisposable
rescoreWhenDone,
StationNumber,
computerName)).ConfigureAwait(false);
if (!sent)
{
// nothing to ask over. Left running, the row would say it is
// resynchronising for the rest of the contest
station.IsResyncing = false;
station.ResyncStatus = "Stopped: no connection";
StationsChanged?.Invoke(this, EventArgs.Empty);
}
}
/// A station asked whether we hold the same block. This end is the