From f336b89c3767d1c8dc4ed201f91804da3b35ab36 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sat, 24 Oct 2026 00:41:51 +0000 Subject: [PATCH] Send contacts in the layout N1MM actually reads QsoRecord followed MultiOpManager.QSOString, a helper N1MM does not use for the network. The messages use Contact.QSOString: 43 fields, the continent before the contact type, and flags as numbers. N1MM converts the multiplier flags with ToShort and threw on 'True', so every contact logged here raised an error at the other computer. The id now goes out as a GUID and is kept on arrival, which is what the other station's checksum of a resync block is made over. Co-Authored-By: Claude Sonnet 5.5 --- src/Nonemm.Network/QsoRecord.cs | 83 ++++++++++++-------- tests/Nonemm.Network.Tests/QsoRecordTests.cs | 76 ++++++++++++++++++ 2 files changed, 128 insertions(+), 31 deletions(-) create mode 100644 tests/Nonemm.Network.Tests/QsoRecordTests.cs diff --git a/src/Nonemm.Network/QsoRecord.cs b/src/Nonemm.Network/QsoRecord.cs index a654caf..5b541be 100644 --- a/src/Nonemm.Network/QsoRecord.cs +++ b/src/Nonemm.Network/QsoRecord.cs @@ -3,32 +3,38 @@ 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. +/// A contact as it travels between two logging computers: the forty-three +/// fields of N1MM's `Contact.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`. +/// reads the fields by position, with `SetNewContact`. N1MM converts a flag +/// with `ToShort`, so it has to be a number: it throws on `True`. The only +/// flag it reads in words as well is `IsOriginal`. /// /// | 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 | | | +/// | 0 | timestamp | 22 | power | +/// | 1 | callsign | 23 | band, MHz | +/// | 2 | frequency, kHz | 24 | WPX prefix | +/// | 3 | transmit frequency | 25 | exchange 1 | +/// | 4 | mode | 26 | radio number | +/// | 5 | contest name | 27 | operator | +/// | 6 | sent report | 28 | grid square | +/// | 7 | received report | 29 | contest number | +/// | 8 | country prefix | 30 | multiplier 3 | +/// | 9 | station prefix | 31 | misc text | +/// | 10 | QTH | 32 | continent | +/// | 11 | name | 33 | contact type | +/// | 12 | comment | 34 | run 1 or run 2 | +/// | 13 | received serial | 35 | rover location | +/// | 14 | section | 36 | radio interfaced | +/// | 15 | precedence | 37 | station number | +/// | 16 | check | 38 | computer name | +/// | 17 | zone | 39 | original | +/// | 18 | sent serial | 40 | run QSO | +/// | 19 | points | 41 | id | +/// | 20 | multiplier 1 | 42 | claimed | +/// | 21 | multiplier 2 | | | /// /// 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 @@ -38,7 +44,7 @@ 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 const int FieldCount = 43; public static List Write(Qso qso) => [ @@ -63,8 +69,8 @@ public static class QsoRecord Text(qso.Zone), Text(qso.SentNumber), Text(qso.Points), - StationRecord.Written(qso.IsMultiplier1), - StationRecord.Written(qso.IsMultiplier2), + Flag(qso.IsMultiplier1), + Flag(qso.IsMultiplier2), qso.Power, StationRecord.Written(qso.Band?.MegahertzLabel ?? 0), qso.WpxPrefix, @@ -73,11 +79,20 @@ public static class QsoRecord qso.Operator, qso.GridSquare, Text(qso.ContestNumber), - StationRecord.Written(qso.IsMultiplier3), + Flag(qso.IsMultiplier3), qso.MiscText, + qso.Continent, qso.ContactType, Text(qso.RunPosition), - qso.Continent, + qso.RoverLocation, + Flag(qso.IsRadioInterfaced), + Text(qso.NetworkedComputerNumber), + qso.StationName, + Flag(qso.IsOriginal), + Flag(qso.IsRunQso), + // N1MM parses this as a GUID and throws on anything else + Guid.TryParse(qso.Id, out Guid id) ? id.ToString("N") : "", + Flag(qso.IsClaimed), ]; /// The contact the fields describe. `at` is where the fields start, which @@ -96,7 +111,7 @@ public static class QsoRecord Frequency transmit = Frequency.FromKilohertz(record.Decimal(at + 3)); return new Qso { - Id = Qso.NewId(), + Id = Guid.TryParse(record.Field(at + 41), out Guid sent) ? sent.ToString("N") : Qso.NewId(), TimestampUtc = record.Time(at), Call = Callsign.Parse(record.Field(at + 1)), Frequency = frequency, @@ -128,15 +143,21 @@ public static class QsoRecord 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), + Continent = record.Field(at + 32), + ContactType = record.Field(at + 33), + RunPosition = record.Number(at + 34), + RoverLocation = record.Field(at + 35), + IsRunQso = record.Flag(at + 40), + // a station on an older version stops before this + IsClaimed = record.Field(at + 42).Length == 0 || record.Flag(at + 42), StationName = stationName, - NetworkedComputerNumber = record.StationNumber, + NetworkedComputerNumber = record.Number(at + 37) is > 0 and var made ? made : record.StationNumber, // it was made at another radio, on another computer IsOriginal = false, }; } + private static string Flag(bool flag) => flag ? "1" : "0"; + private static string Text(int number) => number.ToString(CultureInfo.InvariantCulture); } diff --git a/tests/Nonemm.Network.Tests/QsoRecordTests.cs b/tests/Nonemm.Network.Tests/QsoRecordTests.cs new file mode 100644 index 0000000..10e339f --- /dev/null +++ b/tests/Nonemm.Network.Tests/QsoRecordTests.cs @@ -0,0 +1,76 @@ +using Nonemm.Core; + +namespace Nonemm.Network.Tests; + +/// N1MM reads a contact by position, with `SetNewContact`, and it throws on a +/// flag written as `True`. +public class QsoRecordTests +{ + private static readonly Qso Worked = new() + { + Id = Qso.NewId(), + TimestampUtc = new DateTime(2026, 10, 24, 2, 29, 21, DateTimeKind.Utc), + Call = Callsign.Parse("G3XYZ"), + Frequency = Frequency.FromKilohertz(14_025), + Mode = Modes.Cw, + ContestName = "CQWW", + Points = 3, + IsMultiplier1 = true, + IsMultiplier3 = true, + Continent = "EU", + ContactType = "D", + StationName = "RUN-PC", + NetworkedComputerNumber = 2, + }; + + [Fact] + public void AContactIsFortyThreeFields() + { + Assert.Equal(QsoRecord.FieldCount, QsoRecord.Write(Worked).Count); + } + + /// The fields N1MM converts with `ToShort` are 20, 21, 30, 36, 40 and 42. + [Fact] + public void TheFlagsN1MmConvertToNumbersAreNumbers() + { + List fields = QsoRecord.Write(Worked); + + foreach (int at in new[] { 20, 21, 30, 36, 40, 42 }) + { + Assert.True(short.TryParse(fields[at], out _), $"field {at} is '{fields[at]}'"); + } + Assert.Equal("1", fields[20]); + Assert.Equal("0", fields[21]); + } + + [Fact] + public void TheContinentComesBeforeTheContactType() + { + List fields = QsoRecord.Write(Worked); + + Assert.Equal("EU", fields[32]); + Assert.Equal("D", fields[33]); + } + + [Fact] + public void TheIdIsSentAsAGuidAndKept() + { + List fields = QsoRecord.Write(Worked); + StationRecord record = new(2, "RUN-PC", "QSO", [fields[0], .. fields]); + + Qso read = QsoRecord.Read(record, 1, "RUN-PC"); + + Assert.Equal(Worked.Id, read.Id); + Assert.Equal(Worked.Call.Text, read.Call.Text); + Assert.Equal("EU", read.Continent); + Assert.Equal("D", read.ContactType); + Assert.Equal(2, read.NetworkedComputerNumber); + } + + /// N1MM parses the id as a GUID, so one that is not goes out empty. + [Fact] + public void AnIdThatIsNotAGuidIsLeftOut() + { + Assert.Equal("", QsoRecord.Write(Worked with { Id = "not-a-guid" })[41]); + } +}