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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string> 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);
|
||||
}
|
||||
|
||||
@@ -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<string> 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<string> fields = QsoRecord.Write(Worked);
|
||||
|
||||
Assert.Equal("EU", fields[32]);
|
||||
Assert.Equal("D", fields[33]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheIdIsSentAsAGuidAndKept()
|
||||
{
|
||||
List<string> 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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user