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]); + } +}