Talk to the other logging computers on N1MM's own port

N1MM has two networks and this program had only one of them. Port 12060
carries XML to other programs — a spot tool, a score poster — and
`StationNetwork` already writes that. Computer to computer, N1MM uses
port 12070 and a different protocol, which is what this adds.

The wire format, from `MultiOpManager` and `MultiStation`:

    DATA__07%SHACK-PC%QSO%2026-09-03 12:34:56%DL1ABC%…~__DATA

the sending station's number, its computer name, the message type and
the fields of that type. `%` and `~` cannot appear in a field, so N1MM
writes `!` in their place. `StationRecord` reads and writes that, and
holds what has arrived until a `~` says a message is whole: TCP hands
over half a message as often as two.

Discovery is a UDP broadcast to the same port, six fields wide.
**N1MM refuses a station whose version is not its own** — it says
"Software versions must match" and drops it — so the version this
program broadcasts is a setting rather than its own version, and a
station that says something else is kept in the list with `Refused` set
so the operator can see why nothing is arriving.

`StationLink` runs both sockets, opens a connection to every station it
hears, and reports what arrives. `QsoRecord` is the thirty-five fields
of a contact in N1MM's order, from `MultiOpManager.cs:1007`; points and
multiplier flags are read but the log works them out again from the
rules, so two stations cannot disagree about a score. `AddStation` names
a station by hand, for a network where a broadcast does not reach.

A station can also be named rather than heard, and one that is connected
may say nothing for a while, so `NetworkedStation` is heard-from as soon
as it exists. Left at nothing, the sweep that drops quiet stations read
every new station as quiet since the beginning of time and closed the
connection the moment it opened.

Tested as bytes and over loopback. Nothing has talked to a real N1MM.

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 15:19:03 +00:00
co-authored by Claude Opus 5
parent 9f635550bd
commit c0e1bc1e1b
8 changed files with 1643 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
using System.Globalization;
using Nonemm.Core;
namespace Nonemm.Network;
/// A contact as it travels between two logging computers: the thirty-five
/// fields of N1MM's `QSOString`, in N1MM's order.
///
/// The order is what matters and it cannot be changed, because the other end
/// reads the fields by position. It is taken from `MultiOpManager.cs:1007`.
///
/// | At | Field | At | Field |
/// |---|---|---|---|
/// | 0 | timestamp | 18 | sent serial |
/// | 1 | callsign | 19 | points |
/// | 2 | frequency, kHz | 20 | multiplier 1 |
/// | 3 | transmit frequency | 21 | multiplier 2 |
/// | 4 | mode | 22 | power |
/// | 5 | contest name | 23 | band, MHz |
/// | 6 | sent report | 24 | WPX prefix |
/// | 7 | received report | 25 | exchange 1 |
/// | 8 | country prefix | 26 | radio number |
/// | 9 | station prefix | 27 | operator |
/// | 10 | QTH | 28 | grid square |
/// | 11 | name | 29 | contest number |
/// | 12 | comment | 30 | multiplier 3 |
/// | 13 | received serial | 31 | misc text |
/// | 14 | section | 32 | contact type |
/// | 15 | precedence | 33 | run 1 or run 2 |
/// | 16 | check | 34 | continent |
/// | 17 | zone | | |
///
/// The contact carries its points and its multiplier flags, and they are read
/// rather than trusted: the log works them out again from the contest rules, so
/// two stations cannot disagree about a score because one of them was running
/// an older set of rules.
public static class QsoRecord
{
/// How many fields there are. A message with fewer is read as far as it
/// goes, because a station on another version sends a shorter one.
public const int FieldCount = 35;
public static List<string> Write(Qso qso) =>
[
StationRecord.Written(qso.TimestampUtc),
qso.Call.Text,
StationRecord.Written(qso.Frequency.Kilohertz),
StationRecord.Written(
(qso.QsxFrequency.Hertz == 0 ? qso.Frequency : qso.QsxFrequency).Kilohertz),
qso.Mode.Name,
qso.ContestName,
qso.SentReport,
qso.ReceivedReport,
qso.CountryPrefix,
qso.StationPrefix,
qso.Qth,
qso.Name,
qso.Comment,
Text(qso.ReceivedNumber),
qso.Section,
qso.Precedence,
Text(qso.Check),
Text(qso.Zone),
Text(qso.SentNumber),
Text(qso.Points),
StationRecord.Written(qso.IsMultiplier1),
StationRecord.Written(qso.IsMultiplier2),
qso.Power,
StationRecord.Written(qso.Band?.MegahertzLabel ?? 0),
qso.WpxPrefix,
qso.Exchange1,
Text(qso.RadioNumber),
qso.Operator,
qso.GridSquare,
Text(qso.ContestNumber),
StationRecord.Written(qso.IsMultiplier3),
qso.MiscText,
qso.ContactType,
Text(qso.RunPosition),
qso.Continent,
];
/// The contact the fields describe. `at` is where the fields start, which
/// is not always 0: an edit puts the old timestamp in front of them and a
/// replace the old callsign as well.
///
/// `stationName` is the computer that sent it, kept on the contact so the
/// log window can say where each row came from.
///
/// The band, field 23, is not read back: this program works the band out
/// from the frequency, and reading it would believe a station that
/// disagreed with the band plan about where 14.2 MHz is.
public static Qso Read(StationRecord record, int at, string stationName)
{
Frequency frequency = Frequency.FromKilohertz(record.Decimal(at + 2));
Frequency transmit = Frequency.FromKilohertz(record.Decimal(at + 3));
return new Qso
{
Id = Qso.NewId(),
TimestampUtc = record.Time(at),
Call = Callsign.Parse(record.Field(at + 1)),
Frequency = frequency,
QsxFrequency = transmit == frequency ? Frequency.Zero : transmit,
Mode = Modes.Parse(record.Field(at + 4)) ?? Modes.Cw,
ContestName = record.Field(at + 5),
SentReport = record.Field(at + 6),
ReceivedReport = record.Field(at + 7),
CountryPrefix = record.Field(at + 8),
StationPrefix = record.Field(at + 9),
Qth = record.Field(at + 10),
Name = record.Field(at + 11),
Comment = record.Field(at + 12),
ReceivedNumber = record.Number(at + 13),
Section = record.Field(at + 14),
Precedence = record.Field(at + 15),
Check = record.Number(at + 16),
Zone = record.Number(at + 17),
SentNumber = record.Number(at + 18),
Points = record.Number(at + 19),
IsMultiplier1 = record.Flag(at + 20),
IsMultiplier2 = record.Flag(at + 21),
Power = record.Field(at + 22),
WpxPrefix = record.Field(at + 24),
Exchange1 = record.Field(at + 25),
RadioNumber = Math.Max(1, record.Number(at + 26)),
Operator = record.Field(at + 27),
GridSquare = record.Field(at + 28),
ContestNumber = record.Number(at + 29),
IsMultiplier3 = record.Flag(at + 30),
MiscText = record.Field(at + 31),
ContactType = record.Field(at + 32),
RunPosition = record.Number(at + 33),
Continent = record.Field(at + 34),
StationName = stationName,
NetworkedComputerNumber = record.StationNumber,
// it was made at another radio, on another computer
IsOriginal = false,
};
}
private static string Text(int number) => number.ToString(CultureInfo.InvariantCulture);
}